frontend-plugin-api: add aliasFor option to createRouteRef

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-07-30 17:12:11 +02:00
parent f2c4abfc03
commit d9e00e37b2
6 changed files with 103 additions and 14 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 />
)),
},
}),
],
});
```
@@ -46,6 +46,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;
@@ -99,7 +104,7 @@ function routeInfoFromExtensions(extensions: ExtensionDefinition[]) {
instantiateAppNodeTree(tree.root, TestApiRegistry.from());
return extractRouteInfoFromAppNode(tree.root);
return extractRouteInfoFromAppNode(tree.root, emptyRouteRefsById);
}
function sortedEntries<T>(map: Map<RouteRef, T>): [RouteRef, T][] {
@@ -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<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
@@ -54,6 +90,8 @@ export function extractRouteInfoFromAppNode(node: AppNode): {
// ref or extension/source based on our current location.
const routeObjects = new Array<BackstageRouteObject>();
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;
@@ -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);
+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>;
}