From a3458208a5a4152a1c7827d483ece194781cfac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 7 May 2026 13:57:23 +0200 Subject: [PATCH 1/2] plugin-app: substitute path params in app/routes redirect targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app/routes redirect config now performs the same :param and * substitution that the legacy Redirect component did before navigating. Named params captured by the `from` pattern are replaced in the `to` string, enabling redirects like /users/:userId → /profile/:userId and /old-docs → /docs/* (with splat forwarding). Adds tests for both named-param and splat substitution. Signed-off-by: Patrik Oldsberg Signed-off-by: Fredrik Adelöw Co-authored-by: Cursor --- .../app-routes-param-redirect-substitution.md | 17 ++++ plugins/app/src/extensions/AppRoutes.test.tsx | 96 +++++++++++++++++++ plugins/app/src/extensions/AppRoutes.tsx | 13 ++- 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 .changeset/app-routes-param-redirect-substitution.md diff --git a/.changeset/app-routes-param-redirect-substitution.md b/.changeset/app-routes-param-redirect-substitution.md new file mode 100644 index 0000000000..66fc77a4ea --- /dev/null +++ b/.changeset/app-routes-param-redirect-substitution.md @@ -0,0 +1,17 @@ +--- +'@backstage/plugin-app': patch +--- + +The `app/routes` redirect config now supports path parameter substitution in the `to` target. Named params (`:userId`) and splat params (`*`) captured by the `from` path are replaced in the `to` string before navigating, making it possible to express redirects like: + +```yaml +app: + extensions: + - app/routes: + config: + redirects: + - from: /users/:userId + to: /profile/:userId + - from: /old-docs + to: /docs/* +``` diff --git a/plugins/app/src/extensions/AppRoutes.test.tsx b/plugins/app/src/extensions/AppRoutes.test.tsx index 9aa6474189..f8ce09a155 100644 --- a/plugins/app/src/extensions/AppRoutes.test.tsx +++ b/plugins/app/src/extensions/AppRoutes.test.tsx @@ -443,6 +443,102 @@ describe('AppRoutes', () => { }); }); + it('should substitute named path params in redirect target', async () => { + const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; + }; + + const profilePage = PageBlueprint.make({ + name: 'profile', + params: { + path: '/profile/:userId', + loader: async () => ( +
+ Profile Page + +
+ ), + }, + }); + + renderTestApp({ + extensions: [profilePage], + initialRouteEntries: ['/users/alice'], + config: { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [ + { + 'app/routes': { + config: { + redirects: [ + { from: '/users/:userId', to: '/profile/:userId' }, + ], + }, + }, + }, + ], + }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('Profile Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent( + '/profile/alice', + ); + }); + }); + + it('should substitute splat param in redirect target', async () => { + const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; + }; + + const docsPage = PageBlueprint.make({ + name: 'docs', + params: { + path: '/docs', + loader: async () => ( +
+ Docs Page + +
+ ), + }, + }); + + renderTestApp({ + extensions: [docsPage], + initialRouteEntries: ['/d/default/component/my-entity'], + config: { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [ + { + 'app/routes': { + config: { + redirects: [{ from: '/d', to: '/docs/*' }], + }, + }, + }, + ], + }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('Docs Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent( + '/docs/default/component/my-entity', + ); + }); + }); + it('should not interfere with normal routes when redirects are configured', async () => { const homePage = PageBlueprint.make({ name: 'home', diff --git a/plugins/app/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx index 75d590283b..8dd05aaa5a 100644 --- a/plugins/app/src/extensions/AppRoutes.tsx +++ b/plugins/app/src/extensions/AppRoutes.tsx @@ -21,7 +21,16 @@ import { createExtensionInput, NotFoundErrorPage, } from '@backstage/frontend-plugin-api'; -import { Navigate, useRoutes } from 'react-router-dom'; +import { Navigate, useParams, useRoutes } from 'react-router-dom'; + +function RedirectWithParams({ to }: { to: string }) { + const params = useParams() as Record; + let target = to; + for (const [name, value] of Object.entries(params)) { + target = target.replaceAll(name === '*' ? '*' : `:${name}`, value ?? ''); + } + return ; +} export const AppRoutes = createExtension({ name: 'routes', @@ -54,7 +63,7 @@ export const AppRoutes = createExtension({ redirect.from === '/' ? redirect.from : `${redirect.from.replace(/\/$/, '')}/*`, - element: , + element: , })), ...inputs.routes.map(route => { const routePath = route.get(coreExtensionData.routePath); From 5ebd1a14a071d4a15e02062772a3eb02e6ed05a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 7 May 2026 14:03:07 +0200 Subject: [PATCH 2/2] plugin-app: fix param substitution to use word boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a word-boundary regex (:name\b) instead of a plain string replace so that a shorter param like :a doesn't corrupt a longer param :ab when both are present in the route. Signed-off-by: Patrik Oldsberg Signed-off-by: Fredrik Adelöw Co-authored-by: Cursor --- plugins/app/src/extensions/AppRoutes.test.tsx | 47 +++++++++++++++++++ plugins/app/src/extensions/AppRoutes.tsx | 7 ++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/plugins/app/src/extensions/AppRoutes.test.tsx b/plugins/app/src/extensions/AppRoutes.test.tsx index f8ce09a155..83b0c596b4 100644 --- a/plugins/app/src/extensions/AppRoutes.test.tsx +++ b/plugins/app/src/extensions/AppRoutes.test.tsx @@ -539,6 +539,53 @@ describe('AppRoutes', () => { }); }); + it('should not corrupt a longer param when a shorter param is a prefix of it', async () => { + const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; + }; + + const targetPage = PageBlueprint.make({ + name: 'target', + params: { + path: '/target/:ab/:a', + loader: async () => ( +
+ Target Page + +
+ ), + }, + }); + + renderTestApp({ + extensions: [targetPage], + initialRouteEntries: ['/source/bar/foo'], + config: { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [ + { + 'app/routes': { + config: { + redirects: [{ from: '/source/:ab/:a', to: '/target/:ab/:a' }], + }, + }, + }, + ], + }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('Target Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent( + '/target/bar/foo', + ); + }); + }); + it('should not interfere with normal routes when redirects are configured', async () => { const homePage = PageBlueprint.make({ name: 'home', diff --git a/plugins/app/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx index 8dd05aaa5a..04d94ebc35 100644 --- a/plugins/app/src/extensions/AppRoutes.tsx +++ b/plugins/app/src/extensions/AppRoutes.tsx @@ -27,7 +27,12 @@ function RedirectWithParams({ to }: { to: string }) { const params = useParams() as Record; let target = to; for (const [name, value] of Object.entries(params)) { - target = target.replaceAll(name === '*' ? '*' : `:${name}`, value ?? ''); + // Use \b (word boundary) for named params so that `:a` doesn't + // accidentally match inside `:ab` when both are present. + target = target.replace( + name === '*' ? /\*/g : new RegExp(`:${name}\\b`, 'g'), + value ?? '', + ); } return ; }