core-plugin-api: add forwards compatibility for useApp and useRouteRef

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-11-23 00:44:12 +01:00
parent 64e521767d
commit 358c6f7021
5 changed files with 453 additions and 117 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': patch
---
The `useApp` and `useRouteRef` functions are now forwards compatible with the new frontend system. Along with the previous route reference changes this means that there is no longer a need to use `compatWrapper` from `@backstage/core-compat-api` to make code based on `@backstage/core-plugin-api` compatible with `@backstage/frontend-plugin-api` APIs.
@@ -15,20 +15,129 @@
*/
import { renderHook } from '@testing-library/react';
import { PropsWithChildren } from 'react';
import { createVersionedContextForTesting } from '@backstage/version-bridge';
import {
appTreeApiRef,
iconsApiRef,
AppTreeApi,
IconsApi,
AppTree,
AppNode,
} from '@backstage/frontend-plugin-api';
import { useApp } from './useApp';
import { TestApiProvider, withLogCollector } from '@backstage/test-utils';
describe('v1 consumer', () => {
const context = createVersionedContextForTesting('app-context');
describe('useApp', () => {
describe('old system', () => {
const context = createVersionedContextForTesting('app-context');
afterEach(() => {
context.reset();
afterEach(() => {
context.reset();
});
it('should provide an app context', () => {
const wrapper = ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[]}>{children}</TestApiProvider>
);
context.set({ 1: 'context-value' });
const renderedHook = renderHook(() => useApp(), { wrapper });
expect(renderedHook.result.current).toBe('context-value');
});
});
it('should provide an app context', () => {
context.set({ 1: 'context-value' });
describe('new system', () => {
const mockIcon = () => null;
const mockIconsApi: IconsApi = {
getIcon: jest.fn((key: string) =>
key === 'test-icon' ? mockIcon : undefined,
),
listIconKeys: jest.fn(() => ['test-icon']),
};
const renderedHook = renderHook(() => useApp());
expect(renderedHook.result.current).toBe('context-value');
const mockPlugin = {
id: 'test-plugin',
};
const mockAppNode: AppNode = {
spec: {
id: 'test-node',
attachTo: { id: 'root', input: 'children' },
extension: {} as any,
disabled: false,
plugin: mockPlugin as any,
},
edges: {
attachments: new Map(),
},
};
const mockAppTree: AppTree = {
root: mockAppNode,
nodes: new Map([['test-node', mockAppNode]]),
orphans: [],
};
const mockAppTreeApi: AppTreeApi = {
getTree: jest.fn(() => ({ tree: mockAppTree })),
getNodesByRoutePath: jest.fn(() => ({ nodes: [] })),
};
it('should provide an app context from new app system', () => {
const wrapper = ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider
apis={[
[appTreeApiRef, mockAppTreeApi],
[iconsApiRef, mockIconsApi],
]}
>
{children}
</TestApiProvider>
);
const renderedHook = renderHook(() => useApp(), { wrapper });
const appContext = renderedHook.result.current;
expect(appContext).toBeDefined();
expect(appContext.getPlugins()).toHaveLength(1);
expect(appContext.getPlugins()[0].getId()).toBe('test-plugin');
expect(appContext.getSystemIcon('test-icon')).toBe(mockIcon);
expect(appContext.getSystemIcons()).toEqual({ 'test-icon': mockIcon });
expect(appContext.getComponents().Progress).toBeDefined();
expect(appContext.getComponents().NotFoundErrorPage).toBeDefined();
});
it('should error on missing appTreeApi or iconsApi', () => {
withLogCollector(['error'], () => {
expect(() =>
renderHook(() => useApp(), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[]}>{children}</TestApiProvider>
),
}),
).toThrow('App context is not available');
expect(() =>
renderHook(() => useApp(), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[appTreeApiRef, mockAppTreeApi]]}>
{children}
</TestApiProvider>
),
}),
).toThrow('App context is not available');
expect(() =>
renderHook(() => useApp(), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[iconsApiRef, mockIconsApi]]}>
{children}
</TestApiProvider>
),
}),
).toThrow('App context is not available');
});
});
});
});
+132
View File
@@ -14,18 +14,150 @@
* limitations under the License.
*/
import { useMemo } from 'react';
import { useVersionedContext } from '@backstage/version-bridge';
import {
appTreeApiRef,
iconsApiRef,
useApiHolder,
ErrorDisplay,
NotFoundErrorPage,
Progress,
createFrontendPlugin,
FrontendPlugin,
} from '@backstage/frontend-plugin-api';
import {
AppComponents,
IconComponent,
BackstagePlugin,
} from '@backstage/core-plugin-api';
import { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
import { AppContext as AppContextV1 } from './types';
const legacyPluginStore = getOrCreateGlobalSingleton(
'legacy-plugin-compatibility-store',
() => new WeakMap<FrontendPlugin, BackstagePlugin>(),
);
function toLegacyPlugin(plugin: FrontendPlugin): BackstagePlugin {
let legacy = legacyPluginStore.get(plugin);
if (legacy) {
return legacy;
}
const errorMsg = 'Not implemented in legacy plugin compatibility layer';
const notImplemented = () => {
throw new Error(errorMsg);
};
legacy = {
getId(): string {
return plugin.id;
},
get routes() {
return {};
},
get externalRoutes() {
return {};
},
getApis: notImplemented,
getFeatureFlags: notImplemented,
provide: notImplemented,
};
legacyPluginStore.set(plugin, legacy);
return legacy;
}
function toNewPlugin(plugin: BackstagePlugin): FrontendPlugin {
return createFrontendPlugin({
pluginId: plugin.getId(),
});
}
/**
* React hook providing {@link AppContext}.
*
* @public
*/
export const useApp = (): AppContextV1 => {
const apiHolder = useApiHolder();
const appTreeApi = apiHolder.get(appTreeApiRef);
const versionedContext = useVersionedContext<{ 1: AppContextV1 }>(
'app-context',
);
const newAppContext = useMemo<AppContextV1 | null>(() => {
if (!appTreeApi) {
return null;
}
const iconsApi = apiHolder.get(iconsApiRef);
if (!iconsApi) {
return null;
}
const { tree } = appTreeApi.getTree();
let gatheredPlugins: BackstagePlugin[] | undefined = undefined;
const ErrorBoundaryFallbackWrapper: AppComponents['ErrorBoundaryFallback'] =
({ plugin, ...rest }) => (
<ErrorDisplay {...rest} plugin={plugin && toNewPlugin(plugin)} />
);
return {
getPlugins(): BackstagePlugin[] {
if (gatheredPlugins) {
return gatheredPlugins;
}
const pluginSet = new Set<BackstagePlugin>();
for (const node of tree.nodes.values()) {
const plugin = node.spec.plugin;
if (plugin) {
pluginSet.add(toLegacyPlugin(plugin));
}
}
gatheredPlugins = Array.from(pluginSet);
return gatheredPlugins;
},
getSystemIcon(key: string): IconComponent | undefined {
return iconsApi.getIcon(key);
},
getSystemIcons(): Record<string, IconComponent> {
return Object.fromEntries(
iconsApi.listIconKeys().map(key => [key, iconsApi.getIcon(key)!]),
);
},
getComponents(): AppComponents {
return {
NotFoundErrorPage: NotFoundErrorPage,
BootErrorPage() {
throw new Error(
'The BootErrorPage app component should not be accessed by plugins',
);
},
Progress: Progress,
Router() {
throw new Error(
'The Router app component should not be accessed by plugins',
);
},
ErrorBoundaryFallback: ErrorBoundaryFallbackWrapper,
};
},
};
}, [appTreeApi, apiHolder]);
if (newAppContext) {
return newAppContext;
}
if (!versionedContext) {
throw new Error('App context is not available');
}
@@ -18,140 +18,203 @@ import { renderHook } from '@testing-library/react';
import { PropsWithChildren } from 'react';
import { MemoryRouter, Router } from 'react-router-dom';
import { createVersionedContextForTesting } from '@backstage/version-bridge';
import {
routeResolutionApiRef,
RouteResolutionApi,
RouteFunc,
} from '@backstage/frontend-plugin-api';
import { useRouteRef } from './useRouteRef';
import { createRouteRef } from './RouteRef';
import { createExternalRouteRef } from './ExternalRouteRef';
import { createBrowserHistory } from 'history';
import { TestApiProvider } from '@backstage/test-utils';
describe('v1 consumer', () => {
const context = createVersionedContextForTesting('routing-context');
describe('useRouteRef', () => {
describe('old app system', () => {
const context = createVersionedContextForTesting('routing-context');
afterEach(() => {
context.reset();
});
it('should resolve routes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef({ id: 'ref1' });
const renderedHook = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<MemoryRouter initialEntries={['/my-page']} children={children} />
),
afterEach(() => {
context.reset();
});
const routeFunc = renderedHook.result.current;
expect(routeFunc()).toBe('/hello');
expect(resolve).toHaveBeenCalledWith(
routeRef,
expect.objectContaining({
pathname: '/my-page',
}),
);
});
it('should resolve routes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
it('re-resolves the routeFunc when the search parameters change', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef({ id: 'ref1' });
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
const renderedHook = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<MemoryRouter initialEntries={['/my-page']} children={children} />
),
});
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
const routeFunc = renderedHook.result.current;
expect(routeFunc()).toBe('/hello');
expect(resolve).toHaveBeenCalledWith(
routeRef,
expect.objectContaining({
pathname: '/my-page',
}),
);
});
expect(resolve).toHaveBeenCalledTimes(1);
it('re-resolves the routeFunc when the search parameters change', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
history.push('/my-new-page');
rerender();
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
expect(resolve).toHaveBeenCalledTimes(2);
});
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
it('does not re-resolve the routeFunc the location pathname does not change', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
expect(resolve).toHaveBeenCalledTimes(1);
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
history.push('/my-new-page');
rerender();
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
expect(resolve).toHaveBeenCalledTimes(2);
});
expect(resolve).toHaveBeenCalledTimes(1);
it('does not re-resolve the routeFunc the location pathname does not change', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
history.push('/my-page');
rerender();
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
expect(resolve).toHaveBeenCalledTimes(1);
});
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
it('does not re-resolve the routeFunc when the search parameter changes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
expect(resolve).toHaveBeenCalledTimes(1);
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
history.push('/my-page');
rerender();
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
expect(resolve).toHaveBeenCalledTimes(1);
});
expect(resolve).toHaveBeenCalledTimes(1);
it('does not re-resolve the routeFunc when the search parameter changes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
history.push('/my-page?foo=bar');
rerender();
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
expect(resolve).toHaveBeenCalledTimes(1);
});
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
it('does not re-resolve the routeFunc when the hash parameter changes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
expect(resolve).toHaveBeenCalledTimes(1);
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
history.push('/my-page?foo=bar');
rerender();
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
expect(resolve).toHaveBeenCalledTimes(1);
});
expect(resolve).toHaveBeenCalledTimes(1);
it('does not re-resolve the routeFunc when the hash parameter changes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
history.push('/my-page#foo');
rerender();
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
expect(resolve).toHaveBeenCalledTimes(1);
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
expect(resolve).toHaveBeenCalledTimes(1);
history.push('/my-page#foo');
rerender();
expect(resolve).toHaveBeenCalledTimes(1);
});
});
describe('new app system', () => {
it('should resolve routes using routeResolutionApi', () => {
const routeRef = createRouteRef({ id: 'ref1' });
const mockRouteFunc: RouteFunc<any> = jest.fn(() => '/new-route');
const mockRouteResolutionApi: RouteResolutionApi = {
resolve: jest.fn(() => mockRouteFunc),
};
const wrapper = ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider
apis={[[routeResolutionApiRef, mockRouteResolutionApi]]}
>
<MemoryRouter initialEntries={['/my-page']}>{children}</MemoryRouter>
</TestApiProvider>
);
const renderedHook = renderHook(() => useRouteRef(routeRef), { wrapper });
const routeFunc = renderedHook.result.current;
expect(routeFunc).toBe(mockRouteFunc);
expect(mockRouteResolutionApi.resolve).toHaveBeenCalledWith(
expect.anything(),
{ sourcePath: '/my-page' },
);
expect(routeFunc()).toBe('/new-route');
});
it('should handle optional external route refs', () => {
const externalRouteRef = createExternalRouteRef({
id: 'external-ref',
optional: true,
});
const mockRouteResolutionApi: RouteResolutionApi = {
resolve: jest.fn(() => undefined),
};
const wrapper = ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider
apis={[[routeResolutionApiRef, mockRouteResolutionApi]]}
>
<MemoryRouter initialEntries={['/my-page']}>{children}</MemoryRouter>
</TestApiProvider>
);
const renderedHook = renderHook(() => useRouteRef(externalRouteRef), {
wrapper,
});
expect(renderedHook.result.current).toBeUndefined();
});
});
});
@@ -17,6 +17,10 @@
import { useMemo } from 'react';
import { matchRoutes, useLocation } from 'react-router-dom';
import { useVersionedContext } from '@backstage/version-bridge';
import {
routeResolutionApiRef,
useApiHolder,
} from '@backstage/frontend-plugin-api';
import {
AnyParams,
ExternalRouteRef,
@@ -86,30 +90,53 @@ export function useRouteRef<Params extends AnyParams>(
| ExternalRouteRef<Params, any>,
): RouteFunc<Params> | undefined {
const { pathname } = useLocation();
const apiHolder = useApiHolder();
const routeResolutionApi = apiHolder.get(routeResolutionApiRef);
const versionedContext = useVersionedContext<{ 1: RouteResolver }>(
'routing-context',
);
if (!versionedContext) {
throw new Error('Routing context is not available');
}
const resolver = versionedContext.atVersion(1);
const routeFunc = useMemo(
const resolver = versionedContext?.atVersion(1);
const newRouteFunc = useMemo(() => {
if (!routeResolutionApi) {
return null;
}
try {
return routeResolutionApi.resolve(routeRef, {
sourcePath: pathname,
});
} catch {
return null;
}
}, [routeResolutionApi, routeRef, pathname]);
const legacyRouteFunc = useMemo(
() => resolver && resolver.resolve(routeRef, { pathname }),
[resolver, routeRef, pathname],
);
if (!versionedContext) {
throw new Error('useRouteRef used outside of routing context');
if (newRouteFunc !== null) {
const isOptional = 'optional' in routeRef && routeRef.optional;
if (!newRouteFunc && !isOptional) {
throw new Error(`No path for ${routeRef}`);
}
return newRouteFunc;
}
if (!versionedContext) {
throw new Error('Routing context is not available');
}
if (!resolver) {
throw new Error('RoutingContext v1 not available');
}
const isOptional = 'optional' in routeRef && routeRef.optional;
if (!routeFunc && !isOptional) {
if (!legacyRouteFunc && !isOptional) {
throw new Error(`No path for ${routeRef}`);
}
return routeFunc;
return legacyRouteFunc;
}