Merge pull request #30689 from backstage/rugvip/route-alias

frontend-plugin-api: add aliasFor option to createRouteRef
This commit is contained in:
Patrik Oldsberg
2025-08-04 11:02:50 +02:00
committed by GitHub
11 changed files with 383 additions and 18 deletions
+24
View File
@@ -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 => (
<m.CustomCatalogIndexPage />
)),
},
}),
],
});
```
@@ -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 => (
<m.CustomCatalogIndexPage />
)),
},
}),
],
});
```
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' }),
);
// ...
}
```
@@ -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<RouteRef, RouteRef | undefined>,
): RouteAliasResolver {
const resolver = (routeRef?: RouteRef) => {
if (routeRef && routeAliases.has(routeRef)) {
return routeAliases.get(routeRef);
}
return routeRef;
};
return resolver as RouteAliasResolver;
}
@@ -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<RouteRef, string>([[ref1, 'my-route']]),
new Map(),
[
{
routeRefs: new Set([ref1]),
path: 'my-route',
...rest,
children: [],
},
],
new Map<ExternalRouteRef, RouteRef | SubRouteRef>(),
'',
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<RouteRef, string>([
@@ -167,6 +208,7 @@ describe('RouteResolver', () => {
],
new Map<ExternalRouteRef, RouteRef | SubRouteRef>(),
'',
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(
@@ -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<TParams extends AnyRouteRefParams>(
@@ -200,7 +202,9 @@ export class RouteResolver implements RouteResolutionApi {
): RouteFunc<TParams> | 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,
);
@@ -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();
@@ -46,6 +47,11 @@ const ref4 = createRouteRef();
const ref5 = createRouteRef();
const refOrder: RouteRef<AnyRouteRefParams>[] = [ref1, ref2, ref3, ref4, ref5];
const emptyRouteRefsById = {
routes: new Map(),
externalRoutes: new Map(),
};
function createTestExtension(options: {
name: string;
parent?: string;
@@ -79,7 +85,10 @@ function createTestExtension(options: {
});
}
function routeInfoFromExtensions(extensions: ExtensionDefinition[]) {
function routeInfoFromExtensions(
extensions: ExtensionDefinition[],
routeRefsById?: Record<string, RouteRef>,
) {
const plugin = createFrontendPlugin({
pluginId: 'test',
extensions,
@@ -99,7 +108,13 @@ function routeInfoFromExtensions(extensions: ExtensionDefinition[]) {
instantiateAppNodeTree(tree.root, TestApiRegistry.from());
return extractRouteInfoFromAppNode(tree.root);
return extractRouteInfoFromAppNode(
tree.root,
createRouteAliasResolver({
...emptyRouteRefsById,
...(routeRefsById && { routes: new Map(Object.entries(routeRefsById)) }),
}),
);
}
function sortedEntries<T>(map: Map<RouteRef, T>): [RouteRef, T][] {
@@ -618,4 +633,106 @@ 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 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' });
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+'}/,
);
});
});
});
@@ -18,6 +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 {
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.
@@ -40,10 +44,14 @@ export function joinPaths(...paths: string[]): string {
return normalized;
}
export function extractRouteInfoFromAppNode(node: AppNode): {
export function extractRouteInfoFromAppNode(
node: AppNode,
routeAliasResolver: RouteAliasResolver,
): {
routePaths: Map<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
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<RouteRef, string>();
@@ -53,6 +61,9 @@ export function extractRouteInfoFromAppNode(node: AppNode): {
// 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<BackstageRouteObject>();
// 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<RouteRef, RouteRef | undefined>();
function visit(
current: AppNode,
@@ -65,7 +76,13 @@ export function extractRouteInfoFromAppNode(node: AppNode): {
const routePath = current.instance
?.getData(coreExtensionData.routePath)
?.replace(/^\//, '');
const routeRef = 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;
@@ -145,5 +162,10 @@ export function extractRouteInfoFromAppNode(node: AppNode): {
visit(node);
return { routePaths, routeParents, routeObjects };
return {
routePaths,
routeParents,
routeObjects,
routeAliasResolver: createExactRouteAliasResolver(routeAliases),
};
}
@@ -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;
@@ -247,12 +249,10 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
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,
);
@@ -298,7 +298,10 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
mergeExtensionFactoryMiddleware(options?.extensionFactoryMiddleware),
);
const routeInfo = extractRouteInfoFromAppNode(tree.root);
const routeInfo = extractRouteInfoFromAppNode(
tree.root,
createRouteAliasResolver(routeRefsById),
);
routeResolutionApi.initialize(routeInfo);
appTreeApi.initialize(routeInfo);
@@ -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<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
routeAliasResolver: RouteAliasResolver;
};
+4 -1
View File
@@ -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
@@ -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<any>;
}