diff --git a/.changeset/brave-eels-allow.md b/.changeset/brave-eels-allow.md new file mode 100644 index 0000000000..8fbe7ba8d2 --- /dev/null +++ b/.changeset/brave-eels-allow.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': minor +--- + +Updated the React Router wiring to make use of the new `basename` property of the router components in React Router v6 stable. To implement this, a new optional `basename` property has been added to the `Router` app component, which can be forwarded to the concrete router implementation in order to support this new behavior. This is done by default in any app that does not have a `Router` component override. diff --git a/.changeset/eleven-pets-sneeze.md b/.changeset/eleven-pets-sneeze.md new file mode 100644 index 0000000000..7c0d8147cc --- /dev/null +++ b/.changeset/eleven-pets-sneeze.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': minor +--- + +The app `Router` component now accepts an optional `basename` property. diff --git a/.changeset/shaggy-colts-watch.md b/.changeset/shaggy-colts-watch.md new file mode 100644 index 0000000000..67003bd2d3 --- /dev/null +++ b/.changeset/shaggy-colts-watch.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Disable base path workaround in `Link` component when React Router v6 stable is used. diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 4d1db6fde1..70312a54a5 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -144,7 +144,9 @@ export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; BootErrorPage: ComponentType; Progress: ComponentType<{}>; - Router: ComponentType<{}>; + Router: ComponentType<{ + basename?: string; + }>; ErrorBoundaryFallback: ComponentType; ThemeProvider?: ComponentType<{}>; SignInPage?: ComponentType; diff --git a/packages/core-app-api/src/app/AppManager.compat.test.tsx b/packages/core-app-api/src/app/AppManager.compat.test.tsx new file mode 100644 index 0000000000..bdebd79dc4 --- /dev/null +++ b/packages/core-app-api/src/app/AppManager.compat.test.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2020 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 tlr, { render } from '@testing-library/react'; +import React from 'react'; + +describe.each(['beta', 'stable'])('react-router %s', rrVersion => { + beforeAll(() => { + jest.doMock('react', () => React); + // This has some side effects, so need this to be stable to avoid re-require + jest.doMock('@testing-library/react', () => tlr); + jest.doMock('react-router', () => + rrVersion === 'beta' + ? jest.requireActual('react-router-beta') + : jest.requireActual('react-router-stable'), + ); + jest.doMock('react-router-dom', () => + rrVersion === 'beta' + ? jest.requireActual('react-router-dom-beta') + : jest.requireActual('react-router-dom-stable'), + ); + }); + + afterAll(() => { + jest.resetModules(); + }); + + function requireDeps() { + return { + ...(require('./AppManager') as typeof import('./AppManager')), + ...(require('../routing') as typeof import('../routing')), + ...(require('react-router-dom') as typeof import('react-router-dom')), + ...(require('@backstage/test-utils') as typeof import('@backstage/test-utils')), + }; + } + + describe('AppManager', () => { + it('supports base path', async () => { + const { AppManager, MemoryRouter, Navigate, Route, FlatRoutes } = + requireDeps(); + const app = new AppManager({ + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + Provider: ({ children }) => <>{children}, + }, + ], + icons: {} as any, + plugins: [], + components: { + NotFoundErrorPage: () => null, + BootErrorPage: () => null, + Progress: () => null, + Router: ({ children, basename }) => ( + + ), + ErrorBoundaryFallback: () => null, + ThemeProvider: ({ children }) => <>{children}, + }, + configLoader: async () => [ + { + context: 'test', + data: { app: { baseUrl: 'http://localhost/foo' } }, + }, + ], + bindRoutes: () => {}, + }); + + const AppProvider = app.getProvider(); + const AppRouter = app.getRouter(); + + const rendered = render( + + + + } /> + bar} /> + + + , + ); + + await expect(rendered.findByText('bar')).resolves.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/core-app-api/src/app/AppManager.stable.test.tsx b/packages/core-app-api/src/app/AppManager.stable.test.tsx new file mode 100644 index 0000000000..a9948a6070 --- /dev/null +++ b/packages/core-app-api/src/app/AppManager.stable.test.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2020 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 { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Navigate, Route } from 'react-router-dom'; +import { FlatRoutes } from '../routing'; +import { AppManager } from './AppManager'; +import { AppOptions } from './types'; + +jest.mock('react-router', () => jest.requireActual('react-router-stable')); +jest.mock('react-router-dom', () => + jest.requireActual('react-router-dom-stable'), +); + +const mockAppOptions: AppOptions = { + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + Provider: ({ children }) => <>{children}, + }, + ], + icons: {} as any, + plugins: [], + components: { + NotFoundErrorPage: () => null, + BootErrorPage: () => null, + Progress: () => null, + Router: props => , + ErrorBoundaryFallback: () => null, + ThemeProvider: ({ children }) => <>{children}, + }, + configLoader: async () => [], + bindRoutes: () => {}, +}; + +describe('AppManager', () => { + it('supports base path', async () => { + const app = new AppManager({ + ...mockAppOptions, + components: { + ...mockAppOptions.components, + Router: props => , + }, + configLoader: async () => [ + { + context: 'test', + data: { app: { baseUrl: 'http://localhost/foo' } }, + }, + ], + }); + + const AppProvider = app.getProvider(); + const AppRouter = app.getRouter(); + + const rendered = render( + + + + } /> + bar} /> + + + , + ); + + await expect(rendered.findByText('bar')).resolves.toBeInTheDocument(); + }); + + it('supports base path with absolute navigation', async () => { + const app = new AppManager({ + ...mockAppOptions, + components: { + ...mockAppOptions.components, + Router: props => , + }, + configLoader: async () => [ + { + context: 'test', + data: { app: { baseUrl: 'http://localhost/foo' } }, + }, + ], + }); + + const AppProvider = app.getProvider(); + const AppRouter = app.getRouter(); + + const rendered = render( + + + + } /> + bar} /> + + + , + ); + + await expect(rendered.findByText('bar')).resolves.toBeInTheDocument(); + }); +}); diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 89402476e3..0ca09c0a0c 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -99,6 +99,21 @@ const InternalAppContext = createContext<{ * 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://dummy.dev', // baseUrl can be specified as just a path @@ -361,7 +376,7 @@ export class AppManager implements BackstageApp { const AppRouter = ({ children }: PropsWithChildren<{}>) => { const configApi = useApi(configApiRef); - const basePath = getBasePath(configApi); + const basePath = readBasePath(configApi); const mountPath = `${basePath}/*`; const { routeObjects } = useContext(InternalAppContext); @@ -390,23 +405,43 @@ export class AppManager implements BackstageApp { { signOutTargetUrl: basePath || '/' }, ); + if (isReactRouterBeta()) { + return ( + + + + {children}} /> + + + ); + } + + return ( + + + {children} + + ); + } + + if (isReactRouterBeta()) { return ( - - {children}} /> - + + + {children}} /> + + ); } return ( - + - - {children}} /> - + <>{children} ); diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index 39bf9c0554..70022534a1 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -69,7 +69,7 @@ export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; BootErrorPage: ComponentType; Progress: ComponentType<{}>; - Router: ComponentType<{}>; + Router: ComponentType<{ basename?: string }>; ErrorBoundaryFallback: ComponentType; ThemeProvider?: ComponentType<{}>; diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index 52e687d2e4..d65351d840 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -107,58 +107,6 @@ describe('', () => { }); }); - describe('resolves a sub-path correctly', () => { - it('when it starts with base path', async () => { - const testString = 'This is test string'; - const linkText = 'Navigate!'; - const configApi = new ConfigReader({ - app: { baseUrl: 'http://localhost:3000/example' }, - }); - - const { getByText } = render( - wrapInTestApp( - - {linkText} - - {testString}

} /> -
-
, - ), - ); - - expect(() => getByText(testString)).toThrow(); - fireEvent.click(getByText(linkText)); - await waitFor(() => { - expect(getByText(testString)).toBeInTheDocument(); - }); - }); - - it('when it does not start with base path', async () => { - const testString = 'This is test string'; - const linkText = 'Navigate!'; - const configApi = new ConfigReader({ - app: { baseUrl: 'http://localhost:3000/example' }, - }); - - const { getByText } = render( - wrapInTestApp( - - {linkText} - - {testString}

} /> -
-
, - ), - ); - - expect(() => getByText(testString)).toThrow(); - fireEvent.click(getByText(linkText)); - await waitFor(() => { - expect(getByText(testString)).toBeInTheDocument(); - }); - }); - }); - describe('isExternalUri', () => { it.each([ [true, 'http://'], diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx index 92ac92daad..07bf8fd78f 100644 --- a/packages/core-components/src/components/Link/Link.tsx +++ b/packages/core-components/src/components/Link/Link.tsx @@ -26,6 +26,12 @@ import { LinkProps as RouterLinkProps, } from 'react-router-dom'; import { trimEnd } from 'lodash'; +import { createRoutesFromChildren, Route } from 'react-router-dom'; + +export function isReactRouterBeta(): boolean { + const [obj] = createRoutesFromChildren(} />); + return !obj.index; +} const useStyles = makeStyles( { @@ -79,6 +85,7 @@ const useBasePath = () => { return trimEnd(pathname, '/'); }; +/** @deprecated Remove once we no longer support React Router v6 beta */ export const useResolvedPath = (uri: LinkProps['to']) => { let resolvedPath = String(uri); @@ -125,7 +132,12 @@ export const Link = React.forwardRef( ({ onClick, noTrack, ...props }, ref) => { const classes = useStyles(); const analytics = useAnalytics(); - const to = useResolvedPath(props.to); + + // Adding the base path to URLs breaks react-router v6 stable, so we only + // do it for beta. The react router version won't change at runtime so it is + // fine to ignore the rules of hooks. + // eslint-disable-next-line react-hooks/rules-of-hooks + const to = isReactRouterBeta() ? useResolvedPath(props.to) : props.to; const linkText = getNodeText(props.children) || to; const external = isExternalUri(to); const newWindow = external && !!/^https?:/.exec(to); diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 0738d7bac5..cebf09e908 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -139,7 +139,9 @@ export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; BootErrorPage: ComponentType; Progress: ComponentType<{}>; - Router: ComponentType<{}>; + Router: ComponentType<{ + basename?: string; + }>; ErrorBoundaryFallback: ComponentType; ThemeProvider?: ComponentType<{}>; SignInPage?: ComponentType;