frontend-app-api: add lazy external route ref resolution

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-08-16 14:46:39 +02:00
parent a1127c8c93
commit 391f0ca10d
8 changed files with 126 additions and 70 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
External route references are no longer required to be exported via a plugin instance to function. The default target will still be resolved even if the external route reference is not included in `externalRoutes` of a plugin, but users of the plugin will not be able to configure the target of the route. This is particularly useful when building modules or overrides for existing plugins, allowing you add external routes both within and out from the plugin.
@@ -279,7 +279,7 @@ Another thing to note is that this indirection in the routing is particularly us
### Default Targets for External Route References
It is possible to define a default target for an external route reference, potentially removing the need to bind the route in the app. This reduces the need for configuration when installing new plugins through providing a sensible default. It is of course still possible to override the route binding in the app.
It is possible to define a default target for an external route reference, potentially removing the need to bind the route in the app. This reduces the need for configuration when installing new plugins through providing a sensible default. It is of course still possible to override the route binding in the app, as long as the external route ref is exported via the `externalRoutes` property of the plugin instance.
The default target uses the same syntax as the route binding configuration, and will only be used if the target plugin and route exist. For example, this is how the catalog can define a default target for the create component external route in a way that removes the need for the binding in the previous example:
@@ -62,6 +62,7 @@ describe('RouteResolver', () => {
new Map(),
'',
emptyResolver,
new Map(),
);
expect(r.resolve(ref1, src('/'))?.()).toBe(undefined);
@@ -84,6 +85,7 @@ describe('RouteResolver', () => {
new Map(),
'',
emptyResolver,
new Map(),
);
expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route');
@@ -124,6 +126,7 @@ describe('RouteResolver', () => {
]),
'',
emptyResolver,
new Map(),
);
expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route');
@@ -164,6 +167,7 @@ describe('RouteResolver', () => {
routes: new Map([['test.ref1', ref1]]),
externalRoutes: new Map(),
}),
new Map(),
);
expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route');
@@ -172,6 +176,67 @@ describe('RouteResolver', () => {
).toBe('/my-route');
});
it('should resolve default route ref targets lazily', () => {
// Same of the common test ones, but not bound and with default targets
const externalRefDefault1 = createExternalRouteRef({
defaultTarget: 'test.root',
});
const externalRefDefault2 = createExternalRouteRef({
params: ['x'],
defaultTarget: 'test.param',
});
const r = new RouteResolver(
new Map<RouteRef, string>([
[ref1, 'my-route'],
[ref2, 'my-parent/:x'],
]),
new Map(),
[
{
routeRefs: new Set([ref1]),
path: 'my-route',
...rest,
children: [],
},
{
routeRefs: new Set([ref2]),
path: 'my-parent',
...rest,
children: [],
},
],
new Map<ExternalRouteRef, RouteRef | SubRouteRef>([
[externalRef1, ref1],
[externalRef2, subRef3],
]),
'',
createRouteAliasResolver({
routes: new Map([['test.param', ref2]]),
externalRoutes: new Map(),
}),
new Map<string, RouteRef | SubRouteRef>([
['test.root', subRef1],
['test.param', ref2],
]),
);
expect(r.resolve(externalRef1, src('/'))?.()).toBe('/my-route');
expect(r.resolve(externalRefDefault1, src('/'))?.()).toBe('/my-route/foo');
expect(r.resolve(externalRef2, src('/'))?.({ x: '1x' })).toBe(
'/my-parent/1x/bar',
);
expect(r.resolve(externalRefDefault2, src('/'))?.({ x: '1x' })).toBe(
'/my-parent/1x',
);
expect(
r.resolve(
createRouteRef({ aliasFor: 'test.param', params: ['x'] }),
src('/'),
)?.({ x: '1x' }),
).toBe('/my-parent/1x');
});
it('should resolve the most specific match', () => {
const r = new RouteResolver(
new Map<RouteRef, string>([
@@ -209,6 +274,7 @@ describe('RouteResolver', () => {
new Map<ExternalRouteRef, RouteRef | SubRouteRef>(),
'',
emptyResolver,
new Map(),
);
expect(r.resolve(ref2, src('/'))?.({ x: 'x' })).toBe('/root/x');
@@ -265,6 +331,7 @@ describe('RouteResolver', () => {
]),
'',
emptyResolver,
new Map(),
);
const l = '/my-grandparent/my-y/my-parent/my-x';
@@ -342,6 +409,7 @@ describe('RouteResolver', () => {
new Map(),
'/base',
emptyResolver,
new Map(),
);
expect(r.resolve(ref2, src('/'))?.({ x: 'a/#&?b' })).toBe(
@@ -33,7 +33,10 @@ import {
toInternalSubRouteRef,
} 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 {
isExternalRouteRef,
toInternalExternalRouteRef,
} from '../../../frontend-plugin-api/src/routing/ExternalRouteRef';
import { RouteAliasResolver } from './RouteAliasResolver';
// Joins a list of paths together, avoiding trailing and duplicate slashes
@@ -52,54 +55,50 @@ export function joinPaths(...paths: string[]): string {
* Returns an undefined target ref if one could not be fully resolved.
*/
function resolveTargetRef(
anyRouteRef: AnyRouteRef,
targetRouteRef: AnyRouteRef,
routePaths: Map<RouteRef, string>,
routeBindings: Map<AnyRouteRef, AnyRouteRef | undefined>,
routeRefsById: Map<string, RouteRef | SubRouteRef>,
): readonly [RouteRef | undefined, string] {
// First we figure out which absolute route ref we're dealing with, an if there was an sub route path to append.
// For sub routes it will be the parent path, while for external routes it will be the bound route.
let targetRef: RouteRef;
let subRoutePath = '';
if (isRouteRef(anyRouteRef)) {
targetRef = anyRouteRef;
} else if (isSubRouteRef(anyRouteRef)) {
const internal = toInternalSubRouteRef(anyRouteRef);
targetRef = internal.getParent();
subRoutePath = internal.path;
} else if (isExternalRouteRef(anyRouteRef)) {
const resolvedRoute = routeBindings.get(anyRouteRef);
let ref: AnyRouteRef = targetRouteRef;
let path = '';
if (isExternalRouteRef(ref)) {
let resolvedRoute = routeBindings.get(ref);
if (!resolvedRoute) {
const internal = toInternalExternalRouteRef(ref);
const defaultTarget = internal.getDefaultTarget();
if (defaultTarget) {
resolvedRoute = routeRefsById.get(defaultTarget);
}
}
if (!resolvedRoute) {
return [undefined, ''];
}
if (isRouteRef(resolvedRoute)) {
targetRef = resolvedRoute;
} else if (isSubRouteRef(resolvedRoute)) {
const internal = toInternalSubRouteRef(resolvedRoute);
targetRef = internal.getParent();
subRoutePath = resolvedRoute.path;
} else {
throw new Error(
`ExternalRouteRef was bound to invalid target, ${resolvedRoute}`,
);
}
} else {
throw new Error(`Unknown object passed to useRouteRef, got ${anyRouteRef}`);
ref = resolvedRoute;
}
// Bail if no absolute path could be resolved
if (!targetRef) {
return [undefined, ''];
if (isSubRouteRef(ref)) {
const internal = toInternalSubRouteRef(ref);
path = ref.path;
ref = internal.getParent();
}
if (!isRouteRef(ref)) {
throw new Error(
`Unexpectedly resolved ${targetRouteRef} to a non-route ref ${ref}`,
);
}
// Find the path that our target route is bound to
const resolvedPath = routePaths.get(targetRef);
const resolvedPath = routePaths.get(ref);
if (resolvedPath === undefined) {
return [undefined, ''];
}
// SubRouteRefs join the path from the parent route with its own path
const targetPath = joinPaths(resolvedPath, subRoutePath);
return [targetRef, targetPath];
return [ref, path ? joinPaths(resolvedPath, path) : resolvedPath];
}
/**
@@ -190,6 +189,7 @@ export class RouteResolver implements RouteResolutionApi {
>,
private readonly appBasePath: string, // base path without a trailing slash
private readonly routeAliasResolver: RouteAliasResolver,
private readonly routeRefsById: Map<string, RouteRef | SubRouteRef>,
) {}
resolve<TParams extends AnyRouteRefParams>(
@@ -206,6 +206,7 @@ export class RouteResolver implements RouteResolutionApi {
: anyRouteRef,
this.routePaths,
this.routeBindings,
this.routeRefsById,
);
if (!targetRef) {
return undefined;
@@ -23,6 +23,14 @@ import {
RouteAliasResolver,
} from './RouteAliasResolver';
/** @internal */
export type RouteInfo = {
routePaths: Map<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
routeAliasResolver: 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.
// The route resolver then takes care of selecting the most specific match in order to find
@@ -47,12 +55,7 @@ export function joinPaths(...paths: string[]): string {
export function extractRouteInfoFromAppNode(
node: AppNode,
routeAliasResolver: RouteAliasResolver,
): {
routePaths: Map<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
routeAliasResolver: RouteAliasResolver;
} {
): RouteInfo {
// 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>();
// This tracks the parents of each route ref. To find the full path of any route ref you traverse
@@ -52,7 +52,10 @@ import {
toInternalExtension,
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { extractRouteInfoFromAppNode } from '../routing/extractRouteInfoFromAppNode';
import {
extractRouteInfoFromAppNode,
RouteInfo,
} from '../routing/extractRouteInfoFromAppNode';
import { CreateAppRouteBinder } from '../routing';
import { RouteResolver } from '../routing/RouteResolver';
@@ -74,7 +77,6 @@ import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
import { BackstageRouteObject } from '../routing/types';
import { RouteInfo } from './types';
import { matchRoutes } from 'react-router-dom';
import {
createPluginInfoAttacher,
@@ -180,7 +182,10 @@ class RouteResolutionApiProxy implements RouteResolutionApi {
return this.#delegate.resolve(anyRouteRef, options);
}
initialize(routeInfo: RouteInfo) {
initialize(
routeInfo: RouteInfo,
routeRefsById: Map<string, RouteRef | SubRouteRef>,
) {
this.#delegate = new RouteResolver(
routeInfo.routePaths,
routeInfo.routeParents,
@@ -188,6 +193,7 @@ class RouteResolutionApiProxy implements RouteResolutionApi {
this.routeBindings,
this.appBasePath,
routeInfo.routeAliasResolver,
routeRefsById,
);
this.#routeObjects = routeInfo.routeObjects;
@@ -357,7 +363,7 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
createRouteAliasResolver(routeRefsById),
);
routeResolutionApi.initialize(routeInfo);
routeResolutionApi.initialize(routeInfo, routeRefsById.routes);
appTreeApi.initialize(routeInfo);
return { apis, tree };
@@ -19,4 +19,3 @@ export {
type CreateSpecializedAppOptions,
} from './createSpecializedApp';
export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher';
export * from './types';
@@ -1,26 +0,0 @@
/*
* Copyright 2024 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 { BackstageRouteObject } from '../routing/types';
import { RouteAliasResolver } from '../routing/RouteAliasResolver';
/** @internal */
export type RouteInfo = {
routePaths: Map<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
routeAliasResolver: RouteAliasResolver;
};