From a5f8ef0b860859e32df4f72ba5edbe0f4ba27e87 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 30 Apr 2026 20:39:57 +0200 Subject: [PATCH 1/8] fix(react-router): Link isActive on competing optional segment routes --- packages/react-router/tests/link.test.tsx | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index b9c739becd6..0f223faf369 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -5314,6 +5314,59 @@ describe('Link', () => { await runTest({ expectedPreload: false, testIdToHover: 'link-2' }) }) }) + + test('edge-case: competing optional segment links', async () => { + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + To index + + To /foo/bar + + To /bar + + + ) + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

index

, + }) + const optRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/{-$foo}/bar', + component: () => { + const params = useParams({ strict: false }) + return
{JSON.stringify(params)}
+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, optRoute]), + history, + }) + + render() + expect(await screen.findByText('index')).toBeInTheDocument() + + const indexLink = await screen.findByRole('link', { name: 'To index' }) + const fooBarLink = await screen.findByRole('link', { name: 'To /foo/bar' }) + const barLink = await screen.findByRole('link', { name: 'To /bar' }) + + fireEvent.click(fooBarLink) + expect(fooBarLink).toHaveAttribute('aria-current', 'page') + expect(barLink).not.toHaveAttribute('aria-current', 'page') + + fireEvent.click(indexLink) + expect(indexLink).toHaveAttribute('aria-current', 'page') + + fireEvent.click(barLink) + expect(fooBarLink).not.toHaveAttribute('aria-current', 'page') + expect(barLink).toHaveAttribute('aria-current', 'page') + }) }) describe('createLink', () => { From 83140d755e3285355259d6ce6d746ccc4eeb17ab Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 30 Apr 2026 22:08:28 +0200 Subject: [PATCH 2/8] fix --- packages/router-core/src/router.ts | 22 +-- .../router-core/tests/build-location.test.ts | 135 ++++++++++++++++++ packages/solid-router/tests/link.test.tsx | 57 ++++++++ packages/vue-router/tests/link.test.tsx | 57 ++++++++ 4 files changed, 262 insertions(+), 9 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index bd8a2898ec4..41e434e64ae 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1860,15 +1860,19 @@ export class RouterCore< : sourcePath // Resolve the next params - const nextParams = - dest.params === false || dest.params === null - ? Object.create(null) - : (dest.params ?? true) === true - ? fromParams - : Object.assign( - fromParams, - functionalUpdate(dest.params as any, fromParams), - ) + const inheritParams = + dest.from || dest.params === true || typeof dest.params === 'function' + const baseParams = inheritParams ? fromParams : Object.create(null) + const nextParams = inheritParams + ? dest.params === true + ? baseParams + : Object.assign( + baseParams, + functionalUpdate(dest.params as any, fromParams), + ) + : dest.params + ? Object.assign(baseParams, dest.params) + : baseParams const destRoute = this.routesByPath[ trimPathRight(nextTo) as keyof typeof this.routesByPath diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index c8c12ce42e2..8877bad0307 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -1036,6 +1036,141 @@ describe('buildLocation - params edge cases', () => { expect(location.pathname).toBe('/users/456') }) + test('omitted params should not inherit current params without from', async () => { + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$orgId', + }) + const userRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/users/$userId', + }) + + const routeTree = rootRoute.addChildren([orgRoute.addChildren([userRoute])]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/orgs/abc/users/123'] }), + }) + + await router.load() + + const location = router.buildLocation({ + to: '/orgs/$orgId/users/$userId', + }) + + expect(location.pathname).toBe('/orgs/undefined/users/undefined') + }) + + test('omitted params should not inherit current optional params without from', async () => { + const rootRoute = new BaseRootRoute({}) + const optRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/{-$foo}/bar', + }) + + const routeTree = rootRoute.addChildren([optRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/foo/bar'] }), + }) + + await router.load() + + const location = router.buildLocation({ + to: '/{-$foo}/bar', + }) + + expect(location.pathname).toBe('/bar') + }) + + test('params as object should not merge current params without from', async () => { + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$orgId', + }) + const userRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/users/$userId', + }) + + const routeTree = rootRoute.addChildren([orgRoute.addChildren([userRoute])]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/orgs/abc/users/123'] }), + }) + + await router.load() + + const location = router.buildLocation({ + to: '/orgs/$orgId/users/$userId', + params: { userId: '456' }, + }) + + expect(location.pathname).toBe('/orgs/undefined/users/456') + }) + + test('omitted params should inherit params with explicit from', async () => { + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$orgId', + }) + const userRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/users/$userId', + }) + + const routeTree = rootRoute.addChildren([orgRoute.addChildren([userRoute])]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/orgs/abc/users/123'] }), + }) + + await router.load() + + const location = router.buildLocation({ + from: '/orgs/$orgId/users/$userId', + to: '/orgs/$orgId/users/$userId', + }) + + expect(location.pathname).toBe('/orgs/abc/users/123') + }) + + test('params as object should merge params with explicit from', async () => { + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$orgId', + }) + const userRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/users/$userId', + }) + + const routeTree = rootRoute.addChildren([orgRoute.addChildren([userRoute])]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/orgs/abc/users/123'] }), + }) + + await router.load() + + const location = router.buildLocation({ + from: '/orgs/$orgId/users/$userId', + to: '/orgs/$orgId/users/$userId', + params: { userId: '456' }, + }) + + expect(location.pathname).toBe('/orgs/abc/users/456') + }) + test('params as object should merge with current params', async () => { const rootRoute = new BaseRootRoute({}) const orgRoute = new BaseRoute({ diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index d0fc7ae4697..2d1a628221a 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -5286,6 +5286,63 @@ describe('Link', () => { await runTest({ expectedPreload: false, testIdToHover: 'link-2' }) }) }) + + test('edge-case: competing optional segment links', async () => { + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + To index + + To /foo/bar + + To /bar + + + ) + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

index

, + }) + const optRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/{-$foo}/bar', + component: () => { + const params = useParams({ strict: false }) + return
{JSON.stringify(params())}
+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, optRoute]), + history, + }) + + render(() => ) + expect(await screen.findByText('index')).toBeInTheDocument() + + const indexLink = await screen.findByRole('link', { name: 'To index' }) + const fooBarLink = await screen.findByRole('link', { name: 'To /foo/bar' }) + const barLink = await screen.findByRole('link', { name: 'To /bar' }) + + await fireEvent.click(fooBarLink) + await waitFor(() => + expect(fooBarLink).toHaveAttribute('aria-current', 'page'), + ) + expect(barLink).not.toHaveAttribute('aria-current', 'page') + + await fireEvent.click(indexLink) + await waitFor(() => + expect(indexLink).toHaveAttribute('aria-current', 'page'), + ) + + await fireEvent.click(barLink) + await waitFor(() => expect(barLink).toHaveAttribute('aria-current', 'page')) + expect(fooBarLink).not.toHaveAttribute('aria-current', 'page') + }) }) describe('createLink', () => { diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index e047fe59933..6b506248d32 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -5446,6 +5446,63 @@ describe('Link', () => { await runTest({ expectedPreload: false, testIdToHover: 'link-2' }) }) }) + + test('edge-case: competing optional segment links', async () => { + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + To index + + To /foo/bar + + To /bar + + + ) + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

index

, + }) + const optRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/{-$foo}/bar', + component: () => { + const params = useParams({ strict: false }) + return
{JSON.stringify(params.value)}
+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, optRoute]), + history, + }) + + render() + expect(await screen.findByText('index')).toBeInTheDocument() + + const indexLink = await screen.findByRole('link', { name: 'To index' }) + const fooBarLink = await screen.findByRole('link', { name: 'To /foo/bar' }) + const barLink = await screen.findByRole('link', { name: 'To /bar' }) + + await fireEvent.click(fooBarLink) + await waitFor(() => + expect(fooBarLink).toHaveAttribute('aria-current', 'page'), + ) + expect(barLink).not.toHaveAttribute('aria-current', 'page') + + await fireEvent.click(indexLink) + await waitFor(() => + expect(indexLink).toHaveAttribute('aria-current', 'page'), + ) + + await fireEvent.click(barLink) + await waitFor(() => expect(barLink).toHaveAttribute('aria-current', 'page')) + expect(fooBarLink).not.toHaveAttribute('aria-current', 'page') + }) }) describe('createLink', () => { From 1beb4b6add0ebec784798dc97a8cf692289065dc Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 30 Apr 2026 22:08:47 +0200 Subject: [PATCH 3/8] fix related unit tests --- packages/react-router/tests/link.test.tsx | 14 +++++++++----- packages/react-router/tests/navigate.test.tsx | 14 ++++++++++---- packages/react-router/tests/useNavigate.test.tsx | 5 +++-- packages/solid-router/tests/link.test.tsx | 14 +++++++++----- packages/solid-router/tests/navigate.test.tsx | 14 ++++++++++---- packages/solid-router/tests/useNavigate.test.tsx | 5 +++-- packages/vue-router/tests/link.test.tsx | 14 +++++++++----- packages/vue-router/tests/navigate.test.tsx | 14 ++++++++++---- packages/vue-router/tests/useNavigate.test.tsx | 5 +++-- 9 files changed, 66 insertions(+), 33 deletions(-) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 0f223faf369..f89536da552 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -6005,7 +6005,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

B Route

- Link to Parent + + Link to Parent + ) }, @@ -6043,7 +6045,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( Link to .. from /param/foo/a - + Link to .. from current active route @@ -6059,7 +6061,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param B Route

- Link to Parent + + Link to Parent + Link to . with param:bar @@ -6886,10 +6890,10 @@ describe('relative links to from route', () => { return ( <>

Post Detail

- + To post info - + To post notes { + it('should carry over optional parameters from current route when params is true', async () => { const { router } = createOptionalParamTestRouter( createMemoryHistory({ initialEntries: ['/posts/tech'] }), ) @@ -798,7 +804,7 @@ describe('router.navigate navigation using optional path parameters - object syn // Navigate back to posts - should carry over 'news' from current params await router.navigate({ to: '/posts/{-$category}', - params: {}, + params: true, }) await router.invalidate() @@ -1039,7 +1045,7 @@ describe('router.navigate navigation using optional path parameters - parameter // Navigate to articles without specifying category await router.navigate({ to: '/articles/{-$category}', - params: {}, + params: true, }) await router.invalidate() @@ -1067,7 +1073,7 @@ describe('router.navigate navigation using optional path parameters - parameter // Navigate back to posts without explicit category removal await router.navigate({ to: '/posts/{-$category}', - params: {}, + params: true, }) await router.invalidate() diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index b5f473885f7..0de04528e58 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1659,6 +1659,7 @@ test.each([true, false])( (open: boolean) => { navigate({ to: '.', + params: true, search: (prev: {}) => ({ ...prev, [`_${name}`]: open ? true : undefined, @@ -2352,7 +2353,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( Link to .. from /param/foo/a