diff --git a/packages/core-app-api/src/routing/FlatRoutes.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.beta.test.tsx
similarity index 100%
rename from packages/core-app-api/src/routing/FlatRoutes.test.tsx
rename to packages/core-app-api/src/routing/FlatRoutes.beta.test.tsx
diff --git a/packages/core-app-api/src/routing/FlatRoutes.compat.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.compat.test.tsx
new file mode 100644
index 0000000000..c5b65c1461
--- /dev/null
+++ b/packages/core-app-api/src/routing/FlatRoutes.compat.test.tsx
@@ -0,0 +1,155 @@
+/*
+ * 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, RenderResult } from '@testing-library/react';
+import React, { ReactNode } from 'react';
+import { LocalStorageFeatureFlags } from '../apis';
+import { featureFlagsApiRef } from '@backstage/core-plugin-api';
+import { AppContext } from '../app';
+import { AppContextProvider } from '../app/AppContext';
+import { TestApiProvider } from '@backstage/test-utils';
+
+describe.each(['beta', 'stable'])('FlatRoutes %s', rrVersion => {
+ beforeAll(() => {
+ jest.doMock('react', () => React);
+ jest.doMock('react-router', () =>
+ rrVersion === 'beta'
+ ? jest.requireActual('react-router')
+ : jest.requireActual('react-router-stable'),
+ );
+ jest.doMock('react-router-dom', () =>
+ rrVersion === 'beta'
+ ? jest.requireActual('react-router-dom')
+ : jest.requireActual('react-router-dom-stable'),
+ );
+ });
+
+ afterAll(() => {
+ jest.resetModules();
+ jest.resetAllMocks();
+ jest.restoreAllMocks();
+ jest.clearAllMocks();
+ });
+
+ const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
+
+ function requireDeps() {
+ return {
+ ...(require('./FlatRoutes') as typeof import('./FlatRoutes')),
+ ...(require('react-router-dom') as typeof import('react-router-dom')),
+ };
+ }
+
+ function makeRouteRenderer(node: ReactNode) {
+ const { MemoryRouter } = requireDeps();
+ let rendered: RenderResult | undefined = undefined;
+
+ const Wrapper = ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ return (path: string) => {
+ const content = (
+
+ ({
+ NotFoundErrorPage: () => <>Not Found>,
+ }),
+ } as unknown as AppContext
+ }
+ >
+
+
+
+ );
+ if (rendered) {
+ rendered.unmount();
+ rendered.rerender(content);
+ } else {
+ rendered = render(content);
+ }
+ return rendered;
+ };
+ }
+
+ it('renders some routes', () => {
+ const { Route, FlatRoutes } = requireDeps();
+ const renderRoute = makeRouteRenderer(
+
+ a>} />
+ b>} />
+ ,
+ );
+ expect(renderRoute('/a').getByText('a')).toBeInTheDocument();
+ expect(renderRoute('/b').getByText('b')).toBeInTheDocument();
+ expect(renderRoute('/c').getByText('Not Found')).toBeInTheDocument();
+ expect(renderRoute('/b').queryByText('Not Found')).not.toBeInTheDocument();
+ expect(renderRoute('/a').getByText('a')).toBeInTheDocument();
+ });
+
+ it('is not sensitive to ordering and overlapping routes', () => {
+ const { Route, FlatRoutes } = requireDeps();
+ // The '/*' suffixes here are intentional and will be ignored by FlatRoutes
+ const routes = (
+ <>
+ a-1>} />
+ a>} />
+ a-2>} />
+ >
+ );
+ const renderRoute = makeRouteRenderer({routes});
+ expect(renderRoute('/a').getByText('a')).toBeInTheDocument();
+ expect(renderRoute('/a-1').getByText('a-1')).toBeInTheDocument();
+ expect(renderRoute('/a-2').getByText('a-2')).toBeInTheDocument();
+ });
+
+ it('renders children straight as outlets', () => {
+ const { Route, useOutlet, FlatRoutes } = requireDeps();
+ const MyPage = () => {
+ return <>Outlet: {useOutlet()}>;
+ };
+
+ const routes = (
+ <>
+ }>
+ a
+
+ }>
+ a-b
+
+ }>
+ b
+
+ }>c
+ >
+ );
+ const renderRoute = makeRouteRenderer({routes});
+ expect(renderRoute('/a').getByText('Outlet: a')).toBeInTheDocument();
+ expect(renderRoute('/a/b').getByText('Outlet: a-b')).toBeInTheDocument();
+ expect(renderRoute('/b').getByText('Outlet: b')).toBeInTheDocument();
+ expect(renderRoute('/').getByText('Outlet: c')).toBeInTheDocument();
+ expect(
+ renderRoute('/not-found').queryByText('Outlet: c'),
+ ).not.toBeInTheDocument();
+ expect(
+ renderRoute('/not-found').getByText('Not Found'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/packages/core-app-api/src/routing/FlatRoutes.stable.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.stable.test.tsx
new file mode 100644
index 0000000000..93297edc4c
--- /dev/null
+++ b/packages/core-app-api/src/routing/FlatRoutes.stable.test.tsx
@@ -0,0 +1,128 @@
+/*
+ * 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, RenderResult } from '@testing-library/react';
+import React, { ReactNode } from 'react';
+import { MemoryRouter, Route, useOutlet } from 'react-router-dom';
+import { LocalStorageFeatureFlags } from '../apis';
+import { featureFlagsApiRef } from '@backstage/core-plugin-api';
+import { AppContext } from '../app';
+import { AppContextProvider } from '../app/AppContext';
+import { FlatRoutes } from './FlatRoutes';
+import { TestApiProvider } from '@backstage/test-utils';
+
+jest.mock('react-router', () => jest.requireActual('react-router-stable'));
+jest.mock('react-router-dom', () =>
+ jest.requireActual('react-router-dom-stable'),
+);
+
+const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
+const Wrapper = ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+);
+
+function makeRouteRenderer(node: ReactNode) {
+ let rendered: RenderResult | undefined = undefined;
+ return (path: string) => {
+ const content = (
+
+ ({
+ NotFoundErrorPage: () => <>Not Found>,
+ }),
+ } as unknown as AppContext
+ }
+ >
+
+
+
+ );
+ if (rendered) {
+ rendered.unmount();
+ rendered.rerender(content);
+ } else {
+ rendered = render(content);
+ }
+ return rendered;
+ };
+}
+
+describe('FlatRoutes', () => {
+ it('renders some routes', () => {
+ const renderRoute = makeRouteRenderer(
+
+ a>} />
+ b>} />
+ ,
+ );
+ expect(renderRoute('/a').getByText('a')).toBeInTheDocument();
+ expect(renderRoute('/b').getByText('b')).toBeInTheDocument();
+ expect(renderRoute('/c').getByText('Not Found')).toBeInTheDocument();
+ expect(renderRoute('/b').queryByText('Not Found')).not.toBeInTheDocument();
+ expect(renderRoute('/a').getByText('a')).toBeInTheDocument();
+ });
+
+ it('is not sensitive to ordering and overlapping routes', () => {
+ // The '/*' suffixes here are intentional and will be ignored by FlatRoutes
+ const routes = (
+ <>
+ a-1>} />
+ a>} />
+ a-2>} />
+ >
+ );
+ const renderRoute = makeRouteRenderer({routes});
+ expect(renderRoute('/a').getByText('a')).toBeInTheDocument();
+ expect(renderRoute('/a-1').getByText('a-1')).toBeInTheDocument();
+ expect(renderRoute('/a-2').getByText('a-2')).toBeInTheDocument();
+ });
+
+ it('renders children straight as outlets', () => {
+ const MyPage = () => {
+ return <>Outlet: {useOutlet()}>;
+ };
+
+ const routes = (
+ <>
+ }>
+ a
+
+ }>
+ a-b
+
+ }>
+ b
+
+ }>c
+ >
+ );
+ const renderRoute = makeRouteRenderer({routes});
+ expect(renderRoute('/a').getByText('Outlet: a')).toBeInTheDocument();
+ expect(renderRoute('/a/b').getByText('Outlet: a-b')).toBeInTheDocument();
+ expect(renderRoute('/b').getByText('Outlet: b')).toBeInTheDocument();
+ expect(renderRoute('/').getByText('Outlet: c')).toBeInTheDocument();
+ expect(
+ renderRoute('/not-found').queryByText('Outlet: c'),
+ ).not.toBeInTheDocument();
+ expect(
+ renderRoute('/not-found').getByText('Not Found'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx
index 5688b2093e..e2db83442e 100644
--- a/packages/core-app-api/src/routing/FlatRoutes.tsx
+++ b/packages/core-app-api/src/routing/FlatRoutes.tsx
@@ -106,7 +106,7 @@ export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => {
const withNotFound = [
...routes,
{
- path: '/*',
+ path: '*',
element: ,
},
];