From d9e00e37b21bac0df6b4723c3ae8e1bc7f7dd8be Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 30 Jul 2025 17:12:11 +0200 Subject: [PATCH 1/4] frontend-plugin-api: add aliasFor option to createRouteRef Signed-off-by: Patrik Oldsberg --- .changeset/clever-plants-warn.md | 24 ++++++++++ .../extractRouteInfoFromAppNode.test.ts | 7 ++- .../routing/extractRouteInfoFromAppNode.ts | 46 ++++++++++++++++++- .../src/wiring/createSpecializedApp.tsx | 10 ++-- packages/frontend-plugin-api/report.api.md | 5 +- .../src/routing/RouteRef.ts | 25 ++++++++-- 6 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 .changeset/clever-plants-warn.md diff --git a/.changeset/clever-plants-warn.md b/.changeset/clever-plants-warn.md new file mode 100644 index 0000000000..7018aa31ee --- /dev/null +++ b/.changeset/clever-plants-warn.md @@ -0,0 +1,24 @@ +--- +'@backstage/frontend-plugin-api': patch +'@backstage/frontend-app-api': patch +--- + +Add support for a new `aliasFor` option for `createRouteRef`. This allows for the creation of a new route ref that acts as an alias for an existing route ref that is installed in the app. This is particularly useful when creating modules that override existing plugin pages, without referring to the existing plugin. For example: + +```tsx +export default createFrontendModule({ + pluginId: 'catalog', + extensions: [ + PageBlueprint.make({ + params: { + defaultPath: '/catalog', + routeRef: createRouteRef({ aliasFor: 'catalog.catalogIndex' }), + loader: () => + import('./CustomCatalogIndexPage').then(m => ( + + )), + }, + }), + ], +}); +``` diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts index 29e8767fb3..3c6b1b29b1 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts @@ -46,6 +46,11 @@ const ref4 = createRouteRef(); const ref5 = createRouteRef(); const refOrder: RouteRef[] = [ref1, ref2, ref3, ref4, ref5]; +const emptyRouteRefsById = { + routes: new Map(), + externalRoutes: new Map(), +}; + function createTestExtension(options: { name: string; parent?: string; @@ -99,7 +104,7 @@ function routeInfoFromExtensions(extensions: ExtensionDefinition[]) { instantiateAppNodeTree(tree.root, TestApiRegistry.from()); - return extractRouteInfoFromAppNode(tree.root); + return extractRouteInfoFromAppNode(tree.root, emptyRouteRefsById); } function sortedEntries(map: Map): [RouteRef, T][] { diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts index a1abaf1599..03f3398ca9 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts @@ -18,6 +18,9 @@ import { RouteRef, coreExtensionData } from '@backstage/frontend-plugin-api'; import { BackstageRouteObject } from './types'; import { AppNode } from '@backstage/frontend-plugin-api'; import { toLegacyPlugin } from './toLegacyPlugin'; +import { RouteRefsById } from './collectRouteIds'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef'; // We always add a child that matches all subroutes but without any route refs. This makes // sure that we're always able to match each route no matter how deep the navigation goes. @@ -40,7 +43,40 @@ export function joinPaths(...paths: string[]): string { return normalized; } -export function extractRouteInfoFromAppNode(node: AppNode): { +function createRouteAliasResolver(routeRefsById: RouteRefsById) { + return (routeRef?: RouteRef) => { + if (!routeRef) { + return undefined; + } + + let currentRef = routeRef; + for (let i = 0; i < 100; i++) { + const alias = toInternalRouteRef(currentRef).alias; + if (alias) { + const aliasRef = routeRefsById.routes.get(alias); + if (!aliasRef) { + throw new Error( + `Unable to resolve RouteRef alias '${alias}' for ${currentRef}`, + ); + } + if (aliasRef.$$type === '@backstage/SubRouteRef') { + throw new Error( + `RouteRef alias '${alias}' for ${currentRef} points to a SubRouteRef, which is not supported`, + ); + } + currentRef = aliasRef; + } else { + return currentRef; + } + } + throw new Error(`Alias loop detected for ${routeRef}`); + }; +} + +export function extractRouteInfoFromAppNode( + node: AppNode, + routeRefsById: RouteRefsById, +): { routePaths: Map; routeParents: Map; routeObjects: BackstageRouteObject[]; @@ -54,6 +90,8 @@ export function extractRouteInfoFromAppNode(node: AppNode): { // ref or extension/source based on our current location. const routeObjects = new Array(); + const routeAliasResolver = createRouteAliasResolver(routeRefsById); + function visit( current: AppNode, collectedPath?: string, @@ -65,7 +103,11 @@ export function extractRouteInfoFromAppNode(node: AppNode): { const routePath = current.instance ?.getData(coreExtensionData.routePath) ?.replace(/^\//, ''); - const routeRef = current.instance?.getData(coreExtensionData.routeRef); + + const routeRef = routeAliasResolver( + current.instance?.getData(coreExtensionData.routeRef), + ); + const parentChildren = parentObj?.children ?? routeObjects; let currentObj = parentObj; diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 51b0f14250..f7fac49625 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -237,12 +237,10 @@ export function createSpecializedApp(options?: { const factories = createApiFactories({ tree }); const appBasePath = getBasePath(config); const appTreeApi = new AppTreeApiProxy(tree, appBasePath); + + const routeRefsById = collectRouteIds(features); const routeResolutionApi = new RouteResolutionApiProxy( - resolveRouteBindings( - options?.bindRoutes, - config, - collectRouteIds(features), - ), + resolveRouteBindings(options?.bindRoutes, config, routeRefsById), appBasePath, ); @@ -288,7 +286,7 @@ export function createSpecializedApp(options?: { mergeExtensionFactoryMiddleware(options?.extensionFactoryMiddleware), ); - const routeInfo = extractRouteInfoFromAppNode(tree.root); + const routeInfo = extractRouteInfoFromAppNode(tree.root, routeRefsById); routeResolutionApi.initialize(routeInfo); appTreeApi.initialize(routeInfo); diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 8ae1960221..6af80fafa7 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -823,7 +823,10 @@ export function createRouteRef< | undefined = undefined, TParamKeys extends string = string, >(config?: { - readonly params: string extends TParamKeys ? (keyof TParams)[] : TParamKeys[]; + readonly params?: string extends TParamKeys + ? (keyof TParams)[] + : TParamKeys[]; + aliasFor?: string; }): RouteRef< keyof TParams extends never ? undefined diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.ts b/packages/frontend-plugin-api/src/routing/RouteRef.ts index 1678e14cd4..46076e6258 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.ts @@ -41,6 +41,8 @@ export interface InternalRouteRef< getParams(): string[]; getDescription(): string; + alias: string | undefined; + setId(id: string): void; } @@ -68,18 +70,28 @@ export class RouteRefImpl implements InternalRouteRef { declare readonly T: never; #id?: string; - #params: string[]; - #creationSite: string; + readonly #params: string[]; + readonly #creationSite: string; + readonly #alias?: string; - constructor(readonly params: string[] = [], creationSite: string) { + constructor( + readonly params: string[] = [], + creationSite: string, + alias?: string, + ) { this.#params = params; this.#creationSite = creationSite; + this.#alias = alias; } getParams(): string[] { return this.#params; } + get alias(): string | undefined { + return this.#alias; + } + getDescription(): string { if (this.#id) { return this.#id; @@ -121,7 +133,11 @@ export function createRouteRef< TParamKeys extends string = string, >(config?: { /** A list of parameter names that the path that this route ref is bound to must contain */ - readonly params: string extends TParamKeys ? (keyof TParams)[] : TParamKeys[]; + readonly params?: string extends TParamKeys + ? (keyof TParams)[] + : TParamKeys[]; + + aliasFor?: string; }): RouteRef< keyof TParams extends never ? undefined @@ -132,5 +148,6 @@ export function createRouteRef< return new RouteRefImpl( config?.params as string[] | undefined, describeParentCallSite(), + config?.aliasFor, ) as RouteRef; } From 2cd4afea20e254d21d273336795f6ef233c55739 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 31 Jul 2025 10:09:53 +0200 Subject: [PATCH 2/4] frontend-app-api: add tests for route extraction alias resolution Signed-off-by: Patrik Oldsberg --- .../extractRouteInfoFromAppNode.test.ts | 91 ++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts index 3c6b1b29b1..8ac54559d2 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts @@ -84,7 +84,10 @@ function createTestExtension(options: { }); } -function routeInfoFromExtensions(extensions: ExtensionDefinition[]) { +function routeInfoFromExtensions( + extensions: ExtensionDefinition[], + routeRefsById?: Record, +) { const plugin = createFrontendPlugin({ pluginId: 'test', extensions, @@ -104,7 +107,10 @@ function routeInfoFromExtensions(extensions: ExtensionDefinition[]) { instantiateAppNodeTree(tree.root, TestApiRegistry.from()); - return extractRouteInfoFromAppNode(tree.root, emptyRouteRefsById); + return extractRouteInfoFromAppNode(tree.root, { + ...emptyRouteRefsById, + ...(routeRefsById && { routes: new Map(Object.entries(routeRefsById)) }), + }); } function sortedEntries(map: Map): [RouteRef, T][] { @@ -623,4 +629,85 @@ describe('discovery', () => { ), ]); }); + + describe('route aliases', () => { + it('should resolve route aliases', () => { + const r1 = createRouteRef(); + const r2 = createRouteRef({ aliasFor: 'test.r3' }); + const r3 = createRouteRef(); + + const info = routeInfoFromExtensions( + [ + createTestExtension({ + name: 'page1', + path: 'foo', + routeRef: createRouteRef({ aliasFor: 'test.r1' }), + }), + createTestExtension({ + name: 'page3', + path: 'bar', + routeRef: createRouteRef({ aliasFor: 'test.r2' }), + }), + ], + { + 'test.r1': r1, + 'test.r2': r2, + 'test.r3': r3, + }, + ); + + expect(sortedEntries(info.routePaths)).toEqual([ + [r1, 'foo'], + [r3, 'bar'], + ]); + expect(sortedEntries(info.routeParents)).toEqual([ + [r1, undefined], + [r3, undefined], + ]); + expect(info.routeObjects).toEqual([ + routeObj( + 'foo', + [r1], + undefined, + undefined, + expect.any(Object), + expect.any(Object), + ), + routeObj( + 'bar', + [r3], + undefined, + undefined, + expect.any(Object), + expect.any(Object), + ), + ]); + }); + + it('should bail on infinite route alias loops', () => { + const loop1 = createRouteRef({ aliasFor: 'test.loop2' }); + const loop2 = createRouteRef({ aliasFor: 'test.loop3' }); + const loop3 = createRouteRef({ aliasFor: 'test.loop1' }); + + expect(() => + routeInfoFromExtensions( + [ + createTestExtension({ + name: 'page1', + path: 'page1', + routeRef: createRouteRef({ aliasFor: 'test.loop1' }), + }), + ], + + { + 'test.loop1': loop1, + 'test.loop2': loop2, + 'test.loop3': loop3, + }, + ), + ).toThrow( + /Alias loop detected for RouteRef{created at 'at .*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'}/, + ); + }); + }); }); From 230bfb58edd31e1030c22b00f32dc6b239e07807 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 31 Jul 2025 10:54:08 +0200 Subject: [PATCH 3/4] frontend-app-api: rework route aliases to work for resolution and restrict to same plugin Signed-off-by: Patrik Oldsberg --- .../src/routing/RouteAliasResolver.ts | 90 +++++++++++++++++++ .../src/routing/RouteResolver.test.ts | 46 +++++++++- .../src/routing/RouteResolver.ts | 6 +- .../extractRouteInfoFromAppNode.test.ts | 33 ++++++- .../routing/extractRouteInfoFromAppNode.ts | 60 +++++-------- .../src/wiring/createSpecializedApp.tsx | 7 +- packages/frontend-app-api/src/wiring/types.ts | 2 + 7 files changed, 197 insertions(+), 47 deletions(-) create mode 100644 packages/frontend-app-api/src/routing/RouteAliasResolver.ts diff --git a/packages/frontend-app-api/src/routing/RouteAliasResolver.ts b/packages/frontend-app-api/src/routing/RouteAliasResolver.ts new file mode 100644 index 0000000000..64e5eb7ce8 --- /dev/null +++ b/packages/frontend-app-api/src/routing/RouteAliasResolver.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2025 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 { RouteRef } from '@backstage/frontend-plugin-api'; +import { RouteRefsById } from './collectRouteIds'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef'; + +/** + * @internal + */ +export type RouteAliasResolver = { + (routeRef: RouteRef, pluginId?: string): RouteRef; + (routeRef?: RouteRef, pluginId?: string): RouteRef | undefined; +}; + +/** + * Creates a route alias resolver that resolves aliases based on the route IDs + * @internal + */ +export function createRouteAliasResolver( + routeRefsById: RouteRefsById, +): RouteAliasResolver { + const resolver = (routeRef: RouteRef | undefined, pluginId?: string) => { + if (!routeRef) { + return undefined; + } + + let currentRef = routeRef; + for (let i = 0; i < 100; i++) { + const alias = toInternalRouteRef(currentRef).alias; + if (alias) { + if (pluginId) { + const [aliasPluginId] = alias.split('.'); + if (aliasPluginId !== pluginId) { + throw new Error( + `Refused to resolve alias '${alias}' for ${currentRef} as it points to a different plugin, the expected plugin is '${pluginId}' but the alias points to '${aliasPluginId}'`, + ); + } + } + const aliasRef = routeRefsById.routes.get(alias); + if (!aliasRef) { + throw new Error( + `Unable to resolve RouteRef alias '${alias}' for ${currentRef}`, + ); + } + if (aliasRef.$$type === '@backstage/SubRouteRef') { + throw new Error( + `RouteRef alias '${alias}' for ${currentRef} points to a SubRouteRef, which is not supported`, + ); + } + currentRef = aliasRef; + } else { + return currentRef; + } + } + throw new Error(`Alias loop detected for ${routeRef}`); + }; + + return resolver as RouteAliasResolver; +} + +/** + * Creates a route alias resolver that resolves aliases based on a map of route refs to their aliases + * @internal + */ +export function createExactRouteAliasResolver( + routeAliases: Map, +): RouteAliasResolver { + const resolver = (routeRef?: RouteRef) => { + if (routeRef && routeAliases.has(routeRef)) { + return routeAliases.get(routeRef); + } + return routeRef; + }; + return resolver as RouteAliasResolver; +} diff --git a/packages/frontend-app-api/src/routing/RouteResolver.test.ts b/packages/frontend-app-api/src/routing/RouteResolver.test.ts index 497e7397cb..fb5d75decf 100644 --- a/packages/frontend-app-api/src/routing/RouteResolver.test.ts +++ b/packages/frontend-app-api/src/routing/RouteResolver.test.ts @@ -25,6 +25,10 @@ import { import { BackstagePlugin } from '@backstage/core-plugin-api'; import { RouteResolver } from './RouteResolver'; import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode'; +import { + createExactRouteAliasResolver, + createRouteAliasResolver, +} from './RouteAliasResolver'; const rest = { element: null, @@ -47,9 +51,18 @@ function src(sourcePath: string) { return { sourcePath }; } +const emptyResolver = createExactRouteAliasResolver(new Map()); + describe('RouteResolver', () => { it('should not resolve anything with an empty resolver', () => { - const r = new RouteResolver(new Map(), new Map(), [], new Map(), ''); + const r = new RouteResolver( + new Map(), + new Map(), + [], + new Map(), + '', + emptyResolver, + ); expect(r.resolve(ref1, src('/'))?.()).toBe(undefined); expect(r.resolve(ref2, src('/'))?.({ x: '1x' })).toBe(undefined); @@ -70,6 +83,7 @@ describe('RouteResolver', () => { [{ routeRefs: new Set([ref1]), path: 'my-route', ...rest }], new Map(), '', + emptyResolver, ); expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route'); @@ -109,6 +123,7 @@ describe('RouteResolver', () => { [externalRef2, subRef3], ]), '', + emptyResolver, ); expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route'); @@ -131,6 +146,32 @@ describe('RouteResolver', () => { ); }); + it('should resolve a route with an alias', () => { + const r = new RouteResolver( + new Map([[ref1, 'my-route']]), + new Map(), + [ + { + routeRefs: new Set([ref1]), + path: 'my-route', + ...rest, + children: [], + }, + ], + new Map(), + '', + createRouteAliasResolver({ + routes: new Map([['test.ref1', ref1]]), + externalRoutes: new Map(), + }), + ); + + expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route'); + expect( + r.resolve(createRouteRef({ aliasFor: 'test.ref1' }), src('/'))?.(), + ).toBe('/my-route'); + }); + it('should resolve the most specific match', () => { const r = new RouteResolver( new Map([ @@ -167,6 +208,7 @@ describe('RouteResolver', () => { ], new Map(), '', + emptyResolver, ); expect(r.resolve(ref2, src('/'))?.({ x: 'x' })).toBe('/root/x'); @@ -222,6 +264,7 @@ describe('RouteResolver', () => { [externalRef2, subRef3], ]), '', + emptyResolver, ); const l = '/my-grandparent/my-y/my-parent/my-x'; @@ -298,6 +341,7 @@ describe('RouteResolver', () => { ], new Map(), '/base', + emptyResolver, ); expect(r.resolve(ref2, src('/'))?.({ x: 'a/#&?b' })).toBe( diff --git a/packages/frontend-app-api/src/routing/RouteResolver.ts b/packages/frontend-app-api/src/routing/RouteResolver.ts index ff7966c4db..d4706a7bed 100644 --- a/packages/frontend-app-api/src/routing/RouteResolver.ts +++ b/packages/frontend-app-api/src/routing/RouteResolver.ts @@ -35,6 +35,7 @@ import { } from '../../../frontend-plugin-api/src/routing/SubRouteRef'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { isExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef'; +import { RouteAliasResolver } from './RouteAliasResolver'; // Joins a list of paths together, avoiding trailing and duplicate slashes export function joinPaths(...paths: string[]): string { @@ -189,6 +190,7 @@ export class RouteResolver implements RouteResolutionApi { RouteRef | SubRouteRef >, private readonly appBasePath: string, // base path without a trailing slash + private readonly routeAliasResolver: RouteAliasResolver, ) {} resolve( @@ -200,7 +202,9 @@ export class RouteResolver implements RouteResolutionApi { ): RouteFunc | undefined { // First figure out what our target absolute ref is, as well as our target path. const [targetRef, targetPath] = resolveTargetRef( - anyRouteRef, + anyRouteRef?.$$type === '@backstage/RouteRef' + ? this.routeAliasResolver(anyRouteRef) + : anyRouteRef, this.routePaths, this.routeBindings, ); diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts index 8ac54559d2..349e1db2e6 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts @@ -38,6 +38,7 @@ import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree'; import { Root } from '../extensions/Root'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; +import { createRouteAliasResolver } from './RouteAliasResolver'; const ref1 = createRouteRef(); const ref2 = createRouteRef(); @@ -107,10 +108,13 @@ function routeInfoFromExtensions( instantiateAppNodeTree(tree.root, TestApiRegistry.from()); - return extractRouteInfoFromAppNode(tree.root, { - ...emptyRouteRefsById, - ...(routeRefsById && { routes: new Map(Object.entries(routeRefsById)) }), - }); + return extractRouteInfoFromAppNode( + tree.root, + createRouteAliasResolver({ + ...emptyRouteRefsById, + ...(routeRefsById && { routes: new Map(Object.entries(routeRefsById)) }), + }), + ); } function sortedEntries(map: Map): [RouteRef, T][] { @@ -684,6 +688,27 @@ describe('discovery', () => { ]); }); + it('should refuse to resolve aliases pointing to other plugins', () => { + expect(() => + routeInfoFromExtensions( + [ + // Source for this is the 'test' plugin + createTestExtension({ + name: 'page1', + path: 'page1', + routeRef: createRouteRef({ aliasFor: 'other.root' }), + }), + ], + + { + 'other.root': createRouteRef(), + }, + ), + ).toThrow( + /Refused to resolve alias 'other.root' for RouteRef{created at 'at .*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'} as it points to a different plugin, the expected plugin is 'test' but the alias points to 'other'/, + ); + }); + it('should bail on infinite route alias loops', () => { const loop1 = createRouteRef({ aliasFor: 'test.loop2' }); const loop2 = createRouteRef({ aliasFor: 'test.loop3' }); diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts index 03f3398ca9..74dfcf29a5 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts @@ -18,9 +18,10 @@ import { RouteRef, coreExtensionData } from '@backstage/frontend-plugin-api'; import { BackstageRouteObject } from './types'; import { AppNode } from '@backstage/frontend-plugin-api'; import { toLegacyPlugin } from './toLegacyPlugin'; -import { RouteRefsById } from './collectRouteIds'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef'; +import { + createExactRouteAliasResolver, + RouteAliasResolver, +} from './RouteAliasResolver'; // We always add a child that matches all subroutes but without any route refs. This makes // sure that we're always able to match each route no matter how deep the navigation goes. @@ -43,43 +44,14 @@ export function joinPaths(...paths: string[]): string { return normalized; } -function createRouteAliasResolver(routeRefsById: RouteRefsById) { - return (routeRef?: RouteRef) => { - if (!routeRef) { - return undefined; - } - - let currentRef = routeRef; - for (let i = 0; i < 100; i++) { - const alias = toInternalRouteRef(currentRef).alias; - if (alias) { - const aliasRef = routeRefsById.routes.get(alias); - if (!aliasRef) { - throw new Error( - `Unable to resolve RouteRef alias '${alias}' for ${currentRef}`, - ); - } - if (aliasRef.$$type === '@backstage/SubRouteRef') { - throw new Error( - `RouteRef alias '${alias}' for ${currentRef} points to a SubRouteRef, which is not supported`, - ); - } - currentRef = aliasRef; - } else { - return currentRef; - } - } - throw new Error(`Alias loop detected for ${routeRef}`); - }; -} - export function extractRouteInfoFromAppNode( node: AppNode, - routeRefsById: RouteRefsById, + routeAliasResolver: RouteAliasResolver, ): { routePaths: Map; routeParents: Map; routeObjects: BackstageRouteObject[]; + routeAliasResolver: RouteAliasResolver; } { // This tracks the route path for each route ref, the value is the route path relative to the parent ref const routePaths = new Map(); @@ -89,8 +61,9 @@ export function extractRouteInfoFromAppNode( // This route object tree is passed to react-router in order to be able to look up the current route // ref or extension/source based on our current location. const routeObjects = new Array(); - - const routeAliasResolver = createRouteAliasResolver(routeRefsById); + // This tracks all resolved route aliases. By storing and re-using the resolutions here we make sure that it's not + // possible to pass an aliased route ref directly to the resolver, e.g. `useRouteRef(createRouteRef({ aliasFor: 'example.root' }))` + const routeAliases = new Map(); function visit( current: AppNode, @@ -104,9 +77,11 @@ export function extractRouteInfoFromAppNode( ?.getData(coreExtensionData.routePath) ?.replace(/^\//, ''); - const routeRef = routeAliasResolver( - current.instance?.getData(coreExtensionData.routeRef), - ); + const foundRouteRef = current.instance?.getData(coreExtensionData.routeRef); + const routeRef = routeAliasResolver(foundRouteRef, current.spec.plugin?.id); + if (foundRouteRef && routeRef !== foundRouteRef) { + routeAliases.set(foundRouteRef, routeRef); + } const parentChildren = parentObj?.children ?? routeObjects; let currentObj = parentObj; @@ -187,5 +162,10 @@ export function extractRouteInfoFromAppNode( visit(node); - return { routePaths, routeParents, routeObjects }; + return { + routePaths, + routeParents, + routeObjects, + routeAliasResolver: createExactRouteAliasResolver(routeAliases), + }; } diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index f7fac49625..5743200c21 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -81,6 +81,7 @@ import { createPluginInfoAttacher, FrontendPluginInfoResolver, } from './createPluginInfoAttacher'; +import { createRouteAliasResolver } from '../routing/RouteAliasResolver'; function deduplicateFeatures( allFeatures: FrontendFeature[], @@ -187,6 +188,7 @@ class RouteResolutionApiProxy implements RouteResolutionApi { routeInfo.routeObjects, this.routeBindings, this.appBasePath, + routeInfo.routeAliasResolver, ); this.#routeObjects = routeInfo.routeObjects; @@ -286,7 +288,10 @@ export function createSpecializedApp(options?: { mergeExtensionFactoryMiddleware(options?.extensionFactoryMiddleware), ); - const routeInfo = extractRouteInfoFromAppNode(tree.root, routeRefsById); + const routeInfo = extractRouteInfoFromAppNode( + tree.root, + createRouteAliasResolver(routeRefsById), + ); routeResolutionApi.initialize(routeInfo); appTreeApi.initialize(routeInfo); diff --git a/packages/frontend-app-api/src/wiring/types.ts b/packages/frontend-app-api/src/wiring/types.ts index 2bf08f327f..5192ef4139 100644 --- a/packages/frontend-app-api/src/wiring/types.ts +++ b/packages/frontend-app-api/src/wiring/types.ts @@ -16,6 +16,7 @@ import { RouteRef } from '@backstage/frontend-plugin-api'; import { FrontendFeature as PluginApiFrontendFeature } from '@backstage/frontend-plugin-api'; import { BackstageRouteObject } from '../routing/types'; +import { RouteAliasResolver } from '../routing/RouteAliasResolver'; /** @public * @deprecated Use {@link @backstage/frontend-plugin-api#FrontendFeature} instead. @@ -27,4 +28,5 @@ export type RouteInfo = { routePaths: Map; routeParents: Map; routeObjects: BackstageRouteObject[]; + routeAliasResolver: RouteAliasResolver; }; From a849f89b9af047edbf803184dc52a2c8acac6593 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 31 Jul 2025 12:31:12 +0200 Subject: [PATCH 4/4] docs/frontend-system: document route ref aliases Signed-off-by: Patrik Oldsberg --- .../frontend-system/architecture/36-routes.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/frontend-system/architecture/36-routes.md b/docs/frontend-system/architecture/36-routes.md index c07eea304d..53b5d00cdf 100644 --- a/docs/frontend-system/architecture/36-routes.md +++ b/docs/frontend-system/architecture/36-routes.md @@ -419,3 +419,42 @@ export default createFrontendPlugin({ extensions: [catalogIndexPage], }); ``` + +## Route Aliases - Overriding Routed Extensions in Modules + +It is possible to [override extensions of a plugin using a module](./25-extension-overrides.md#creating-a-frontend-module). In some cases the extension you're overriding may require a route reference. You could import import the plugin instance and access the it via the `routes` property, but this creates a direct dependency on the plugin and risks leading to package duplication issues that would also break the route reference. + +Instead of accessing the route reference directly, you can create a new route reference that acts as an alias for the original one from the plugin. For example, you can override the catalog index page with a custom one like this: + +```tsx +const indexRouteRef = createRouteRef({ aliasFor: 'catalog.catalogIndex' }); + +export default createFrontendModule({ + pluginId: 'catalog', + extensions: [ + PageBlueprint.make({ + params: { + defaultPath: '/catalog', + routeRef: indexRouteRef, + loader: () => + import('./CustomCatalogIndexPage').then(m => ( + + )), + }, + }), + ], +}); +``` + +Aliases are limited to the plugin that they are defined in. These aliases can also be imported and used as usual with for example `useRouteRef`, but they must always be registered in the app via an extension for this to work. For example, the following will not work: + +```tsx +function MyInvalidComponent() { + // This is NOT valid + const link = useRouteRef( + createRouteRef({ aliasFor: 'catalog.catalogIndex' }), + ); + + // ... +} +```