From 35af8c87617009e938842e6212c474ebeea8c1f6 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 10 Jul 2026 12:20:16 -0700 Subject: [PATCH 01/18] reproduce matchRoute typed param failure --- .../router-core/tests/match-route.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 packages/router-core/tests/match-route.test.ts diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts new file mode 100644 index 0000000000..a4ee4ac2c0 --- /dev/null +++ b/packages/router-core/tests/match-route.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +describe('matchRoute', () => { + it('matches typed params from routes with custom parse and stringify functions', async () => { + const rootRoute = new BaseRootRoute({}) + const invoiceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: ({ invoiceId }: { invoiceId: string }) => ({ + invoiceId: Number(invoiceId), + }), + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toEqual({ invoiceId: 123 }) + }) +}) From 2471c1c8bd0b45ace9c3bd206117ec0fcdeaa274 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 10 Jul 2026 12:27:42 -0700 Subject: [PATCH 02/18] reproduce matchRoute typed param failures --- packages/react-router/tests/Matches.test.tsx | 35 +++++ .../router-core/tests/match-route.test.ts | 125 +++++++++++++++++- 2 files changed, 154 insertions(+), 6 deletions(-) diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index dd3ca3dfa6..e756a567cd 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -145,6 +145,41 @@ test('should show pendingComponent of root route', async () => { expect(await rendered.findByTestId('root-content')).toBeInTheDocument() }) +test('useMatchRoute matches typed params from routes with custom parse and stringify functions', async () => { + const rootRoute = createRootRoute() + const invoiceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: ({ invoiceId }: { invoiceId: string }) => ({ + invoiceId: Number(invoiceId), + }), + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + component: () => { + const matchRoute = useMatchRoute() + const match = matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }) + + return
{JSON.stringify(match)}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + }) + + render() + + expect(await screen.findByTestId('match')).toHaveTextContent( + '{"invoiceId":123}', + ) +}) + describe('matching on different param types', () => { const testCases = [ { diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts index a4ee4ac2c0..9ed73abf96 100644 --- a/packages/router-core/tests/match-route.test.ts +++ b/packages/router-core/tests/match-route.test.ts @@ -4,24 +4,30 @@ import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter } from './routerTestUtils' describe('matchRoute', () => { - it('matches typed params from routes with custom parse and stringify functions', async () => { + function createInvoiceRouter(initialEntry = '/invoices/123') { const rootRoute = new BaseRootRoute({}) const invoiceRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/invoices/$invoiceId', params: { - parse: ({ invoiceId }: { invoiceId: string }) => ({ - invoiceId: Number(invoiceId), - }), + parse: ({ invoiceId }: { invoiceId: string }) => { + const parsed = Number(invoiceId) + return Number.isInteger(parsed) ? { invoiceId: parsed } : false + }, stringify: ({ invoiceId }: { invoiceId: number }) => ({ invoiceId: String(invoiceId), }), }, }) - const router = createTestRouter({ + + return createTestRouter({ routeTree: rootRoute.addChildren([invoiceRoute]), - history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + history: createMemoryHistory({ initialEntries: [initialEntry] }), }) + } + + it('matches typed params from routes with custom parse and stringify functions', async () => { + const router = createInvoiceRouter() await router.load() @@ -32,4 +38,111 @@ describe('matchRoute', () => { }), ).toEqual({ invoiceId: 123 }) }) + + it('does not match a different typed param', async () => { + const router = createInvoiceRouter() + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 456 }, + }), + ).toBe(false) + }) + + it('does not match params rejected by the route parser', async () => { + const router = createInvoiceRouter('/invoices/not-a-number') + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toBe(false) + }) + + it('matches an internal ID parsed from a non-canonical public slug', async () => { + const rootRoute = new BaseRootRoute({}) + const thingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/things/$thingId', + params: { + parse: ({ thingId }: { thingId: string }) => ({ + thingId: thingId.slice(thingId.lastIndexOf('-') + 1), + }), + stringify: ({ thingId }: { thingId: string }) => ({ + thingId, + }), + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([thingRoute]), + history: createMemoryHistory({ + initialEntries: ['/things/Title-of-the-thing-abc123'], + }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/things/$thingId', + params: { thingId: 'abc123' }, + }), + ).toEqual({ thingId: 'abc123' }) + }) + + it('keeps generic route templates working with string params', async () => { + const router = createInvoiceRouter() + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$id', + params: { id: '123' }, + } as any), + ).toEqual({ id: '123' }) + }) + + it('keeps fuzzy matching behavior with string params', async () => { + const router = createInvoiceRouter('/invoices/123/details') + + await router.load() + + expect( + router.matchRoute( + { + to: '/invoices/$invoiceId', + params: { invoiceId: '123' }, + } as any, + { fuzzy: true }, + ), + ).toEqual({ invoiceId: '123', '**': 'details' }) + }) + + it('keeps search matching behavior', async () => { + const router = createInvoiceRouter('/invoices/123?tab=details') + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: '123' }, + search: { tab: 'details' }, + } as any), + ).toEqual({ invoiceId: '123' }) + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: '123' }, + search: { tab: 'other' }, + } as any), + ).toBe(false) + }) }) From 22af4b81a75ec922fce7ec8f1458e7d075dc1f7a Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Fri, 10 Jul 2026 13:32:04 -0700 Subject: [PATCH 03/18] enhance parameter parsing and matching with custom functions --- packages/router-core/src/router.ts | 30 ++++- .../router-core/tests/match-route.test.ts | 122 ++++++++++++++++-- packages/solid-router/tests/Matches.test.tsx | 35 +++++ packages/vue-router/tests/Matches.test.tsx | 35 +++++ 4 files changed, 211 insertions(+), 11 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..b31d437efb 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2994,19 +2994,43 @@ export class RouterCore< return false } + const params = Object.assign(Object.create(null), match.rawParams) + const destinationPath = trimPathRight(next.pathname) + const destinationRoute = this.routesByPath[ + destinationPath as keyof typeof this.routesByPath + ] as AnyRoute | undefined + if (destinationRoute) { + try { + for (const matchedRoute of this.getRouteBranch(destinationRoute)) { + const parseParams = + matchedRoute.options.params?.parse ?? + matchedRoute.options.parseParams + if (parseParams) { + const parsedParams = parseParams(params as Record) + if (parsedParams === false) { + return false + } + Object.assign(params, parsedParams) + } + } + } catch { + return false + } + } + if (location.params) { - if (!deepEqual(match.rawParams, location.params, { partial: true })) { + if (!deepEqual(params, location.params, { partial: true })) { return false } } if (opts?.includeSearch ?? true) { return deepEqual(baseLocation.search, next.search, { partial: true }) - ? match.rawParams + ? params : false } - return match.rawParams + return params } ssr?: { diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts index 9ed73abf96..22914cb650 100644 --- a/packages/router-core/tests/match-route.test.ts +++ b/packages/router-core/tests/match-route.test.ts @@ -65,6 +65,35 @@ describe('matchRoute', () => { ).toBe(false) }) + it('does not throw parser errors while checking a match', async () => { + const rootRoute = new BaseRootRoute({}) + const invoiceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: () => { + throw new Error('invalid invoice') + }, + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toBe(false) + }) + it('matches an internal ID parsed from a non-canonical public slug', async () => { const rootRoute = new BaseRootRoute({}) const thingRoute = new BaseRoute({ @@ -96,6 +125,50 @@ describe('matchRoute', () => { ).toEqual({ thingId: 'abc123' }) }) + it('passes parsed parent params to child route parsers', async () => { + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$orgId', + params: { + parse: ({ orgId }: { orgId: string }) => ({ + orgId: Number(orgId), + }), + }, + }) + const projectRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/projects/$projectId', + params: { + parse: (params: { projectId: string }) => { + if ( + typeof (params as Record).orgId !== 'number' + ) { + return false + } + return { projectId: Number(params.projectId) } + }, + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + orgRoute.addChildren([projectRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/orgs/42/projects/7'], + }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/orgs/$orgId/projects/$projectId', + params: { orgId: 42, projectId: 7 }, + }), + ).toEqual({ orgId: 42, projectId: 7 }) + }) + it('keeps generic route templates working with string params', async () => { const router = createInvoiceRouter() @@ -118,11 +191,11 @@ describe('matchRoute', () => { router.matchRoute( { to: '/invoices/$invoiceId', - params: { invoiceId: '123' }, - } as any, + params: { invoiceId: 123 }, + }, { fuzzy: true }, ), - ).toEqual({ invoiceId: '123', '**': 'details' }) + ).toEqual({ invoiceId: 123, '**': 'details' }) }) it('keeps search matching behavior', async () => { @@ -133,16 +206,49 @@ describe('matchRoute', () => { expect( router.matchRoute({ to: '/invoices/$invoiceId', - params: { invoiceId: '123' }, + params: { invoiceId: 123 }, search: { tab: 'details' }, - } as any), - ).toEqual({ invoiceId: '123' }) + }), + ).toEqual({ invoiceId: 123 }) expect( router.matchRoute({ to: '/invoices/$invoiceId', - params: { invoiceId: '123' }, + params: { invoiceId: 123 }, search: { tab: 'other' }, - } as any), + }), ).toBe(false) }) + + it('matches typed params with a router basepath', async () => { + const rootRoute = new BaseRootRoute({}) + const invoiceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: ({ invoiceId }: { invoiceId: string }) => ({ + invoiceId: Number(invoiceId), + }), + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + basepath: '/app', + history: createMemoryHistory({ + initialEntries: ['/app/invoices/123'], + }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toEqual({ invoiceId: 123 }) + }) + }) diff --git a/packages/solid-router/tests/Matches.test.tsx b/packages/solid-router/tests/Matches.test.tsx index 2685500fd0..ff251fc418 100644 --- a/packages/solid-router/tests/Matches.test.tsx +++ b/packages/solid-router/tests/Matches.test.tsx @@ -154,6 +154,41 @@ test('should show pendingComponent of root route', async () => { expect(await rendered.findByTestId('root-content')).toBeInTheDocument() }) +test('useMatchRoute matches typed params from routes with custom parse and stringify functions', async () => { + const rootRoute = createRootRoute() + const invoiceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: ({ invoiceId }: { invoiceId: string }) => ({ + invoiceId: Number(invoiceId), + }), + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + component: () => { + const matchRoute = useMatchRoute() + const match = matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }) + + return
{JSON.stringify(match())}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + }) + + render(() => ) + + expect(await screen.findByTestId('match')).toHaveTextContent( + '{"invoiceId":123}', + ) +}) + test('MatchRoute updates for navigation and reactive params changes', async () => { function Layout() { const [postId, setPostId] = createSignal('123') diff --git a/packages/vue-router/tests/Matches.test.tsx b/packages/vue-router/tests/Matches.test.tsx index 4e9e9afa51..a45a4f9146 100644 --- a/packages/vue-router/tests/Matches.test.tsx +++ b/packages/vue-router/tests/Matches.test.tsx @@ -158,6 +158,41 @@ test('should show pendingComponent of root route', async () => { expect(await rendered.findByTestId('root-content')).toBeInTheDocument() }) +test('useMatchRoute matches typed params from routes with custom parse and stringify functions', async () => { + const rootRoute = createRootRoute() + const invoiceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: ({ invoiceId }: { invoiceId: string }) => ({ + invoiceId: Number(invoiceId), + }), + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + component: () => { + const matchRoute = useMatchRoute() + const match = matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }) + + return
{JSON.stringify(match.value)}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + }) + + render() + + expect(await screen.findByTestId('match')).toHaveTextContent( + '{"invoiceId":123}', + ) +}) + describe('matching on different param types', () => { const testCases = [ { From 24d14d8ac4e3e841d4c71178c552b582f6b25a8b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:01:44 +0000 Subject: [PATCH 04/18] ci: apply automated fixes --- packages/router-core/tests/match-route.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts index 22914cb650..fcb384f8b4 100644 --- a/packages/router-core/tests/match-route.test.ts +++ b/packages/router-core/tests/match-route.test.ts @@ -141,9 +141,7 @@ describe('matchRoute', () => { path: '/projects/$projectId', params: { parse: (params: { projectId: string }) => { - if ( - typeof (params as Record).orgId !== 'number' - ) { + if (typeof (params as Record).orgId !== 'number') { return false } return { projectId: Number(params.projectId) } @@ -151,9 +149,7 @@ describe('matchRoute', () => { }, }) const router = createTestRouter({ - routeTree: rootRoute.addChildren([ - orgRoute.addChildren([projectRoute]), - ]), + routeTree: rootRoute.addChildren([orgRoute.addChildren([projectRoute])]), history: createMemoryHistory({ initialEntries: ['/orgs/42/projects/7'], }), @@ -250,5 +246,4 @@ describe('matchRoute', () => { }), ).toEqual({ invoiceId: 123 }) }) - }) From 94c8373ec2ccfed8310cdf5a8eda68a69c46d794 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 10 Jul 2026 17:03:04 -0700 Subject: [PATCH 05/18] changeset --- .changeset/plenty-jokes-listen.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/plenty-jokes-listen.md diff --git a/.changeset/plenty-jokes-listen.md b/.changeset/plenty-jokes-listen.md new file mode 100644 index 0000000000..269f703604 --- /dev/null +++ b/.changeset/plenty-jokes-listen.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Fix matchRoute matching for custom parsed path parameters From 24a76d995b43ea64aab3aeb0be5666136599cd1a Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 11 Jul 2026 08:48:16 -0700 Subject: [PATCH 06/18] updates as per comments --- packages/react-router/tests/Matches.test.tsx | 21 ++++++++------ packages/router-core/src/router.ts | 27 ++++++++++-------- .../router-core/tests/match-route.test.ts | 28 +++++++++++++++++++ 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index e756a567cd..c668ab46a3 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -147,6 +147,17 @@ test('should show pendingComponent of root route', async () => { test('useMatchRoute matches typed params from routes with custom parse and stringify functions', async () => { const rootRoute = createRootRoute() + + function InvoiceComponent() { + const matchRoute = useMatchRoute() + const match = matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }) + + return
{JSON.stringify(match)}
+ } + const invoiceRoute = createRoute({ getParentRoute: () => rootRoute, path: '/invoices/$invoiceId', @@ -158,15 +169,7 @@ test('useMatchRoute matches typed params from routes with custom parse and strin invoiceId: String(invoiceId), }), }, - component: () => { - const matchRoute = useMatchRoute() - const match = matchRoute({ - to: '/invoices/$invoiceId', - params: { invoiceId: 123 }, - }) - - return
{JSON.stringify(match)}
- }, + component: InvoiceComponent, }) const router = createRouter({ routeTree: rootRoute.addChildren([invoiceRoute]), diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index b31d437efb..c6ebb491f1 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2994,24 +2994,29 @@ export class RouterCore< return false } - const params = Object.assign(Object.create(null), match.rawParams) const destinationPath = trimPathRight(next.pathname) const destinationRoute = this.routesByPath[ destinationPath as keyof typeof this.routesByPath ] as AnyRoute | undefined + if (destinationRoute) { + const routeMatch = findRouteMatch( + baseLocation.pathname, + this.processedTree, + opts?.fuzzy ?? false, + ) + if ( + routeMatch && + !routeMatch.branch.some((route) => route.id === destinationRoute.id) + ) { + return false + } + } + + const params = Object.assign(Object.create(null), match.rawParams) if (destinationRoute) { try { for (const matchedRoute of this.getRouteBranch(destinationRoute)) { - const parseParams = - matchedRoute.options.params?.parse ?? - matchedRoute.options.parseParams - if (parseParams) { - const parsedParams = parseParams(params as Record) - if (parsedParams === false) { - return false - } - Object.assign(params, parsedParams) - } + extractStrictParams(matchedRoute, params) } } catch { return false diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts index fcb384f8b4..bbe49d20a5 100644 --- a/packages/router-core/tests/match-route.test.ts +++ b/packages/router-core/tests/match-route.test.ts @@ -52,6 +52,34 @@ describe('matchRoute', () => { ).toBe(false) }) + it('does not match a lower-priority route', async () => { + const rootRoute = new BaseRootRoute({}) + const postRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + params: { + parse: ({ postId }: { postId: string }) => ({ postId }), + }, + }) + const editRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts/edit', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postRoute, editRoute]), + history: createMemoryHistory({ initialEntries: ['/posts/edit'] }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/posts/$postId', + params: { postId: 'edit' }, + }), + ).toBe(false) + }) + it('does not match params rejected by the route parser', async () => { const router = createInvoiceRouter('/invoices/not-a-number') From 06a3de5c6172b99aa896816bd2004fbc40699cd8 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 11 Jul 2026 13:07:11 -0700 Subject: [PATCH 07/18] refactor: replace findSingleMatch with findRouteMatch for improved path matching --- .../router-core/src/new-process-route-tree.ts | 204 ++++++++---- packages/router-core/src/router.ts | 119 +++++-- .../router-core/tests/match-by-path.test.ts | 208 ------------ .../router-core/tests/match-route.test.ts | 301 +++++++++++++++++- .../tests/optional-path-params-clean.test.ts | 28 +- .../tests/optional-path-params.test.ts | 28 +- packages/router-core/tests/path.test.ts | 28 +- 7 files changed, 582 insertions(+), 334 deletions(-) delete mode 100644 packages/router-core/tests/match-by-path.test.ts diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 6978b071ce..299e55250e 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -641,14 +641,11 @@ type RouteLike = { export type ProcessedTree< TTree extends Extract, TFlat extends Extract, - TSingle extends Extract, > = { /** a representation of the `routeTree` as a segment tree */ segmentTree: AnySegmentNode /** a mini route tree generated from the flat `routeMasks` list */ masksTree: AnySegmentNode | null - /** @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree */ - singleCache: LRUCache> /** a cache of route matches from the `segmentTree` */ matchCache: LRUCache | null> /** a cache of route matches from the `masksTree` */ @@ -657,10 +654,7 @@ export type ProcessedTree< export function processRouteMasks< TRouteLike extends Extract, ->( - routeList: Array, - processedTree: ProcessedTree, -) { +>(routeList: Array, processedTree: ProcessedTree) { const segmentTree = createStaticNode('/') const data = new Uint16Array(6) for (const route of routeList) { @@ -681,7 +675,7 @@ export function findFlatMatch>( /** The path to match. */ path: string, /** The `processedTree` returned by the initial `processRouteTree` call. */ - processedTree: ProcessedTree, + processedTree: ProcessedTree, ) { path ||= '/' const cached = processedTree.flatCache!.get(path) @@ -691,35 +685,15 @@ export function findFlatMatch>( return result } -/** - * @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree - */ -export function findSingleMatch( - from: string, - caseSensitive: boolean, - fuzzy: boolean, - path: string, - processedTree: ProcessedTree, -) { - from ||= '/' - path ||= '/' - const key = caseSensitive ? `case\0${from}` : from - let tree = processedTree.singleCache.get(key) - if (!tree) { - // single flat routes (router.matchRoute) are not eagerly processed, - // if we haven't seen this route before, process it now - tree = createStaticNode<{ from: string }>('/') - const data = new Uint16Array(6) - parseSegments(caseSensitive, data, { from }, 1, tree, 0) - processedTree.singleCache.set(key, tree) - } - return findMatch(path, tree, fuzzy) -} - type RouteMatch> = { route: T rawParams: Record branch: ReadonlyArray + matchData: ReadonlyArray<{ + rawParams?: Record + pathnameEnd: number + caseSensitive: boolean + }> } export function findRouteMatch< @@ -728,7 +702,7 @@ export function findRouteMatch< /** The path to match against the route tree. */ path: string, /** The `processedTree` returned by the initial `processRouteTree` call. */ - processedTree: ProcessedTree, + processedTree: ProcessedTree, /** If `true`, allows fuzzy matching (partial matches), i.e. which node in the tree would have been an exact match if the `path` had been shorter? */ fuzzy = false, ): RouteMatch | null { @@ -743,6 +717,7 @@ export function findRouteMatch< path, processedTree.segmentTree, fuzzy, + true, ) as RouteMatch | null } catch (err) { if (err instanceof URIError) { @@ -766,7 +741,7 @@ export interface ProcessRouteTreeResult< TRouteLike extends Extract & { id: string }, > { /** Should be considered a black box, needs to be provided to all matching functions in this module. */ - processedTree: ProcessedTree + processedTree: ProcessedTree /** A lookup map of routes by their unique IDs. */ routesById: Record /** A lookup map of routes by their trimmed full paths. */ @@ -817,9 +792,8 @@ export function processRouteTree< index++ }) sortTreeNodes(segmentTree) - const processedTree: ProcessedTree = { + const processedTree: ProcessedTree = { segmentTree, - singleCache: createLRUCache>(1000), matchCache: createLRUCache | null>(1000), flatCache: null, masksTree: null, @@ -835,6 +809,7 @@ function findMatch( path: string, segmentTree: AnySegmentNode, fuzzy = false, + includeRouteParams = false, ): { route: T /** @@ -842,14 +817,34 @@ function findMatch( * This will be the exhaustive list of all params defined in the route's path. */ rawParams: Record + matchData?: ReadonlyArray<{ + rawParams?: Record + pathnameEnd: number + caseSensitive: boolean + }> } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null - const [rawParams] = extractParams(path, parts, leaf) + const routeBranch = includeRouteParams + ? buildRouteBranch(leaf.node.route!) + : undefined + const leafRawParams = (leaf as { rawParams?: Record }) + .rawParams + const [rawParams, , , matchData] = extractParams( + path, + parts, + { + node: leaf.node, + skipped: leaf.skipped, + rawParams: leafRawParams, + }, + routeBranch, + ) return { route: leaf.node.route!, rawParams, + matchData, } } @@ -860,6 +855,12 @@ type ParamExtractionState = { segment: number } +type RouteMatchData = { + rawParams?: Record + pathnameEnd: number + caseSensitive: boolean +} + /** * This function is "resumable": * - the `leaf` input can contain `extract` and `rawParams` properties from a previous `extractParams` call @@ -876,10 +877,29 @@ function extractParams( extract?: ParamExtractionState rawParams?: Record }, -): [rawParams: Record, state: ParamExtractionState] { + routeBranch?: ReadonlyArray, +): [ + rawParams: Record, + state: ParamExtractionState, + extractedParams: Record, + matchData?: Array<{ + rawParams?: Record + pathnameEnd: number + caseSensitive: boolean + }>, +] { const list = buildBranch(leaf.node) let nodeParts: Array | null = null - const rawParams: Record = Object.create(null) + const rawParams: Record = Object.assign( + Object.create(null), + leaf.rawParams, + ) + const extractedParams: Record = Object.create(null) + const matchData: Array | undefined = routeBranch + ? [] + : undefined + let routeIndex = 0 + let routeParams: Record | undefined /** which segment of the path we're currently processing */ let partIndex = leaf.extract?.part ?? 0 /** which node of the route tree branch we're currently processing */ @@ -888,6 +908,25 @@ function extractParams( let pathIndex = leaf.extract?.path ?? 0 /** which fullPath segment we're currently processing */ let segmentCount = leaf.extract?.segment ?? 0 + let caseSensitive = true + const recordRouteMatches = (pathnameEnd: number) => { + while (routeBranch && routeIndex < routeBranch.length) { + const route = routeBranch[routeIndex]! + const routePath = route.fullPath ?? route.from + const routeSegment = + routePath === '/' ? 0 : routePath.split('/').length - 1 + if (routeSegment > segmentCount) { + break + } + matchData!.push({ + rawParams: routeParams, + pathnameEnd, + caseSensitive, + }) + routeParams = undefined + routeIndex++ + } + } for ( ; nodeIndex < list.length; @@ -895,46 +934,59 @@ function extractParams( ) { const node = list[nodeIndex]! // index nodes are terminating nodes, nothing to extract, just leave - if (node.kind === SEGMENT_TYPE_INDEX) break + if (node.kind === SEGMENT_TYPE_INDEX) { + recordRouteMatches(pathIndex) + break + } // pathless nodes do not consume a path segment if (node.kind === SEGMENT_TYPE_PATHLESS) { segmentCount-- partIndex-- pathIndex-- + recordRouteMatches(pathIndex) continue } const part = parts[partIndex] const currentPathIndex = pathIndex if (part) pathIndex += part.length - if (node.kind === SEGMENT_TYPE_PARAM) { - nodeParts ??= leaf.node.fullPath.split('/') - const nodePart = nodeParts[segmentCount]! + nodeParts ??= leaf.node.fullPath.split('/') + const nodePart = nodeParts[segmentCount]! + if (node.kind === SEGMENT_TYPE_PATHNAME) { + caseSensitive &&= part === nodePart + } else if (node.kind === SEGMENT_TYPE_PARAM) { const preLength = node.prefix?.length ?? 0 // we can't rely on the presence of prefix/suffix to know whether it's curly-braced or not, because `/{$param}/` is valid, but has no prefix/suffix const isCurlyBraced = nodePart.charCodeAt(preLength) === 123 // '{' // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node if (isCurlyBraced) { const sufLength = node.suffix?.length ?? 0 + caseSensitive &&= + part!.startsWith(nodePart.substring(0, preLength)) && + part!.endsWith(nodePart.substring(nodePart.length - sufLength)) const name = nodePart.substring( preLength + 2, nodePart.length - sufLength - 1, ) const value = part!.substring(preLength, part!.length - sufLength) - rawParams[name] = decodeURIComponent(value) + rawParams[name] = extractedParams[name] = decodeURIComponent(value) + ;(routeParams ??= Object.create(null))[name] = rawParams[name]! } else { const name = nodePart.substring(1) - rawParams[name] = decodeURIComponent(part!) + rawParams[name] = extractedParams[name] = decodeURIComponent(part!) + ;(routeParams ??= Object.create(null))[name] = rawParams[name]! } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { if (leaf.skipped & (1 << nodeIndex)) { + recordRouteMatches(currentPathIndex) partIndex-- // stay on the same part pathIndex = currentPathIndex - 1 // undo pathIndex advancement; -1 to account for loop increment continue } - nodeParts ??= leaf.node.fullPath.split('/') - const nodePart = nodeParts[segmentCount]! const preLength = node.prefix?.length ?? 0 const sufLength = node.suffix?.length ?? 0 + caseSensitive &&= + part!.startsWith(nodePart.substring(0, preLength)) && + part!.endsWith(nodePart.substring(nodePart.length - sufLength)) const name = nodePart.substring( preLength + 3, nodePart.length - sufLength - 1, @@ -943,21 +995,34 @@ function extractParams( node.suffix || node.prefix ? part!.substring(preLength, part!.length - sufLength) : part - if (value) rawParams[name] = decodeURIComponent(value) + if (value) { + rawParams[name] = extractedParams[name] = decodeURIComponent(value) + ;(routeParams ??= Object.create(null))[name] = rawParams[name]! + } } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node + const preLength = n.prefix?.length ?? 0 + const sufLength = n.suffix?.length ?? 0 + const remainder = path.substring(currentPathIndex) + caseSensitive &&= + remainder.startsWith(nodePart.substring(0, preLength)) && + remainder.endsWith(nodePart.substring(nodePart.length - sufLength)) const value = path.substring( - currentPathIndex + (n.prefix?.length ?? 0), - path.length - (n.suffix?.length ?? 0), + currentPathIndex + preLength, + path.length - sufLength, ) const splat = decodeURIComponent(value) // TODO: Deprecate * - rawParams['*'] = splat - rawParams._splat = splat + rawParams['*'] = extractedParams['*'] = splat + rawParams._splat = extractedParams._splat = splat + const wildcardParams = (routeParams ??= Object.create(null)) + wildcardParams['*'] = splat + wildcardParams._splat = splat + recordRouteMatches(path.length) break } + recordRouteMatches(pathIndex) } - if (leaf.rawParams) Object.assign(rawParams, leaf.rawParams) return [ rawParams, { @@ -966,6 +1031,8 @@ function extractParams( path: pathIndex, segment: segmentCount, }, + extractedParams, + matchData, ] } @@ -1009,6 +1076,7 @@ type MatchStackFrame = { extract?: ParamExtractionState /** intermediary params from param extraction */ rawParams?: Record + parsedParams?: Record } function getNodeMatch( @@ -1056,7 +1124,7 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! const { node, index, skipped, depth, statics, dynamics, optionals } = frame - let { extract, rawParams } = frame + let { extract, rawParams, parsedParams } = frame // Wildcard candidates are pushed speculatively as fallbacks in case a // higher-priority wildcard later fails params.parse. If a better wildcard @@ -1075,6 +1143,7 @@ function getNodeMatch( if (!result) continue rawParams = frame.rawParams extract = frame.extract + parsedParams = frame.parsedParams } // In fuzzy mode, track the best partial match we've found so far @@ -1118,6 +1187,7 @@ function getNodeMatch( optionals, extract, rawParams, + parsedParams, } let indexValid = true if (node.index.parse) { @@ -1171,6 +1241,7 @@ function getNodeMatch( optionals, extract, rawParams, + parsedParams, }) } } @@ -1192,6 +1263,7 @@ function getNodeMatch( optionals, extract, rawParams, + parsedParams, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -1215,6 +1287,7 @@ function getNodeMatch( optionals: optionals + segmentScore(partsLength, index), extract, rawParams, + parsedParams, }) } } @@ -1242,6 +1315,7 @@ function getNodeMatch( optionals, extract, rawParams, + parsedParams, }) } } @@ -1262,6 +1336,7 @@ function getNodeMatch( optionals, extract, rawParams, + parsedParams, }) } } @@ -1280,6 +1355,7 @@ function getNodeMatch( optionals, extract, rawParams, + parsedParams, }) } } @@ -1299,6 +1375,7 @@ function getNodeMatch( optionals, extract, rawParams, + parsedParams, }) } } @@ -1340,9 +1417,10 @@ function validateParseParams( ) { let rawParams: Record let state: ParamExtractionState + let extractedParams: Record try { - ;[rawParams, state] = extractParams(path, parts, frame) + ;[rawParams, state, extractedParams] = extractParams(path, parts, frame) } catch { return null } @@ -1353,7 +1431,19 @@ function validateParseParams( if (!frame.node.parse) return true try { - if (frame.node.parse(rawParams) === false) return null + const params = Object.assign( + Object.create(null), + rawParams, + frame.parsedParams, + extractedParams, + ) + const result = frame.node.parse(params as Record) + if (result === false) return null + frame.parsedParams = Object.assign( + Object.create(null), + frame.parsedParams, + result, + ) } catch { // Thrown parse errors should be surfaced on the selected match by // extractStrictParams, not used as fallback route selection. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index c6ebb491f1..ecc41e395c 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -18,7 +18,6 @@ import { buildRouteBranch, findFlatMatch, findRouteMatch, - findSingleMatch, processRouteMasks, processRouteTree, } from './new-process-route-tree' @@ -26,6 +25,7 @@ import { cleanPath, compileDecodeCharMap, interpolatePath, + removeTrailingSlash, resolvePath, trimPath, trimPathRight, @@ -1011,7 +1011,7 @@ export class RouterCore< routeTree!: TRouteTree routesById!: RoutesById routesByPath!: RoutesByPath - processedTree!: ProcessedTree + processedTree!: ProcessedTree resolvePathCache!: LRUCache private routeBranchCache = new WeakMap>() private lightweightCache = new WeakMap< @@ -2982,44 +2982,99 @@ export class RouterCore< ? this.latestLocation : this.stores.resolvedLocation.get() || this.stores.location.get() - const match = findSingleMatch( - next.pathname, - opts?.caseSensitive ?? false, - opts?.fuzzy ?? false, - baseLocation.pathname, - this.processedTree, - ) + const destinationPath = trimPathRight(next.pathname) + const requestedRoute = (this.routesByPath[ + destinationPath as keyof typeof this.routesByPath + ] ?? + (destinationPath === '/' ? this.routesById[rootRouteId] : undefined)) as + | AnyRoute + | undefined + if (!requestedRoute) { + return false + } + + const parentRoute = requestedRoute.parentRoute + const destinationRoute: AnyRoute = + opts?.fuzzy && + requestedRoute.path === '/' && + parentRoute && + trimPathRight(parentRoute.fullPath) === + trimPathRight(requestedRoute.fullPath) + ? parentRoute + : requestedRoute + + const pathname = opts?.fuzzy + ? baseLocation.pathname + : removeTrailingSlash(baseLocation.pathname, this.basepath) + + const match = + findRouteMatch(pathname, this.processedTree, opts?.fuzzy ?? false) ?? + (destinationRoute.id === rootRouteId && pathname === '/' + ? { + route: destinationRoute, + rawParams: Object.create(null), + branch: [destinationRoute], + matchData: [ + { + rawParams: undefined, + pathnameEnd: 1, + caseSensitive: true, + }, + ], + } + : null) if (!match) { return false } - const destinationPath = trimPathRight(next.pathname) - const destinationRoute = this.routesByPath[ - destinationPath as keyof typeof this.routesByPath - ] as AnyRoute | undefined - if (destinationRoute) { - const routeMatch = findRouteMatch( - baseLocation.pathname, - this.processedTree, - opts?.fuzzy ?? false, - ) - if ( - routeMatch && - !routeMatch.branch.some((route) => route.id === destinationRoute.id) - ) { - return false + const matchesDestination = opts?.fuzzy + ? match.branch.some((route) => route.id === destinationRoute.id) + : match.route.id === destinationRoute.id + if (!matchesDestination) { + return false + } + + const destinationIndex = match.branch.findIndex( + (route) => route.id === destinationRoute.id, + ) + const destinationMatchData = match.matchData[destinationIndex]! + if (opts?.caseSensitive && !destinationMatchData.caseSensitive) { + return false + } + + const params = Object.create(null) + try { + for (const [routeIndex, matchedRoute] of this.getRouteBranch( + destinationRoute, + ).entries()) { + const { usedParams } = interpolatePath({ + path: matchedRoute.path ?? '', + params: match.matchData[routeIndex]?.rawParams ?? {}, + decoder: this.pathParamsDecoder, + server: this.isServer, + }) + Object.assign(params, usedParams) + extractStrictParams(matchedRoute, params) } + } catch { + return false } - const params = Object.assign(Object.create(null), match.rawParams) - if (destinationRoute) { - try { - for (const matchedRoute of this.getRouteBranch(destinationRoute)) { - extractStrictParams(matchedRoute, params) + if (opts?.fuzzy) { + const matchedPathname = trimPathRight( + baseLocation.pathname.slice(0, destinationMatchData.pathnameEnd) || '/', + ) + const remainder = baseLocation.pathname.slice(matchedPathname.length) + if (remainder) { + try { + params['**'] = + remainder === '/' + ? remainder + : decodeURIComponent(remainder.replace(/^\/+/, '')) + } catch { + return false } - } catch { - return false } } @@ -3136,7 +3191,7 @@ export function getMatchedRoutes({ }: { pathname: string routesById: Record - processedTree: ProcessedTree + processedTree: ProcessedTree }) { const routeParams: Record = Object.create(null) const trimmedPath = trimPathRight(pathname) diff --git a/packages/router-core/tests/match-by-path.test.ts b/packages/router-core/tests/match-by-path.test.ts deleted file mode 100644 index f742c22df9..0000000000 --- a/packages/router-core/tests/match-by-path.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - findSingleMatch, - processRouteTree, -} from '../src/new-process-route-tree' - -const { processedTree } = processRouteTree({ - id: '__root__', - isRoot: true, - fullPath: '/', - path: '/', -}) - -describe('default path matching', () => { - it.each([ - ['', '', {}], - ['/', '', {}], - ['', '/', {}], - ['/', '/', {}], - ['/', '/', {}], - ['/a', '/a', {}], - ['/a/b', '/a/b', {}], - ['/a', '/a/', {}], - ['/a/', '/a/', {}], - ['/a/', '/a', undefined], - ['/b', '/a', undefined], - ])('static %s %s => %s', (path, pattern, result) => { - const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/1', '/a/$id', { id: '1' }], - ['/a/1/b', '/a/$id/b', { id: '1' }], - ['/a/1/b/2', '/a/$id/b/$other', { id: '1', other: '2' }], - ['/a/1_/b/2', '/a/$id/b/$other', { id: '1_', other: '2' }], - ['/a/1/b/2', '/a/$id/b/$id', { id: '2' }], - ])('params %s => %s', (path, pattern, result) => { - const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it('params support more than alphanumeric characters', () => { - // in the value: basically everything except / and % - const anyValueResult = findSingleMatch( - '/a/$id', - false, - false, - '/a/@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', - processedTree, - ) - expect(anyValueResult?.rawParams).toEqual({ - id: '@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', - }) - // in the key: basically everything except / and % and $ - const anyKeyResult = findSingleMatch( - '/a/$@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', - false, - false, - '/a/1', - processedTree, - ) - expect(anyKeyResult?.rawParams).toEqual({ - '@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{': '1', - }) - }) - - it.each([ - ['/a/1', '/a/{-$id}', { id: '1' }], - ['/a', '/a/{-$id}', {}], - ['/a/1/b', '/a/{-$id}/b', { id: '1' }], - ['/a/b', '/a/{-$id}/b', {}], - ['/a/1/b/2', '/a/{-$id}/b/{-$other}', { id: '1', other: '2' }], - ['/a/b/2', '/a/{-$id}/b/{-$other}', { other: '2' }], - ['/a/1/b', '/a/{-$id}/b/{-$other}', { id: '1' }], - ['/a/b', '/a/{-$id}/b/{-$other}', {}], - ['/a/1/b/2', '/a/{-$id}/b/{-$id}', { id: '2' }], - ])('optional %s => %s', (path, pattern, result) => { - const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/b/c', '/a/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/a/$', { _splat: '', '*': '' }], - ['/a', '/a/$', { _splat: '', '*': '' }], - ['/a/b/c', '/a/$/foo', { _splat: 'b/c', '*': 'b/c' }], - ])('wildcard %s => %s', (path, pattern, result) => { - const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) -}) - -describe('case insensitive path matching', () => { - it.each([ - ['', '', '', {}], - ['', '/', '', {}], - ['', '', '/', {}], - ['', '/', '/', {}], - ['/', '/', '/', {}], - ['/', '/a', '/A', {}], - ['/', '/a/b', '/A/B', {}], - ['/', '/a', '/A/', {}], - ['/', '/a/', '/A/', {}], - ['/', '/a/', '/A', undefined], - ['/', '/b', '/A', undefined], - ])('static %s %s => %s', (base, path, pattern, result) => { - const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/1', '/A/$id', { id: '1' }], - ['/a/1/b', '/A/$id/B', { id: '1' }], - ['/a/1/b/2', '/A/$id/B/$other', { id: '1', other: '2' }], - ['/a/1/b/2', '/A/$id/B/$id', { id: '2' }], - ])('params %s => %s', (path, pattern, result) => { - const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/1', '/A/{-$id}', { id: '1' }], - ['/a', '/A/{-$id}', {}], - ['/a/1/b', '/A/{-$id}/B', { id: '1' }], - ['/a/1_/b', '/A/{-$id}/B', { id: '1_' }], - // ['/a/b', '/A/{-$id}/B', {}], - ['/a/1/b/2', '/A/{-$id}/B/{-$other}', { id: '1', other: '2' }], - // ['/a/b/2', '/A/{-$id}/B/{-$other}', { other: '2' }], - ['/a/1/b', '/A/{-$id}/B/{-$other}', { id: '1' }], - // ['/a/b', '/A/{-$id}/B/{-$other}', {}], - ['/a/1/b/2', '/A/{-$id}/B/{-$id}', { id: '2' }], - ['/a/1/b/2_', '/A/{-$id}/B/{-$id}', { id: '2_' }], - ])('optional %s => %s', (path, pattern, result) => { - const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/b/c', '/A/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/A/$', { _splat: '', '*': '' }], - ['/a', '/A/$', { _splat: '', '*': '' }], - ['/a/b/c', '/A/$/foo', { _splat: 'b/c', '*': 'b/c' }], - ])('wildcard %s => %s', (path, pattern, result) => { - const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) -}) - -describe('fuzzy path matching', () => { - it.each([ - ['', '', '', {}], - ['', '/', '', {}], - ['', '', '/', {}], - ['', '/', '/', {}], - ['/', '/', '/', {}], - ['/', '/a', '/a', {}], - ['/', '/a', '/a/', {}], - ['/', '/a/', '/a/', {}], - ['/', '/a/', '/a', { '**': '/' }], - ['/', '/a/b', '/a/b', {}], - ['/', '/a/b', '/a', { '**': 'b' }], - ['/', '/a/b/', '/a', { '**': 'b/' }], - ['/', '/a/b/c', '/a', { '**': 'b/c' }], - ['/', '/a', '/a/b', undefined], - ['/', '/b', '/a', undefined], - ['/', '/a', '/b', undefined], - ])('static %s %s => %s', (base, path, pattern, result) => { - const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/1', '/a/$id', { id: '1' }], - ['/a/1/b', '/a/$id', { id: '1', '**': 'b' }], - ['/a/1/', '/a/$id/', { id: '1' }], - ['/a/1/b/2', '/a/$id/b/$other', { id: '1', other: '2' }], - ['/a/1/b/2/c', '/a/$id/b/$other', { id: '1', other: '2', '**': 'c' }], - ])('params %s => %s', (path, pattern, result) => { - const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/1', '/a/{-$id}', { id: '1' }], - ['/a', '/a/{-$id}', {}], - ['/a/1/b', '/a/{-$id}', { '**': 'b', id: '1' }], - ['/a/1/b', '/a/{-$id}/b', { id: '1' }], - ['/a/b', '/a/{-$id}/b', {}], - ['/a/b/c', '/a/{-$id}/b', { '**': 'c' }], - ['/a/b', '/a/{-$id}/b/{-$other}', {}], - ['/a/b/2/d', '/a/{-$id}/b/{-$other}', { other: '2', '**': 'd' }], - ['/a/1/b/2/c', '/a/{-$id}/b/{-$other}', { id: '1', other: '2', '**': 'c' }], - ])('optional %s => %s', (path, pattern, result) => { - const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/b/c', '/a/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/a/$', { _splat: '', '*': '' }], - ['/a', '/a/$', { _splat: '', '*': '' }], - ['/a/b/c/d', '/a/$/foo', { _splat: 'b/c/d', '*': 'b/c/d' }], - ])('wildcard %s => %s', (path, pattern, result) => { - const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) -}) diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts index bbe49d20a5..0777374505 100644 --- a/packages/router-core/tests/match-route.test.ts +++ b/packages/router-core/tests/match-route.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter } from './routerTestUtils' @@ -93,6 +93,39 @@ describe('matchRoute', () => { ).toBe(false) }) + it('returns parsed params on repeated calls', async () => { + const parse = vi.fn(({ invoiceId }: { invoiceId: string }) => ({ + invoiceId: Number(invoiceId), + })) + const rootRoute = new BaseRootRoute({}) + const invoiceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { parse }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + }) + + await router.load() + parse.mockClear() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toEqual({ invoiceId: 123 }) + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toEqual({ invoiceId: 123 }) + expect(parse).toHaveBeenCalledWith({ invoiceId: '123' }) + }) + it('does not throw parser errors while checking a match', async () => { const rootRoute = new BaseRootRoute({}) const invoiceRoute = new BaseRoute({ @@ -193,7 +226,7 @@ describe('matchRoute', () => { ).toEqual({ orgId: 42, projectId: 7 }) }) - it('keeps generic route templates working with string params', async () => { + it('does not match a template absent from the route tree', async () => { const router = createInvoiceRouter() await router.load() @@ -203,7 +236,249 @@ describe('matchRoute', () => { to: '/invoices/$id', params: { id: '123' }, } as any), - ).toEqual({ id: '123' }) + ).toBe(false) + }) + + it('does not match an absent static destination', async () => { + const router = createInvoiceRouter() + + await router.load() + + expect(router.matchRoute({ to: '/missing' } as any)).toBe(false) + }) + + it('only matches an active ancestor when fuzzy matching', async () => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const postRoute = new BaseRoute({ + getParentRoute: () => postsRoute, + path: '/$postId', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postsRoute.addChildren([postRoute])]), + history: createMemoryHistory({ initialEntries: ['/posts/123'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/posts' })).toBe(false) + expect(router.matchRoute({ to: '/posts' }, { fuzzy: true })).toEqual({ + '**': '123', + }) + }) + + it('matches the root route without an index child', async () => { + const rootRoute = new BaseRootRoute({}) + const router = createTestRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/' })).toEqual({}) + }) + + it('matches encoded params without corrupting fuzzy remainders', async () => { + const rootRoute = new BaseRootRoute({}) + const itemRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/items/$itemId', + }) + const exactRouter = createTestRouter({ + routeTree: rootRoute.addChildren([itemRoute]), + history: createMemoryHistory({ + initialEntries: ['/items/hello%20world'], + }), + }) + + await exactRouter.load() + + expect( + exactRouter.matchRoute( + { + to: '/items/$itemId', + params: { itemId: 'hello world' }, + }, + { caseSensitive: true }, + ), + ).toEqual({ itemId: 'hello world' }) + + const fuzzyRouter = createTestRouter({ + routeTree: rootRoute.addChildren([itemRoute]), + history: createMemoryHistory({ + initialEntries: ['/items/hello%20world/details'], + }), + }) + + await fuzzyRouter.load() + + expect( + fuzzyRouter.matchRoute( + { + to: '/items/$itemId', + params: { itemId: 'hello world' }, + }, + { fuzzy: true }, + ), + ).toEqual({ itemId: 'hello world', '**': 'details' }) + }) + + it('keeps ancestor params when a descendant reuses the param name', async () => { + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$id', + }) + const projectRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/projects/$id', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([orgRoute.addChildren([projectRoute])]), + history: createMemoryHistory({ + initialEntries: ['/orgs/one/projects/two'], + }), + }) + + await router.load() + + expect( + router.matchRoute( + { to: '/orgs/$id', params: { id: 'one' } }, + { fuzzy: true }, + ), + ).toEqual({ id: 'one', '**': 'projects/two' }) + }) + + it('passes a reused child param name to the child parser', async () => { + const childParse = vi.fn(({ id }: { id: string }) => ({ + id: `child:${id}`, + })) + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$id', + params: { + parse: ({ id }: { id: string }) => ({ id: `parent:${id}` }), + }, + }) + const projectRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/projects/$id', + params: { parse: childParse }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([orgRoute.addChildren([projectRoute])]), + history: createMemoryHistory({ + initialEntries: ['/orgs/one/projects/two'], + }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/orgs/$id/projects/$id', + params: { id: 'child:two' }, + }), + ).toEqual({ id: 'child:two' }) + expect(childParse).toHaveBeenCalledWith({ id: 'two' }) + }) + + it('returns false for a malformed fuzzy remainder', async () => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const malformedRoute = new BaseRoute({ + getParentRoute: () => postsRoute, + path: '/%ZZ', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + postsRoute.addChildren([malformedRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/posts/%ZZ'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/posts' }, { fuzzy: true })).toBe(false) + }) + + it('fuzzy matches a layout route that has an index child', async () => { + const rootRoute = new BaseRootRoute({}) + const dashboardRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + }) + const dashboardIndexRoute = new BaseRoute({ + getParentRoute: () => dashboardRoute, + path: '/', + }) + const detailsRoute = new BaseRoute({ + getParentRoute: () => dashboardRoute, + path: '/details', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + dashboardRoute.addChildren([dashboardIndexRoute, detailsRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/dashboard/details'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/dashboard' }, { fuzzy: true })).toEqual({ + '**': 'details', + }) + }) + + it('respects the caseSensitive matching option', async () => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/Posts', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + history: createMemoryHistory({ initialEntries: ['/posts'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/Posts' })).toEqual({}) + expect(router.matchRoute({ to: '/Posts' }, { caseSensitive: true })).toBe( + false, + ) + }) + + it('checks case sensitivity against the selected sibling route', async () => { + const rootRoute = new BaseRootRoute({}) + const upperRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/Foo/a', + }) + const lowerRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo/b', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([upperRoute, lowerRoute]), + history: createMemoryHistory({ initialEntries: ['/FOO/b'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/foo/b' })).toEqual({}) + expect(router.matchRoute({ to: '/foo/b' }, { caseSensitive: true })).toBe( + false, + ) }) it('keeps fuzzy matching behavior with string params', async () => { @@ -274,4 +549,24 @@ describe('matchRoute', () => { }), ).toEqual({ invoiceId: 123 }) }) + + it.each(['never', 'always', 'preserve'] as const)( + 'matches a trailing slash with the %s policy', + async (trailingSlash) => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + trailingSlash, + history: createMemoryHistory({ initialEntries: ['/posts/'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/posts' })).toEqual({}) + }, + ) }) diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index e3a09c2cf2..dcc203c697 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -3,7 +3,7 @@ import { interpolatePath } from '../src/path' import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PATHNAME, - findSingleMatch, + findRouteMatch, parseSegment, processRouteTree, } from '../src/new-process-route-tree' @@ -138,22 +138,30 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { }) describe('matchPathname', () => { - const { processedTree } = processRouteTree({ - id: '__root__', - isRoot: true, - fullPath: '/', - path: '/', - }) const matchPathname = ( from: string, options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, ) => { - const match = findSingleMatch( - options.to, + const { processedTree } = processRouteTree( + { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: options.to, + fullPath: options.to, + path: options.to.slice(1), + }, + ], + }, options.caseSensitive ?? false, - options.fuzzy ?? false, + ) + const match = findRouteMatch( from, processedTree, + options.fuzzy ?? false, ) const result = match ? match.rawParams : undefined if (options.to && !result) return diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index b58c0b14af..7f610fdb6b 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -5,7 +5,7 @@ import { SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, - findSingleMatch, + findRouteMatch, parseSegment, processRouteTree, } from '../src/new-process-route-tree' @@ -349,23 +349,27 @@ describe('Optional Path Parameters', () => { }) }) - const { processedTree } = processRouteTree({ - id: '__root__', - isRoot: true, - fullPath: '/', - path: '/', - }) const matchPathname = ( from: string, options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, ) => { - const match = findSingleMatch( - options.to, + const { processedTree } = processRouteTree( + { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: options.to, + fullPath: options.to, + path: options.to.slice(1), + }, + ], + }, options.caseSensitive ?? false, - options.fuzzy ?? false, - from, - processedTree, ) + const match = findRouteMatch(from, processedTree, options.fuzzy ?? false) const result = match ? match.rawParams : undefined if (options.to && !result) return return result ?? {} diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 4e49412209..54c80fcf38 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -11,7 +11,7 @@ import { SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, - findSingleMatch, + findRouteMatch, parseSegment, processRouteTree, } from '../src/new-process-route-tree' @@ -663,23 +663,27 @@ describe.each([{ server: true }, { server: false }])( ) describe('matchPathname', () => { - const { processedTree } = processRouteTree({ - id: '__root__', - isRoot: true, - fullPath: '/', - path: '/', - }) const matchPathname = ( from: string, options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, ) => { - const match = findSingleMatch( - options.to, + const { processedTree } = processRouteTree( + { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: options.to, + fullPath: options.to, + path: options.to.slice(1), + }, + ], + }, options.caseSensitive ?? false, - options.fuzzy ?? false, - from, - processedTree, ) + const match = findRouteMatch(from, processedTree, options.fuzzy ?? false) const result = match ? match.rawParams : undefined if (options.to && !result) return return result ?? {} From df013479d0ca2c3db94bb377a332c19a989d6cad Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 11 Jul 2026 13:08:55 -0700 Subject: [PATCH 08/18] update changeset --- .changeset/plenty-jokes-listen.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/plenty-jokes-listen.md b/.changeset/plenty-jokes-listen.md index 269f703604..e70b44fe94 100644 --- a/.changeset/plenty-jokes-listen.md +++ b/.changeset/plenty-jokes-listen.md @@ -2,4 +2,4 @@ '@tanstack/router-core': patch --- -Fix matchRoute matching for custom parsed path parameters +Fix `matchRoute` to respect parsed path parameters, route precedence, and exact trailing-slash matching. From cbe350662a3f4dfc83eb47dd4da9484b78056fd9 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 11 Jul 2026 16:12:58 -0700 Subject: [PATCH 09/18] return parsed params from matchRoute --- .../router-core/src/new-process-route-tree.ts | 118 +++++++---- packages/router-core/src/router.ts | 53 ++--- .../router-core/tests/match-route.test.ts | 197 ++++++++++++++++-- 3 files changed, 281 insertions(+), 87 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 299e55250e..f0f847ffee 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -688,12 +688,10 @@ export function findFlatMatch>( type RouteMatch> = { route: T rawParams: Record + params: Record + paramsError: boolean branch: ReadonlyArray - matchData: ReadonlyArray<{ - rawParams?: Record - pathnameEnd: number - caseSensitive: boolean - }> + matchData: ReadonlyArray } export function findRouteMatch< @@ -727,7 +725,6 @@ export function findRouteMatch< } } - if (result) result.branch = buildRouteBranch(result.route) processedTree.matchCache.set(key, result) return result } @@ -817,11 +814,10 @@ function findMatch( * This will be the exhaustive list of all params defined in the route's path. */ rawParams: Record - matchData?: ReadonlyArray<{ - rawParams?: Record - pathnameEnd: number - caseSensitive: boolean - }> + params?: Record + paramsError?: boolean + branch?: ReadonlyArray + matchData?: ReadonlyArray } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) @@ -829,8 +825,7 @@ function findMatch( const routeBranch = includeRouteParams ? buildRouteBranch(leaf.node.route!) : undefined - const leafRawParams = (leaf as { rawParams?: Record }) - .rawParams + const leafRawParams = leaf.rawParams const [rawParams, , , matchData] = extractParams( path, parts, @@ -840,10 +835,15 @@ function findMatch( rawParams: leafRawParams, }, routeBranch, + leaf.parsedParamsState, ) + const params = matchData?.at(-1)?.[2] ?? rawParams return { route: leaf.node.route!, rawParams, + params, + paramsError: matchData?.at(-1)?.[3] ?? false, + branch: routeBranch, matchData, } } @@ -855,11 +855,12 @@ type ParamExtractionState = { segment: number } -type RouteMatchData = { - rawParams?: Record - pathnameEnd: number - caseSensitive: boolean -} +type RouteMatchData = readonly [ + pathnameEnd: number, + caseSensitive: boolean, + params: Record, + paramsError: boolean, +] /** * This function is "resumable": @@ -878,15 +879,12 @@ function extractParams( rawParams?: Record }, routeBranch?: ReadonlyArray, + parsedParamsState?: ParsedParamsState, ): [ rawParams: Record, state: ParamExtractionState, extractedParams: Record, - matchData?: Array<{ - rawParams?: Record - pathnameEnd: number - caseSensitive: boolean - }>, + matchData?: Array, ] { const list = buildBranch(leaf.node) let nodeParts: Array | null = null @@ -900,6 +898,9 @@ function extractParams( : undefined let routeIndex = 0 let routeParams: Record | undefined + const matchParams: Record = Object.create(null) + let appliedParsedParamsDepth = -1 + let matchParamsError = false /** which segment of the path we're currently processing */ let partIndex = leaf.extract?.part ?? 0 /** which node of the route tree branch we're currently processing */ @@ -918,11 +919,30 @@ function extractParams( if (routeSegment > segmentCount) { break } - matchData!.push({ - rawParams: routeParams, + Object.assign(matchParams, routeParams) + let currentParsedParamsState = parsedParamsState + while ( + currentParsedParamsState && + currentParsedParamsState.depth > nodeIndex + ) { + currentParsedParamsState = currentParsedParamsState.previous + } + if ( + currentParsedParamsState && + currentParsedParamsState.depth > appliedParsedParamsDepth + ) { + Object.assign(matchParams, currentParsedParamsState.params) + appliedParsedParamsDepth = currentParsedParamsState.depth + } + if (currentParsedParamsState?.hasError) { + matchParamsError = true + } + matchData!.push([ pathnameEnd, caseSensitive, - }) + Object.assign(Object.create(null), matchParams), + matchParamsError, + ]) routeParams = undefined routeIndex++ } @@ -1077,6 +1097,14 @@ type MatchStackFrame = { /** intermediary params from param extraction */ rawParams?: Record parsedParams?: Record + parsedParamsState?: ParsedParamsState +} + +type ParsedParamsState = { + depth: number + params: Record + hasError: boolean + previous?: ParsedParamsState } function getNodeMatch( @@ -1084,14 +1112,21 @@ function getNodeMatch( parts: Array, segmentTree: AnySegmentNode, fuzzy: boolean, -) { +): MatchStackFrame | null { // quick check for root index // this is an optimization, algorithm should work correctly without this block - if (path === '/' && segmentTree.index) - return { node: segmentTree.index, skipped: 0 } as Pick< - Frame, - 'node' | 'skipped' - > + if (path === '/' && segmentTree.index) { + const frame = { + node: segmentTree.index, + index: 1, + skipped: 0, + depth: 1, + statics: 0, + dynamics: 0, + optionals: 0, + } + return validateParseParams(path, parts, frame) ? frame : null + } const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' @@ -1439,14 +1474,27 @@ function validateParseParams( ) const result = frame.node.parse(params as Record) if (result === false) return null - frame.parsedParams = Object.assign( + const parsedParams = Object.assign( Object.create(null), frame.parsedParams, result, ) + frame.parsedParams = parsedParams + frame.parsedParamsState = { + depth: frame.node.depth, + params: parsedParams, + hasError: frame.parsedParamsState?.hasError ?? false, + previous: frame.parsedParamsState, + } } catch { - // Thrown parse errors should be surfaced on the selected match by - // extractStrictParams, not used as fallback route selection. + const parsedParams = Object.assign(Object.create(null), frame.parsedParams) + frame.parsedParams = parsedParams + frame.parsedParamsState = { + depth: frame.node.depth, + params: parsedParams, + hasError: true, + previous: frame.parsedParamsState, + } } return true diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index ecc41e395c..9e6d6d551e 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2985,8 +2985,8 @@ export class RouterCore< const destinationPath = trimPathRight(next.pathname) const requestedRoute = (this.routesByPath[ destinationPath as keyof typeof this.routesByPath - ] ?? - (destinationPath === '/' ? this.routesById[rootRouteId] : undefined)) as + ] || + (destinationPath === '/' && this.routesById[rootRouteId])) as | AnyRoute | undefined if (!requestedRoute) { @@ -3013,14 +3013,10 @@ export class RouterCore< ? { route: destinationRoute, rawParams: Object.create(null), + params: Object.create(null), + paramsError: false, branch: [destinationRoute], - matchData: [ - { - rawParams: undefined, - pathnameEnd: 1, - caseSensitive: true, - }, - ], + matchData: [[1, true, Object.create(null), false] as const], } : null) @@ -3028,42 +3024,33 @@ export class RouterCore< return false } - const matchesDestination = opts?.fuzzy - ? match.branch.some((route) => route.id === destinationRoute.id) - : match.route.id === destinationRoute.id - if (!matchesDestination) { + const destinationIndex = match.branch.indexOf( + destinationRoute as TRouteTree, + ) + if ( + destinationIndex < 0 || + (!opts?.fuzzy && match.route !== destinationRoute) + ) { return false } - const destinationIndex = match.branch.findIndex( - (route) => route.id === destinationRoute.id, - ) const destinationMatchData = match.matchData[destinationIndex]! - if (opts?.caseSensitive && !destinationMatchData.caseSensitive) { + if (opts?.caseSensitive && !destinationMatchData[1]) { return false } - const params = Object.create(null) - try { - for (const [routeIndex, matchedRoute] of this.getRouteBranch( - destinationRoute, - ).entries()) { - const { usedParams } = interpolatePath({ - path: matchedRoute.path ?? '', - params: match.matchData[routeIndex]?.rawParams ?? {}, - decoder: this.pathParamsDecoder, - server: this.isServer, - }) - Object.assign(params, usedParams) - extractStrictParams(matchedRoute, params) - } - } catch { + const matchParams = opts?.fuzzy ? destinationMatchData[2] : match.params + const paramsError = opts?.fuzzy + ? destinationMatchData[3] + : match.paramsError + if (paramsError) { return false } + const params = Object.assign(Object.create(null), matchParams) if (opts?.fuzzy) { const matchedPathname = trimPathRight( - baseLocation.pathname.slice(0, destinationMatchData.pathnameEnd) || '/', + baseLocation.pathname.slice(0, destinationMatchData[0]) || '/', ) const remainder = baseLocation.pathname.slice(matchedPathname.length) if (remainder) { diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts index 0777374505..ab6aaeee68 100644 --- a/packages/router-core/tests/match-route.test.ts +++ b/packages/router-core/tests/match-route.test.ts @@ -93,7 +93,7 @@ describe('matchRoute', () => { ).toBe(false) }) - it('returns parsed params on repeated calls', async () => { + it('returns parsed params on repeated calls without rerunning the parser', async () => { const parse = vi.fn(({ invoiceId }: { invoiceId: string }) => ({ invoiceId: Number(invoiceId), })) @@ -123,6 +123,7 @@ describe('matchRoute', () => { params: { invoiceId: 123 }, }), ).toEqual({ invoiceId: 123 }) + expect(parse).toHaveBeenCalledOnce() expect(parse).toHaveBeenCalledWith({ invoiceId: '123' }) }) @@ -226,26 +227,16 @@ describe('matchRoute', () => { ).toEqual({ orgId: 42, projectId: 7 }) }) - it('does not match a template absent from the route tree', async () => { - const router = createInvoiceRouter() - - await router.load() - - expect( - router.matchRoute({ - to: '/invoices/$id', - params: { id: '123' }, - } as any), - ).toBe(false) - }) - - it('does not match an absent static destination', async () => { - const router = createInvoiceRouter() + it.each([{ to: '/invoices/$id', params: { id: '123' } }, { to: '/missing' }])( + 'does not match an unregistered destination', + async (location) => { + const router = createInvoiceRouter() - await router.load() + await router.load() - expect(router.matchRoute({ to: '/missing' } as any)).toBe(false) - }) + expect(router.matchRoute(location as any)).toBe(false) + }, + ) it('only matches an active ancestor when fuzzy matching', async () => { const rootRoute = new BaseRootRoute({}) @@ -282,6 +273,25 @@ describe('matchRoute', () => { expect(router.matchRoute({ to: '/' })).toEqual({}) }) + it('does not match a root index route rejected by its parser', async () => { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + params: { + parse: () => false, + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/' })).toBe(false) + }) + it('matches encoded params without corrupting fuzzy remainders', async () => { const rootRoute = new BaseRootRoute({}) const itemRoute = new BaseRoute({ @@ -354,6 +364,66 @@ describe('matchRoute', () => { ).toEqual({ id: 'one', '**': 'projects/two' }) }) + it('returns parsed ancestor params when fuzzy matching', async () => { + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$orgId', + params: { + parse: ({ orgId }: { orgId: string }) => ({ orgId: Number(orgId) }), + }, + }) + const projectRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/projects/$projectId', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([orgRoute.addChildren([projectRoute])]), + history: createMemoryHistory({ + initialEntries: ['/orgs/42/projects/7'], + }), + }) + + await router.load() + + expect( + router.matchRoute( + { to: '/orgs/$orgId', params: { orgId: 42 } }, + { fuzzy: true }, + ), + ).toEqual({ orgId: 42, '**': 'projects/7' }) + }) + + it('keeps a reused child param value on exact matches', async () => { + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$id', + params: { + parse: ({ id }: { id: string }) => ({ id: `parent:${id}` }), + }, + }) + const projectRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/projects/$id', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([orgRoute.addChildren([projectRoute])]), + history: createMemoryHistory({ + initialEntries: ['/orgs/one/projects/two'], + }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/orgs/$id/projects/$id', + params: { id: 'two' }, + }), + ).toEqual({ id: 'two' }) + }) + it('passes a reused child param name to the child parser', async () => { const childParse = vi.fn(({ id }: { id: string }) => ({ id: `child:${id}`, @@ -389,6 +459,95 @@ describe('matchRoute', () => { expect(childParse).toHaveBeenCalledWith({ id: 'two' }) }) + it('returns parsed optional params', async () => { + const rootRoute = new BaseRootRoute({}) + const postRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts/{-$postId}', + params: { + parse: ({ postId }: { postId?: string }) => ({ + postId: postId ? Number(postId) : undefined, + }), + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postRoute]), + history: createMemoryHistory({ initialEntries: ['/posts/42'] }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/posts/{-$postId}', + params: { postId: 42 }, + }), + ).toEqual({ postId: 42 }) + }) + + it('returns wildcard params', async () => { + const rootRoute = new BaseRootRoute({}) + const filesRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/files/$', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([filesRoute]), + history: createMemoryHistory({ initialEntries: ['/files/a/b'] }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/files/$', + params: { _splat: 'a/b' }, + }), + ).toEqual({ '*': 'a/b', _splat: 'a/b' }) + }) + + it('passes parsed params through pathless route parsers', async () => { + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$orgId', + params: { + parse: ({ orgId }: { orgId: string }) => ({ orgId: Number(orgId) }), + }, + }) + const layoutRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + id: '/_layout', + params: { + parse: (params: Record) => { + if (typeof params.orgId !== 'number') { + return false + } + return { organizationId: params.orgId } + }, + }, + }) + const projectRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/projects', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + orgRoute.addChildren([layoutRoute.addChildren([projectRoute])]), + ]), + history: createMemoryHistory({ initialEntries: ['/orgs/42/projects'] }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/orgs/$orgId/projects', + params: { orgId: 42, organizationId: 42 }, + }), + ).toEqual({ orgId: 42, organizationId: 42 }) + }) + it('returns false for a malformed fuzzy remainder', async () => { const rootRoute = new BaseRootRoute({}) const postsRoute = new BaseRoute({ From 23c1da9150c4dfc2d1311a46fd8be53c177a5a2a Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 11 Jul 2026 16:18:54 -0700 Subject: [PATCH 10/18] add brackets to make coderabbit happy --- .../router-core/src/new-process-route-tree.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index f0f847ffee..dce3d8c1d1 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -968,7 +968,9 @@ function extractParams( } const part = parts[partIndex] const currentPathIndex = pathIndex - if (part) pathIndex += part.length + if (part) { + pathIndex += part.length + } nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[segmentCount]! if (node.kind === SEGMENT_TYPE_PATHNAME) { @@ -1159,7 +1161,7 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! const { node, index, skipped, depth, statics, dynamics, optionals } = frame - let { extract, rawParams, parsedParams } = frame + let { extract, rawParams, parsedParams, parsedParamsState } = frame // Wildcard candidates are pushed speculatively as fallbacks in case a // higher-priority wildcard later fails params.parse. If a better wildcard @@ -1179,6 +1181,7 @@ function getNodeMatch( rawParams = frame.rawParams extract = frame.extract parsedParams = frame.parsedParams + parsedParamsState = frame.parsedParamsState } // In fuzzy mode, track the best partial match we've found so far @@ -1223,6 +1226,7 @@ function getNodeMatch( extract, rawParams, parsedParams, + parsedParamsState, } let indexValid = true if (node.index.parse) { @@ -1277,6 +1281,7 @@ function getNodeMatch( extract, rawParams, parsedParams, + parsedParamsState, }) } } @@ -1299,6 +1304,7 @@ function getNodeMatch( extract, rawParams, parsedParams, + parsedParamsState, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -1323,6 +1329,7 @@ function getNodeMatch( extract, rawParams, parsedParams, + parsedParamsState, }) } } @@ -1351,6 +1358,7 @@ function getNodeMatch( extract, rawParams, parsedParams, + parsedParamsState, }) } } @@ -1372,6 +1380,7 @@ function getNodeMatch( extract, rawParams, parsedParams, + parsedParamsState, }) } } @@ -1391,6 +1400,7 @@ function getNodeMatch( extract, rawParams, parsedParams, + parsedParamsState, }) } } @@ -1411,6 +1421,7 @@ function getNodeMatch( extract, rawParams, parsedParams, + parsedParamsState, }) } } @@ -1473,7 +1484,9 @@ function validateParseParams( extractedParams, ) const result = frame.node.parse(params as Record) - if (result === false) return null + if (result === false) { + return null + } const parsedParams = Object.assign( Object.create(null), frame.parsedParams, From 8363a294a10061f8978094c64bfdfe01b406743a Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 11 Jul 2026 16:21:15 -0700 Subject: [PATCH 11/18] Revert "return parsed params from matchRoute" This reverts commit cbe350662a3f4dfc83eb47dd4da9484b78056fd9. --- .../router-core/src/new-process-route-tree.ts | 137 ++++-------- packages/router-core/src/router.ts | 53 +++-- .../router-core/tests/match-route.test.ts | 197 ++---------------- 3 files changed, 90 insertions(+), 297 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index dce3d8c1d1..299e55250e 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -688,10 +688,12 @@ export function findFlatMatch>( type RouteMatch> = { route: T rawParams: Record - params: Record - paramsError: boolean branch: ReadonlyArray - matchData: ReadonlyArray + matchData: ReadonlyArray<{ + rawParams?: Record + pathnameEnd: number + caseSensitive: boolean + }> } export function findRouteMatch< @@ -725,6 +727,7 @@ export function findRouteMatch< } } + if (result) result.branch = buildRouteBranch(result.route) processedTree.matchCache.set(key, result) return result } @@ -814,10 +817,11 @@ function findMatch( * This will be the exhaustive list of all params defined in the route's path. */ rawParams: Record - params?: Record - paramsError?: boolean - branch?: ReadonlyArray - matchData?: ReadonlyArray + matchData?: ReadonlyArray<{ + rawParams?: Record + pathnameEnd: number + caseSensitive: boolean + }> } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) @@ -825,7 +829,8 @@ function findMatch( const routeBranch = includeRouteParams ? buildRouteBranch(leaf.node.route!) : undefined - const leafRawParams = leaf.rawParams + const leafRawParams = (leaf as { rawParams?: Record }) + .rawParams const [rawParams, , , matchData] = extractParams( path, parts, @@ -835,15 +840,10 @@ function findMatch( rawParams: leafRawParams, }, routeBranch, - leaf.parsedParamsState, ) - const params = matchData?.at(-1)?.[2] ?? rawParams return { route: leaf.node.route!, rawParams, - params, - paramsError: matchData?.at(-1)?.[3] ?? false, - branch: routeBranch, matchData, } } @@ -855,12 +855,11 @@ type ParamExtractionState = { segment: number } -type RouteMatchData = readonly [ - pathnameEnd: number, - caseSensitive: boolean, - params: Record, - paramsError: boolean, -] +type RouteMatchData = { + rawParams?: Record + pathnameEnd: number + caseSensitive: boolean +} /** * This function is "resumable": @@ -879,12 +878,15 @@ function extractParams( rawParams?: Record }, routeBranch?: ReadonlyArray, - parsedParamsState?: ParsedParamsState, ): [ rawParams: Record, state: ParamExtractionState, extractedParams: Record, - matchData?: Array, + matchData?: Array<{ + rawParams?: Record + pathnameEnd: number + caseSensitive: boolean + }>, ] { const list = buildBranch(leaf.node) let nodeParts: Array | null = null @@ -898,9 +900,6 @@ function extractParams( : undefined let routeIndex = 0 let routeParams: Record | undefined - const matchParams: Record = Object.create(null) - let appliedParsedParamsDepth = -1 - let matchParamsError = false /** which segment of the path we're currently processing */ let partIndex = leaf.extract?.part ?? 0 /** which node of the route tree branch we're currently processing */ @@ -919,30 +918,11 @@ function extractParams( if (routeSegment > segmentCount) { break } - Object.assign(matchParams, routeParams) - let currentParsedParamsState = parsedParamsState - while ( - currentParsedParamsState && - currentParsedParamsState.depth > nodeIndex - ) { - currentParsedParamsState = currentParsedParamsState.previous - } - if ( - currentParsedParamsState && - currentParsedParamsState.depth > appliedParsedParamsDepth - ) { - Object.assign(matchParams, currentParsedParamsState.params) - appliedParsedParamsDepth = currentParsedParamsState.depth - } - if (currentParsedParamsState?.hasError) { - matchParamsError = true - } - matchData!.push([ + matchData!.push({ + rawParams: routeParams, pathnameEnd, caseSensitive, - Object.assign(Object.create(null), matchParams), - matchParamsError, - ]) + }) routeParams = undefined routeIndex++ } @@ -968,9 +948,7 @@ function extractParams( } const part = parts[partIndex] const currentPathIndex = pathIndex - if (part) { - pathIndex += part.length - } + if (part) pathIndex += part.length nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[segmentCount]! if (node.kind === SEGMENT_TYPE_PATHNAME) { @@ -1099,14 +1077,6 @@ type MatchStackFrame = { /** intermediary params from param extraction */ rawParams?: Record parsedParams?: Record - parsedParamsState?: ParsedParamsState -} - -type ParsedParamsState = { - depth: number - params: Record - hasError: boolean - previous?: ParsedParamsState } function getNodeMatch( @@ -1114,21 +1084,14 @@ function getNodeMatch( parts: Array, segmentTree: AnySegmentNode, fuzzy: boolean, -): MatchStackFrame | null { +) { // quick check for root index // this is an optimization, algorithm should work correctly without this block - if (path === '/' && segmentTree.index) { - const frame = { - node: segmentTree.index, - index: 1, - skipped: 0, - depth: 1, - statics: 0, - dynamics: 0, - optionals: 0, - } - return validateParseParams(path, parts, frame) ? frame : null - } + if (path === '/' && segmentTree.index) + return { node: segmentTree.index, skipped: 0 } as Pick< + Frame, + 'node' | 'skipped' + > const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' @@ -1161,7 +1124,7 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! const { node, index, skipped, depth, statics, dynamics, optionals } = frame - let { extract, rawParams, parsedParams, parsedParamsState } = frame + let { extract, rawParams, parsedParams } = frame // Wildcard candidates are pushed speculatively as fallbacks in case a // higher-priority wildcard later fails params.parse. If a better wildcard @@ -1181,7 +1144,6 @@ function getNodeMatch( rawParams = frame.rawParams extract = frame.extract parsedParams = frame.parsedParams - parsedParamsState = frame.parsedParamsState } // In fuzzy mode, track the best partial match we've found so far @@ -1226,7 +1188,6 @@ function getNodeMatch( extract, rawParams, parsedParams, - parsedParamsState, } let indexValid = true if (node.index.parse) { @@ -1281,7 +1242,6 @@ function getNodeMatch( extract, rawParams, parsedParams, - parsedParamsState, }) } } @@ -1304,7 +1264,6 @@ function getNodeMatch( extract, rawParams, parsedParams, - parsedParamsState, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -1329,7 +1288,6 @@ function getNodeMatch( extract, rawParams, parsedParams, - parsedParamsState, }) } } @@ -1358,7 +1316,6 @@ function getNodeMatch( extract, rawParams, parsedParams, - parsedParamsState, }) } } @@ -1380,7 +1337,6 @@ function getNodeMatch( extract, rawParams, parsedParams, - parsedParamsState, }) } } @@ -1400,7 +1356,6 @@ function getNodeMatch( extract, rawParams, parsedParams, - parsedParamsState, }) } } @@ -1421,7 +1376,6 @@ function getNodeMatch( extract, rawParams, parsedParams, - parsedParamsState, }) } } @@ -1484,30 +1438,15 @@ function validateParseParams( extractedParams, ) const result = frame.node.parse(params as Record) - if (result === false) { - return null - } - const parsedParams = Object.assign( + if (result === false) return null + frame.parsedParams = Object.assign( Object.create(null), frame.parsedParams, result, ) - frame.parsedParams = parsedParams - frame.parsedParamsState = { - depth: frame.node.depth, - params: parsedParams, - hasError: frame.parsedParamsState?.hasError ?? false, - previous: frame.parsedParamsState, - } } catch { - const parsedParams = Object.assign(Object.create(null), frame.parsedParams) - frame.parsedParams = parsedParams - frame.parsedParamsState = { - depth: frame.node.depth, - params: parsedParams, - hasError: true, - previous: frame.parsedParamsState, - } + // Thrown parse errors should be surfaced on the selected match by + // extractStrictParams, not used as fallback route selection. } return true diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 9e6d6d551e..ecc41e395c 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2985,8 +2985,8 @@ export class RouterCore< const destinationPath = trimPathRight(next.pathname) const requestedRoute = (this.routesByPath[ destinationPath as keyof typeof this.routesByPath - ] || - (destinationPath === '/' && this.routesById[rootRouteId])) as + ] ?? + (destinationPath === '/' ? this.routesById[rootRouteId] : undefined)) as | AnyRoute | undefined if (!requestedRoute) { @@ -3013,10 +3013,14 @@ export class RouterCore< ? { route: destinationRoute, rawParams: Object.create(null), - params: Object.create(null), - paramsError: false, branch: [destinationRoute], - matchData: [[1, true, Object.create(null), false] as const], + matchData: [ + { + rawParams: undefined, + pathnameEnd: 1, + caseSensitive: true, + }, + ], } : null) @@ -3024,33 +3028,42 @@ export class RouterCore< return false } - const destinationIndex = match.branch.indexOf( - destinationRoute as TRouteTree, - ) - if ( - destinationIndex < 0 || - (!opts?.fuzzy && match.route !== destinationRoute) - ) { + const matchesDestination = opts?.fuzzy + ? match.branch.some((route) => route.id === destinationRoute.id) + : match.route.id === destinationRoute.id + if (!matchesDestination) { return false } + const destinationIndex = match.branch.findIndex( + (route) => route.id === destinationRoute.id, + ) const destinationMatchData = match.matchData[destinationIndex]! - if (opts?.caseSensitive && !destinationMatchData[1]) { + if (opts?.caseSensitive && !destinationMatchData.caseSensitive) { return false } - const matchParams = opts?.fuzzy ? destinationMatchData[2] : match.params - const paramsError = opts?.fuzzy - ? destinationMatchData[3] - : match.paramsError - if (paramsError) { + const params = Object.create(null) + try { + for (const [routeIndex, matchedRoute] of this.getRouteBranch( + destinationRoute, + ).entries()) { + const { usedParams } = interpolatePath({ + path: matchedRoute.path ?? '', + params: match.matchData[routeIndex]?.rawParams ?? {}, + decoder: this.pathParamsDecoder, + server: this.isServer, + }) + Object.assign(params, usedParams) + extractStrictParams(matchedRoute, params) + } + } catch { return false } - const params = Object.assign(Object.create(null), matchParams) if (opts?.fuzzy) { const matchedPathname = trimPathRight( - baseLocation.pathname.slice(0, destinationMatchData[0]) || '/', + baseLocation.pathname.slice(0, destinationMatchData.pathnameEnd) || '/', ) const remainder = baseLocation.pathname.slice(matchedPathname.length) if (remainder) { diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts index ab6aaeee68..0777374505 100644 --- a/packages/router-core/tests/match-route.test.ts +++ b/packages/router-core/tests/match-route.test.ts @@ -93,7 +93,7 @@ describe('matchRoute', () => { ).toBe(false) }) - it('returns parsed params on repeated calls without rerunning the parser', async () => { + it('returns parsed params on repeated calls', async () => { const parse = vi.fn(({ invoiceId }: { invoiceId: string }) => ({ invoiceId: Number(invoiceId), })) @@ -123,7 +123,6 @@ describe('matchRoute', () => { params: { invoiceId: 123 }, }), ).toEqual({ invoiceId: 123 }) - expect(parse).toHaveBeenCalledOnce() expect(parse).toHaveBeenCalledWith({ invoiceId: '123' }) }) @@ -227,16 +226,26 @@ describe('matchRoute', () => { ).toEqual({ orgId: 42, projectId: 7 }) }) - it.each([{ to: '/invoices/$id', params: { id: '123' } }, { to: '/missing' }])( - 'does not match an unregistered destination', - async (location) => { - const router = createInvoiceRouter() + it('does not match a template absent from the route tree', async () => { + const router = createInvoiceRouter() - await router.load() + await router.load() - expect(router.matchRoute(location as any)).toBe(false) - }, - ) + expect( + router.matchRoute({ + to: '/invoices/$id', + params: { id: '123' }, + } as any), + ).toBe(false) + }) + + it('does not match an absent static destination', async () => { + const router = createInvoiceRouter() + + await router.load() + + expect(router.matchRoute({ to: '/missing' } as any)).toBe(false) + }) it('only matches an active ancestor when fuzzy matching', async () => { const rootRoute = new BaseRootRoute({}) @@ -273,25 +282,6 @@ describe('matchRoute', () => { expect(router.matchRoute({ to: '/' })).toEqual({}) }) - it('does not match a root index route rejected by its parser', async () => { - const rootRoute = new BaseRootRoute({}) - const indexRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/', - params: { - parse: () => false, - }, - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([indexRoute]), - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - await router.load() - - expect(router.matchRoute({ to: '/' })).toBe(false) - }) - it('matches encoded params without corrupting fuzzy remainders', async () => { const rootRoute = new BaseRootRoute({}) const itemRoute = new BaseRoute({ @@ -364,66 +354,6 @@ describe('matchRoute', () => { ).toEqual({ id: 'one', '**': 'projects/two' }) }) - it('returns parsed ancestor params when fuzzy matching', async () => { - const rootRoute = new BaseRootRoute({}) - const orgRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/orgs/$orgId', - params: { - parse: ({ orgId }: { orgId: string }) => ({ orgId: Number(orgId) }), - }, - }) - const projectRoute = new BaseRoute({ - getParentRoute: () => orgRoute, - path: '/projects/$projectId', - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([orgRoute.addChildren([projectRoute])]), - history: createMemoryHistory({ - initialEntries: ['/orgs/42/projects/7'], - }), - }) - - await router.load() - - expect( - router.matchRoute( - { to: '/orgs/$orgId', params: { orgId: 42 } }, - { fuzzy: true }, - ), - ).toEqual({ orgId: 42, '**': 'projects/7' }) - }) - - it('keeps a reused child param value on exact matches', async () => { - const rootRoute = new BaseRootRoute({}) - const orgRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/orgs/$id', - params: { - parse: ({ id }: { id: string }) => ({ id: `parent:${id}` }), - }, - }) - const projectRoute = new BaseRoute({ - getParentRoute: () => orgRoute, - path: '/projects/$id', - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([orgRoute.addChildren([projectRoute])]), - history: createMemoryHistory({ - initialEntries: ['/orgs/one/projects/two'], - }), - }) - - await router.load() - - expect( - router.matchRoute({ - to: '/orgs/$id/projects/$id', - params: { id: 'two' }, - }), - ).toEqual({ id: 'two' }) - }) - it('passes a reused child param name to the child parser', async () => { const childParse = vi.fn(({ id }: { id: string }) => ({ id: `child:${id}`, @@ -459,95 +389,6 @@ describe('matchRoute', () => { expect(childParse).toHaveBeenCalledWith({ id: 'two' }) }) - it('returns parsed optional params', async () => { - const rootRoute = new BaseRootRoute({}) - const postRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/posts/{-$postId}', - params: { - parse: ({ postId }: { postId?: string }) => ({ - postId: postId ? Number(postId) : undefined, - }), - }, - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([postRoute]), - history: createMemoryHistory({ initialEntries: ['/posts/42'] }), - }) - - await router.load() - - expect( - router.matchRoute({ - to: '/posts/{-$postId}', - params: { postId: 42 }, - }), - ).toEqual({ postId: 42 }) - }) - - it('returns wildcard params', async () => { - const rootRoute = new BaseRootRoute({}) - const filesRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/files/$', - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([filesRoute]), - history: createMemoryHistory({ initialEntries: ['/files/a/b'] }), - }) - - await router.load() - - expect( - router.matchRoute({ - to: '/files/$', - params: { _splat: 'a/b' }, - }), - ).toEqual({ '*': 'a/b', _splat: 'a/b' }) - }) - - it('passes parsed params through pathless route parsers', async () => { - const rootRoute = new BaseRootRoute({}) - const orgRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/orgs/$orgId', - params: { - parse: ({ orgId }: { orgId: string }) => ({ orgId: Number(orgId) }), - }, - }) - const layoutRoute = new BaseRoute({ - getParentRoute: () => orgRoute, - id: '/_layout', - params: { - parse: (params: Record) => { - if (typeof params.orgId !== 'number') { - return false - } - return { organizationId: params.orgId } - }, - }, - }) - const projectRoute = new BaseRoute({ - getParentRoute: () => layoutRoute, - path: '/projects', - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([ - orgRoute.addChildren([layoutRoute.addChildren([projectRoute])]), - ]), - history: createMemoryHistory({ initialEntries: ['/orgs/42/projects'] }), - }) - - await router.load() - - expect( - router.matchRoute({ - to: '/orgs/$orgId/projects', - params: { orgId: 42, organizationId: 42 }, - }), - ).toEqual({ orgId: 42, organizationId: 42 }) - }) - it('returns false for a malformed fuzzy remainder', async () => { const rootRoute = new BaseRootRoute({}) const postsRoute = new BaseRoute({ From 7dd0bab999489dfd9fd969b4c7e9a0e79d78f015 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 11 Jul 2026 18:09:50 -0700 Subject: [PATCH 12/18] reuse parsed route matches in matchRoute --- packages/router-core/src/router.ts | 144 ++++++++++-------- .../router-core/tests/match-route.test.ts | 4 +- 2 files changed, 84 insertions(+), 64 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index ecc41e395c..b5fa952a7a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -739,6 +739,11 @@ export type GetMatchRoutesFn = (pathname: string) => { matchedRoutes: ReadonlyArray /** exhaustive params, still in their string form */ routeParams: Record + routeMatchData?: ReadonlyArray<{ + rawParams?: Record + pathnameEnd: number + caseSensitive: boolean + }> foundRoute: AnyRoute | undefined parseError?: unknown } @@ -1445,7 +1450,12 @@ export class RouterCore< opts?: MatchRoutesOpts, ): Array { const matchedRoutesResult = this.getMatchedRoutes(next.pathname) - const { foundRoute, routeParams } = matchedRoutesResult + const { + foundRoute, + routeMatchData, + routeParams: rawRouteParams, + } = matchedRoutesResult + const routeParams: Record = Object.create(null) let { matchedRoutes } = matchedRoutesResult let isGlobalNotFound = false @@ -1453,7 +1463,7 @@ export class RouterCore< if ( // If we found a route, and it's not an index route and we have left over path foundRoute - ? foundRoute.path !== '/' && routeParams['**'] + ? foundRoute.path !== '/' && rawRouteParams['**'] : // Or if we didn't find a route and we have left over path trimPathRight(next.pathname) ) { @@ -1540,12 +1550,17 @@ export class RouterCore< const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : '' - const { interpolatedPath, usedParams } = interpolatePath({ + const { interpolatedPath } = interpolatePath({ path: route.fullPath, - params: routeParams, + params: rawRouteParams, decoder: this.pathParamsDecoder, server: this.isServer, }) + const strictParamsForRoute = Object.assign( + Object.create(null), + routeParams, + routeMatchData?.[index]?.rawParams, + ) // Waste not, want not. If we already have a match for this route, // reuse it. This is important for layout routes, which might stick @@ -1565,7 +1580,7 @@ export class RouterCore< const previousMatch = previousActiveMatchesByRouteId.get(route.id) - const strictParams = existingMatch?._strictParams ?? usedParams + const strictParams = existingMatch?._strictParams ?? strictParamsForRoute let paramsError: unknown = undefined @@ -3007,73 +3022,70 @@ export class RouterCore< ? baseLocation.pathname : removeTrailingSlash(baseLocation.pathname, this.basepath) - const match = - findRouteMatch(pathname, this.processedTree, opts?.fuzzy ?? false) ?? - (destinationRoute.id === rootRouteId && pathname === '/' - ? { - route: destinationRoute, - rawParams: Object.create(null), - branch: [destinationRoute], - matchData: [ - { - rawParams: undefined, - pathnameEnd: 1, - caseSensitive: true, - }, - ], - } - : null) - - if (!match) { + const storedMatches = pending + ? this.stores.pendingMatches.get() + : this.stores.matches.get() + const routeMatches = storedMatches.length + ? storedMatches + : this.matchRoutes(baseLocation) + const destinationIndex = routeMatches.findIndex( + (match) => match.routeId === destinationRoute.id, + ) + if ( + destinationIndex === -1 || + (!opts?.fuzzy && last(routeMatches)?.routeId !== destinationRoute.id) + ) { return false } - const matchesDestination = opts?.fuzzy - ? match.branch.some((route) => route.id === destinationRoute.id) - : match.route.id === destinationRoute.id - if (!matchesDestination) { + const destinationMatch = routeMatches[destinationIndex]! + if (destinationMatch.paramsError) { return false } - const destinationIndex = match.branch.findIndex( - (route) => route.id === destinationRoute.id, + const params = Object.assign( + Object.create(null), + destinationMatch._strictParams, ) - const destinationMatchData = match.matchData[destinationIndex]! - if (opts?.caseSensitive && !destinationMatchData.caseSensitive) { - return false - } - const params = Object.create(null) - try { - for (const [routeIndex, matchedRoute] of this.getRouteBranch( - destinationRoute, - ).entries()) { - const { usedParams } = interpolatePath({ - path: matchedRoute.path ?? '', - params: match.matchData[routeIndex]?.rawParams ?? {}, - decoder: this.pathParamsDecoder, - server: this.isServer, - }) - Object.assign(params, usedParams) - extractStrictParams(matchedRoute, params) + if (opts?.caseSensitive || opts?.fuzzy) { + const structuralMatch = findRouteMatch( + pathname, + this.processedTree, + opts?.fuzzy ?? false, + ) + if (!structuralMatch) { + return false } - } catch { - return false - } - if (opts?.fuzzy) { - const matchedPathname = trimPathRight( - baseLocation.pathname.slice(0, destinationMatchData.pathnameEnd) || '/', + const structuralDestinationIndex = structuralMatch.branch.findIndex( + (route) => route.id === destinationRoute.id, ) - const remainder = baseLocation.pathname.slice(matchedPathname.length) - if (remainder) { - try { - params['**'] = - remainder === '/' - ? remainder - : decodeURIComponent(remainder.replace(/^\/+/, '')) - } catch { - return false + if ( + structuralDestinationIndex === -1 || + (opts?.caseSensitive && + !structuralMatch.matchData[structuralDestinationIndex]!.caseSensitive) + ) { + return false + } + + if (opts?.fuzzy) { + const matchedPathname = trimPathRight( + baseLocation.pathname.slice( + 0, + structuralMatch.matchData[structuralDestinationIndex]!.pathnameEnd, + ) || '/', + ) + const remainder = baseLocation.pathname.slice(matchedPathname.length) + if (remainder) { + try { + params['**'] = + remainder === '/' + ? remainder + : decodeURIComponent(remainder.replace(/^\/+/, '')) + } catch { + return false + } } } } @@ -3197,15 +3209,23 @@ export function getMatchedRoutes({ const trimmedPath = trimPathRight(pathname) let foundRoute: TRouteLike | undefined = undefined + let routeMatchData: + | ReadonlyArray<{ + rawParams?: Record + pathnameEnd: number + caseSensitive: boolean + }> + | undefined const match = findRouteMatch(trimmedPath, processedTree, true) if (match) { foundRoute = match.route Object.assign(routeParams, match.rawParams) // Copy params, because they're cached + routeMatchData = match.matchData } const matchedRoutes = match?.branch || [routesById[rootRouteId]!] - return { matchedRoutes, routeParams, foundRoute } + return { matchedRoutes, routeMatchData, routeParams, foundRoute } } /** diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts index 0777374505..c0f0fee4e2 100644 --- a/packages/router-core/tests/match-route.test.ts +++ b/packages/router-core/tests/match-route.test.ts @@ -93,7 +93,7 @@ describe('matchRoute', () => { ).toBe(false) }) - it('returns parsed params on repeated calls', async () => { + it('returns cached parsed params without re-running the parser', async () => { const parse = vi.fn(({ invoiceId }: { invoiceId: string }) => ({ invoiceId: Number(invoiceId), })) @@ -123,7 +123,7 @@ describe('matchRoute', () => { params: { invoiceId: 123 }, }), ).toEqual({ invoiceId: 123 }) - expect(parse).toHaveBeenCalledWith({ invoiceId: '123' }) + expect(parse).not.toHaveBeenCalled() }) it('does not throw parser errors while checking a match', async () => { From c074b62021c93235d6c2dfec1626aaa5b4fbb909 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 11 Jul 2026 20:54:41 -0700 Subject: [PATCH 13/18] improve bundle sizes --- .../router-core/src/new-process-route-tree.ts | 428 +++++++----------- packages/router-core/src/router.ts | 132 ++---- .../router-core/tests/match-route.test.ts | 32 +- .../tests/new-process-route-tree.test.ts | 2 +- 4 files changed, 234 insertions(+), 360 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 299e55250e..5d6ce523b2 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -689,11 +689,7 @@ type RouteMatch> = { route: T rawParams: Record branch: ReadonlyArray - matchData: ReadonlyArray<{ - rawParams?: Record - pathnameEnd: number - caseSensitive: boolean - }> + routeParams: ReadonlyArray | undefined> } export function findRouteMatch< @@ -727,7 +723,6 @@ export function findRouteMatch< } } - if (result) result.branch = buildRouteBranch(result.route) processedTree.matchCache.set(key, result) return result } @@ -817,99 +812,76 @@ function findMatch( * This will be the exhaustive list of all params defined in the route's path. */ rawParams: Record - matchData?: ReadonlyArray<{ - rawParams?: Record - pathnameEnd: number - caseSensitive: boolean - }> + branch: ReadonlyArray + routeParams?: ReadonlyArray | undefined> } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null const routeBranch = includeRouteParams - ? buildRouteBranch(leaf.node.route!) + ? buildRouteBranch(leaf[0].route!) : undefined - const leafRawParams = (leaf as { rawParams?: Record }) - .rawParams - const [rawParams, , , matchData] = extractParams( + const routeParams: Array | undefined> = [] + const rawParams = extractParams( path, parts, - { - node: leaf.node, - skipped: leaf.skipped, - rawParams: leafRawParams, - }, + leaf[0], + leaf[2], + undefined, + undefined, routeBranch, + routeParams, ) + if (leaf[9] !== undefined) { + rawParams['**'] = leaf[9] + } return { - route: leaf.node.route!, - rawParams, - matchData, + route: leaf[0].route!, + rawParams: rawParams as Record, + branch: routeBranch ?? buildRouteBranch(leaf[0].route!), + routeParams, } } -type ParamExtractionState = { - part: number - node: number - path: number - segment: number -} - -type RouteMatchData = { - rawParams?: Record - pathnameEnd: number - caseSensitive: boolean -} +type ParamExtractionState = [ + part: number, + node: number, + path: number, + segment: number, +] /** * This function is "resumable": - * - the `leaf` input can contain `extract` and `rawParams` properties from a previous `extractParams` call - * - the returned `state` can be passed back as `extract` in a future call to continue extracting params from where we left off - * - * Inputs are *not* mutated. + * The optional `state` resumes extraction from where the previous call stopped + * and is updated with the new position. */ function extractParams( path: string, parts: Array, - leaf: { - node: AnySegmentNode - skipped: number - extract?: ParamExtractionState - rawParams?: Record - }, + leaf: AnySegmentNode, + skipped: number, + state?: ParamExtractionState, + params?: Record, routeBranch?: ReadonlyArray, -): [ - rawParams: Record, - state: ParamExtractionState, - extractedParams: Record, - matchData?: Array<{ - rawParams?: Record - pathnameEnd: number - caseSensitive: boolean - }>, -] { - const list = buildBranch(leaf.node) + routeParams?: Array | undefined>, +): Record { + const list = buildBranch(leaf) let nodeParts: Array | null = null - const rawParams: Record = Object.assign( + const rawParams: Record = Object.assign( Object.create(null), - leaf.rawParams, + params, ) - const extractedParams: Record = Object.create(null) - const matchData: Array | undefined = routeBranch - ? [] - : undefined let routeIndex = 0 - let routeParams: Record | undefined + let routeRawParams: Record | undefined /** which segment of the path we're currently processing */ - let partIndex = leaf.extract?.part ?? 0 + let partIndex = state?.[0] ?? 0 /** which node of the route tree branch we're currently processing */ - let nodeIndex = leaf.extract?.node ?? 0 + let nodeIndex = state?.[1] ?? 0 /** index of the 1st character of the segment we're processing in the path string */ - let pathIndex = leaf.extract?.path ?? 0 + let pathIndex = state?.[2] ?? 0 /** which fullPath segment we're currently processing */ - let segmentCount = leaf.extract?.segment ?? 0 - let caseSensitive = true - const recordRouteMatches = (pathnameEnd: number) => { + let segmentCount = state?.[3] ?? 0 + const recordRouteParams = () => { while (routeBranch && routeIndex < routeBranch.length) { const route = routeBranch[routeIndex]! const routePath = route.fullPath ?? route.from @@ -918,12 +890,8 @@ function extractParams( if (routeSegment > segmentCount) { break } - matchData!.push({ - rawParams: routeParams, - pathnameEnd, - caseSensitive, - }) - routeParams = undefined + routeParams!.push(routeRawParams) + routeRawParams = undefined routeIndex++ } } @@ -935,7 +903,7 @@ function extractParams( const node = list[nodeIndex]! // index nodes are terminating nodes, nothing to extract, just leave if (node.kind === SEGMENT_TYPE_INDEX) { - recordRouteMatches(pathIndex) + recordRouteParams() break } // pathless nodes do not consume a path segment @@ -943,50 +911,44 @@ function extractParams( segmentCount-- partIndex-- pathIndex-- - recordRouteMatches(pathIndex) + recordRouteParams() continue } const part = parts[partIndex] const currentPathIndex = pathIndex if (part) pathIndex += part.length - nodeParts ??= leaf.node.fullPath.split('/') + nodeParts ??= leaf.fullPath.split('/') const nodePart = nodeParts[segmentCount]! - if (node.kind === SEGMENT_TYPE_PATHNAME) { - caseSensitive &&= part === nodePart - } else if (node.kind === SEGMENT_TYPE_PARAM) { + if (node.kind === SEGMENT_TYPE_PARAM) { const preLength = node.prefix?.length ?? 0 // we can't rely on the presence of prefix/suffix to know whether it's curly-braced or not, because `/{$param}/` is valid, but has no prefix/suffix const isCurlyBraced = nodePart.charCodeAt(preLength) === 123 // '{' // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node if (isCurlyBraced) { const sufLength = node.suffix?.length ?? 0 - caseSensitive &&= - part!.startsWith(nodePart.substring(0, preLength)) && - part!.endsWith(nodePart.substring(nodePart.length - sufLength)) const name = nodePart.substring( preLength + 2, nodePart.length - sufLength - 1, ) const value = part!.substring(preLength, part!.length - sufLength) - rawParams[name] = extractedParams[name] = decodeURIComponent(value) - ;(routeParams ??= Object.create(null))[name] = rawParams[name]! + const decodedValue = decodeURIComponent(value) + rawParams[name] = decodedValue + ;(routeRawParams ??= Object.create(null))[name] = decodedValue } else { const name = nodePart.substring(1) - rawParams[name] = extractedParams[name] = decodeURIComponent(part!) - ;(routeParams ??= Object.create(null))[name] = rawParams[name]! + const decodedValue = decodeURIComponent(part!) + rawParams[name] = decodedValue + ;(routeRawParams ??= Object.create(null))[name] = decodedValue } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { - if (leaf.skipped & (1 << nodeIndex)) { - recordRouteMatches(currentPathIndex) + if (skipped & (1 << nodeIndex)) { + recordRouteParams() partIndex-- // stay on the same part pathIndex = currentPathIndex - 1 // undo pathIndex advancement; -1 to account for loop increment continue } const preLength = node.prefix?.length ?? 0 const sufLength = node.suffix?.length ?? 0 - caseSensitive &&= - part!.startsWith(nodePart.substring(0, preLength)) && - part!.endsWith(nodePart.substring(nodePart.length - sufLength)) const name = nodePart.substring( preLength + 3, nodePart.length - sufLength - 1, @@ -996,44 +958,37 @@ function extractParams( ? part!.substring(preLength, part!.length - sufLength) : part if (value) { - rawParams[name] = extractedParams[name] = decodeURIComponent(value) - ;(routeParams ??= Object.create(null))[name] = rawParams[name]! + const decodedValue = decodeURIComponent(value) + rawParams[name] = decodedValue + ;(routeRawParams ??= Object.create(null))[name] = decodedValue } } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node const preLength = n.prefix?.length ?? 0 const sufLength = n.suffix?.length ?? 0 - const remainder = path.substring(currentPathIndex) - caseSensitive &&= - remainder.startsWith(nodePart.substring(0, preLength)) && - remainder.endsWith(nodePart.substring(nodePart.length - sufLength)) const value = path.substring( currentPathIndex + preLength, path.length - sufLength, ) const splat = decodeURIComponent(value) // TODO: Deprecate * - rawParams['*'] = extractedParams['*'] = splat - rawParams._splat = extractedParams._splat = splat - const wildcardParams = (routeParams ??= Object.create(null)) + rawParams['*'] = splat + rawParams._splat = splat + const wildcardParams = (routeRawParams ??= Object.create(null)) wildcardParams['*'] = splat wildcardParams._splat = splat - recordRouteMatches(path.length) + recordRouteParams() break } - recordRouteMatches(pathIndex) + recordRouteParams() } - return [ - rawParams, - { - part: partIndex, - node: nodeIndex, - path: pathIndex, - segment: segmentCount, - }, - extractedParams, - matchData, - ] + if (state) { + state[0] = partIndex + state[1] = nodeIndex + state[2] = pathIndex + state[3] = segmentCount + } + return rawParams } export function buildRouteBranch(route: T) { @@ -1055,43 +1010,35 @@ function buildBranch(node: AnySegmentNode) { return list } -type MatchStackFrame = { - node: AnySegmentNode - /** index of the segment of path */ - index: number - /** how many nodes between `node` and the root of the segment tree */ - depth: number - /** - * Bitmask of skipped optional segments. - * - * This is a very performant way of storing an "array of booleans", but it means beyond 32 segments we can't track skipped optionals. - * If we really really need to support more than 32 segments we can switch to using a `BigInt` here. It's about 2x slower in worst case scenarios. - */ - skipped: number - /** Positional bitmasks tracking which consumed URL segments matched each segment kind. */ - statics: number - dynamics: number - optionals: number - /** intermediary state for param extraction */ - extract?: ParamExtractionState - /** intermediary params from param extraction */ - rawParams?: Record - parsedParams?: Record -} +type MatchStackFrame = [ + node: AnySegmentNode, + /** index of the path segment */ + index: number, + /** bitmask of skipped optional segments */ + skipped: number, + /** how many nodes are between `node` and the root */ + depth: number, + /** positional specificity bitmasks */ + statics: number, + dynamics: number, + optionals: number, + /** intermediary parameter extraction state */ + extract?: ParamExtractionState, + params?: Record, + fuzzyRemainder?: string, +] function getNodeMatch( path: string, parts: Array, segmentTree: AnySegmentNode, fuzzy: boolean, -) { +): MatchStackFrame | null { // quick check for root index // this is an optimization, algorithm should work correctly without this block - if (path === '/' && segmentTree.index) - return { node: segmentTree.index, skipped: 0 } as Pick< - Frame, - 'node' | 'skipped' - > + if (path === '/' && segmentTree.index) { + return [segmentTree.index, 1, 0, 1, 0, 0, 0] + } const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' @@ -1106,25 +1053,16 @@ function getNodeMatch( // other possible approaches: // - shift instead of pop (measure performance difference), this allows iterating "forwards" (effectively breadth-first) // - never remove from the stack, keep a cursor instead. Then we can push "forwards" and avoid reversing the order of candidates (effectively breadth-first) - const stack: Array = [ - { - node: segmentTree, - index: 1, - skipped: 0, - depth: 1, - statics: 0, - dynamics: 0, - optionals: 0, - }, - ] + const stack: Array = [[segmentTree, 1, 0, 1, 0, 0, 0]] let bestFuzzy: Frame | null = null let bestMatch: Frame | null = null while (stack.length) { const frame = stack.pop()! - const { node, index, skipped, depth, statics, dynamics, optionals } = frame - let { extract, rawParams, parsedParams } = frame + const [node, index, skipped, depth, statics, dynamics, optionals] = frame + let extract = frame[7] + let params = frame[8] // Wildcard candidates are pushed speculatively as fallbacks in case a // higher-priority wildcard later fails params.parse. If a better wildcard @@ -1141,9 +1079,8 @@ function getNodeMatch( if (node.parse) { const result = validateParseParams(path, parts, frame) if (!result) continue - rawParams = frame.rawParams - extract = frame.extract - parsedParams = frame.parsedParams + params = frame[8] + extract = frame[7] } // In fuzzy mode, track the best partial match we've found so far @@ -1177,24 +1114,18 @@ function getNodeMatch( // 0. Try index match if (isBeyondPath && node.index) { - const indexFrame = { - node: node.index, + const indexFrame: Frame = [ + node.index, index, skipped, - depth: depth + 1, + depth + 1, statics, dynamics, optionals, extract, - rawParams, - parsedParams, - } - let indexValid = true - if (node.index.parse) { - const result = validateParseParams(path, parts, indexFrame) - if (!result) indexValid = false - } - if (indexValid) { + params, + ] + if (!node.index.parse || validateParseParams(path, parts, indexFrame)) { // perfect match, no need to continue // this is an optimization, algorithm should work correctly without this block if ( @@ -1231,18 +1162,17 @@ function getNodeMatch( if (casePart !== suffix) continue } // wildcard matches consume the rest of the URL and cannot have children - stack.push({ - node: segment, - index: partsLength, + stack.push([ + segment, + partsLength, skipped, - depth: depth + 1, + depth + 1, statics, dynamics, optionals, extract, - rawParams, - parsedParams, - }) + params, + ]) } } @@ -1253,18 +1183,17 @@ function getNodeMatch( for (let i = node.optional.length - 1; i >= 0; i--) { const segment = node.optional[i]! // when skipping, node and depth advance by 1, but index doesn't - stack.push({ - node: segment, + stack.push([ + segment, index, - skipped: nextSkipped, - depth: nextDepth, + nextSkipped, + nextDepth, statics, dynamics, optionals, extract, - rawParams, - parsedParams, - }) // enqueue skipping the optional + params, + ]) // enqueue skipping the optional } if (!isBeyondPath) { for (let i = node.optional.length - 1; i >= 0; i--) { @@ -1277,18 +1206,17 @@ function getNodeMatch( if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } - stack.push({ - node: segment, - index: index + 1, + stack.push([ + segment, + index + 1, skipped, - depth: nextDepth, + nextDepth, statics, dynamics, - optionals: optionals + segmentScore(partsLength, index), + optionals + segmentScore(partsLength, index), extract, - rawParams, - parsedParams, - }) + params, + ]) } } } @@ -1305,18 +1233,17 @@ function getNodeMatch( if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } - stack.push({ - node: segment, - index: index + 1, + stack.push([ + segment, + index + 1, skipped, - depth: depth + 1, + depth + 1, statics, - dynamics: dynamics + segmentScore(partsLength, index), + dynamics + segmentScore(partsLength, index), optionals, extract, - rawParams, - parsedParams, - }) + params, + ]) } } @@ -1326,18 +1253,17 @@ function getNodeMatch( (lowerPart ??= part!.toLowerCase()), ) if (match) { - stack.push({ - node: match, - index: index + 1, + stack.push([ + match, + index + 1, skipped, - depth: depth + 1, - statics: statics + segmentScore(partsLength, index), + depth + 1, + statics + segmentScore(partsLength, index), dynamics, optionals, extract, - rawParams, - parsedParams, - }) + params, + ]) } } @@ -1345,18 +1271,17 @@ function getNodeMatch( if (!isBeyondPath && node.static) { const match = node.static.get(part!) if (match) { - stack.push({ - node: match, - index: index + 1, + stack.push([ + match, + index + 1, skipped, - depth: depth + 1, - statics: statics + segmentScore(partsLength, index), + depth + 1, + statics + segmentScore(partsLength, index), dynamics, optionals, extract, - rawParams, - parsedParams, - }) + params, + ]) } } @@ -1365,18 +1290,17 @@ function getNodeMatch( const nextDepth = depth + 1 for (let i = node.pathless.length - 1; i >= 0; i--) { const segment = node.pathless[i]! - stack.push({ - node: segment, + stack.push([ + segment, index, skipped, - depth: nextDepth, + nextDepth, statics, dynamics, optionals, extract, - rawParams, - parsedParams, - }) + params, + ]) } } } @@ -1384,13 +1308,12 @@ function getNodeMatch( if (bestMatch) return bestMatch if (fuzzy && bestFuzzy) { - let sliceIndex = bestFuzzy.index - for (let i = 0; i < bestFuzzy.index; i++) { + let sliceIndex = bestFuzzy[1] + for (let i = 0; i < bestFuzzy[1]; i++) { sliceIndex += parts[i]!.length } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) - bestFuzzy.rawParams ??= Object.create(null) - bestFuzzy.rawParams!['**'] = decodeURIComponent(splat) + bestFuzzy[9] = decodeURIComponent(splat) return bestFuzzy } @@ -1415,35 +1338,22 @@ function validateParseParams( parts: Array, frame: MatchStackFrame, ) { - let rawParams: Record - let state: ParamExtractionState - let extractedParams: Record + let params: Record + const state: ParamExtractionState = frame[7] ? [...frame[7]] : [0, 0, 0, 0] try { - ;[rawParams, state, extractedParams] = extractParams(path, parts, frame) + params = extractParams(path, parts, frame[0], frame[2], state, frame[8]) } catch { return null } - frame.rawParams = rawParams - frame.extract = state - - if (!frame.node.parse) return true + frame[7] = state + frame[8] = params try { - const params = Object.assign( - Object.create(null), - rawParams, - frame.parsedParams, - extractedParams, - ) - const result = frame.node.parse(params as Record) + const result = frame[0].parse!(params as Record) if (result === false) return null - frame.parsedParams = Object.assign( - Object.create(null), - frame.parsedParams, - result, - ) + Object.assign(params, result) } catch { // Thrown parse errors should be surfaced on the selected match by // extractStrictParams, not used as fallback route selection. @@ -1460,16 +1370,16 @@ function isFrameMoreSpecific( ): boolean { if (!prev) return true return ( - next.statics > prev.statics || - (next.statics === prev.statics && - (next.dynamics > prev.dynamics || - (next.dynamics === prev.dynamics && - (next.optionals > prev.optionals || - (next.optionals === prev.optionals && - ((next.node.kind === SEGMENT_TYPE_INDEX) > - (prev.node.kind === SEGMENT_TYPE_INDEX) || - ((next.node.kind === SEGMENT_TYPE_INDEX) === - (prev.node.kind === SEGMENT_TYPE_INDEX) && - next.depth > prev.depth))))))) + next[4] > prev[4] || + (next[4] === prev[4] && + (next[5] > prev[5] || + (next[5] === prev[5] && + (next[6] > prev[6] || + (next[6] === prev[6] && + ((next[0].kind === SEGMENT_TYPE_INDEX) > + (prev[0].kind === SEGMENT_TYPE_INDEX) || + ((next[0].kind === SEGMENT_TYPE_INDEX) === + (prev[0].kind === SEGMENT_TYPE_INDEX) && + next[3] > prev[3]))))))) ) } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index b5fa952a7a..8295816369 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -739,11 +739,7 @@ export type GetMatchRoutesFn = (pathname: string) => { matchedRoutes: ReadonlyArray /** exhaustive params, still in their string form */ routeParams: Record - routeMatchData?: ReadonlyArray<{ - rawParams?: Record - pathnameEnd: number - caseSensitive: boolean - }> + routeMatchData?: ReadonlyArray | undefined> foundRoute: AnyRoute | undefined parseError?: unknown } @@ -1456,6 +1452,7 @@ export class RouterCore< routeParams: rawRouteParams, } = matchedRoutesResult const routeParams: Record = Object.create(null) + const rawParams: Record = Object.create(null) let { matchedRoutes } = matchedRoutesResult let isGlobalNotFound = false @@ -1550,16 +1547,18 @@ export class RouterCore< const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : '' + Object.assign(rawParams, routeMatchData?.[index]) + const { interpolatedPath } = interpolatePath({ path: route.fullPath, - params: rawRouteParams, + params: rawParams, decoder: this.pathParamsDecoder, server: this.isServer, }) const strictParamsForRoute = Object.assign( Object.create(null), routeParams, - routeMatchData?.[index]?.rawParams, + routeMatchData?.[index], ) // Waste not, want not. If we already have a match for this route, @@ -2998,111 +2997,56 @@ export class RouterCore< : this.stores.resolvedLocation.get() || this.stores.location.get() const destinationPath = trimPathRight(next.pathname) - const requestedRoute = (this.routesByPath[ - destinationPath as keyof typeof this.routesByPath - ] ?? - (destinationPath === '/' ? this.routesById[rootRouteId] : undefined)) as - | AnyRoute - | undefined - if (!requestedRoute) { - return false - } - - const parentRoute = requestedRoute.parentRoute - const destinationRoute: AnyRoute = - opts?.fuzzy && - requestedRoute.path === '/' && - parentRoute && - trimPathRight(parentRoute.fullPath) === - trimPathRight(requestedRoute.fullPath) - ? parentRoute - : requestedRoute - - const pathname = opts?.fuzzy - ? baseLocation.pathname - : removeTrailingSlash(baseLocation.pathname, this.basepath) - - const storedMatches = pending + let routeMatches = pending ? this.stores.pendingMatches.get() : this.stores.matches.get() - const routeMatches = storedMatches.length - ? storedMatches - : this.matchRoutes(baseLocation) - const destinationIndex = routeMatches.findIndex( - (match) => match.routeId === destinationRoute.id, - ) + if (!routeMatches.length) { + routeMatches = this.matchRoutes(baseLocation) + } + const destinationMatch = opts?.fuzzy + ? routeMatches.find( + (match) => trimPathRight(match.fullPath) === destinationPath, + ) + : last(routeMatches) if ( - destinationIndex === -1 || - (!opts?.fuzzy && last(routeMatches)?.routeId !== destinationRoute.id) + !destinationMatch || + (!opts?.fuzzy && + trimPathRight(destinationMatch.fullPath) !== destinationPath) || + destinationMatch.paramsError ) { return false } - const destinationMatch = routeMatches[destinationIndex]! - if (destinationMatch.paramsError) { - return false - } - const params = Object.assign( Object.create(null), destinationMatch._strictParams, ) - if (opts?.caseSensitive || opts?.fuzzy) { - const structuralMatch = findRouteMatch( - pathname, - this.processedTree, - opts?.fuzzy ?? false, - ) - if (!structuralMatch) { - return false - } - - const structuralDestinationIndex = structuralMatch.branch.findIndex( - (route) => route.id === destinationRoute.id, + try { + const currentPathname = trimPathRight(decodeURI(baseLocation.pathname)) + const matchedPathname = trimPathRight( + decodeURI(destinationMatch.pathname), ) - if ( - structuralDestinationIndex === -1 || - (opts?.caseSensitive && - !structuralMatch.matchData[structuralDestinationIndex]!.caseSensitive) - ) { + if (opts?.caseSensitive && !currentPathname.startsWith(matchedPathname)) { return false } if (opts?.fuzzy) { - const matchedPathname = trimPathRight( - baseLocation.pathname.slice( - 0, - structuralMatch.matchData[structuralDestinationIndex]!.pathnameEnd, - ) || '/', - ) - const remainder = baseLocation.pathname.slice(matchedPathname.length) + const remainder = currentPathname.slice(matchedPathname.length) if (remainder) { - try { - params['**'] = - remainder === '/' - ? remainder - : decodeURIComponent(remainder.replace(/^\/+/, '')) - } catch { - return false - } + params['**'] = decodeURIComponent(remainder.replace(/^\/+/, '')) } } + } catch { + return false } - if (location.params) { - if (!deepEqual(params, location.params, { partial: true })) { - return false - } - } - - if (opts?.includeSearch ?? true) { - return deepEqual(baseLocation.search, next.search, { partial: true }) - ? params - : false - } - - return params + return (!location.params || + deepEqual(params, location.params, { partial: true })) && + (!(opts?.includeSearch ?? true) || + deepEqual(baseLocation.search, next.search, { partial: true })) + ? params + : false } ssr?: { @@ -3210,17 +3154,13 @@ export function getMatchedRoutes({ let foundRoute: TRouteLike | undefined = undefined let routeMatchData: - | ReadonlyArray<{ - rawParams?: Record - pathnameEnd: number - caseSensitive: boolean - }> + | ReadonlyArray | undefined> | undefined const match = findRouteMatch(trimmedPath, processedTree, true) if (match) { foundRoute = match.route Object.assign(routeParams, match.rawParams) // Copy params, because they're cached - routeMatchData = match.matchData + routeMatchData = match.routeParams } const matchedRoutes = match?.branch || [routesById[rootRouteId]!] diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts index c0f0fee4e2..2c45481174 100644 --- a/packages/router-core/tests/match-route.test.ts +++ b/packages/router-core/tests/match-route.test.ts @@ -355,9 +355,11 @@ describe('matchRoute', () => { }) it('passes a reused child param name to the child parser', async () => { - const childParse = vi.fn(({ id }: { id: string }) => ({ - id: `child:${id}`, - })) + const childParserParams: Array<{ id: string }> = [] + const childParse = vi.fn(({ id }: { id: string }) => { + childParserParams.push({ id }) + return { id: `child:${id}` } + }) const rootRoute = new BaseRootRoute({}) const orgRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -386,7 +388,7 @@ describe('matchRoute', () => { params: { id: 'child:two' }, }), ).toEqual({ id: 'child:two' }) - expect(childParse).toHaveBeenCalledWith({ id: 'two' }) + expect(childParserParams).toContainEqual({ id: 'two' }) }) it('returns false for a malformed fuzzy remainder', async () => { @@ -439,6 +441,28 @@ describe('matchRoute', () => { }) }) + it('exactly matches an index route that shares its layout path', async () => { + const rootRoute = new BaseRootRoute({}) + const dashboardRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + }) + const dashboardIndexRoute = new BaseRoute({ + getParentRoute: () => dashboardRoute, + path: '/', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + dashboardRoute.addChildren([dashboardIndexRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/dashboard'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/dashboard' })).toEqual({}) + }) + it('respects the caseSensitive matching option', async () => { const rootRoute = new BaseRootRoute({}) const postsRoute = new BaseRoute({ diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index d93581355e..ebb733263e 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -1299,7 +1299,7 @@ describe('findRouteMatch', () => { options: { params: { parse: (params: Record) => { - parsedParams = params + parsedParams = { ...params } return { length: params._splat!.length } }, }, From 4191e13e969d04301edece2d427a5645a32cddaa Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 11 Jul 2026 20:56:33 -0700 Subject: [PATCH 14/18] remove experiment --- .../router-core/src/new-process-route-tree.ts | 231 +++++++++--------- 1 file changed, 119 insertions(+), 112 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 5d6ce523b2..cea024056d 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -819,36 +819,33 @@ function findMatch( const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null const routeBranch = includeRouteParams - ? buildRouteBranch(leaf[0].route!) + ? buildRouteBranch(leaf.node.route!) : undefined const routeParams: Array | undefined> = [] const rawParams = extractParams( path, parts, - leaf[0], - leaf[2], - undefined, - undefined, + { node: leaf.node, skipped: leaf.skipped }, routeBranch, routeParams, ) - if (leaf[9] !== undefined) { - rawParams['**'] = leaf[9] + if (leaf.fuzzyRemainder !== undefined) { + rawParams['**'] = leaf.fuzzyRemainder } return { - route: leaf[0].route!, + route: leaf.node.route!, rawParams: rawParams as Record, - branch: routeBranch ?? buildRouteBranch(leaf[0].route!), + branch: routeBranch ?? buildRouteBranch(leaf.node.route!), routeParams, } } -type ParamExtractionState = [ - part: number, - node: number, - path: number, - segment: number, -] +type ParamExtractionState = { + part: number + node: number + path: number + segment: number +} /** * This function is "resumable": @@ -858,29 +855,31 @@ type ParamExtractionState = [ function extractParams( path: string, parts: Array, - leaf: AnySegmentNode, - skipped: number, - state?: ParamExtractionState, - params?: Record, + leaf: { + node: AnySegmentNode + skipped: number + extract?: ParamExtractionState + params?: Record + }, routeBranch?: ReadonlyArray, routeParams?: Array | undefined>, ): Record { - const list = buildBranch(leaf) + const list = buildBranch(leaf.node) let nodeParts: Array | null = null const rawParams: Record = Object.assign( Object.create(null), - params, + leaf.params, ) let routeIndex = 0 let routeRawParams: Record | undefined /** which segment of the path we're currently processing */ - let partIndex = state?.[0] ?? 0 + let partIndex = leaf.extract?.part ?? 0 /** which node of the route tree branch we're currently processing */ - let nodeIndex = state?.[1] ?? 0 + let nodeIndex = leaf.extract?.node ?? 0 /** index of the 1st character of the segment we're processing in the path string */ - let pathIndex = state?.[2] ?? 0 + let pathIndex = leaf.extract?.path ?? 0 /** which fullPath segment we're currently processing */ - let segmentCount = state?.[3] ?? 0 + let segmentCount = leaf.extract?.segment ?? 0 const recordRouteParams = () => { while (routeBranch && routeIndex < routeBranch.length) { const route = routeBranch[routeIndex]! @@ -917,7 +916,7 @@ function extractParams( const part = parts[partIndex] const currentPathIndex = pathIndex if (part) pathIndex += part.length - nodeParts ??= leaf.fullPath.split('/') + nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[segmentCount]! if (node.kind === SEGMENT_TYPE_PARAM) { const preLength = node.prefix?.length ?? 0 @@ -941,7 +940,7 @@ function extractParams( ;(routeRawParams ??= Object.create(null))[name] = decodedValue } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { - if (skipped & (1 << nodeIndex)) { + if (leaf.skipped & (1 << nodeIndex)) { recordRouteParams() partIndex-- // stay on the same part pathIndex = currentPathIndex - 1 // undo pathIndex advancement; -1 to account for loop increment @@ -982,11 +981,11 @@ function extractParams( } recordRouteParams() } - if (state) { - state[0] = partIndex - state[1] = nodeIndex - state[2] = pathIndex - state[3] = segmentCount + leaf.extract = { + part: partIndex, + node: nodeIndex, + path: pathIndex, + segment: segmentCount, } return rawParams } @@ -1010,23 +1009,23 @@ function buildBranch(node: AnySegmentNode) { return list } -type MatchStackFrame = [ - node: AnySegmentNode, +type MatchStackFrame = { + node: AnySegmentNode /** index of the path segment */ - index: number, + index: number /** bitmask of skipped optional segments */ - skipped: number, + skipped: number /** how many nodes are between `node` and the root */ - depth: number, + depth: number /** positional specificity bitmasks */ - statics: number, - dynamics: number, - optionals: number, + statics: number + dynamics: number + optionals: number /** intermediary parameter extraction state */ - extract?: ParamExtractionState, - params?: Record, - fuzzyRemainder?: string, -] + extract?: ParamExtractionState + params?: Record + fuzzyRemainder?: string +} function getNodeMatch( path: string, @@ -1037,7 +1036,7 @@ function getNodeMatch( // quick check for root index // this is an optimization, algorithm should work correctly without this block if (path === '/' && segmentTree.index) { - return [segmentTree.index, 1, 0, 1, 0, 0, 0] + return { node: segmentTree.index, skipped: 0 } as MatchStackFrame } const trailingSlash = !last(parts) @@ -1053,16 +1052,25 @@ function getNodeMatch( // other possible approaches: // - shift instead of pop (measure performance difference), this allows iterating "forwards" (effectively breadth-first) // - never remove from the stack, keep a cursor instead. Then we can push "forwards" and avoid reversing the order of candidates (effectively breadth-first) - const stack: Array = [[segmentTree, 1, 0, 1, 0, 0, 0]] + const stack: Array = [ + { + node: segmentTree, + index: 1, + skipped: 0, + depth: 1, + statics: 0, + dynamics: 0, + optionals: 0, + }, + ] let bestFuzzy: Frame | null = null let bestMatch: Frame | null = null while (stack.length) { const frame = stack.pop()! - const [node, index, skipped, depth, statics, dynamics, optionals] = frame - let extract = frame[7] - let params = frame[8] + const { node, index, skipped, depth, statics, dynamics, optionals } = frame + let { extract, params } = frame // Wildcard candidates are pushed speculatively as fallbacks in case a // higher-priority wildcard later fails params.parse. If a better wildcard @@ -1079,8 +1087,8 @@ function getNodeMatch( if (node.parse) { const result = validateParseParams(path, parts, frame) if (!result) continue - params = frame[8] - extract = frame[7] + params = frame.params + extract = frame.extract } // In fuzzy mode, track the best partial match we've found so far @@ -1114,17 +1122,17 @@ function getNodeMatch( // 0. Try index match if (isBeyondPath && node.index) { - const indexFrame: Frame = [ - node.index, + const indexFrame: Frame = { + node: node.index, index, skipped, - depth + 1, + depth: depth + 1, statics, dynamics, optionals, extract, params, - ] + } if (!node.index.parse || validateParseParams(path, parts, indexFrame)) { // perfect match, no need to continue // this is an optimization, algorithm should work correctly without this block @@ -1162,17 +1170,17 @@ function getNodeMatch( if (casePart !== suffix) continue } // wildcard matches consume the rest of the URL and cannot have children - stack.push([ - segment, - partsLength, + stack.push({ + node: segment, + index: partsLength, skipped, - depth + 1, + depth: depth + 1, statics, dynamics, optionals, extract, params, - ]) + }) } } @@ -1183,17 +1191,17 @@ function getNodeMatch( for (let i = node.optional.length - 1; i >= 0; i--) { const segment = node.optional[i]! // when skipping, node and depth advance by 1, but index doesn't - stack.push([ - segment, + stack.push({ + node: segment, index, - nextSkipped, - nextDepth, + skipped: nextSkipped, + depth: nextDepth, statics, dynamics, optionals, extract, params, - ]) // enqueue skipping the optional + }) // enqueue skipping the optional } if (!isBeyondPath) { for (let i = node.optional.length - 1; i >= 0; i--) { @@ -1206,17 +1214,17 @@ function getNodeMatch( if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } - stack.push([ - segment, - index + 1, + stack.push({ + node: segment, + index: index + 1, skipped, - nextDepth, + depth: nextDepth, statics, dynamics, - optionals + segmentScore(partsLength, index), + optionals: optionals + segmentScore(partsLength, index), extract, params, - ]) + }) } } } @@ -1233,17 +1241,17 @@ function getNodeMatch( if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } - stack.push([ - segment, - index + 1, + stack.push({ + node: segment, + index: index + 1, skipped, - depth + 1, + depth: depth + 1, statics, - dynamics + segmentScore(partsLength, index), + dynamics: dynamics + segmentScore(partsLength, index), optionals, extract, params, - ]) + }) } } @@ -1253,17 +1261,17 @@ function getNodeMatch( (lowerPart ??= part!.toLowerCase()), ) if (match) { - stack.push([ - match, - index + 1, + stack.push({ + node: match, + index: index + 1, skipped, - depth + 1, - statics + segmentScore(partsLength, index), + depth: depth + 1, + statics: statics + segmentScore(partsLength, index), dynamics, optionals, extract, params, - ]) + }) } } @@ -1271,17 +1279,17 @@ function getNodeMatch( if (!isBeyondPath && node.static) { const match = node.static.get(part!) if (match) { - stack.push([ - match, - index + 1, + stack.push({ + node: match, + index: index + 1, skipped, - depth + 1, - statics + segmentScore(partsLength, index), + depth: depth + 1, + statics: statics + segmentScore(partsLength, index), dynamics, optionals, extract, params, - ]) + }) } } @@ -1290,17 +1298,17 @@ function getNodeMatch( const nextDepth = depth + 1 for (let i = node.pathless.length - 1; i >= 0; i--) { const segment = node.pathless[i]! - stack.push([ - segment, + stack.push({ + node: segment, index, skipped, - nextDepth, + depth: nextDepth, statics, dynamics, optionals, extract, params, - ]) + }) } } } @@ -1308,12 +1316,12 @@ function getNodeMatch( if (bestMatch) return bestMatch if (fuzzy && bestFuzzy) { - let sliceIndex = bestFuzzy[1] - for (let i = 0; i < bestFuzzy[1]; i++) { + let sliceIndex = bestFuzzy.index + for (let i = 0; i < bestFuzzy.index; i++) { sliceIndex += parts[i]!.length } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) - bestFuzzy[9] = decodeURIComponent(splat) + bestFuzzy.fuzzyRemainder = decodeURIComponent(splat) return bestFuzzy } @@ -1339,19 +1347,18 @@ function validateParseParams( frame: MatchStackFrame, ) { let params: Record - const state: ParamExtractionState = frame[7] ? [...frame[7]] : [0, 0, 0, 0] + frame.extract = frame.extract ? { ...frame.extract } : undefined try { - params = extractParams(path, parts, frame[0], frame[2], state, frame[8]) + params = extractParams(path, parts, frame) } catch { return null } - frame[7] = state - frame[8] = params + frame.params = params try { - const result = frame[0].parse!(params as Record) + const result = frame.node.parse!(params as Record) if (result === false) return null Object.assign(params, result) } catch { @@ -1370,16 +1377,16 @@ function isFrameMoreSpecific( ): boolean { if (!prev) return true return ( - next[4] > prev[4] || - (next[4] === prev[4] && - (next[5] > prev[5] || - (next[5] === prev[5] && - (next[6] > prev[6] || - (next[6] === prev[6] && - ((next[0].kind === SEGMENT_TYPE_INDEX) > - (prev[0].kind === SEGMENT_TYPE_INDEX) || - ((next[0].kind === SEGMENT_TYPE_INDEX) === - (prev[0].kind === SEGMENT_TYPE_INDEX) && - next[3] > prev[3]))))))) + next.statics > prev.statics || + (next.statics === prev.statics && + (next.dynamics > prev.dynamics || + (next.dynamics === prev.dynamics && + (next.optionals > prev.optionals || + (next.optionals === prev.optionals && + ((next.node.kind === SEGMENT_TYPE_INDEX) > + (prev.node.kind === SEGMENT_TYPE_INDEX) || + ((next.node.kind === SEGMENT_TYPE_INDEX) === + (prev.node.kind === SEGMENT_TYPE_INDEX) && + next.depth > prev.depth))))))) ) } From 7ecd4881b099e11c821f031c2a8aa1534d26619f Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 11 Jul 2026 21:35:23 -0700 Subject: [PATCH 15/18] refactor: streamline parameter extraction and matching logic in RouterCore --- .../router-core/src/new-process-route-tree.ts | 162 +++++++++--------- packages/router-core/src/router.ts | 21 +-- 2 files changed, 89 insertions(+), 94 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index cea024056d..1b2db28ffb 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -880,106 +880,101 @@ function extractParams( let pathIndex = leaf.extract?.path ?? 0 /** which fullPath segment we're currently processing */ let segmentCount = leaf.extract?.segment ?? 0 - const recordRouteParams = () => { - while (routeBranch && routeIndex < routeBranch.length) { - const route = routeBranch[routeIndex]! - const routePath = route.fullPath ?? route.from - const routeSegment = - routePath === '/' ? 0 : routePath.split('/').length - 1 - if (routeSegment > segmentCount) { - break - } - routeParams!.push(routeRawParams) - routeRawParams = undefined - routeIndex++ - } - } for ( ; nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++, segmentCount++ ) { const node = list[nodeIndex]! - // index nodes are terminating nodes, nothing to extract, just leave - if (node.kind === SEGMENT_TYPE_INDEX) { - recordRouteParams() - break - } - // pathless nodes do not consume a path segment - if (node.kind === SEGMENT_TYPE_PATHLESS) { + const done = node.kind === SEGMENT_TYPE_INDEX + if (done) { + // index nodes are terminating nodes and have nothing to extract + } else if (node.kind === SEGMENT_TYPE_PATHLESS) { + // pathless nodes do not consume a path segment segmentCount-- partIndex-- pathIndex-- - recordRouteParams() - continue - } - const part = parts[partIndex] - const currentPathIndex = pathIndex - if (part) pathIndex += part.length - nodeParts ??= leaf.node.fullPath.split('/') - const nodePart = nodeParts[segmentCount]! - if (node.kind === SEGMENT_TYPE_PARAM) { - const preLength = node.prefix?.length ?? 0 - // we can't rely on the presence of prefix/suffix to know whether it's curly-braced or not, because `/{$param}/` is valid, but has no prefix/suffix - const isCurlyBraced = nodePart.charCodeAt(preLength) === 123 // '{' - // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node - if (isCurlyBraced) { + } else { + const part = parts[partIndex] + const currentPathIndex = pathIndex + if (part) pathIndex += part.length + nodeParts ??= leaf.node.fullPath.split('/') + const nodePart = nodeParts[segmentCount]! + if (node.kind === SEGMENT_TYPE_PARAM) { + const preLength = node.prefix?.length ?? 0 + // we can't rely on the presence of prefix/suffix to know whether it's curly-braced or not, because `/{$param}/` is valid, but has no prefix/suffix + const isCurlyBraced = nodePart.charCodeAt(preLength) === 123 // '{' + // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node + if (isCurlyBraced) { + const sufLength = node.suffix?.length ?? 0 + const name = nodePart.substring( + preLength + 2, + nodePart.length - sufLength - 1, + ) + const value = part!.substring(preLength, part!.length - sufLength) + const decodedValue = decodeURIComponent(value) + rawParams[name] = decodedValue + ;(routeRawParams ??= Object.create(null))[name] = decodedValue + } else { + const name = nodePart.substring(1) + const decodedValue = decodeURIComponent(part!) + rawParams[name] = decodedValue + ;(routeRawParams ??= Object.create(null))[name] = decodedValue + } + } else if ( + node.kind === SEGMENT_TYPE_OPTIONAL_PARAM && + leaf.skipped & (1 << nodeIndex) + ) { + partIndex-- // stay on the same part + pathIndex = currentPathIndex - 1 // undo pathIndex advancement; -1 to account for loop increment + } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + const preLength = node.prefix?.length ?? 0 const sufLength = node.suffix?.length ?? 0 const name = nodePart.substring( - preLength + 2, + preLength + 3, nodePart.length - sufLength - 1, ) - const value = part!.substring(preLength, part!.length - sufLength) - const decodedValue = decodeURIComponent(value) - rawParams[name] = decodedValue - ;(routeRawParams ??= Object.create(null))[name] = decodedValue - } else { - const name = nodePart.substring(1) - const decodedValue = decodeURIComponent(part!) - rawParams[name] = decodedValue - ;(routeRawParams ??= Object.create(null))[name] = decodedValue - } - } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { - if (leaf.skipped & (1 << nodeIndex)) { - recordRouteParams() - partIndex-- // stay on the same part - pathIndex = currentPathIndex - 1 // undo pathIndex advancement; -1 to account for loop increment - continue + const value = + node.suffix || node.prefix + ? part!.substring(preLength, part!.length - sufLength) + : part + if (value) { + const decodedValue = decodeURIComponent(value) + rawParams[name] = decodedValue + ;(routeRawParams ??= Object.create(null))[name] = decodedValue + } + } else if (node.kind === SEGMENT_TYPE_WILDCARD) { + const preLength = node.prefix?.length ?? 0 + const sufLength = node.suffix?.length ?? 0 + const value = path.substring( + currentPathIndex + preLength, + path.length - sufLength, + ) + const splat = decodeURIComponent(value) + // TODO: Deprecate * + rawParams['*'] = splat + rawParams._splat = splat + const wildcardParams = (routeRawParams ??= Object.create(null)) + wildcardParams['*'] = splat + wildcardParams._splat = splat } - const preLength = node.prefix?.length ?? 0 - const sufLength = node.suffix?.length ?? 0 - const name = nodePart.substring( - preLength + 3, - nodePart.length - sufLength - 1, - ) - const value = - node.suffix || node.prefix - ? part!.substring(preLength, part!.length - sufLength) - : part - if (value) { - const decodedValue = decodeURIComponent(value) - rawParams[name] = decodedValue - ;(routeRawParams ??= Object.create(null))[name] = decodedValue + } + + while (routeBranch && routeIndex < routeBranch.length) { + const route = routeBranch[routeIndex]! + if ( + route.fullPath !== '/' && + route.fullPath!.split('/').length - 1 > segmentCount + ) { + break } - } else if (node.kind === SEGMENT_TYPE_WILDCARD) { - const n = node - const preLength = n.prefix?.length ?? 0 - const sufLength = n.suffix?.length ?? 0 - const value = path.substring( - currentPathIndex + preLength, - path.length - sufLength, - ) - const splat = decodeURIComponent(value) - // TODO: Deprecate * - rawParams['*'] = splat - rawParams._splat = splat - const wildcardParams = (routeRawParams ??= Object.create(null)) - wildcardParams['*'] = splat - wildcardParams._splat = splat - recordRouteParams() + routeParams!.push(routeRawParams) + routeRawParams = undefined + routeIndex++ + } + if (done || node.kind === SEGMENT_TYPE_WILDCARD) { break } - recordRouteParams() } leaf.extract = { part: partIndex, @@ -1347,7 +1342,6 @@ function validateParseParams( frame: MatchStackFrame, ) { let params: Record - frame.extract = frame.extract ? { ...frame.extract } : undefined try { params = extractParams(path, parts, frame) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 8295816369..2b2b2d516b 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1555,12 +1555,6 @@ export class RouterCore< decoder: this.pathParamsDecoder, server: this.isServer, }) - const strictParamsForRoute = Object.assign( - Object.create(null), - routeParams, - routeMatchData?.[index], - ) - // Waste not, want not. If we already have a match for this route, // reuse it. This is important for layout routes, which might stick // around between navigation actions that only change leaf routes. @@ -1579,7 +1573,13 @@ export class RouterCore< const previousMatch = previousActiveMatchesByRouteId.get(route.id) - const strictParams = existingMatch?._strictParams ?? strictParamsForRoute + const strictParams = + existingMatch?._strictParams ?? + Object.assign( + Object.create(null), + routeParams, + routeMatchData?.[index], + ) let paramsError: unknown = undefined @@ -2997,20 +2997,21 @@ export class RouterCore< : this.stores.resolvedLocation.get() || this.stores.location.get() const destinationPath = trimPathRight(next.pathname) + const fuzzy = opts?.fuzzy let routeMatches = pending ? this.stores.pendingMatches.get() : this.stores.matches.get() if (!routeMatches.length) { routeMatches = this.matchRoutes(baseLocation) } - const destinationMatch = opts?.fuzzy + const destinationMatch = fuzzy ? routeMatches.find( (match) => trimPathRight(match.fullPath) === destinationPath, ) : last(routeMatches) if ( !destinationMatch || - (!opts?.fuzzy && + (!fuzzy && trimPathRight(destinationMatch.fullPath) !== destinationPath) || destinationMatch.paramsError ) { @@ -3031,7 +3032,7 @@ export class RouterCore< return false } - if (opts?.fuzzy) { + if (fuzzy) { const remainder = currentPathname.slice(matchedPathname.length) if (remainder) { params['**'] = decodeURIComponent(remainder.replace(/^\/+/, '')) From c1f3318c6042e4ed4a7653b7a53bbfde618c7cbb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:37:29 +0000 Subject: [PATCH 16/18] ci: apply automated fixes --- packages/router-core/src/router.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2b2b2d516b..9f298301ea 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1575,11 +1575,7 @@ export class RouterCore< const strictParams = existingMatch?._strictParams ?? - Object.assign( - Object.create(null), - routeParams, - routeMatchData?.[index], - ) + Object.assign(Object.create(null), routeParams, routeMatchData?.[index]) let paramsError: unknown = undefined From 618b07fbd4e872bbc1faf57f8a86cceab55dad41 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 11 Jul 2026 22:05:09 -0700 Subject: [PATCH 17/18] add tests for reusing active matches and decoding fuzzy parameters --- packages/router-core/src/router.ts | 5 +- .../router-core/tests/match-route.test.ts | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 9f298301ea..af6d42ff91 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2997,6 +2997,9 @@ export class RouterCore< let routeMatches = pending ? this.stores.pendingMatches.get() : this.stores.matches.get() + if (!routeMatches.length && !opts?.pending) { + routeMatches = this.stores.matches.get() + } if (!routeMatches.length) { routeMatches = this.matchRoutes(baseLocation) } @@ -3020,7 +3023,7 @@ export class RouterCore< ) try { - const currentPathname = trimPathRight(decodeURI(baseLocation.pathname)) + const currentPathname = trimPathRight(baseLocation.pathname) const matchedPathname = trimPathRight( decodeURI(destinationMatch.pathname), ) diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts index 2c45481174..0a0aa38128 100644 --- a/packages/router-core/tests/match-route.test.ts +++ b/packages/router-core/tests/match-route.test.ts @@ -126,6 +126,26 @@ describe('matchRoute', () => { expect(parse).not.toHaveBeenCalled() }) + it('reuses active matches instead of rebuilding them while idle', async () => { + const loaderDeps = vi.fn(() => ({})) + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loaderDeps, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + history: createMemoryHistory({ initialEntries: ['/posts'] }), + }) + + await router.load() + loaderDeps.mockClear() + + expect(router.matchRoute({ to: '/posts' })).toEqual({}) + expect(loaderDeps).not.toHaveBeenCalled() + }) + it('does not throw parser errors while checking a match', async () => { const rootRoute = new BaseRootRoute({}) const invoiceRoute = new BaseRoute({ @@ -327,6 +347,35 @@ describe('matchRoute', () => { ).toEqual({ itemId: 'hello world', '**': 'details' }) }) + it.each([ + ['%25', '%'], + ['%2520', '%20'], + ])('decodes the fuzzy remainder %s exactly once', async (encoded, decoded) => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => postsRoute, + path: '/$value', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + postsRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ + initialEntries: [`/posts/${encoded}`], + }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/posts' }, { fuzzy: true })).toEqual({ + '**': decoded, + }) + }) + it('keeps ancestor params when a descendant reuses the param name', async () => { const rootRoute = new BaseRootRoute({}) const orgRoute = new BaseRoute({ From 9276fddd2eda95a27e1b1189e1670321e30a19b5 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:07:27 +0000 Subject: [PATCH 18/18] ci: apply automated fixes --- .../router-core/tests/match-route.test.ts | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts index 0a0aa38128..bf7e78678d 100644 --- a/packages/router-core/tests/match-route.test.ts +++ b/packages/router-core/tests/match-route.test.ts @@ -350,31 +350,34 @@ describe('matchRoute', () => { it.each([ ['%25', '%'], ['%2520', '%20'], - ])('decodes the fuzzy remainder %s exactly once', async (encoded, decoded) => { - const rootRoute = new BaseRootRoute({}) - const postsRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/posts', - }) - const childRoute = new BaseRoute({ - getParentRoute: () => postsRoute, - path: '/$value', - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([ - postsRoute.addChildren([childRoute]), - ]), - history: createMemoryHistory({ - initialEntries: [`/posts/${encoded}`], - }), - }) + ])( + 'decodes the fuzzy remainder %s exactly once', + async (encoded, decoded) => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => postsRoute, + path: '/$value', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + postsRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ + initialEntries: [`/posts/${encoded}`], + }), + }) - await router.load() + await router.load() - expect(router.matchRoute({ to: '/posts' }, { fuzzy: true })).toEqual({ - '**': decoded, - }) - }) + expect(router.matchRoute({ to: '/posts' }, { fuzzy: true })).toEqual({ + '**': decoded, + }) + }, + ) it('keeps ancestor params when a descendant reuses the param name', async () => { const rootRoute = new BaseRootRoute({})