diff --git a/.changeset/fuzzy-rivers-search.md b/.changeset/fuzzy-rivers-search.md new file mode 100644 index 0000000000..4a82d1884c --- /dev/null +++ b/.changeset/fuzzy-rivers-search.md @@ -0,0 +1,50 @@ +--- +'@backstage/core-app-api': minor +--- + +Added a new `AppRouter` component and `app.createRoot()` method that replaces `app.getRouter()` and `app.getProvider()`, which are now deprecated. The new `AppRouter` component is a drop-in replacement for the old router component, while the new `app.createRoot()` method is used instead of the old provider component. + +An old app setup might look like this: + +```tsx +const app = createApp(/* ... */); + +const AppProvider = app.getProvider(); +const AppRouter = app.getRouter(); + +const routes = ...; + +const App = () => ( + + + + + {routes} + + +); + +export default App; +``` + +With these new APIs, the setup now looks like this: + +```tsx +import { AppRouter } from '@backstage/core-app-api'; + +const app = createApp(/* ... */); + +const routes = ...; + +export default app.createRoot( + <> + + + + {routes} + + , +); +``` + +Note that `app.createRoot()` accepts a React element, rather than a component. diff --git a/.changeset/shy-birds-hammer.md b/.changeset/shy-birds-hammer.md new file mode 100644 index 0000000000..116652f740 --- /dev/null +++ b/.changeset/shy-birds-hammer.md @@ -0,0 +1,48 @@ +--- +'@backstage/create-app': patch +--- + +Updated the app template to use the new `AppRouter` component instead of `app.getRouter()`, as well as `app.createRoot()` instead of `app.getProvider()`. + +To apply this change to an existing app, make the following change to `packages/app/src/App.tsx`: + +```diff +-import { FlatRoutes } from '@backstage/core-app-api'; ++import { AppRouter, FlatRoutes } from '@backstage/core-app-api'; + + ... + +-const AppProvider = app.getProvider(); +-const AppRouter = app.getRouter(); + + ... + +-const App = () => ( ++export default app.createRoot( +- ++ <> + + + + {routes} + +- ++ , + ); +``` + +The final export step should end up looking something like this: + +```tsx +export default app.createRoot( + <> + + + + {routes} + + , +); +``` + +Note that `app.createRoot()` accepts a React element, rather than a component. diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 05ef61c11a..f4a50a0902 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -27,7 +27,7 @@ import { RELATION_PROVIDES_API, } from '@backstage/catalog-model'; import { createApp } from '@backstage/app-defaults'; -import { FlatRoutes } from '@backstage/core-app-api'; +import { AppRouter, FlatRoutes } from '@backstage/core-app-api'; import { AlertDisplay, OAuthRequestDialog, @@ -145,9 +145,6 @@ const app = createApp({ }, }); -const AppProvider = app.getProvider(); -const AppRouter = app.getRouter(); - const routes = ( } /> @@ -278,14 +275,12 @@ const routes = ( ); -const App = () => ( - +export default app.createRoot( + <> {routes} - + , ); - -export default App; diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 70312a54a5..4d867f0b37 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -227,6 +227,15 @@ export type AppRouteBinder = < >, ) => void; +// @public +export function AppRouter(props: AppRouterProps): JSX.Element; + +// @public +export interface AppRouterProps { + // (undocumented) + children?: ReactNode; +} + // @public export class AppThemeSelector implements AppThemeApi { constructor(themes: AppTheme[]); @@ -259,6 +268,7 @@ export type AuthApiCreateOptions = { export type BackstageApp = { getPlugins(): BackstagePlugin[]; getSystemIcon(key: string): IconComponent | undefined; + createRoot(element: JSX.Element): ComponentType<{}>; getProvider(): ComponentType<{}>; getRouter(): ComponentType<{}>; }; diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index f4fa2d8442..5a752ad5df 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -17,15 +17,10 @@ import { AppConfig, Config } from '@backstage/config'; import React, { ComponentType, - createContext, PropsWithChildren, - ReactElement, - useContext, useMemo, useRef, - useState, } from 'react'; -import { Route, Routes } from 'react-router-dom'; import useAsync from 'react-use/lib/useAsync'; import { ApiProvider, @@ -34,7 +29,6 @@ import { LocalStorageFeatureFlags, } from '../apis'; import { - useApi, AnyApiFactory, ApiHolder, IconComponent, @@ -44,7 +38,6 @@ import { AppThemeApi, ConfigApi, featureFlagsApiRef, - IdentityApi, identityApiRef, BackstagePlugin, } from '@backstage/core-plugin-api'; @@ -61,7 +54,6 @@ import { routingV2Collector, } from '../routing/collectors'; import { RoutingProvider } from '../routing/RoutingProvider'; -import { RouteTracker } from '../routing/RouteTracker'; import { validateRouteParameters, validateRouteBindings, @@ -74,14 +66,14 @@ import { AppContext, AppOptions, BackstageApp, - SignInPageProps, } from './types'; import { AppThemeProvider } from './AppThemeProvider'; import { defaultConfigLoader } from './defaultConfigLoader'; import { ApiRegistry } from '../apis/system/ApiRegistry'; import { resolveRouteBindings } from './resolveRouteBindings'; -import { BackstageRouteObject } from '../routing/types'; import { isReactRouterBeta } from './isReactRouterBeta'; +import { InternalAppContext } from './InternalAppContext'; +import { AppRouter, getBasePath } from './AppRouter'; type CompatiblePlugin = | BackstagePlugin @@ -89,39 +81,6 @@ type CompatiblePlugin = output(): Array<{ type: 'feature-flag'; name: string }>; }); -const InternalAppContext = createContext<{ - routeObjects: BackstageRouteObject[]; -}>({ routeObjects: [] }); - -/** - * Get the app base path from the configured app baseUrl. - * - * The returned path does not have a trailing slash. - */ -function getBasePath(configApi: Config) { - if (!isReactRouterBeta()) { - // When using rr v6 stable the base path is handled through the - // basename prop on the router component instead. - return ''; - } - - return readBasePath(configApi); -} - -/** - * Read the configured base path. - * - * The returned path does not have a trailing slash. - */ -function readBasePath(configApi: ConfigApi) { - let { pathname } = new URL( - configApi.getOptionalString('app.baseUrl') ?? '/', - 'http://sample.dev', // baseUrl can be specified as just a path - ); - pathname = pathname.replace(/\/*$/, ''); - return pathname; -} - function useConfigLoader( configLoader: AppConfigLoader | undefined, components: AppComponents, @@ -289,7 +248,23 @@ export class AppManager implements BackstageApp { return this.components; } + createRoot(element: JSX.Element): ComponentType<{}> { + const AppProvider = this.getProvider(); + const AppRoot = () => { + return {element}; + }; + return AppRoot; + } + + #getProviderCalled = false; getProvider(): ComponentType<{}> { + if (this.#getProviderCalled) { + throw new Error( + 'app.getProvider() or app.createRoot() has already been called, and can only be called once', + ); + } + this.#getProviderCalled = true; + const appContext = new AppContextImpl(this); // We only validate routes once @@ -413,7 +388,10 @@ export class AppManager implements BackstageApp { basePath={getBasePath(loadedConfig.api)} > {children} @@ -427,104 +405,6 @@ export class AppManager implements BackstageApp { } getRouter(): ComponentType<{}> { - const { Router: RouterComponent, SignInPage: SignInPageComponent } = - this.components; - - // This wraps the sign-in page and waits for sign-in to be completed before rendering the app - const SignInPageWrapper = ({ - component: Component, - children, - }: { - component: ComponentType; - children: ReactElement; - }) => { - const [identityApi, setIdentityApi] = useState(); - const configApi = useApi(configApiRef); - const basePath = getBasePath(configApi); - - if (!identityApi) { - return ; - } - - this.appIdentityProxy.setTarget(identityApi, { - signOutTargetUrl: basePath || '/', - }); - return children; - }; - - const AppRouter = ({ children }: PropsWithChildren<{}>) => { - const configApi = useApi(configApiRef); - const basePath = readBasePath(configApi); - const mountPath = `${basePath}/*`; - const { routeObjects } = useContext(InternalAppContext); - - // If the app hasn't configured a sign-in page, we just continue as guest. - if (!SignInPageComponent) { - this.appIdentityProxy.setTarget( - { - getUserId: () => 'guest', - getIdToken: async () => undefined, - getProfile: () => ({ - email: 'guest@example.com', - displayName: 'Guest', - }), - getProfileInfo: async () => ({ - email: 'guest@example.com', - displayName: 'Guest', - }), - getBackstageIdentity: async () => ({ - type: 'user', - userEntityRef: 'user:default/guest', - ownershipEntityRefs: ['user:default/guest'], - }), - getCredentials: async () => ({}), - signOut: async () => {}, - }, - { signOutTargetUrl: basePath || '/' }, - ); - - if (isReactRouterBeta()) { - return ( - - - - {children}} /> - - - ); - } - - return ( - - - {children} - - ); - } - - if (isReactRouterBeta()) { - return ( - - - - - {children}} /> - - - - ); - } - - return ( - - - - <>{children} - - - ); - }; - return AppRouter; } diff --git a/packages/core-app-api/src/app/AppRouter.test.tsx b/packages/core-app-api/src/app/AppRouter.test.tsx new file mode 100644 index 0000000000..75f28a6bfe --- /dev/null +++ b/packages/core-app-api/src/app/AppRouter.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + AppComponents, + configApiRef, + IdentityApi, + identityApiRef, + SignInPageProps, + useApi, +} from '@backstage/core-plugin-api'; +import { InternalAppContext } from './InternalAppContext'; +import { MemoryRouter } from 'react-router-dom'; +import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy'; +import { render, screen } from '@testing-library/react'; +import { AppRouter } from './AppRouter'; +import useAsync from 'react-use/lib/useAsync'; +import { AppContextProvider } from './AppContext'; +import { TestApiProvider } from '@backstage/test-utils'; +import { ConfigReader } from '@backstage/config'; + +function UserRefDisplay() { + const identityApi = useApi(identityApiRef); + const { value } = useAsync(() => identityApi.getBackstageIdentity()); + return
ref: {value?.userEntityRef}
; +} + +describe('AppRouter', () => { + const mockComponents = { + Router: MemoryRouter, + } as AppComponents; + + it('should fall back to guest if there is no sign-in page', async () => { + const appIdentityProxy = new AppIdentityProxy(); + + render( + + + mockComponents } as any} + > + + + + + + , + , + ); + + await expect( + screen.findByText('ref: user:default/guest'), + ).resolves.toBeInTheDocument(); + }); + + it('should use the result from the sign-in page', async () => { + const appIdentityProxy = new AppIdentityProxy(); + + const SignInPage = (props: SignInPageProps) => { + props.onSignInSuccess({ + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/test', + ownershipEntityRefs: ['user:default/test'], + }), + } as IdentityApi); + return null; + }; + + render( + + + ({ ...mockComponents, SignInPage }), + } as any + } + > + + + + + + , + ); + + await expect( + screen.findByText('ref: user:default/test'), + ).resolves.toBeInTheDocument(); + }); +}); diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx new file mode 100644 index 0000000000..b799983a3b --- /dev/null +++ b/packages/core-app-api/src/app/AppRouter.tsx @@ -0,0 +1,188 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useContext, ReactNode, ComponentType, useState } from 'react'; +import { + ConfigApi, + configApiRef, + IdentityApi, + SignInPageProps, + useApi, + useApp, +} from '@backstage/core-plugin-api'; +import { InternalAppContext } from './InternalAppContext'; +import { isReactRouterBeta } from './isReactRouterBeta'; +import { RouteTracker } from '../routing/RouteTracker'; +import { Route, Routes } from 'react-router-dom'; +import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy'; + +/** + * Get the app base path from the configured app baseUrl. + * + * The returned path does not have a trailing slash. + */ +export function getBasePath(configApi: ConfigApi) { + if (!isReactRouterBeta()) { + // When using rr v6 stable the base path is handled through the + // basename prop on the router component instead. + return ''; + } + + return readBasePath(configApi); +} + +/** + * Read the configured base path. + * + * The returned path does not have a trailing slash. + */ +function readBasePath(configApi: ConfigApi) { + let { pathname } = new URL( + configApi.getOptionalString('app.baseUrl') ?? '/', + 'http://sample.dev', // baseUrl can be specified as just a path + ); + pathname = pathname.replace(/\/*$/, ''); + return pathname; +} + +// This wraps the sign-in page and waits for sign-in to be completed before rendering the app +function SignInPageWrapper({ + component: Component, + appIdentityProxy, + children, +}: { + component: ComponentType; + appIdentityProxy: AppIdentityProxy; + children: ReactNode; +}) { + const [identityApi, setIdentityApi] = useState(); + const configApi = useApi(configApiRef); + const basePath = getBasePath(configApi); + + if (!identityApi) { + return ; + } + + appIdentityProxy.setTarget(identityApi, { + signOutTargetUrl: basePath || '/', + }); + return <>{children}; +} + +/** + * Props for the {@link AppRouter} component. + * @public + */ +export interface AppRouterProps { + children?: ReactNode; +} + +/** + * App router and sign-in page wrapper. + * + * @public + * @remarks + * + * The AppRouter provides the routing context and renders the sign-in page. + * Until the user has successfully signed in, this component will render + * the sign-in page. Once the user has signed-in, it will instead render + * the app, while providing routing and route tracking for the app. + */ +export function AppRouter(props: AppRouterProps) { + const { Router: RouterComponent, SignInPage: SignInPageComponent } = + useApp().getComponents(); + + const configApi = useApi(configApiRef); + const basePath = readBasePath(configApi); + const mountPath = `${basePath}/*`; + const internalAppContext = useContext(InternalAppContext); + if (!internalAppContext) { + throw new Error('AppRouter must be rendered within the AppProvider'); + } + const { routeObjects, appIdentityProxy } = internalAppContext; + + // If the app hasn't configured a sign-in page, we just continue as guest. + if (!SignInPageComponent) { + appIdentityProxy.setTarget( + { + getUserId: () => 'guest', + getIdToken: async () => undefined, + getProfile: () => ({ + email: 'guest@example.com', + displayName: 'Guest', + }), + getProfileInfo: async () => ({ + email: 'guest@example.com', + displayName: 'Guest', + }), + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/guest', + ownershipEntityRefs: ['user:default/guest'], + }), + getCredentials: async () => ({}), + signOut: async () => {}, + }, + { signOutTargetUrl: basePath || '/' }, + ); + + if (isReactRouterBeta()) { + return ( + + + + {props.children}} /> + + + ); + } + + return ( + + + {props.children} + + ); + } + + if (isReactRouterBeta()) { + return ( + + + + + {props.children}} /> + + + + ); + } + + return ( + + + + {props.children} + + + ); +} diff --git a/packages/core-app-api/src/app/InternalAppContext.ts b/packages/core-app-api/src/app/InternalAppContext.ts new file mode 100644 index 0000000000..81382acf62 --- /dev/null +++ b/packages/core-app-api/src/app/InternalAppContext.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext } from 'react'; +import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy'; +import { BackstageRouteObject } from '../routing/types'; + +export const InternalAppContext = createContext< + | undefined + | { + routeObjects: BackstageRouteObject[]; + appIdentityProxy: AppIdentityProxy; + } +>(undefined); diff --git a/packages/core-app-api/src/app/index.ts b/packages/core-app-api/src/app/index.ts index 7843b36339..156c59d0c7 100644 --- a/packages/core-app-api/src/app/index.ts +++ b/packages/core-app-api/src/app/index.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +export { AppRouter } from './AppRouter'; +export type { AppRouterProps } from './AppRouter'; export { createSpecializedApp } from './createSpecializedApp'; export { defaultConfigLoader } from './defaultConfigLoader'; export * from './types'; diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index 70022534a1..82c05ce268 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -298,15 +298,44 @@ export type BackstageApp = { */ getSystemIcon(key: string): IconComponent | undefined; + /** + * Creates the root component that renders the entire app. + * + * @remarks + * + * This method must only be called once, and you have to provide it the entire + * app element tree. The element tree will be analyzed to discover plugins, + * routes, and other app features. The returned component will render all + * of the app elements wrapped within the app context provider. + * + * @example + * ```tsx + * export default app.createRoot( + * <> + * + * + * + * {routes} + * + * , + * ); + * ``` + */ + createRoot(element: JSX.Element): ComponentType<{}>; + /** * Provider component that should wrap the Router created with getRouter() * and any other components that need to be within the app context. + * + * @deprecated Use {@link BackstageApp.createRoot} instead. */ getProvider(): ComponentType<{}>; /** * Router component that should wrap the App Routes create with getRoutes() * and any other components that should only be available while signed in. + * + * @deprecated Import and use the {@link AppRouter} component from `@backstage/core-app-api` instead */ getRouter(): ComponentType<{}>; }; diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 46cb786399..95fc94703e 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -29,7 +29,7 @@ import { Root } from './components/Root'; import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components'; import { createApp } from '@backstage/app-defaults'; -import { FlatRoutes } from '@backstage/core-app-api'; +import { AppRouter, FlatRoutes } from '@backstage/core-app-api'; import { CatalogGraphPage } from '@backstage/plugin-catalog-graph'; import { RequirePermission } from '@backstage/plugin-permission-react'; import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha'; @@ -53,9 +53,6 @@ const app = createApp({ }, }); -const AppProvider = app.getProvider(); -const AppRouter = app.getRouter(); - const routes = ( } /> @@ -97,14 +94,12 @@ const routes = ( ); -const App = () => ( - +export default app.createRoot( + <> {routes} - + , ); - -export default App;