diff --git a/.changeset/metal-boxes-laugh.md b/.changeset/metal-boxes-laugh.md
new file mode 100644
index 0000000000..9a5ab24e0e
--- /dev/null
+++ b/.changeset/metal-boxes-laugh.md
@@ -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.
diff --git a/packages/core-plugin-api/src/app/useApp.test.tsx b/packages/core-plugin-api/src/app/useApp.test.tsx
index 34c956138e..2cee7f6f3c 100644
--- a/packages/core-plugin-api/src/app/useApp.test.tsx
+++ b/packages/core-plugin-api/src/app/useApp.test.tsx
@@ -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<{}>) => (
+ {children}
+ );
+ 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<{}>) => (
+
+ {children}
+
+ );
+
+ 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<{}>) => (
+ {children}
+ ),
+ }),
+ ).toThrow('App context is not available');
+
+ expect(() =>
+ renderHook(() => useApp(), {
+ wrapper: ({ children }: PropsWithChildren<{}>) => (
+
+ {children}
+
+ ),
+ }),
+ ).toThrow('App context is not available');
+
+ expect(() =>
+ renderHook(() => useApp(), {
+ wrapper: ({ children }: PropsWithChildren<{}>) => (
+
+ {children}
+
+ ),
+ }),
+ ).toThrow('App context is not available');
+ });
+ });
});
});
diff --git a/packages/core-plugin-api/src/app/useApp.tsx b/packages/core-plugin-api/src/app/useApp.tsx
index 05723ee387..8368ed955f 100644
--- a/packages/core-plugin-api/src/app/useApp.tsx
+++ b/packages/core-plugin-api/src/app/useApp.tsx
@@ -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(),
+);
+
+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(() => {
+ 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 }) => (
+
+ );
+
+ return {
+ getPlugins(): BackstagePlugin[] {
+ if (gatheredPlugins) {
+ return gatheredPlugins;
+ }
+
+ const pluginSet = new Set();
+ 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 {
+ 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');
}
diff --git a/packages/core-plugin-api/src/routing/useRouteRef.test.tsx b/packages/core-plugin-api/src/routing/useRouteRef.test.tsx
index b96fc5fc23..325690b6eb 100644
--- a/packages/core-plugin-api/src/routing/useRouteRef.test.tsx
+++ b/packages/core-plugin-api/src/routing/useRouteRef.test.tsx
@@ -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<{}>) => (
-
- ),
+ 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<{}>) => (
+
+ ),
+ });
- const { rerender } = renderHook(() => useRouteRef(routeRef), {
- wrapper: ({ children }: PropsWithChildren<{}>) => (
-
- ),
+ 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<{}>) => (
+
+ ),
+ });
- 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<{}>) => (
-
- ),
+ 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<{}>) => (
+
+ ),
+ });
- 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<{}>) => (
-
- ),
+ 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<{}>) => (
+
+ ),
+ });
- 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<{}>) => (
-
- ),
+ 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<{}>) => (
+
+ ),
+ });
+
+ 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 = jest.fn(() => '/new-route');
+
+ const mockRouteResolutionApi: RouteResolutionApi = {
+ resolve: jest.fn(() => mockRouteFunc),
+ };
+
+ const wrapper = ({ children }: PropsWithChildren<{}>) => (
+
+ {children}
+
+ );
+
+ 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<{}>) => (
+
+ {children}
+
+ );
+
+ const renderedHook = renderHook(() => useRouteRef(externalRouteRef), {
+ wrapper,
+ });
+
+ expect(renderedHook.result.current).toBeUndefined();
+ });
});
});
diff --git a/packages/core-plugin-api/src/routing/useRouteRef.tsx b/packages/core-plugin-api/src/routing/useRouteRef.tsx
index cf292b110f..a46e96ba9e 100644
--- a/packages/core-plugin-api/src/routing/useRouteRef.tsx
+++ b/packages/core-plugin-api/src/routing/useRouteRef.tsx
@@ -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(
| ExternalRouteRef,
): RouteFunc | 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;
}