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/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<{}>;