From 63200f6cb5834f3566df6b1f5c0fd47eb7890dc0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 19 Jul 2025 13:33:21 +0200 Subject: [PATCH 01/57] feat(router-plugin) Build-time route matching --- packages/router-core/tests/built.test.ts | 226 +++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 packages/router-core/tests/built.test.ts diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts new file mode 100644 index 00000000000..4bfad4dacf7 --- /dev/null +++ b/packages/router-core/tests/built.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest' +import { joinPaths, parsePathname, processRouteTree, removeBasepath } from '../src' + +interface TestRoute { + id: string + isRoot?: boolean + path?: string + fullPath: string + rank?: number + parentRoute?: TestRoute + children?: Array + options?: { + caseSensitive?: boolean + } +} + +type PathOrChildren = string | [string, Array] + + +function createRoute( + pathOrChildren: Array, + parentPath: string, +): Array { + return pathOrChildren.map((route) => { + if (Array.isArray(route)) { + const fullPath = joinPaths([parentPath, route[0]]) + const children = createRoute(route[1], fullPath) + const r = { + id: fullPath, + path: route[0], + fullPath, + children: children, + } + children.forEach((child) => { + child.parentRoute = r + }) + + return r + } + + const fullPath = joinPaths([parentPath, route]) + + return { + id: fullPath, + path: route, + fullPath, + } + }) +} + +function createRouteTree(pathOrChildren: Array): TestRoute { + return { + id: '__root__', + fullPath: '', + isRoot: true, + path: undefined, + children: createRoute(pathOrChildren, ''), + } +} + + + +const routeTree = createRouteTree([ + '/users/profile/settings', // static-deep (longest static path) + '/users/profile', // static-medium (medium static path) + '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) + '/users/$id', // param-simple (plain param) + '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) + '/files/$', // wildcard (lowest priority) + '/about', // static-shallow (shorter static path) + '/a/profile/settings', + '/a/profile', + '/a/user-{$id}', + '/a/$id', + '/a/{-$slug}', + '/a/$', + '/a', + '/b/profile/settings', + '/b/profile', + '/b/user-{$id}', + '/b/$id', + '/b/{-$slug}', + '/b/$', + '/b', +]) + +const result = processRouteTree({ routeTree }) + +it('work in progress', () => { + const parsedRoutes = result.flatRoutes.map((route) => parsePathname(route.fullPath)) + + const logParsed = (parsed: ReturnType) => '/' + parsed.slice(1).map(s => s.value).join('/') + + const initialDepth = 1 + let fn = 'const toSegments = parsePathname(to);' + fn += '\nconst l = toSegments.length;' + fn += `\nconst s = toSegments[${initialDepth}];` + + function recursiveStaticMatch(parsedRoutes: Array>, depth = initialDepth) { + const resolved = new Set>() + for (const parsed of parsedRoutes) { + if (resolved.has(parsed)) continue // already resolved + console.log('\n') + console.log('resolving: depth=', depth, 'parsed=', logParsed(parsed)) + console.log('\u001b[34m' + fn + '\u001b[0m') + const candidates = parsedRoutes.filter((r) => { + const rParsed = r[depth] + if (!rParsed) return false + return parsed[depth] && rParsed.type === parsed[depth].type && rParsed.value === parsed[depth].value && rParsed.hasStaticAfter === parsed[depth].hasStaticAfter && rParsed.prefixSegment === parsed[depth].prefixSegment && rParsed.suffixSegment === parsed[depth].suffixSegment + }) + console.log('candidates:', candidates.map(logParsed)) + if (candidates.length === 0) { + continue // TODO: this should not happen but it does, fix this + console.log(parsedRoutes.length, parsedRoutes.map(r => r.map(s => s.value).join('/'))) + throw new Error(`No candidates found for depth ${depth} with type ${parsed[depth]!.type} and value ${parsed[depth]!.value}`) + } + const indent = ' '.repeat(depth - initialDepth) + fn += `\n${indent}if (l > ${depth} && s.type === ${parsed[depth]!.type} && s.value === '${parsed[depth]!.value}') {` + if (candidates.length > 1) { + const deeper = candidates.filter(c => c.length > depth - 1) + const leaves = candidates.filter(c => c.length === depth - 1) + if (deeper.length > 0) { + fn += `\n${indent} const s = toSegments[${depth + 1}];` + recursiveStaticMatch(deeper, depth + 1) + } + if (leaves.length > 1) { + throw new Error(`Multiple candidates found for depth ${depth} with type ${parsed[depth]!.type} and value ${parsed[depth]!.value}: ${leaves.map(logParsed).join(', ')}`) + } else if (leaves.length === 1) { + fn += `\n${indent} return '/${leaves[0]!.slice(1).map(s => s.value).join('/')}';` // return the full path + } else { + fn += `\n${indent} return undefined;` // no match found + } + } else { + fn += `\n${indent} return '/${candidates[0]!.slice(1).map(s => s.value).join('/')}';` // return the full path + } + fn += `\n${indent}}` + candidates.forEach(c => resolved.add(c)) + } + } + + recursiveStaticMatch(parsedRoutes) + + console.log('\u001b[34m' + fn + '\u001b[0m') + + expect(fn).toMatchInlineSnapshot(` + "const toSegments = parsePathname(to); + const l = toSegments.length; + const s = toSegments[1]; + if (l > 1 && s.type === 0 && s.value === 'a') { + const s = toSegments[2]; + if (l > 2 && s.type === 0 && s.value === 'profile') { + const s = toSegments[3]; + if (l > 3 && s.type === 0 && s.value === 'settings') { + return '/a/profile/settings'; + } + return undefined; + } + if (l > 2 && s.type === 1 && s.value === '$id') { + return '/a/$id'; + } + if (l > 2 && s.type === 1 && s.value === '$id') { + return '/a/$id'; + } + if (l > 2 && s.type === 3 && s.value === '$slug') { + return '/a/$slug'; + } + if (l > 2 && s.type === 2 && s.value === '$') { + return '/a/$'; + } + return undefined; + } + if (l > 1 && s.type === 0 && s.value === 'b') { + const s = toSegments[2]; + if (l > 2 && s.type === 0 && s.value === 'profile') { + const s = toSegments[3]; + if (l > 3 && s.type === 0 && s.value === 'settings') { + return '/b/profile/settings'; + } + return undefined; + } + if (l > 2 && s.type === 1 && s.value === '$id') { + return '/b/$id'; + } + if (l > 2 && s.type === 1 && s.value === '$id') { + return '/b/$id'; + } + if (l > 2 && s.type === 3 && s.value === '$slug') { + return '/b/$slug'; + } + if (l > 2 && s.type === 2 && s.value === '$') { + return '/b/$'; + } + return undefined; + } + if (l > 1 && s.type === 0 && s.value === 'users') { + const s = toSegments[2]; + if (l > 2 && s.type === 0 && s.value === 'profile') { + const s = toSegments[3]; + if (l > 3 && s.type === 0 && s.value === 'settings') { + return '/users/profile/settings'; + } + return undefined; + } + if (l > 2 && s.type === 1 && s.value === '$id') { + return '/users/$id'; + } + return undefined; + } + if (l > 1 && s.type === 0 && s.value === 'api') { + return '/api/$id'; + } + if (l > 1 && s.type === 0 && s.value === 'posts') { + return '/posts/$slug'; + } + if (l > 1 && s.type === 0 && s.value === 'files') { + return '/files/$'; + } + if (l > 1 && s.type === 0 && s.value === 'about') { + return '/about'; + }" + `) + + const yo = new Function('parsePathname', 'to', fn) as (parser: typeof parsePathname, to: string) => string | undefined + expect(yo(parsePathname, '/users/profile/settings')).toBe('/users/profile/settings') + +}) \ No newline at end of file From a72186ea1f564b9859a08e6546b22635c262bbb1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 11:36:30 +0000 Subject: [PATCH 02/57] ci: apply automated fixes --- packages/router-core/tests/built.test.ts | 76 +++++++++++++++++------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 4bfad4dacf7..f5e7ba1c0ae 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { joinPaths, parsePathname, processRouteTree, removeBasepath } from '../src' +import { + joinPaths, + parsePathname, + processRouteTree, + removeBasepath, +} from '../src' interface TestRoute { id: string @@ -16,7 +21,6 @@ interface TestRoute { type PathOrChildren = string | [string, Array] - function createRoute( pathOrChildren: Array, parentPath: string, @@ -58,8 +62,6 @@ function createRouteTree(pathOrChildren: Array): TestRoute { } } - - const routeTree = createRouteTree([ '/users/profile/settings', // static-deep (longest static path) '/users/profile', // static-medium (medium static path) @@ -87,16 +89,26 @@ const routeTree = createRouteTree([ const result = processRouteTree({ routeTree }) it('work in progress', () => { - const parsedRoutes = result.flatRoutes.map((route) => parsePathname(route.fullPath)) + const parsedRoutes = result.flatRoutes.map((route) => + parsePathname(route.fullPath), + ) - const logParsed = (parsed: ReturnType) => '/' + parsed.slice(1).map(s => s.value).join('/') + const logParsed = (parsed: ReturnType) => + '/' + + parsed + .slice(1) + .map((s) => s.value) + .join('/') const initialDepth = 1 let fn = 'const toSegments = parsePathname(to);' fn += '\nconst l = toSegments.length;' fn += `\nconst s = toSegments[${initialDepth}];` - function recursiveStaticMatch(parsedRoutes: Array>, depth = initialDepth) { + function recursiveStaticMatch( + parsedRoutes: Array>, + depth = initialDepth, + ) { const resolved = new Set>() for (const parsed of parsedRoutes) { if (resolved.has(parsed)) continue // already resolved @@ -106,35 +118,55 @@ it('work in progress', () => { const candidates = parsedRoutes.filter((r) => { const rParsed = r[depth] if (!rParsed) return false - return parsed[depth] && rParsed.type === parsed[depth].type && rParsed.value === parsed[depth].value && rParsed.hasStaticAfter === parsed[depth].hasStaticAfter && rParsed.prefixSegment === parsed[depth].prefixSegment && rParsed.suffixSegment === parsed[depth].suffixSegment + return ( + parsed[depth] && + rParsed.type === parsed[depth].type && + rParsed.value === parsed[depth].value && + rParsed.hasStaticAfter === parsed[depth].hasStaticAfter && + rParsed.prefixSegment === parsed[depth].prefixSegment && + rParsed.suffixSegment === parsed[depth].suffixSegment + ) }) console.log('candidates:', candidates.map(logParsed)) if (candidates.length === 0) { continue // TODO: this should not happen but it does, fix this - console.log(parsedRoutes.length, parsedRoutes.map(r => r.map(s => s.value).join('/'))) - throw new Error(`No candidates found for depth ${depth} with type ${parsed[depth]!.type} and value ${parsed[depth]!.value}`) + console.log( + parsedRoutes.length, + parsedRoutes.map((r) => r.map((s) => s.value).join('/')), + ) + throw new Error( + `No candidates found for depth ${depth} with type ${parsed[depth]!.type} and value ${parsed[depth]!.value}`, + ) } const indent = ' '.repeat(depth - initialDepth) fn += `\n${indent}if (l > ${depth} && s.type === ${parsed[depth]!.type} && s.value === '${parsed[depth]!.value}') {` if (candidates.length > 1) { - const deeper = candidates.filter(c => c.length > depth - 1) - const leaves = candidates.filter(c => c.length === depth - 1) + const deeper = candidates.filter((c) => c.length > depth - 1) + const leaves = candidates.filter((c) => c.length === depth - 1) if (deeper.length > 0) { fn += `\n${indent} const s = toSegments[${depth + 1}];` recursiveStaticMatch(deeper, depth + 1) } if (leaves.length > 1) { - throw new Error(`Multiple candidates found for depth ${depth} with type ${parsed[depth]!.type} and value ${parsed[depth]!.value}: ${leaves.map(logParsed).join(', ')}`) + throw new Error( + `Multiple candidates found for depth ${depth} with type ${parsed[depth]!.type} and value ${parsed[depth]!.value}: ${leaves.map(logParsed).join(', ')}`, + ) } else if (leaves.length === 1) { - fn += `\n${indent} return '/${leaves[0]!.slice(1).map(s => s.value).join('/')}';` // return the full path + fn += `\n${indent} return '/${leaves[0]! + .slice(1) + .map((s) => s.value) + .join('/')}';` // return the full path } else { fn += `\n${indent} return undefined;` // no match found } } else { - fn += `\n${indent} return '/${candidates[0]!.slice(1).map(s => s.value).join('/')}';` // return the full path + fn += `\n${indent} return '/${candidates[0]! + .slice(1) + .map((s) => s.value) + .join('/')}';` // return the full path } fn += `\n${indent}}` - candidates.forEach(c => resolved.add(c)) + candidates.forEach((c) => resolved.add(c)) } } @@ -220,7 +252,11 @@ it('work in progress', () => { }" `) - const yo = new Function('parsePathname', 'to', fn) as (parser: typeof parsePathname, to: string) => string | undefined - expect(yo(parsePathname, '/users/profile/settings')).toBe('/users/profile/settings') - -}) \ No newline at end of file + const yo = new Function('parsePathname', 'to', fn) as ( + parser: typeof parsePathname, + to: string, + ) => string | undefined + expect(yo(parsePathname, '/users/profile/settings')).toBe( + '/users/profile/settings', + ) +}) From 02ccf6403e919ee78fb256efc68d5e220bb36c83 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 19 Jul 2025 14:06:58 +0200 Subject: [PATCH 03/57] fix depth iteration issue --- packages/router-core/tests/built.test.ts | 43 ++++++++++++------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index f5e7ba1c0ae..bffa4ee5b2c 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -1,10 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { - joinPaths, - parsePathname, - processRouteTree, - removeBasepath, -} from '../src' +import { expect, it } from 'vitest' +import { joinPaths, parsePathname, processRouteTree } from '../src' interface TestRoute { id: string @@ -115,21 +110,27 @@ it('work in progress', () => { console.log('\n') console.log('resolving: depth=', depth, 'parsed=', logParsed(parsed)) console.log('\u001b[34m' + fn + '\u001b[0m') + const currentSegment = parsed[depth] + if (!currentSegment) { + // should not be possible + throw new Error( + `No segment found at depth ${depth} in parsed route: ${logParsed(parsed)}`, + ) // no segment at this depth, so we can't match + } const candidates = parsedRoutes.filter((r) => { const rParsed = r[depth] if (!rParsed) return false return ( - parsed[depth] && - rParsed.type === parsed[depth].type && - rParsed.value === parsed[depth].value && - rParsed.hasStaticAfter === parsed[depth].hasStaticAfter && - rParsed.prefixSegment === parsed[depth].prefixSegment && - rParsed.suffixSegment === parsed[depth].suffixSegment + rParsed.type === currentSegment.type && + rParsed.value === currentSegment.value && + rParsed.hasStaticAfter === currentSegment.hasStaticAfter && + rParsed.prefixSegment === currentSegment.prefixSegment && + rParsed.suffixSegment === currentSegment.suffixSegment ) }) console.log('candidates:', candidates.map(logParsed)) if (candidates.length === 0) { - continue // TODO: this should not happen but it does, fix this + // should not be possible console.log( parsedRoutes.length, parsedRoutes.map((r) => r.map((s) => s.value).join('/')), @@ -141,8 +142,8 @@ it('work in progress', () => { const indent = ' '.repeat(depth - initialDepth) fn += `\n${indent}if (l > ${depth} && s.type === ${parsed[depth]!.type} && s.value === '${parsed[depth]!.value}') {` if (candidates.length > 1) { - const deeper = candidates.filter((c) => c.length > depth - 1) - const leaves = candidates.filter((c) => c.length === depth - 1) + const deeper = candidates.filter((c) => c.length > depth + 1) + const leaves = candidates.filter((c) => c.length <= depth + 1) if (deeper.length > 0) { fn += `\n${indent} const s = toSegments[${depth + 1}];` recursiveStaticMatch(deeper, depth + 1) @@ -185,7 +186,7 @@ it('work in progress', () => { if (l > 3 && s.type === 0 && s.value === 'settings') { return '/a/profile/settings'; } - return undefined; + return '/a/profile'; } if (l > 2 && s.type === 1 && s.value === '$id') { return '/a/$id'; @@ -199,7 +200,7 @@ it('work in progress', () => { if (l > 2 && s.type === 2 && s.value === '$') { return '/a/$'; } - return undefined; + return '/a'; } if (l > 1 && s.type === 0 && s.value === 'b') { const s = toSegments[2]; @@ -208,7 +209,7 @@ it('work in progress', () => { if (l > 3 && s.type === 0 && s.value === 'settings') { return '/b/profile/settings'; } - return undefined; + return '/b/profile'; } if (l > 2 && s.type === 1 && s.value === '$id') { return '/b/$id'; @@ -222,7 +223,7 @@ it('work in progress', () => { if (l > 2 && s.type === 2 && s.value === '$') { return '/b/$'; } - return undefined; + return '/b'; } if (l > 1 && s.type === 0 && s.value === 'users') { const s = toSegments[2]; @@ -231,7 +232,7 @@ it('work in progress', () => { if (l > 3 && s.type === 0 && s.value === 'settings') { return '/users/profile/settings'; } - return undefined; + return '/users/profile'; } if (l > 2 && s.type === 1 && s.value === '$id') { return '/users/$id'; From 90ae08207b56e50084eff09f7ab71d023ea31415 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 19 Jul 2025 15:13:51 +0200 Subject: [PATCH 04/57] better leaf conditions --- packages/router-core/tests/built.test.ts | 274 +++++++++++++++++------ 1 file changed, 209 insertions(+), 65 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index bffa4ee5b2c..d96e9a796ce 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -79,11 +79,54 @@ const routeTree = createRouteTree([ '/b/{-$slug}', '/b/$', '/b', + '/foo/bar/$id', + '/foo/$id/bar', + '/$id/bar/foo', + '/$id/foo/bar', + '/a/b/c/d/e/f', + '/beep/boop', + '/one/two', + '/one' ]) const result = processRouteTree({ routeTree }) it('work in progress', () => { + + expect(result.flatRoutes.map(r => r.id)).toMatchInlineSnapshot(` + [ + "/a/b/c/d/e/f", + "/a/profile/settings", + "/b/profile/settings", + "/users/profile/settings", + "/foo/bar/$id", + "/a/profile", + "/b/profile", + "/beep/boop", + "/one/two", + "/users/profile", + "/foo/$id/bar", + "/a/user-{$id}", + "/api/user-{$id}", + "/b/user-{$id}", + "/a/$id", + "/b/$id", + "/users/$id", + "/a/{-$slug}", + "/b/{-$slug}", + "/posts/{-$slug}", + "/a/$", + "/b/$", + "/files/$", + "/a", + "/about", + "/b", + "/one", + "/$id/bar/foo", + "/$id/foo/bar", + ] + `) + const parsedRoutes = result.flatRoutes.map((route) => parsePathname(route.fullPath), ) @@ -96,9 +139,9 @@ it('work in progress', () => { .join('/') const initialDepth = 1 - let fn = 'const toSegments = parsePathname(to);' - fn += '\nconst l = toSegments.length;' - fn += `\nconst s = toSegments[${initialDepth}];` + let fn = 'const baseSegments = parsePathname(from);' + fn += '\nconst l = baseSegments.length;' + fn += `\nconst {type, value} = baseSegments[${initialDepth}];` function recursiveStaticMatch( parsedRoutes: Array>, @@ -112,10 +155,7 @@ it('work in progress', () => { console.log('\u001b[34m' + fn + '\u001b[0m') const currentSegment = parsed[depth] if (!currentSegment) { - // should not be possible - throw new Error( - `No segment found at depth ${depth} in parsed route: ${logParsed(parsed)}`, - ) // no segment at this depth, so we can't match + throw new Error("Implementation error: this should not happen") } const candidates = parsedRoutes.filter((r) => { const rParsed = r[depth] @@ -130,22 +170,19 @@ it('work in progress', () => { }) console.log('candidates:', candidates.map(logParsed)) if (candidates.length === 0) { - // should not be possible - console.log( - parsedRoutes.length, - parsedRoutes.map((r) => r.map((s) => s.value).join('/')), - ) - throw new Error( - `No candidates found for depth ${depth} with type ${parsed[depth]!.type} and value ${parsed[depth]!.value}`, - ) + throw new Error("Implementation error: this should not happen") } const indent = ' '.repeat(depth - initialDepth) - fn += `\n${indent}if (l > ${depth} && s.type === ${parsed[depth]!.type} && s.value === '${parsed[depth]!.value}') {` + const lCondition = depth > initialDepth ? `l > ${depth} && ` : '' if (candidates.length > 1) { + fn += `\n${indent}if (${lCondition}type === ${parsed[depth]!.type} && value === '${parsed[depth]!.value}') {` const deeper = candidates.filter((c) => c.length > depth + 1) - const leaves = candidates.filter((c) => c.length <= depth + 1) + const leaves = candidates.filter((c) => c.length === depth + 1) + if (deeper.length + leaves.length !== candidates.length) { + throw new Error("Implementation error: this should not happen") + } if (deeper.length > 0) { - fn += `\n${indent} const s = toSegments[${depth + 1}];` + fn += `\n${indent} const {type, value} = baseSegments[${depth + 1}];` recursiveStaticMatch(deeper, depth + 1) } if (leaves.length > 1) { @@ -153,18 +190,25 @@ it('work in progress', () => { `Multiple candidates found for depth ${depth} with type ${parsed[depth]!.type} and value ${parsed[depth]!.value}: ${leaves.map(logParsed).join(', ')}`, ) } else if (leaves.length === 1) { - fn += `\n${indent} return '/${leaves[0]! - .slice(1) - .map((s) => s.value) - .join('/')}';` // return the full path - } else { - fn += `\n${indent} return undefined;` // no match found + fn += `\n${indent} if (l === ${leaves[0]!.length}) {` + fn += `\n${indent} return '/${leaves[0]!.slice(1).map((s) => s.value).join('/')}';` + fn += `\n${indent} }` } } else { - fn += `\n${indent} return '/${candidates[0]! + const leaf = candidates[0]! + const done = `return '/${leaf .slice(1) .map((s) => s.value) - .join('/')}';` // return the full path + .join('/')}';` + fn += `\n${indent}if (l === ${leaf.length}` + for (let i = depth; i < leaf.length; i++) { + const segment = leaf[i]! + const type = i === depth ? 'type' : `baseSegments[${i}].type` + const value = i === depth ? 'value' : `baseSegments[${i}].value` + fn += `\n${indent} && ${type} === ${segment.type} && ${value} === '${segment.value}'` + } + fn += `\n${indent}) {` + fn += `\n${indent} ${done}` } fn += `\n${indent}}` candidates.forEach((c) => resolved.add(c)) @@ -176,86 +220,186 @@ it('work in progress', () => { console.log('\u001b[34m' + fn + '\u001b[0m') expect(fn).toMatchInlineSnapshot(` - "const toSegments = parsePathname(to); - const l = toSegments.length; - const s = toSegments[1]; - if (l > 1 && s.type === 0 && s.value === 'a') { - const s = toSegments[2]; - if (l > 2 && s.type === 0 && s.value === 'profile') { - const s = toSegments[3]; - if (l > 3 && s.type === 0 && s.value === 'settings') { + "const baseSegments = parsePathname(from); + const l = baseSegments.length; + const {type, value} = baseSegments[1]; + if (type === 0 && value === 'a') { + const {type, value} = baseSegments[2]; + if (l === 7 + && type === 0 && value === 'b' + && baseSegments[3].type === 0 && baseSegments[3].value === 'c' + && baseSegments[4].type === 0 && baseSegments[4].value === 'd' + && baseSegments[5].type === 0 && baseSegments[5].value === 'e' + && baseSegments[6].type === 0 && baseSegments[6].value === 'f' + ) { + return '/a/b/c/d/e/f'; + } + if (l > 2 && type === 0 && value === 'profile') { + const {type, value} = baseSegments[3]; + if (l === 4 + && type === 0 && value === 'settings' + ) { return '/a/profile/settings'; } - return '/a/profile'; + if (l === 3) { + return '/a/profile'; + } } - if (l > 2 && s.type === 1 && s.value === '$id') { + if (l === 3 + && type === 1 && value === '$id' + ) { return '/a/$id'; } - if (l > 2 && s.type === 1 && s.value === '$id') { + if (l === 3 + && type === 1 && value === '$id' + ) { return '/a/$id'; } - if (l > 2 && s.type === 3 && s.value === '$slug') { + if (l === 3 + && type === 3 && value === '$slug' + ) { return '/a/$slug'; } - if (l > 2 && s.type === 2 && s.value === '$') { + if (l === 3 + && type === 2 && value === '$' + ) { return '/a/$'; } - return '/a'; + if (l === 2) { + return '/a'; + } } - if (l > 1 && s.type === 0 && s.value === 'b') { - const s = toSegments[2]; - if (l > 2 && s.type === 0 && s.value === 'profile') { - const s = toSegments[3]; - if (l > 3 && s.type === 0 && s.value === 'settings') { + if (type === 0 && value === 'b') { + const {type, value} = baseSegments[2]; + if (l > 2 && type === 0 && value === 'profile') { + const {type, value} = baseSegments[3]; + if (l === 4 + && type === 0 && value === 'settings' + ) { return '/b/profile/settings'; } - return '/b/profile'; + if (l === 3) { + return '/b/profile'; + } } - if (l > 2 && s.type === 1 && s.value === '$id') { + if (l === 3 + && type === 1 && value === '$id' + ) { return '/b/$id'; } - if (l > 2 && s.type === 1 && s.value === '$id') { + if (l === 3 + && type === 1 && value === '$id' + ) { return '/b/$id'; } - if (l > 2 && s.type === 3 && s.value === '$slug') { + if (l === 3 + && type === 3 && value === '$slug' + ) { return '/b/$slug'; } - if (l > 2 && s.type === 2 && s.value === '$') { + if (l === 3 + && type === 2 && value === '$' + ) { return '/b/$'; } - return '/b'; + if (l === 2) { + return '/b'; + } } - if (l > 1 && s.type === 0 && s.value === 'users') { - const s = toSegments[2]; - if (l > 2 && s.type === 0 && s.value === 'profile') { - const s = toSegments[3]; - if (l > 3 && s.type === 0 && s.value === 'settings') { + if (type === 0 && value === 'users') { + const {type, value} = baseSegments[2]; + if (l > 2 && type === 0 && value === 'profile') { + const {type, value} = baseSegments[3]; + if (l === 4 + && type === 0 && value === 'settings' + ) { return '/users/profile/settings'; } - return '/users/profile'; + if (l === 3) { + return '/users/profile'; + } } - if (l > 2 && s.type === 1 && s.value === '$id') { + if (l === 3 + && type === 1 && value === '$id' + ) { return '/users/$id'; } - return undefined; } - if (l > 1 && s.type === 0 && s.value === 'api') { + if (type === 0 && value === 'foo') { + const {type, value} = baseSegments[2]; + if (l === 4 + && type === 0 && value === 'bar' + && baseSegments[3].type === 1 && baseSegments[3].value === '$id' + ) { + return '/foo/bar/$id'; + } + if (l === 4 + && type === 1 && value === '$id' + && baseSegments[3].type === 0 && baseSegments[3].value === 'bar' + ) { + return '/foo/$id/bar'; + } + } + if (l === 3 + && type === 0 && value === 'beep' + && baseSegments[2].type === 0 && baseSegments[2].value === 'boop' + ) { + return '/beep/boop'; + } + if (type === 0 && value === 'one') { + const {type, value} = baseSegments[2]; + if (l === 3 + && type === 0 && value === 'two' + ) { + return '/one/two'; + } + if (l === 2) { + return '/one'; + } + } + if (l === 3 + && type === 0 && value === 'api' + && baseSegments[2].type === 1 && baseSegments[2].value === '$id' + ) { return '/api/$id'; } - if (l > 1 && s.type === 0 && s.value === 'posts') { + if (l === 3 + && type === 0 && value === 'posts' + && baseSegments[2].type === 3 && baseSegments[2].value === '$slug' + ) { return '/posts/$slug'; } - if (l > 1 && s.type === 0 && s.value === 'files') { + if (l === 3 + && type === 0 && value === 'files' + && baseSegments[2].type === 2 && baseSegments[2].value === '$' + ) { return '/files/$'; } - if (l > 1 && s.type === 0 && s.value === 'about') { + if (l === 2 + && type === 0 && value === 'about' + ) { return '/about'; + } + if (type === 1 && value === '$id') { + const {type, value} = baseSegments[2]; + if (l === 4 + && type === 0 && value === 'bar' + && baseSegments[3].type === 0 && baseSegments[3].value === 'foo' + ) { + return '/$id/bar/foo'; + } + if (l === 4 + && type === 0 && value === 'foo' + && baseSegments[3].type === 0 && baseSegments[3].value === 'bar' + ) { + return '/$id/foo/bar'; + } }" `) - const yo = new Function('parsePathname', 'to', fn) as ( + const yo = new Function('parsePathname', 'from', fn) as ( parser: typeof parsePathname, - to: string, + from: string, ) => string | undefined expect(yo(parsePathname, '/users/profile/settings')).toBe( '/users/profile/settings', From a66b79fd11200133d09cad220fc36e0abc5d8bff Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:15:14 +0000 Subject: [PATCH 05/57] ci: apply automated fixes --- packages/router-core/tests/built.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index d96e9a796ce..6397ad4e8be 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -86,14 +86,13 @@ const routeTree = createRouteTree([ '/a/b/c/d/e/f', '/beep/boop', '/one/two', - '/one' + '/one', ]) const result = processRouteTree({ routeTree }) it('work in progress', () => { - - expect(result.flatRoutes.map(r => r.id)).toMatchInlineSnapshot(` + expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` [ "/a/b/c/d/e/f", "/a/profile/settings", @@ -155,7 +154,7 @@ it('work in progress', () => { console.log('\u001b[34m' + fn + '\u001b[0m') const currentSegment = parsed[depth] if (!currentSegment) { - throw new Error("Implementation error: this should not happen") + throw new Error('Implementation error: this should not happen') } const candidates = parsedRoutes.filter((r) => { const rParsed = r[depth] @@ -170,7 +169,7 @@ it('work in progress', () => { }) console.log('candidates:', candidates.map(logParsed)) if (candidates.length === 0) { - throw new Error("Implementation error: this should not happen") + throw new Error('Implementation error: this should not happen') } const indent = ' '.repeat(depth - initialDepth) const lCondition = depth > initialDepth ? `l > ${depth} && ` : '' @@ -179,7 +178,7 @@ it('work in progress', () => { const deeper = candidates.filter((c) => c.length > depth + 1) const leaves = candidates.filter((c) => c.length === depth + 1) if (deeper.length + leaves.length !== candidates.length) { - throw new Error("Implementation error: this should not happen") + throw new Error('Implementation error: this should not happen') } if (deeper.length > 0) { fn += `\n${indent} const {type, value} = baseSegments[${depth + 1}];` @@ -191,7 +190,10 @@ it('work in progress', () => { ) } else if (leaves.length === 1) { fn += `\n${indent} if (l === ${leaves[0]!.length}) {` - fn += `\n${indent} return '/${leaves[0]!.slice(1).map((s) => s.value).join('/')}';` + fn += `\n${indent} return '/${leaves[0]! + .slice(1) + .map((s) => s.value) + .join('/')}';` fn += `\n${indent} }` } } else { From 5f0b3270c6639851580b1df767beebb4dd74cd8c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 19 Jul 2025 19:18:44 +0200 Subject: [PATCH 06/57] 2 more cases --- packages/router-core/tests/built.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 6397ad4e8be..c3a444683e6 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -81,6 +81,8 @@ const routeTree = createRouteTree([ '/b', '/foo/bar/$id', '/foo/$id/bar', + '/foo/$bar/', + '/foo/{-$bar}/qux', '/$id/bar/foo', '/$id/foo/bar', '/a/b/c/d/e/f', @@ -105,9 +107,11 @@ it('work in progress', () => { "/one/two", "/users/profile", "/foo/$id/bar", + "/foo/{-$bar}/qux", "/a/user-{$id}", "/api/user-{$id}", "/b/user-{$id}", + "/foo/$bar/", "/a/$id", "/b/$id", "/users/$id", @@ -341,6 +345,18 @@ it('work in progress', () => { ) { return '/foo/$id/bar'; } + if (l === 4 + && type === 3 && value === '$bar' + && baseSegments[3].type === 0 && baseSegments[3].value === 'qux' + ) { + return '/foo/$bar/qux'; + } + if (l === 4 + && type === 1 && value === '$bar' + && baseSegments[3].type === 0 && baseSegments[3].value === '/' + ) { + return '/foo/$bar//'; + } } if (l === 3 && type === 0 && value === 'beep' From 1eb8ce5741c8b2e55dc4518bc6354be015d69c22 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 19 Jul 2025 19:45:51 +0200 Subject: [PATCH 07/57] test setup now shows actual results --- packages/router-core/tests/built.test.ts | 436 ++++++++++++----------- 1 file changed, 231 insertions(+), 205 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index c3a444683e6..59101146e6c 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -1,5 +1,10 @@ -import { expect, it } from 'vitest' -import { joinPaths, parsePathname, processRouteTree } from '../src' +import { describe, expect, it, test } from 'vitest' +import { + joinPaths, + matchPathname, + parsePathname, + processRouteTree, +} from '../src' interface TestRoute { id: string @@ -93,42 +98,51 @@ const routeTree = createRouteTree([ const result = processRouteTree({ routeTree }) -it('work in progress', () => { - expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` - [ - "/a/b/c/d/e/f", - "/a/profile/settings", - "/b/profile/settings", - "/users/profile/settings", - "/foo/bar/$id", - "/a/profile", - "/b/profile", - "/beep/boop", - "/one/two", - "/users/profile", - "/foo/$id/bar", - "/foo/{-$bar}/qux", - "/a/user-{$id}", - "/api/user-{$id}", - "/b/user-{$id}", - "/foo/$bar/", - "/a/$id", - "/b/$id", - "/users/$id", - "/a/{-$slug}", - "/b/{-$slug}", - "/posts/{-$slug}", - "/a/$", - "/b/$", - "/files/$", - "/a", - "/about", - "/b", - "/one", - "/$id/bar/foo", - "/$id/foo/bar", - ] - `) +function originalMatcher(from: string): string | undefined { + const match = result.flatRoutes.find((r) => + matchPathname('/', from, { to: r.fullPath }), + ) + return match?.fullPath +} + +describe('work in progress', () => { + it('is ordrered', () => { + expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` + [ + "/a/b/c/d/e/f", + "/a/profile/settings", + "/b/profile/settings", + "/users/profile/settings", + "/foo/bar/$id", + "/a/profile", + "/b/profile", + "/beep/boop", + "/one/two", + "/users/profile", + "/foo/$id/bar", + "/foo/{-$bar}/qux", + "/a/user-{$id}", + "/api/user-{$id}", + "/b/user-{$id}", + "/foo/$bar/", + "/a/$id", + "/b/$id", + "/users/$id", + "/a/{-$slug}", + "/b/{-$slug}", + "/posts/{-$slug}", + "/a/$", + "/b/$", + "/files/$", + "/a", + "/about", + "/b", + "/one", + "/$id/bar/foo", + "/$id/foo/bar", + ] + `) + }) const parsedRoutes = result.flatRoutes.map((route) => parsePathname(route.fullPath), @@ -196,7 +210,7 @@ it('work in progress', () => { fn += `\n${indent} if (l === ${leaves[0]!.length}) {` fn += `\n${indent} return '/${leaves[0]! .slice(1) - .map((s) => s.value) + .map((s) => (s.value === '/' ? '' : s.value)) .join('/')}';` fn += `\n${indent} }` } @@ -204,7 +218,7 @@ it('work in progress', () => { const leaf = candidates[0]! const done = `return '/${leaf .slice(1) - .map((s) => s.value) + .map((s) => (s.value === '/' ? '' : s.value)) .join('/')}';` fn += `\n${indent}if (l === ${leaf.length}` for (let i = depth; i < leaf.length; i++) { @@ -223,203 +237,215 @@ it('work in progress', () => { recursiveStaticMatch(parsedRoutes) - console.log('\u001b[34m' + fn + '\u001b[0m') - - expect(fn).toMatchInlineSnapshot(` - "const baseSegments = parsePathname(from); - const l = baseSegments.length; - const {type, value} = baseSegments[1]; - if (type === 0 && value === 'a') { - const {type, value} = baseSegments[2]; - if (l === 7 - && type === 0 && value === 'b' - && baseSegments[3].type === 0 && baseSegments[3].value === 'c' - && baseSegments[4].type === 0 && baseSegments[4].value === 'd' - && baseSegments[5].type === 0 && baseSegments[5].value === 'e' - && baseSegments[6].type === 0 && baseSegments[6].value === 'f' - ) { - return '/a/b/c/d/e/f'; - } - if (l > 2 && type === 0 && value === 'profile') { - const {type, value} = baseSegments[3]; - if (l === 4 - && type === 0 && value === 'settings' + it('generates a matching function', () => { + expect(fn).toMatchInlineSnapshot(` + "const baseSegments = parsePathname(from); + const l = baseSegments.length; + const {type, value} = baseSegments[1]; + if (type === 0 && value === 'a') { + const {type, value} = baseSegments[2]; + if (l === 7 + && type === 0 && value === 'b' + && baseSegments[3].type === 0 && baseSegments[3].value === 'c' + && baseSegments[4].type === 0 && baseSegments[4].value === 'd' + && baseSegments[5].type === 0 && baseSegments[5].value === 'e' + && baseSegments[6].type === 0 && baseSegments[6].value === 'f' + ) { + return '/a/b/c/d/e/f'; + } + if (l > 2 && type === 0 && value === 'profile') { + const {type, value} = baseSegments[3]; + if (l === 4 + && type === 0 && value === 'settings' + ) { + return '/a/profile/settings'; + } + if (l === 3) { + return '/a/profile'; + } + } + if (l === 3 + && type === 1 && value === '$id' ) { - return '/a/profile/settings'; + return '/a/$id'; } - if (l === 3) { - return '/a/profile'; + if (l === 3 + && type === 1 && value === '$id' + ) { + return '/a/$id'; + } + if (l === 3 + && type === 3 && value === '$slug' + ) { + return '/a/$slug'; + } + if (l === 3 + && type === 2 && value === '$' + ) { + return '/a/$'; + } + if (l === 2) { + return '/a'; } } - if (l === 3 - && type === 1 && value === '$id' - ) { - return '/a/$id'; + if (type === 0 && value === 'b') { + const {type, value} = baseSegments[2]; + if (l > 2 && type === 0 && value === 'profile') { + const {type, value} = baseSegments[3]; + if (l === 4 + && type === 0 && value === 'settings' + ) { + return '/b/profile/settings'; + } + if (l === 3) { + return '/b/profile'; + } + } + if (l === 3 + && type === 1 && value === '$id' + ) { + return '/b/$id'; + } + if (l === 3 + && type === 1 && value === '$id' + ) { + return '/b/$id'; + } + if (l === 3 + && type === 3 && value === '$slug' + ) { + return '/b/$slug'; + } + if (l === 3 + && type === 2 && value === '$' + ) { + return '/b/$'; + } + if (l === 2) { + return '/b'; + } } - if (l === 3 - && type === 1 && value === '$id' - ) { - return '/a/$id'; + if (type === 0 && value === 'users') { + const {type, value} = baseSegments[2]; + if (l > 2 && type === 0 && value === 'profile') { + const {type, value} = baseSegments[3]; + if (l === 4 + && type === 0 && value === 'settings' + ) { + return '/users/profile/settings'; + } + if (l === 3) { + return '/users/profile'; + } + } + if (l === 3 + && type === 1 && value === '$id' + ) { + return '/users/$id'; + } } - if (l === 3 - && type === 3 && value === '$slug' - ) { - return '/a/$slug'; + if (type === 0 && value === 'foo') { + const {type, value} = baseSegments[2]; + if (l === 4 + && type === 0 && value === 'bar' + && baseSegments[3].type === 1 && baseSegments[3].value === '$id' + ) { + return '/foo/bar/$id'; + } + if (l === 4 + && type === 1 && value === '$id' + && baseSegments[3].type === 0 && baseSegments[3].value === 'bar' + ) { + return '/foo/$id/bar'; + } + if (l === 4 + && type === 3 && value === '$bar' + && baseSegments[3].type === 0 && baseSegments[3].value === 'qux' + ) { + return '/foo/$bar/qux'; + } + if (l === 4 + && type === 1 && value === '$bar' + && baseSegments[3].type === 0 && baseSegments[3].value === '/' + ) { + return '/foo/$bar/'; + } } if (l === 3 - && type === 2 && value === '$' + && type === 0 && value === 'beep' + && baseSegments[2].type === 0 && baseSegments[2].value === 'boop' ) { - return '/a/$'; - } - if (l === 2) { - return '/a'; + return '/beep/boop'; } - } - if (type === 0 && value === 'b') { - const {type, value} = baseSegments[2]; - if (l > 2 && type === 0 && value === 'profile') { - const {type, value} = baseSegments[3]; - if (l === 4 - && type === 0 && value === 'settings' + if (type === 0 && value === 'one') { + const {type, value} = baseSegments[2]; + if (l === 3 + && type === 0 && value === 'two' ) { - return '/b/profile/settings'; + return '/one/two'; } - if (l === 3) { - return '/b/profile'; + if (l === 2) { + return '/one'; } } if (l === 3 - && type === 1 && value === '$id' + && type === 0 && value === 'api' + && baseSegments[2].type === 1 && baseSegments[2].value === '$id' ) { - return '/b/$id'; + return '/api/$id'; } if (l === 3 - && type === 1 && value === '$id' + && type === 0 && value === 'posts' + && baseSegments[2].type === 3 && baseSegments[2].value === '$slug' ) { - return '/b/$id'; + return '/posts/$slug'; } if (l === 3 - && type === 3 && value === '$slug' + && type === 0 && value === 'files' + && baseSegments[2].type === 2 && baseSegments[2].value === '$' ) { - return '/b/$slug'; + return '/files/$'; } - if (l === 3 - && type === 2 && value === '$' + if (l === 2 + && type === 0 && value === 'about' ) { - return '/b/$'; - } - if (l === 2) { - return '/b'; + return '/about'; } - } - if (type === 0 && value === 'users') { - const {type, value} = baseSegments[2]; - if (l > 2 && type === 0 && value === 'profile') { - const {type, value} = baseSegments[3]; + if (type === 1 && value === '$id') { + const {type, value} = baseSegments[2]; if (l === 4 - && type === 0 && value === 'settings' + && type === 0 && value === 'bar' + && baseSegments[3].type === 0 && baseSegments[3].value === 'foo' ) { - return '/users/profile/settings'; + return '/$id/bar/foo'; } - if (l === 3) { - return '/users/profile'; + if (l === 4 + && type === 0 && value === 'foo' + && baseSegments[3].type === 0 && baseSegments[3].value === 'bar' + ) { + return '/$id/foo/bar'; } - } - if (l === 3 - && type === 1 && value === '$id' - ) { - return '/users/$id'; - } - } - if (type === 0 && value === 'foo') { - const {type, value} = baseSegments[2]; - if (l === 4 - && type === 0 && value === 'bar' - && baseSegments[3].type === 1 && baseSegments[3].value === '$id' - ) { - return '/foo/bar/$id'; - } - if (l === 4 - && type === 1 && value === '$id' - && baseSegments[3].type === 0 && baseSegments[3].value === 'bar' - ) { - return '/foo/$id/bar'; - } - if (l === 4 - && type === 3 && value === '$bar' - && baseSegments[3].type === 0 && baseSegments[3].value === 'qux' - ) { - return '/foo/$bar/qux'; - } - if (l === 4 - && type === 1 && value === '$bar' - && baseSegments[3].type === 0 && baseSegments[3].value === '/' - ) { - return '/foo/$bar//'; - } - } - if (l === 3 - && type === 0 && value === 'beep' - && baseSegments[2].type === 0 && baseSegments[2].value === 'boop' - ) { - return '/beep/boop'; - } - if (type === 0 && value === 'one') { - const {type, value} = baseSegments[2]; - if (l === 3 - && type === 0 && value === 'two' - ) { - return '/one/two'; - } - if (l === 2) { - return '/one'; - } - } - if (l === 3 - && type === 0 && value === 'api' - && baseSegments[2].type === 1 && baseSegments[2].value === '$id' - ) { - return '/api/$id'; - } - if (l === 3 - && type === 0 && value === 'posts' - && baseSegments[2].type === 3 && baseSegments[2].value === '$slug' - ) { - return '/posts/$slug'; - } - if (l === 3 - && type === 0 && value === 'files' - && baseSegments[2].type === 2 && baseSegments[2].value === '$' - ) { - return '/files/$'; - } - if (l === 2 - && type === 0 && value === 'about' - ) { - return '/about'; - } - if (type === 1 && value === '$id') { - const {type, value} = baseSegments[2]; - if (l === 4 - && type === 0 && value === 'bar' - && baseSegments[3].type === 0 && baseSegments[3].value === 'foo' - ) { - return '/$id/bar/foo'; - } - if (l === 4 - && type === 0 && value === 'foo' - && baseSegments[3].type === 0 && baseSegments[3].value === 'bar' - ) { - return '/$id/foo/bar'; - } - }" - `) + }" + `) + }) - const yo = new Function('parsePathname', 'from', fn) as ( + const buildMatcher = new Function('parsePathname', 'from', fn) as ( parser: typeof parsePathname, from: string, ) => string | undefined - expect(yo(parsePathname, '/users/profile/settings')).toBe( + + test.each([ '/users/profile/settings', - ) + '/foo/$bar/', + '/foo/123', + '/b/$id', + '/b/123', + ])('matching %s', (s) => { + const originalMatch = originalMatcher(s) + const buildMatch = buildMatcher(parsePathname, s) + console.log( + `matching: ${s}, originalMatch: ${originalMatch}, buildMatch: ${buildMatch}`, + ) + expect(buildMatch).toBe(originalMatch) + }) }) From 0d19c8b29cdd2ed6ed19e32c51d97dbfead021f1 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 19 Jul 2025 20:45:15 +0200 Subject: [PATCH 08/57] can condense multiple depths in a single if --- packages/router-core/tests/built.test.ts | 139 ++++++++++++++--------- 1 file changed, 85 insertions(+), 54 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 59101146e6c..59159c87da8 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -94,6 +94,10 @@ const routeTree = createRouteTree([ '/beep/boop', '/one/two', '/one', + '/z/y/x/w', + '/z/y/x/v', + '/z/y/x/u', + '/z/y/x', ]) const result = processRouteTree({ routeTree }) @@ -110,9 +114,13 @@ describe('work in progress', () => { expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` [ "/a/b/c/d/e/f", + "/z/y/x/u", + "/z/y/x/v", + "/z/y/x/w", "/a/profile/settings", "/b/profile/settings", "/users/profile/settings", + "/z/y/x", "/foo/bar/$id", "/a/profile", "/b/profile", @@ -158,11 +166,11 @@ describe('work in progress', () => { const initialDepth = 1 let fn = 'const baseSegments = parsePathname(from);' fn += '\nconst l = baseSegments.length;' - fn += `\nconst {type, value} = baseSegments[${initialDepth}];` function recursiveStaticMatch( parsedRoutes: Array>, depth = initialDepth, + indent = '', ) { const resolved = new Set>() for (const parsed of parsedRoutes) { @@ -189,24 +197,31 @@ describe('work in progress', () => { if (candidates.length === 0) { throw new Error('Implementation error: this should not happen') } - const indent = ' '.repeat(depth - initialDepth) - const lCondition = depth > initialDepth ? `l > ${depth} && ` : '' if (candidates.length > 1) { - fn += `\n${indent}if (${lCondition}type === ${parsed[depth]!.type} && value === '${parsed[depth]!.value}') {` - const deeper = candidates.filter((c) => c.length > depth + 1) - const leaves = candidates.filter((c) => c.length === depth + 1) + let skipDepth = parsed.slice(depth + 1).findIndex((s, i) => candidates.some(c => { + const segment = c[depth + 1 + i] + return !segment || segment.type !== s.type || segment.value !== s.value || segment.hasStaticAfter !== s.hasStaticAfter || segment.prefixSegment !== s.prefixSegment || segment.suffixSegment !== s.suffixSegment + })) + if (skipDepth === -1) skipDepth = parsed.length - depth - 1 + const lCondition = skipDepth || depth > initialDepth ? `l > ${depth + skipDepth} && ` : '' + const skipConditions = skipDepth + ? `\n${indent} && ` + Array.from({ length: skipDepth }, (_, i) => `baseSegments[${depth + 1 + i}].type === ${candidates[0]![depth + 1 + i]!.type} && baseSegments[${depth + 1 + i}].value === '${candidates[0]![depth + 1 + i]!.value}'`).join(`\n${indent} && `) + `\n${indent}` + : '' + fn += `\n${indent}if (${lCondition}baseSegments[${depth}].type === ${parsed[depth]!.type} && baseSegments[${depth}].value === '${parsed[depth]!.value}'${skipConditions}) {` + const deeper = candidates.filter((c) => c.length > depth + 1 + skipDepth) + const leaves = candidates.filter((c) => c.length <= depth + 1 + skipDepth) if (deeper.length + leaves.length !== candidates.length) { throw new Error('Implementation error: this should not happen') } if (deeper.length > 0) { - fn += `\n${indent} const {type, value} = baseSegments[${depth + 1}];` - recursiveStaticMatch(deeper, depth + 1) + recursiveStaticMatch(deeper, depth + 1 + skipDepth, indent + ' ') } if (leaves.length > 1) { throw new Error( `Multiple candidates found for depth ${depth} with type ${parsed[depth]!.type} and value ${parsed[depth]!.value}: ${leaves.map(logParsed).join(', ')}`, ) } else if (leaves.length === 1) { + // WARN: is it ok that the leaf is matched last? fn += `\n${indent} if (l === ${leaves[0]!.length}) {` fn += `\n${indent} return '/${leaves[0]! .slice(1) @@ -223,8 +238,8 @@ describe('work in progress', () => { fn += `\n${indent}if (l === ${leaf.length}` for (let i = depth; i < leaf.length; i++) { const segment = leaf[i]! - const type = i === depth ? 'type' : `baseSegments[${i}].type` - const value = i === depth ? 'value' : `baseSegments[${i}].value` + const type = `baseSegments[${i}].type` + const value = `baseSegments[${i}].value` fn += `\n${indent} && ${type} === ${segment.type} && ${value} === '${segment.value}'` } fn += `\n${indent}) {` @@ -241,11 +256,9 @@ describe('work in progress', () => { expect(fn).toMatchInlineSnapshot(` "const baseSegments = parsePathname(from); const l = baseSegments.length; - const {type, value} = baseSegments[1]; - if (type === 0 && value === 'a') { - const {type, value} = baseSegments[2]; + if (baseSegments[1].type === 0 && baseSegments[1].value === 'a') { if (l === 7 - && type === 0 && value === 'b' + && baseSegments[2].type === 0 && baseSegments[2].value === 'b' && baseSegments[3].type === 0 && baseSegments[3].value === 'c' && baseSegments[4].type === 0 && baseSegments[4].value === 'd' && baseSegments[5].type === 0 && baseSegments[5].value === 'e' @@ -253,10 +266,9 @@ describe('work in progress', () => { ) { return '/a/b/c/d/e/f'; } - if (l > 2 && type === 0 && value === 'profile') { - const {type, value} = baseSegments[3]; + if (l > 2 && baseSegments[2].type === 0 && baseSegments[2].value === 'profile') { if (l === 4 - && type === 0 && value === 'settings' + && baseSegments[3].type === 0 && baseSegments[3].value === 'settings' ) { return '/a/profile/settings'; } @@ -265,22 +277,22 @@ describe('work in progress', () => { } } if (l === 3 - && type === 1 && value === '$id' + && baseSegments[2].type === 1 && baseSegments[2].value === '$id' ) { return '/a/$id'; } if (l === 3 - && type === 1 && value === '$id' + && baseSegments[2].type === 1 && baseSegments[2].value === '$id' ) { return '/a/$id'; } if (l === 3 - && type === 3 && value === '$slug' + && baseSegments[2].type === 3 && baseSegments[2].value === '$slug' ) { return '/a/$slug'; } if (l === 3 - && type === 2 && value === '$' + && baseSegments[2].type === 2 && baseSegments[2].value === '$' ) { return '/a/$'; } @@ -288,12 +300,33 @@ describe('work in progress', () => { return '/a'; } } - if (type === 0 && value === 'b') { - const {type, value} = baseSegments[2]; - if (l > 2 && type === 0 && value === 'profile') { - const {type, value} = baseSegments[3]; + if (l > 3 && baseSegments[1].type === 0 && baseSegments[1].value === 'z' + && baseSegments[2].type === 0 && baseSegments[2].value === 'y' + && baseSegments[3].type === 0 && baseSegments[3].value === 'x' + ) { + if (l === 5 + && baseSegments[4].type === 0 && baseSegments[4].value === 'u' + ) { + return '/z/y/x/u'; + } + if (l === 5 + && baseSegments[4].type === 0 && baseSegments[4].value === 'v' + ) { + return '/z/y/x/v'; + } + if (l === 5 + && baseSegments[4].type === 0 && baseSegments[4].value === 'w' + ) { + return '/z/y/x/w'; + } + if (l === 4) { + return '/z/y/x'; + } + } + if (baseSegments[1].type === 0 && baseSegments[1].value === 'b') { + if (l > 2 && baseSegments[2].type === 0 && baseSegments[2].value === 'profile') { if (l === 4 - && type === 0 && value === 'settings' + && baseSegments[3].type === 0 && baseSegments[3].value === 'settings' ) { return '/b/profile/settings'; } @@ -302,22 +335,22 @@ describe('work in progress', () => { } } if (l === 3 - && type === 1 && value === '$id' + && baseSegments[2].type === 1 && baseSegments[2].value === '$id' ) { return '/b/$id'; } if (l === 3 - && type === 1 && value === '$id' + && baseSegments[2].type === 1 && baseSegments[2].value === '$id' ) { return '/b/$id'; } if (l === 3 - && type === 3 && value === '$slug' + && baseSegments[2].type === 3 && baseSegments[2].value === '$slug' ) { return '/b/$slug'; } if (l === 3 - && type === 2 && value === '$' + && baseSegments[2].type === 2 && baseSegments[2].value === '$' ) { return '/b/$'; } @@ -325,12 +358,10 @@ describe('work in progress', () => { return '/b'; } } - if (type === 0 && value === 'users') { - const {type, value} = baseSegments[2]; - if (l > 2 && type === 0 && value === 'profile') { - const {type, value} = baseSegments[3]; + if (baseSegments[1].type === 0 && baseSegments[1].value === 'users') { + if (l > 2 && baseSegments[2].type === 0 && baseSegments[2].value === 'profile') { if (l === 4 - && type === 0 && value === 'settings' + && baseSegments[3].type === 0 && baseSegments[3].value === 'settings' ) { return '/users/profile/settings'; } @@ -339,48 +370,46 @@ describe('work in progress', () => { } } if (l === 3 - && type === 1 && value === '$id' + && baseSegments[2].type === 1 && baseSegments[2].value === '$id' ) { return '/users/$id'; } } - if (type === 0 && value === 'foo') { - const {type, value} = baseSegments[2]; + if (baseSegments[1].type === 0 && baseSegments[1].value === 'foo') { if (l === 4 - && type === 0 && value === 'bar' + && baseSegments[2].type === 0 && baseSegments[2].value === 'bar' && baseSegments[3].type === 1 && baseSegments[3].value === '$id' ) { return '/foo/bar/$id'; } if (l === 4 - && type === 1 && value === '$id' + && baseSegments[2].type === 1 && baseSegments[2].value === '$id' && baseSegments[3].type === 0 && baseSegments[3].value === 'bar' ) { return '/foo/$id/bar'; } if (l === 4 - && type === 3 && value === '$bar' + && baseSegments[2].type === 3 && baseSegments[2].value === '$bar' && baseSegments[3].type === 0 && baseSegments[3].value === 'qux' ) { return '/foo/$bar/qux'; } if (l === 4 - && type === 1 && value === '$bar' + && baseSegments[2].type === 1 && baseSegments[2].value === '$bar' && baseSegments[3].type === 0 && baseSegments[3].value === '/' ) { return '/foo/$bar/'; } } if (l === 3 - && type === 0 && value === 'beep' + && baseSegments[1].type === 0 && baseSegments[1].value === 'beep' && baseSegments[2].type === 0 && baseSegments[2].value === 'boop' ) { return '/beep/boop'; } - if (type === 0 && value === 'one') { - const {type, value} = baseSegments[2]; + if (baseSegments[1].type === 0 && baseSegments[1].value === 'one') { if (l === 3 - && type === 0 && value === 'two' + && baseSegments[2].type === 0 && baseSegments[2].value === 'two' ) { return '/one/two'; } @@ -389,38 +418,37 @@ describe('work in progress', () => { } } if (l === 3 - && type === 0 && value === 'api' + && baseSegments[1].type === 0 && baseSegments[1].value === 'api' && baseSegments[2].type === 1 && baseSegments[2].value === '$id' ) { return '/api/$id'; } if (l === 3 - && type === 0 && value === 'posts' + && baseSegments[1].type === 0 && baseSegments[1].value === 'posts' && baseSegments[2].type === 3 && baseSegments[2].value === '$slug' ) { return '/posts/$slug'; } if (l === 3 - && type === 0 && value === 'files' + && baseSegments[1].type === 0 && baseSegments[1].value === 'files' && baseSegments[2].type === 2 && baseSegments[2].value === '$' ) { return '/files/$'; } if (l === 2 - && type === 0 && value === 'about' + && baseSegments[1].type === 0 && baseSegments[1].value === 'about' ) { return '/about'; } - if (type === 1 && value === '$id') { - const {type, value} = baseSegments[2]; + if (baseSegments[1].type === 1 && baseSegments[1].value === '$id') { if (l === 4 - && type === 0 && value === 'bar' + && baseSegments[2].type === 0 && baseSegments[2].value === 'bar' && baseSegments[3].type === 0 && baseSegments[3].value === 'foo' ) { return '/$id/bar/foo'; } if (l === 4 - && type === 0 && value === 'foo' + && baseSegments[2].type === 0 && baseSegments[2].value === 'foo' && baseSegments[3].type === 0 && baseSegments[3].value === 'bar' ) { return '/$id/foo/bar'; @@ -440,6 +468,9 @@ describe('work in progress', () => { '/foo/123', '/b/$id', '/b/123', + '/foo/{-$bar}/qux', + '/foo/123/qux', + '/foo/qux', ])('matching %s', (s) => { const originalMatch = originalMatcher(s) const buildMatch = buildMatcher(parsePathname, s) From 3251741a575b3c300f0d373c81d264bf388b04a1 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 19 Jul 2025 20:45:37 +0200 Subject: [PATCH 09/57] prettier --- packages/router-core/tests/built.test.ts | 38 +++++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 59159c87da8..c064809d819 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -198,18 +198,40 @@ describe('work in progress', () => { throw new Error('Implementation error: this should not happen') } if (candidates.length > 1) { - let skipDepth = parsed.slice(depth + 1).findIndex((s, i) => candidates.some(c => { - const segment = c[depth + 1 + i] - return !segment || segment.type !== s.type || segment.value !== s.value || segment.hasStaticAfter !== s.hasStaticAfter || segment.prefixSegment !== s.prefixSegment || segment.suffixSegment !== s.suffixSegment - })) + let skipDepth = parsed.slice(depth + 1).findIndex((s, i) => + candidates.some((c) => { + const segment = c[depth + 1 + i] + return ( + !segment || + segment.type !== s.type || + segment.value !== s.value || + segment.hasStaticAfter !== s.hasStaticAfter || + segment.prefixSegment !== s.prefixSegment || + segment.suffixSegment !== s.suffixSegment + ) + }), + ) if (skipDepth === -1) skipDepth = parsed.length - depth - 1 - const lCondition = skipDepth || depth > initialDepth ? `l > ${depth + skipDepth} && ` : '' + const lCondition = + skipDepth || depth > initialDepth + ? `l > ${depth + skipDepth} && ` + : '' const skipConditions = skipDepth - ? `\n${indent} && ` + Array.from({ length: skipDepth }, (_, i) => `baseSegments[${depth + 1 + i}].type === ${candidates[0]![depth + 1 + i]!.type} && baseSegments[${depth + 1 + i}].value === '${candidates[0]![depth + 1 + i]!.value}'`).join(`\n${indent} && `) + `\n${indent}` + ? `\n${indent} && ` + + Array.from( + { length: skipDepth }, + (_, i) => + `baseSegments[${depth + 1 + i}].type === ${candidates[0]![depth + 1 + i]!.type} && baseSegments[${depth + 1 + i}].value === '${candidates[0]![depth + 1 + i]!.value}'`, + ).join(`\n${indent} && `) + + `\n${indent}` : '' fn += `\n${indent}if (${lCondition}baseSegments[${depth}].type === ${parsed[depth]!.type} && baseSegments[${depth}].value === '${parsed[depth]!.value}'${skipConditions}) {` - const deeper = candidates.filter((c) => c.length > depth + 1 + skipDepth) - const leaves = candidates.filter((c) => c.length <= depth + 1 + skipDepth) + const deeper = candidates.filter( + (c) => c.length > depth + 1 + skipDepth, + ) + const leaves = candidates.filter( + (c) => c.length <= depth + 1 + skipDepth, + ) if (deeper.length + leaves.length !== candidates.length) { throw new Error('Implementation error: this should not happen') } From 7890c3f9336da72d72b9fc3a79306115bd485052 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 19 Jul 2025 22:55:07 +0200 Subject: [PATCH 10/57] rename stuff --- packages/router-core/tests/built.test.ts | 32 +++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index c064809d819..91c9fdcc25d 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -173,12 +173,12 @@ describe('work in progress', () => { indent = '', ) { const resolved = new Set>() - for (const parsed of parsedRoutes) { - if (resolved.has(parsed)) continue // already resolved + for (const routeSegments of parsedRoutes) { + if (resolved.has(routeSegments)) continue // already resolved console.log('\n') - console.log('resolving: depth=', depth, 'parsed=', logParsed(parsed)) + console.log('resolving: depth=', depth, 'parsed=', logParsed(routeSegments)) console.log('\u001b[34m' + fn + '\u001b[0m') - const currentSegment = parsed[depth] + const currentSegment = routeSegments[depth] if (!currentSegment) { throw new Error('Implementation error: this should not happen') } @@ -198,7 +198,7 @@ describe('work in progress', () => { throw new Error('Implementation error: this should not happen') } if (candidates.length > 1) { - let skipDepth = parsed.slice(depth + 1).findIndex((s, i) => + let skipDepth = routeSegments.slice(depth + 1).findIndex((s, i) => candidates.some((c) => { const segment = c[depth + 1 + i] return ( @@ -211,21 +211,19 @@ describe('work in progress', () => { ) }), ) - if (skipDepth === -1) skipDepth = parsed.length - depth - 1 + if (skipDepth === -1) skipDepth = routeSegments.length - depth - 1 const lCondition = skipDepth || depth > initialDepth ? `l > ${depth + skipDepth} && ` : '' - const skipConditions = skipDepth - ? `\n${indent} && ` + - Array.from( - { length: skipDepth }, - (_, i) => - `baseSegments[${depth + 1 + i}].type === ${candidates[0]![depth + 1 + i]!.type} && baseSegments[${depth + 1 + i}].value === '${candidates[0]![depth + 1 + i]!.value}'`, - ).join(`\n${indent} && `) + - `\n${indent}` - : '' - fn += `\n${indent}if (${lCondition}baseSegments[${depth}].type === ${parsed[depth]!.type} && baseSegments[${depth}].value === '${parsed[depth]!.value}'${skipConditions}) {` + const skipConditions = + Array.from( + { length: skipDepth + 1 }, + (_, i) => + `baseSegments[${depth + i}].type === ${candidates[0]![depth + i]!.type} && baseSegments[${depth + i}].value === '${candidates[0]![depth + i]!.value}'`, + ).join(`\n${indent} && `) + + (skipDepth ? `\n${indent}` : '') + fn += `\n${indent}if (${lCondition}${skipConditions}) {` const deeper = candidates.filter( (c) => c.length > depth + 1 + skipDepth, ) @@ -240,7 +238,7 @@ describe('work in progress', () => { } if (leaves.length > 1) { throw new Error( - `Multiple candidates found for depth ${depth} with type ${parsed[depth]!.type} and value ${parsed[depth]!.value}: ${leaves.map(logParsed).join(', ')}`, + `Multiple candidates found for depth ${depth} with type ${routeSegments[depth]!.type} and value ${routeSegments[depth]!.value}: ${leaves.map(logParsed).join(', ')}`, ) } else if (leaves.length === 1) { // WARN: is it ok that the leaf is matched last? From af589e5636480306334d53e160cee593f94c6f7b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 00:03:39 +0200 Subject: [PATCH 11/57] dynamic params --- packages/router-core/tests/built.test.ts | 227 +++++++++++++---------- 1 file changed, 133 insertions(+), 94 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 91c9fdcc25d..34fd544d889 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -86,7 +86,7 @@ const routeTree = createRouteTree([ '/b', '/foo/bar/$id', '/foo/$id/bar', - '/foo/$bar/', + '/foo/$bar', '/foo/{-$bar}/qux', '/$id/bar/foo', '/$id/foo/bar', @@ -132,9 +132,9 @@ describe('work in progress', () => { "/a/user-{$id}", "/api/user-{$id}", "/b/user-{$id}", - "/foo/$bar/", "/a/$id", "/b/$id", + "/foo/$bar", "/users/$id", "/a/{-$slug}", "/b/{-$slug}", @@ -163,6 +163,12 @@ describe('work in progress', () => { .map((s) => s.value) .join('/') + const rebuildPath = (leaf: ReturnType) => + `/${leaf + .slice(1) + .map((s) => (s.value === '/' ? '' : `${s.prefixSegment ?? ''}${s.prefixSegment || s.suffixSegment ? '{' : ''}${s.value}${s.prefixSegment || s.suffixSegment ? '}' : ''}${s.suffixSegment ?? ''}`)) + .join('/')}` + const initialDepth = 1 let fn = 'const baseSegments = parsePathname(from);' fn += '\nconst l = baseSegments.length;' @@ -185,6 +191,17 @@ describe('work in progress', () => { const candidates = parsedRoutes.filter((r) => { const rParsed = r[depth] if (!rParsed) return false + + // For SEGMENT_TYPE_PARAM (type 1), match only on type and prefix/suffix constraints + if (currentSegment.type === 1) { + return ( + rParsed.type === 1 && + rParsed.prefixSegment === currentSegment.prefixSegment && + rParsed.suffixSegment === currentSegment.suffixSegment + ) + } + + // For all other segment types (SEGMENT_TYPE_PATHNAME, etc.), use exact matching return ( rParsed.type === currentSegment.type && rParsed.value === currentSegment.value && @@ -214,16 +231,31 @@ describe('work in progress', () => { if (skipDepth === -1) skipDepth = routeSegments.length - depth - 1 const lCondition = skipDepth || depth > initialDepth - ? `l > ${depth + skipDepth} && ` + ? `l > ${depth + skipDepth}` : '' const skipConditions = Array.from( { length: skipDepth + 1 }, - (_, i) => - `baseSegments[${depth + i}].type === ${candidates[0]![depth + i]!.type} && baseSegments[${depth + i}].value === '${candidates[0]![depth + i]!.value}'`, - ).join(`\n${indent} && `) + + (_, i) => { + const segment = candidates[0]![depth + i]! + if (segment.type === 1) { + const conditions = [] + if (segment.prefixSegment) { + conditions.push(`baseSegments[${depth + i}].value.startsWith('${segment.prefixSegment}')`) + } + if (segment.suffixSegment) { + conditions.push(`baseSegments[${depth + i}].value.endsWith('${segment.suffixSegment}')`) + } + return conditions.join(' && ') + } + return `baseSegments[${depth + i}].value === '${segment.value}'` + } + ).filter(Boolean).join(`\n${indent} && `) + (skipDepth ? `\n${indent}` : '') - fn += `\n${indent}if (${lCondition}${skipConditions}) {` + const hasCondition = Boolean(lCondition || skipConditions) + if (hasCondition) { + fn += `\n${indent}if (${lCondition}${lCondition && skipConditions ? ' && ' : ''}${skipConditions}) {` + } const deeper = candidates.filter( (c) => c.length > depth + 1 + skipDepth, ) @@ -234,7 +266,7 @@ describe('work in progress', () => { throw new Error('Implementation error: this should not happen') } if (deeper.length > 0) { - recursiveStaticMatch(deeper, depth + 1 + skipDepth, indent + ' ') + recursiveStaticMatch(deeper, depth + 1 + skipDepth, hasCondition ? indent + ' ' : indent) } if (leaves.length > 1) { throw new Error( @@ -243,29 +275,41 @@ describe('work in progress', () => { } else if (leaves.length === 1) { // WARN: is it ok that the leaf is matched last? fn += `\n${indent} if (l === ${leaves[0]!.length}) {` - fn += `\n${indent} return '/${leaves[0]! - .slice(1) - .map((s) => (s.value === '/' ? '' : s.value)) - .join('/')}';` + fn += `\n${indent} return '${rebuildPath(leaves[0]!)}';` fn += `\n${indent} }` } + if (hasCondition) { + fn += `\n${indent}}` + } } else { const leaf = candidates[0]! - const done = `return '/${leaf - .slice(1) - .map((s) => (s.value === '/' ? '' : s.value)) - .join('/')}';` + const done = `return '${rebuildPath(leaf)}';` fn += `\n${indent}if (l === ${leaf.length}` for (let i = depth; i < leaf.length; i++) { const segment = leaf[i]! - const type = `baseSegments[${i}].type` const value = `baseSegments[${i}].value` - fn += `\n${indent} && ${type} === ${segment.type} && ${value} === '${segment.value}'` + + // For SEGMENT_TYPE_PARAM (type 1), check if base has static segment (type 0) that satisfies constraints + if (segment.type === 1) { + if (segment.prefixSegment || segment.suffixSegment) { + fn += `\n${indent} ` + } + // Add prefix/suffix checks for parameters with prefix/suffix + if (segment.prefixSegment) { + fn += ` && ${value}.startsWith('${segment.prefixSegment}')` + } + if (segment.suffixSegment) { + fn += ` && ${value}.endsWith('${segment.suffixSegment}')` + } + } else { + // For other segment types, use exact matching + fn += `\n${indent} && ${value} === '${segment.value}'` + } } fn += `\n${indent}) {` fn += `\n${indent} ${done}` + fn += `\n${indent}}` } - fn += `\n${indent}}` candidates.forEach((c) => resolved.add(c)) } } @@ -276,19 +320,19 @@ describe('work in progress', () => { expect(fn).toMatchInlineSnapshot(` "const baseSegments = parsePathname(from); const l = baseSegments.length; - if (baseSegments[1].type === 0 && baseSegments[1].value === 'a') { + if (baseSegments[1].value === 'a') { if (l === 7 - && baseSegments[2].type === 0 && baseSegments[2].value === 'b' - && baseSegments[3].type === 0 && baseSegments[3].value === 'c' - && baseSegments[4].type === 0 && baseSegments[4].value === 'd' - && baseSegments[5].type === 0 && baseSegments[5].value === 'e' - && baseSegments[6].type === 0 && baseSegments[6].value === 'f' + && baseSegments[2].value === 'b' + && baseSegments[3].value === 'c' + && baseSegments[4].value === 'd' + && baseSegments[5].value === 'e' + && baseSegments[6].value === 'f' ) { return '/a/b/c/d/e/f'; } - if (l > 2 && baseSegments[2].type === 0 && baseSegments[2].value === 'profile') { + if (l > 2 && baseSegments[2].value === 'profile') { if (l === 4 - && baseSegments[3].type === 0 && baseSegments[3].value === 'settings' + && baseSegments[3].value === 'settings' ) { return '/a/profile/settings'; } @@ -297,22 +341,21 @@ describe('work in progress', () => { } } if (l === 3 - && baseSegments[2].type === 1 && baseSegments[2].value === '$id' + && baseSegments[2].value.startsWith('user-') ) { - return '/a/$id'; + return '/a/user-{$id}'; } if (l === 3 - && baseSegments[2].type === 1 && baseSegments[2].value === '$id' ) { return '/a/$id'; } if (l === 3 - && baseSegments[2].type === 3 && baseSegments[2].value === '$slug' + && baseSegments[2].value === '$slug' ) { return '/a/$slug'; } if (l === 3 - && baseSegments[2].type === 2 && baseSegments[2].value === '$' + && baseSegments[2].value === '$' ) { return '/a/$'; } @@ -320,22 +363,22 @@ describe('work in progress', () => { return '/a'; } } - if (l > 3 && baseSegments[1].type === 0 && baseSegments[1].value === 'z' - && baseSegments[2].type === 0 && baseSegments[2].value === 'y' - && baseSegments[3].type === 0 && baseSegments[3].value === 'x' + if (l > 3 && baseSegments[1].value === 'z' + && baseSegments[2].value === 'y' + && baseSegments[3].value === 'x' ) { if (l === 5 - && baseSegments[4].type === 0 && baseSegments[4].value === 'u' + && baseSegments[4].value === 'u' ) { return '/z/y/x/u'; } if (l === 5 - && baseSegments[4].type === 0 && baseSegments[4].value === 'v' + && baseSegments[4].value === 'v' ) { return '/z/y/x/v'; } if (l === 5 - && baseSegments[4].type === 0 && baseSegments[4].value === 'w' + && baseSegments[4].value === 'w' ) { return '/z/y/x/w'; } @@ -343,10 +386,10 @@ describe('work in progress', () => { return '/z/y/x'; } } - if (baseSegments[1].type === 0 && baseSegments[1].value === 'b') { - if (l > 2 && baseSegments[2].type === 0 && baseSegments[2].value === 'profile') { + if (baseSegments[1].value === 'b') { + if (l > 2 && baseSegments[2].value === 'profile') { if (l === 4 - && baseSegments[3].type === 0 && baseSegments[3].value === 'settings' + && baseSegments[3].value === 'settings' ) { return '/b/profile/settings'; } @@ -355,22 +398,21 @@ describe('work in progress', () => { } } if (l === 3 - && baseSegments[2].type === 1 && baseSegments[2].value === '$id' + && baseSegments[2].value.startsWith('user-') ) { - return '/b/$id'; + return '/b/user-{$id}'; } if (l === 3 - && baseSegments[2].type === 1 && baseSegments[2].value === '$id' ) { return '/b/$id'; } if (l === 3 - && baseSegments[2].type === 3 && baseSegments[2].value === '$slug' + && baseSegments[2].value === '$slug' ) { return '/b/$slug'; } if (l === 3 - && baseSegments[2].type === 2 && baseSegments[2].value === '$' + && baseSegments[2].value === '$' ) { return '/b/$'; } @@ -378,10 +420,10 @@ describe('work in progress', () => { return '/b'; } } - if (baseSegments[1].type === 0 && baseSegments[1].value === 'users') { - if (l > 2 && baseSegments[2].type === 0 && baseSegments[2].value === 'profile') { + if (baseSegments[1].value === 'users') { + if (l > 2 && baseSegments[2].value === 'profile') { if (l === 4 - && baseSegments[3].type === 0 && baseSegments[3].value === 'settings' + && baseSegments[3].value === 'settings' ) { return '/users/profile/settings'; } @@ -390,46 +432,42 @@ describe('work in progress', () => { } } if (l === 3 - && baseSegments[2].type === 1 && baseSegments[2].value === '$id' ) { return '/users/$id'; } } - if (baseSegments[1].type === 0 && baseSegments[1].value === 'foo') { + if (baseSegments[1].value === 'foo') { if (l === 4 - && baseSegments[2].type === 0 && baseSegments[2].value === 'bar' - && baseSegments[3].type === 1 && baseSegments[3].value === '$id' + && baseSegments[2].value === 'bar' ) { return '/foo/bar/$id'; } - if (l === 4 - && baseSegments[2].type === 1 && baseSegments[2].value === '$id' - && baseSegments[3].type === 0 && baseSegments[3].value === 'bar' - ) { - return '/foo/$id/bar'; + if (l > 2) { + if (l === 4 + && baseSegments[3].value === 'bar' + ) { + return '/foo/$id/bar'; + } + if (l === 3) { + return '/foo/$bar'; + } } if (l === 4 - && baseSegments[2].type === 3 && baseSegments[2].value === '$bar' - && baseSegments[3].type === 0 && baseSegments[3].value === 'qux' + && baseSegments[2].value === '$bar' + && baseSegments[3].value === 'qux' ) { return '/foo/$bar/qux'; } - if (l === 4 - && baseSegments[2].type === 1 && baseSegments[2].value === '$bar' - && baseSegments[3].type === 0 && baseSegments[3].value === '/' - ) { - return '/foo/$bar/'; - } } if (l === 3 - && baseSegments[1].type === 0 && baseSegments[1].value === 'beep' - && baseSegments[2].type === 0 && baseSegments[2].value === 'boop' + && baseSegments[1].value === 'beep' + && baseSegments[2].value === 'boop' ) { return '/beep/boop'; } - if (baseSegments[1].type === 0 && baseSegments[1].value === 'one') { + if (baseSegments[1].value === 'one') { if (l === 3 - && baseSegments[2].type === 0 && baseSegments[2].value === 'two' + && baseSegments[2].value === 'two' ) { return '/one/two'; } @@ -438,41 +476,39 @@ describe('work in progress', () => { } } if (l === 3 - && baseSegments[1].type === 0 && baseSegments[1].value === 'api' - && baseSegments[2].type === 1 && baseSegments[2].value === '$id' + && baseSegments[1].value === 'api' + && baseSegments[2].value.startsWith('user-') ) { - return '/api/$id'; + return '/api/user-{$id}'; } if (l === 3 - && baseSegments[1].type === 0 && baseSegments[1].value === 'posts' - && baseSegments[2].type === 3 && baseSegments[2].value === '$slug' + && baseSegments[1].value === 'posts' + && baseSegments[2].value === '$slug' ) { return '/posts/$slug'; } if (l === 3 - && baseSegments[1].type === 0 && baseSegments[1].value === 'files' - && baseSegments[2].type === 2 && baseSegments[2].value === '$' + && baseSegments[1].value === 'files' + && baseSegments[2].value === '$' ) { return '/files/$'; } if (l === 2 - && baseSegments[1].type === 0 && baseSegments[1].value === 'about' + && baseSegments[1].value === 'about' ) { return '/about'; } - if (baseSegments[1].type === 1 && baseSegments[1].value === '$id') { - if (l === 4 - && baseSegments[2].type === 0 && baseSegments[2].value === 'bar' - && baseSegments[3].type === 0 && baseSegments[3].value === 'foo' - ) { - return '/$id/bar/foo'; - } - if (l === 4 - && baseSegments[2].type === 0 && baseSegments[2].value === 'foo' - && baseSegments[3].type === 0 && baseSegments[3].value === 'bar' - ) { - return '/$id/foo/bar'; - } + if (l === 4 + && baseSegments[2].value === 'bar' + && baseSegments[3].value === 'foo' + ) { + return '/$id/bar/foo'; + } + if (l === 4 + && baseSegments[2].value === 'foo' + && baseSegments[3].value === 'bar' + ) { + return '/$id/foo/bar'; }" `) }) @@ -482,15 +518,17 @@ describe('work in progress', () => { from: string, ) => string | undefined + // WARN: some of these don't work yet, they're just here to show the differences test.each([ '/users/profile/settings', - '/foo/$bar/', '/foo/123', - '/b/$id', '/b/123', - '/foo/{-$bar}/qux', + '/foo/qux', '/foo/123/qux', '/foo/qux', + '/a/user-123', + '/files/hello-world.txt', + '/something/foo/bar' ])('matching %s', (s) => { const originalMatch = originalMatcher(s) const buildMatch = buildMatcher(parsePathname, s) @@ -500,3 +538,4 @@ describe('work in progress', () => { expect(buildMatch).toBe(originalMatch) }) }) + From 6e9c36119b1cd4d7094e8608ddfb46a147a6f910 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 00:04:21 +0200 Subject: [PATCH 12/57] prettier --- packages/router-core/tests/built.test.ts | 60 ++++++++++++++---------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 34fd544d889..41017fbc4e3 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -166,7 +166,11 @@ describe('work in progress', () => { const rebuildPath = (leaf: ReturnType) => `/${leaf .slice(1) - .map((s) => (s.value === '/' ? '' : `${s.prefixSegment ?? ''}${s.prefixSegment || s.suffixSegment ? '{' : ''}${s.value}${s.prefixSegment || s.suffixSegment ? '}' : ''}${s.suffixSegment ?? ''}`)) + .map((s) => + s.value === '/' + ? '' + : `${s.prefixSegment ?? ''}${s.prefixSegment || s.suffixSegment ? '{' : ''}${s.value}${s.prefixSegment || s.suffixSegment ? '}' : ''}${s.suffixSegment ?? ''}`, + ) .join('/')}` const initialDepth = 1 @@ -182,7 +186,12 @@ describe('work in progress', () => { for (const routeSegments of parsedRoutes) { if (resolved.has(routeSegments)) continue // already resolved console.log('\n') - console.log('resolving: depth=', depth, 'parsed=', logParsed(routeSegments)) + console.log( + 'resolving: depth=', + depth, + 'parsed=', + logParsed(routeSegments), + ) console.log('\u001b[34m' + fn + '\u001b[0m') const currentSegment = routeSegments[depth] if (!currentSegment) { @@ -230,28 +239,28 @@ describe('work in progress', () => { ) if (skipDepth === -1) skipDepth = routeSegments.length - depth - 1 const lCondition = - skipDepth || depth > initialDepth - ? `l > ${depth + skipDepth}` - : '' + skipDepth || depth > initialDepth ? `l > ${depth + skipDepth}` : '' const skipConditions = - Array.from( - { length: skipDepth + 1 }, - (_, i) => { - const segment = candidates[0]![depth + i]! - if (segment.type === 1) { - const conditions = [] - if (segment.prefixSegment) { - conditions.push(`baseSegments[${depth + i}].value.startsWith('${segment.prefixSegment}')`) - } - if (segment.suffixSegment) { - conditions.push(`baseSegments[${depth + i}].value.endsWith('${segment.suffixSegment}')`) - } - return conditions.join(' && ') + Array.from({ length: skipDepth + 1 }, (_, i) => { + const segment = candidates[0]![depth + i]! + if (segment.type === 1) { + const conditions = [] + if (segment.prefixSegment) { + conditions.push( + `baseSegments[${depth + i}].value.startsWith('${segment.prefixSegment}')`, + ) } - return `baseSegments[${depth + i}].value === '${segment.value}'` + if (segment.suffixSegment) { + conditions.push( + `baseSegments[${depth + i}].value.endsWith('${segment.suffixSegment}')`, + ) + } + return conditions.join(' && ') } - ).filter(Boolean).join(`\n${indent} && `) + - (skipDepth ? `\n${indent}` : '') + return `baseSegments[${depth + i}].value === '${segment.value}'` + }) + .filter(Boolean) + .join(`\n${indent} && `) + (skipDepth ? `\n${indent}` : '') const hasCondition = Boolean(lCondition || skipConditions) if (hasCondition) { fn += `\n${indent}if (${lCondition}${lCondition && skipConditions ? ' && ' : ''}${skipConditions}) {` @@ -266,7 +275,11 @@ describe('work in progress', () => { throw new Error('Implementation error: this should not happen') } if (deeper.length > 0) { - recursiveStaticMatch(deeper, depth + 1 + skipDepth, hasCondition ? indent + ' ' : indent) + recursiveStaticMatch( + deeper, + depth + 1 + skipDepth, + hasCondition ? indent + ' ' : indent, + ) } if (leaves.length > 1) { throw new Error( @@ -528,7 +541,7 @@ describe('work in progress', () => { '/foo/qux', '/a/user-123', '/files/hello-world.txt', - '/something/foo/bar' + '/something/foo/bar', ])('matching %s', (s) => { const originalMatch = originalMatcher(s) const buildMatch = buildMatcher(parsePathname, s) @@ -538,4 +551,3 @@ describe('work in progress', () => { expect(buildMatch).toBe(originalMatch) }) }) - From 6799fcb8836844e9a88e81296e563feb7d2ca527 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 00:59:26 +0200 Subject: [PATCH 13/57] support for wildcard --- packages/router-core/tests/built.test.ts | 147 ++++++++++++++++++----- 1 file changed, 116 insertions(+), 31 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 41017fbc4e3..cfafe15d81f 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -98,6 +98,9 @@ const routeTree = createRouteTree([ '/z/y/x/v', '/z/y/x/u', '/z/y/x', + '/images/thumb_{$}', // wildcard with prefix + '/logs/{$}.txt', // wildcard with suffix + '/cache/temp_{$}.log', // wildcard with prefix and suffix ]) const result = processRouteTree({ routeTree }) @@ -139,6 +142,9 @@ describe('work in progress', () => { "/a/{-$slug}", "/b/{-$slug}", "/posts/{-$slug}", + "/cache/temp_{$}.log", + "/images/thumb_{$}", + "/logs/{$}.txt", "/a/$", "/b/$", "/files/$", @@ -210,6 +216,15 @@ describe('work in progress', () => { ) } + // For SEGMENT_TYPE_WILDCARD (type 2), match only on type and prefix/suffix constraints + if (currentSegment.type === 2) { + return ( + rParsed.type === 2 && + rParsed.prefixSegment === currentSegment.prefixSegment && + rParsed.suffixSegment === currentSegment.suffixSegment + ) + } + // For all other segment types (SEGMENT_TYPE_PATHNAME, etc.), use exact matching return ( rParsed.type === currentSegment.type && @@ -257,6 +272,10 @@ describe('work in progress', () => { } return conditions.join(' && ') } + if (segment.type === 2) { + // Wildcards consume all remaining segments, no checking needed + return '' + } return `baseSegments[${depth + i}].value === '${segment.value}'` }) .filter(Boolean) @@ -282,6 +301,9 @@ describe('work in progress', () => { ) } if (leaves.length > 1) { + // WARN: we should probably support "multiple leaves" + // 1. user error: it's possible that a user created both `/a/$id` and `/a/$foo`, they'd be both matched, just use the 1st one + // 2. wildcards: if a user created both `/a/$` and `/a/b`, we could have 2 leaves. the order in `leaves` will be `[/a/b, /a/$]` which is correct, try to match `/a/b` first, then `/a/$` throw new Error( `Multiple candidates found for depth ${depth} with type ${routeSegments[depth]!.type} and value ${routeSegments[depth]!.value}: ${leaves.map(logParsed).join(', ')}`, ) @@ -296,32 +318,85 @@ describe('work in progress', () => { } } else { const leaf = candidates[0]! - const done = `return '${rebuildPath(leaf)}';` - fn += `\n${indent}if (l === ${leaf.length}` - for (let i = depth; i < leaf.length; i++) { - const segment = leaf[i]! - const value = `baseSegments[${i}].value` - - // For SEGMENT_TYPE_PARAM (type 1), check if base has static segment (type 0) that satisfies constraints - if (segment.type === 1) { - if (segment.prefixSegment || segment.suffixSegment) { - fn += `\n${indent} ` + + // Check if this route contains a wildcard segment + const wildcardIndex = leaf.findIndex(s => s && s.type === 2) + + if (wildcardIndex !== -1 && wildcardIndex >= depth) { + // This route has a wildcard at or after the current depth + const wildcardSegment = leaf[wildcardIndex]! + const done = `return '${rebuildPath(leaf)}';` + + // For wildcards, we need to check: + // 1. All static/param segments before the wildcard match + // 2. There are remaining segments for the wildcard to consume (l >= wildcardIndex) + // 3. Handle prefix/suffix constraints for the wildcard if present + + const conditions = [`l >= ${wildcardIndex}`] + + // Add conditions for all segments before the wildcard + for (let i = depth; i < wildcardIndex; i++) { + const segment = leaf[i]! + const value = `baseSegments[${i}].value` + + if (segment.type === 1) { + // Parameter segment + if (segment.prefixSegment) { + conditions.push(`${value}.startsWith('${segment.prefixSegment}')`) + } + if (segment.suffixSegment) { + conditions.push(`${value}.endsWith('${segment.suffixSegment}')`) + } + } else if (segment.type === 0) { + // Static segment + conditions.push(`${value} === '${segment.value}'`) } - // Add prefix/suffix checks for parameters with prefix/suffix - if (segment.prefixSegment) { - fn += ` && ${value}.startsWith('${segment.prefixSegment}')` + } + + // Handle prefix/suffix for the wildcard itself + if (wildcardSegment.prefixSegment || wildcardSegment.suffixSegment) { + const wildcardValue = `baseSegments[${wildcardIndex}].value` + if (wildcardSegment.prefixSegment) { + conditions.push(`${wildcardValue}.startsWith('${wildcardSegment.prefixSegment}')`) } - if (segment.suffixSegment) { - fn += ` && ${value}.endsWith('${segment.suffixSegment}')` + if (wildcardSegment.suffixSegment) { + // For suffix wildcard, we need to check the last segment + conditions.push(`baseSegments[l - 1].value.endsWith('${wildcardSegment.suffixSegment}')`) } - } else { - // For other segment types, use exact matching - fn += `\n${indent} && ${value} === '${segment.value}'` } + + fn += `\n${indent}if (${conditions.join(' && ')}) {` + fn += `\n${indent} ${done}` + fn += `\n${indent}}` + } else { + // No wildcard in this route, use the original logic + const done = `return '${rebuildPath(leaf)}';` + fn += `\n${indent}if (l === ${leaf.length}` + for (let i = depth; i < leaf.length; i++) { + const segment = leaf[i]! + const value = `baseSegments[${i}].value` + + // For SEGMENT_TYPE_PARAM (type 1), check if base has static segment (type 0) that satisfies constraints + if (segment.type === 1) { + if (segment.prefixSegment || segment.suffixSegment) { + fn += `\n${indent} ` + } + // Add prefix/suffix checks for parameters with prefix/suffix + if (segment.prefixSegment) { + fn += ` && ${value}.startsWith('${segment.prefixSegment}')` + } + if (segment.suffixSegment) { + fn += ` && ${value}.endsWith('${segment.suffixSegment}')` + } + } else { + // For other segment types, use exact matching + fn += `\n${indent} && ${value} === '${segment.value}'` + } + } + fn += `\n${indent}) {` + fn += `\n${indent} ${done}` + fn += `\n${indent}}` } - fn += `\n${indent}) {` - fn += `\n${indent} ${done}` - fn += `\n${indent}}` } candidates.forEach((c) => resolved.add(c)) } @@ -367,9 +442,7 @@ describe('work in progress', () => { ) { return '/a/$slug'; } - if (l === 3 - && baseSegments[2].value === '$' - ) { + if (l >= 2) { return '/a/$'; } if (l === 2) { @@ -424,9 +497,7 @@ describe('work in progress', () => { ) { return '/b/$slug'; } - if (l === 3 - && baseSegments[2].value === '$' - ) { + if (l >= 2) { return '/b/$'; } if (l === 2) { @@ -500,10 +571,16 @@ describe('work in progress', () => { ) { return '/posts/$slug'; } - if (l === 3 - && baseSegments[1].value === 'files' - && baseSegments[2].value === '$' - ) { + if (l >= 2 && baseSegments[1].value === 'cache' && baseSegments[2].value.startsWith('temp_') && baseSegments[l - 1].value.endsWith('.log')) { + return '/cache/temp_{$}.log'; + } + if (l >= 2 && baseSegments[1].value === 'images' && baseSegments[2].value.startsWith('thumb_')) { + return '/images/thumb_{$}'; + } + if (l >= 2 && baseSegments[1].value === 'logs' && baseSegments[l - 1].value.endsWith('.txt')) { + return '/logs/{$}.txt'; + } + if (l >= 2 && baseSegments[1].value === 'files') { return '/files/$'; } if (l === 2 @@ -540,8 +617,16 @@ describe('work in progress', () => { '/foo/123/qux', '/foo/qux', '/a/user-123', + '/a/123', + '/a/123/more', + '/files', '/files/hello-world.txt', '/something/foo/bar', + '/files/deep/nested/file.json', + '/files/', + '/images/thumb_200x300.jpg', + '/logs/error.txt', + '/cache/temp_user456.log', ])('matching %s', (s) => { const originalMatch = originalMatcher(s) const buildMatch = buildMatcher(parsePathname, s) From c74a36aedd2c6c2c3e6c772beb955a5260af32e7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 00:59:56 +0200 Subject: [PATCH 14/57] prettier --- packages/router-core/tests/built.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index cfafe15d81f..19ac6fc62fc 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -320,7 +320,7 @@ describe('work in progress', () => { const leaf = candidates[0]! // Check if this route contains a wildcard segment - const wildcardIndex = leaf.findIndex(s => s && s.type === 2) + const wildcardIndex = leaf.findIndex((s) => s && s.type === 2) if (wildcardIndex !== -1 && wildcardIndex >= depth) { // This route has a wildcard at or after the current depth @@ -342,7 +342,9 @@ describe('work in progress', () => { if (segment.type === 1) { // Parameter segment if (segment.prefixSegment) { - conditions.push(`${value}.startsWith('${segment.prefixSegment}')`) + conditions.push( + `${value}.startsWith('${segment.prefixSegment}')`, + ) } if (segment.suffixSegment) { conditions.push(`${value}.endsWith('${segment.suffixSegment}')`) @@ -357,11 +359,15 @@ describe('work in progress', () => { if (wildcardSegment.prefixSegment || wildcardSegment.suffixSegment) { const wildcardValue = `baseSegments[${wildcardIndex}].value` if (wildcardSegment.prefixSegment) { - conditions.push(`${wildcardValue}.startsWith('${wildcardSegment.prefixSegment}')`) + conditions.push( + `${wildcardValue}.startsWith('${wildcardSegment.prefixSegment}')`, + ) } if (wildcardSegment.suffixSegment) { // For suffix wildcard, we need to check the last segment - conditions.push(`baseSegments[l - 1].value.endsWith('${wildcardSegment.suffixSegment}')`) + conditions.push( + `baseSegments[l - 1].value.endsWith('${wildcardSegment.suffixSegment}')`, + ) } } From e40ccbd3630550772960243f0d86be4a33c1f45b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 01:13:28 +0200 Subject: [PATCH 15/57] root route --- packages/router-core/tests/built.test.ts | 326 ++++++++++++----------- 1 file changed, 168 insertions(+), 158 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 19ac6fc62fc..2979fcadbd1 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -63,6 +63,7 @@ function createRouteTree(pathOrChildren: Array): TestRoute { } const routeTree = createRouteTree([ + '/', '/users/profile/settings', // static-deep (longest static path) '/users/profile', // static-medium (medium static path) '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) @@ -152,6 +153,7 @@ describe('work in progress', () => { "/about", "/b", "/one", + "/", "/$id/bar/foo", "/$id/foo/bar", ] @@ -179,7 +181,7 @@ describe('work in progress', () => { ) .join('/')}` - const initialDepth = 1 + const initialDepth = 0 let fn = 'const baseSegments = parsePathname(from);' fn += '\nconst l = baseSegments.length;' @@ -201,7 +203,7 @@ describe('work in progress', () => { console.log('\u001b[34m' + fn + '\u001b[0m') const currentSegment = routeSegments[depth] if (!currentSegment) { - throw new Error('Implementation error: this should not happen') + throw new Error('Implementation error: this should not happen, depth=' + depth + `, route=${rebuildPath(routeSegments)}`) } const candidates = parsedRoutes.filter((r) => { const rParsed = r[depth] @@ -414,197 +416,204 @@ describe('work in progress', () => { expect(fn).toMatchInlineSnapshot(` "const baseSegments = parsePathname(from); const l = baseSegments.length; - if (baseSegments[1].value === 'a') { - if (l === 7 - && baseSegments[2].value === 'b' - && baseSegments[3].value === 'c' - && baseSegments[4].value === 'd' - && baseSegments[5].value === 'e' - && baseSegments[6].value === 'f' - ) { - return '/a/b/c/d/e/f'; - } - if (l > 2 && baseSegments[2].value === 'profile') { - if (l === 4 - && baseSegments[3].value === 'settings' + if (baseSegments[0].value === '/') { + if (l > 1 && baseSegments[1].value === 'a') { + if (l === 7 + && baseSegments[2].value === 'b' + && baseSegments[3].value === 'c' + && baseSegments[4].value === 'd' + && baseSegments[5].value === 'e' + && baseSegments[6].value === 'f' ) { - return '/a/profile/settings'; + return '/a/b/c/d/e/f'; } - if (l === 3) { - return '/a/profile'; + if (l > 2 && baseSegments[2].value === 'profile') { + if (l === 4 + && baseSegments[3].value === 'settings' + ) { + return '/a/profile/settings'; + } + if (l === 3) { + return '/a/profile'; + } + } + if (l === 3 + && baseSegments[2].value.startsWith('user-') + ) { + return '/a/user-{$id}'; + } + if (l === 3 + ) { + return '/a/$id'; + } + if (l === 3 + && baseSegments[2].value === '$slug' + ) { + return '/a/$slug'; + } + if (l >= 2) { + return '/a/$'; + } + if (l === 2) { + return '/a'; } } - if (l === 3 - && baseSegments[2].value.startsWith('user-') - ) { - return '/a/user-{$id}'; - } - if (l === 3 - ) { - return '/a/$id'; - } - if (l === 3 - && baseSegments[2].value === '$slug' - ) { - return '/a/$slug'; - } - if (l >= 2) { - return '/a/$'; - } - if (l === 2) { - return '/a'; - } - } - if (l > 3 && baseSegments[1].value === 'z' - && baseSegments[2].value === 'y' - && baseSegments[3].value === 'x' - ) { - if (l === 5 - && baseSegments[4].value === 'u' - ) { - return '/z/y/x/u'; - } - if (l === 5 - && baseSegments[4].value === 'v' + if (l > 3 && baseSegments[1].value === 'z' + && baseSegments[2].value === 'y' + && baseSegments[3].value === 'x' ) { - return '/z/y/x/v'; + if (l === 5 + && baseSegments[4].value === 'u' + ) { + return '/z/y/x/u'; + } + if (l === 5 + && baseSegments[4].value === 'v' + ) { + return '/z/y/x/v'; + } + if (l === 5 + && baseSegments[4].value === 'w' + ) { + return '/z/y/x/w'; + } + if (l === 4) { + return '/z/y/x'; + } } - if (l === 5 - && baseSegments[4].value === 'w' - ) { - return '/z/y/x/w'; + if (l > 1 && baseSegments[1].value === 'b') { + if (l > 2 && baseSegments[2].value === 'profile') { + if (l === 4 + && baseSegments[3].value === 'settings' + ) { + return '/b/profile/settings'; + } + if (l === 3) { + return '/b/profile'; + } + } + if (l === 3 + && baseSegments[2].value.startsWith('user-') + ) { + return '/b/user-{$id}'; + } + if (l === 3 + ) { + return '/b/$id'; + } + if (l === 3 + && baseSegments[2].value === '$slug' + ) { + return '/b/$slug'; + } + if (l >= 2) { + return '/b/$'; + } + if (l === 2) { + return '/b'; + } } - if (l === 4) { - return '/z/y/x'; + if (l > 1 && baseSegments[1].value === 'users') { + if (l > 2 && baseSegments[2].value === 'profile') { + if (l === 4 + && baseSegments[3].value === 'settings' + ) { + return '/users/profile/settings'; + } + if (l === 3) { + return '/users/profile'; + } + } + if (l === 3 + ) { + return '/users/$id'; + } } - } - if (baseSegments[1].value === 'b') { - if (l > 2 && baseSegments[2].value === 'profile') { + if (l > 1 && baseSegments[1].value === 'foo') { if (l === 4 - && baseSegments[3].value === 'settings' + && baseSegments[2].value === 'bar' ) { - return '/b/profile/settings'; + return '/foo/bar/$id'; + } + if (l > 2) { + if (l === 4 + && baseSegments[3].value === 'bar' + ) { + return '/foo/$id/bar'; + } + if (l === 3) { + return '/foo/$bar'; + } } - if (l === 3) { - return '/b/profile'; + if (l === 4 + && baseSegments[2].value === '$bar' + && baseSegments[3].value === 'qux' + ) { + return '/foo/$bar/qux'; } } if (l === 3 - && baseSegments[2].value.startsWith('user-') + && baseSegments[1].value === 'beep' + && baseSegments[2].value === 'boop' ) { - return '/b/user-{$id}'; + return '/beep/boop'; + } + if (l > 1 && baseSegments[1].value === 'one') { + if (l === 3 + && baseSegments[2].value === 'two' + ) { + return '/one/two'; + } + if (l === 2) { + return '/one'; + } } if (l === 3 + && baseSegments[1].value === 'api' + && baseSegments[2].value.startsWith('user-') ) { - return '/b/$id'; + return '/api/user-{$id}'; } if (l === 3 + && baseSegments[1].value === 'posts' && baseSegments[2].value === '$slug' ) { - return '/b/$slug'; + return '/posts/$slug'; } - if (l >= 2) { - return '/b/$'; + if (l >= 2 && baseSegments[1].value === 'cache' && baseSegments[2].value.startsWith('temp_') && baseSegments[l - 1].value.endsWith('.log')) { + return '/cache/temp_{$}.log'; } - if (l === 2) { - return '/b'; + if (l >= 2 && baseSegments[1].value === 'images' && baseSegments[2].value.startsWith('thumb_')) { + return '/images/thumb_{$}'; } - } - if (baseSegments[1].value === 'users') { - if (l > 2 && baseSegments[2].value === 'profile') { - if (l === 4 - && baseSegments[3].value === 'settings' - ) { - return '/users/profile/settings'; - } - if (l === 3) { - return '/users/profile'; - } + if (l >= 2 && baseSegments[1].value === 'logs' && baseSegments[l - 1].value.endsWith('.txt')) { + return '/logs/{$}.txt'; } - if (l === 3 - ) { - return '/users/$id'; + if (l >= 2 && baseSegments[1].value === 'files') { + return '/files/$'; } - } - if (baseSegments[1].value === 'foo') { - if (l === 4 - && baseSegments[2].value === 'bar' + if (l === 2 + && baseSegments[1].value === 'about' ) { - return '/foo/bar/$id'; + return '/about'; } - if (l > 2) { + if (l > 1) { if (l === 4 - && baseSegments[3].value === 'bar' + && baseSegments[2].value === 'bar' + && baseSegments[3].value === 'foo' ) { - return '/foo/$id/bar'; + return '/$id/bar/foo'; } - if (l === 3) { - return '/foo/$bar'; + if (l === 4 + && baseSegments[2].value === 'foo' + && baseSegments[3].value === 'bar' + ) { + return '/$id/foo/bar'; } } - if (l === 4 - && baseSegments[2].value === '$bar' - && baseSegments[3].value === 'qux' - ) { - return '/foo/$bar/qux'; + if (l === 1) { + return '/'; } - } - if (l === 3 - && baseSegments[1].value === 'beep' - && baseSegments[2].value === 'boop' - ) { - return '/beep/boop'; - } - if (baseSegments[1].value === 'one') { - if (l === 3 - && baseSegments[2].value === 'two' - ) { - return '/one/two'; - } - if (l === 2) { - return '/one'; - } - } - if (l === 3 - && baseSegments[1].value === 'api' - && baseSegments[2].value.startsWith('user-') - ) { - return '/api/user-{$id}'; - } - if (l === 3 - && baseSegments[1].value === 'posts' - && baseSegments[2].value === '$slug' - ) { - return '/posts/$slug'; - } - if (l >= 2 && baseSegments[1].value === 'cache' && baseSegments[2].value.startsWith('temp_') && baseSegments[l - 1].value.endsWith('.log')) { - return '/cache/temp_{$}.log'; - } - if (l >= 2 && baseSegments[1].value === 'images' && baseSegments[2].value.startsWith('thumb_')) { - return '/images/thumb_{$}'; - } - if (l >= 2 && baseSegments[1].value === 'logs' && baseSegments[l - 1].value.endsWith('.txt')) { - return '/logs/{$}.txt'; - } - if (l >= 2 && baseSegments[1].value === 'files') { - return '/files/$'; - } - if (l === 2 - && baseSegments[1].value === 'about' - ) { - return '/about'; - } - if (l === 4 - && baseSegments[2].value === 'bar' - && baseSegments[3].value === 'foo' - ) { - return '/$id/bar/foo'; - } - if (l === 4 - && baseSegments[2].value === 'foo' - && baseSegments[3].value === 'bar' - ) { - return '/$id/foo/bar'; }" `) }) @@ -616,6 +625,7 @@ describe('work in progress', () => { // WARN: some of these don't work yet, they're just here to show the differences test.each([ + '/', '/users/profile/settings', '/foo/123', '/b/123', From adba433db0b98e0683f2c28feb888d63a03e5ce8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 23:14:43 +0000 Subject: [PATCH 16/57] ci: apply automated fixes --- packages/router-core/tests/built.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 2979fcadbd1..a7851fcc4b6 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -203,7 +203,11 @@ describe('work in progress', () => { console.log('\u001b[34m' + fn + '\u001b[0m') const currentSegment = routeSegments[depth] if (!currentSegment) { - throw new Error('Implementation error: this should not happen, depth=' + depth + `, route=${rebuildPath(routeSegments)}`) + throw new Error( + 'Implementation error: this should not happen, depth=' + + depth + + `, route=${rebuildPath(routeSegments)}`, + ) } const candidates = parsedRoutes.filter((r) => { const rParsed = r[depth] From e5501fb19db6a7ee3ff7b8929a24e67ea690ee61 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 01:33:30 +0200 Subject: [PATCH 17/57] fix route rebuilding --- packages/router-core/tests/built.test.ts | 35 ++++++++++++------------ 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index a7851fcc4b6..5e5dab776e9 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -5,6 +5,7 @@ import { parsePathname, processRouteTree, } from '../src' +import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD } from "../src/path" interface TestRoute { id: string @@ -177,7 +178,7 @@ describe('work in progress', () => { .map((s) => s.value === '/' ? '' - : `${s.prefixSegment ?? ''}${s.prefixSegment || s.suffixSegment ? '{' : ''}${s.value}${s.prefixSegment || s.suffixSegment ? '}' : ''}${s.suffixSegment ?? ''}`, + : `${s.prefixSegment ?? ''}${s.prefixSegment || s.suffixSegment || s.type === SEGMENT_TYPE_OPTIONAL_PARAM ? '{' : ''}${s.type === SEGMENT_TYPE_OPTIONAL_PARAM ? '-' : ''}${s.value}${s.prefixSegment || s.suffixSegment || s.type === SEGMENT_TYPE_OPTIONAL_PARAM ? '}' : ''}${s.suffixSegment ?? ''}`, ) .join('/')}` @@ -205,8 +206,8 @@ describe('work in progress', () => { if (!currentSegment) { throw new Error( 'Implementation error: this should not happen, depth=' + - depth + - `, route=${rebuildPath(routeSegments)}`, + depth + + `, route=${rebuildPath(routeSegments)}`, ) } const candidates = parsedRoutes.filter((r) => { @@ -214,18 +215,18 @@ describe('work in progress', () => { if (!rParsed) return false // For SEGMENT_TYPE_PARAM (type 1), match only on type and prefix/suffix constraints - if (currentSegment.type === 1) { + if (currentSegment.type === SEGMENT_TYPE_PARAM) { return ( - rParsed.type === 1 && + rParsed.type === SEGMENT_TYPE_PARAM && rParsed.prefixSegment === currentSegment.prefixSegment && rParsed.suffixSegment === currentSegment.suffixSegment ) } // For SEGMENT_TYPE_WILDCARD (type 2), match only on type and prefix/suffix constraints - if (currentSegment.type === 2) { + if (currentSegment.type === SEGMENT_TYPE_WILDCARD) { return ( - rParsed.type === 2 && + rParsed.type === SEGMENT_TYPE_WILDCARD && rParsed.prefixSegment === currentSegment.prefixSegment && rParsed.suffixSegment === currentSegment.suffixSegment ) @@ -264,7 +265,7 @@ describe('work in progress', () => { const skipConditions = Array.from({ length: skipDepth + 1 }, (_, i) => { const segment = candidates[0]![depth + i]! - if (segment.type === 1) { + if (segment.type === SEGMENT_TYPE_PARAM) { const conditions = [] if (segment.prefixSegment) { conditions.push( @@ -278,7 +279,7 @@ describe('work in progress', () => { } return conditions.join(' && ') } - if (segment.type === 2) { + if (segment.type === SEGMENT_TYPE_WILDCARD) { // Wildcards consume all remaining segments, no checking needed return '' } @@ -326,7 +327,7 @@ describe('work in progress', () => { const leaf = candidates[0]! // Check if this route contains a wildcard segment - const wildcardIndex = leaf.findIndex((s) => s && s.type === 2) + const wildcardIndex = leaf.findIndex((s) => s && s.type === SEGMENT_TYPE_WILDCARD) if (wildcardIndex !== -1 && wildcardIndex >= depth) { // This route has a wildcard at or after the current depth @@ -345,7 +346,7 @@ describe('work in progress', () => { const segment = leaf[i]! const value = `baseSegments[${i}].value` - if (segment.type === 1) { + if (segment.type === SEGMENT_TYPE_PARAM) { // Parameter segment if (segment.prefixSegment) { conditions.push( @@ -355,7 +356,7 @@ describe('work in progress', () => { if (segment.suffixSegment) { conditions.push(`${value}.endsWith('${segment.suffixSegment}')`) } - } else if (segment.type === 0) { + } else if (segment.type === SEGMENT_TYPE_PATHNAME) { // Static segment conditions.push(`${value} === '${segment.value}'`) } @@ -389,7 +390,7 @@ describe('work in progress', () => { const value = `baseSegments[${i}].value` // For SEGMENT_TYPE_PARAM (type 1), check if base has static segment (type 0) that satisfies constraints - if (segment.type === 1) { + if (segment.type === SEGMENT_TYPE_PARAM) { if (segment.prefixSegment || segment.suffixSegment) { fn += `\n${indent} ` } @@ -453,7 +454,7 @@ describe('work in progress', () => { if (l === 3 && baseSegments[2].value === '$slug' ) { - return '/a/$slug'; + return '/a/{-$slug}'; } if (l >= 2) { return '/a/$'; @@ -508,7 +509,7 @@ describe('work in progress', () => { if (l === 3 && baseSegments[2].value === '$slug' ) { - return '/b/$slug'; + return '/b/{-$slug}'; } if (l >= 2) { return '/b/$'; @@ -553,7 +554,7 @@ describe('work in progress', () => { && baseSegments[2].value === '$bar' && baseSegments[3].value === 'qux' ) { - return '/foo/$bar/qux'; + return '/foo/{-$bar}/qux'; } } if (l === 3 @@ -582,7 +583,7 @@ describe('work in progress', () => { && baseSegments[1].value === 'posts' && baseSegments[2].value === '$slug' ) { - return '/posts/$slug'; + return '/posts/{-$slug}'; } if (l >= 2 && baseSegments[1].value === 'cache' && baseSegments[2].value.startsWith('temp_') && baseSegments[l - 1].value.endsWith('.log')) { return '/cache/temp_{$}.log'; From a5eb6c6c9532ed0e7305d9fe8cc0e83e2be77f03 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 02:24:24 +0200 Subject: [PATCH 18/57] recursion preserves order --- packages/router-core/tests/built.test.ts | 124 +++++++++++------------ 1 file changed, 59 insertions(+), 65 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 5e5dab776e9..5f22bde5c3d 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -161,57 +161,43 @@ describe('work in progress', () => { `) }) - const parsedRoutes = result.flatRoutes.map((route) => - parsePathname(route.fullPath), - ) - - const logParsed = (parsed: ReturnType) => - '/' + - parsed - .slice(1) - .map((s) => s.value) - .join('/') - - const rebuildPath = (leaf: ReturnType) => - `/${leaf - .slice(1) - .map((s) => - s.value === '/' - ? '' - : `${s.prefixSegment ?? ''}${s.prefixSegment || s.suffixSegment || s.type === SEGMENT_TYPE_OPTIONAL_PARAM ? '{' : ''}${s.type === SEGMENT_TYPE_OPTIONAL_PARAM ? '-' : ''}${s.value}${s.prefixSegment || s.suffixSegment || s.type === SEGMENT_TYPE_OPTIONAL_PARAM ? '}' : ''}${s.suffixSegment ?? ''}`, - ) - .join('/')}` + const parsedRoutes = result.flatRoutes.map((route) => ({ + path: route.fullPath, + segments: parsePathname(route.fullPath), + })) const initialDepth = 0 let fn = 'const baseSegments = parsePathname(from);' fn += '\nconst l = baseSegments.length;' + type ParsedRoute = { path: string, segments: ReturnType } + function recursiveStaticMatch( - parsedRoutes: Array>, + parsedRoutes: Array, depth = initialDepth, indent = '', ) { - const resolved = new Set>() - for (const routeSegments of parsedRoutes) { - if (resolved.has(routeSegments)) continue // already resolved + const resolved = new Set() + for (const route of parsedRoutes) { + if (resolved.has(route)) continue // already resolved console.log('\n') console.log( 'resolving: depth=', depth, 'parsed=', - logParsed(routeSegments), + route.path, ) console.log('\u001b[34m' + fn + '\u001b[0m') - const currentSegment = routeSegments[depth] + const currentSegment = route.segments[depth] if (!currentSegment) { throw new Error( 'Implementation error: this should not happen, depth=' + depth + - `, route=${rebuildPath(routeSegments)}`, + `, route=${route.path}`, ) } const candidates = parsedRoutes.filter((r) => { - const rParsed = r[depth] + const rParsed = r.segments[depth] if (!rParsed) return false // For SEGMENT_TYPE_PARAM (type 1), match only on type and prefix/suffix constraints @@ -241,14 +227,14 @@ describe('work in progress', () => { rParsed.suffixSegment === currentSegment.suffixSegment ) }) - console.log('candidates:', candidates.map(logParsed)) + console.log('candidates:', candidates.map(r => r.path)) if (candidates.length === 0) { throw new Error('Implementation error: this should not happen') } if (candidates.length > 1) { - let skipDepth = routeSegments.slice(depth + 1).findIndex((s, i) => + let skipDepth = route.segments.slice(depth + 1).findIndex((s, i) => candidates.some((c) => { - const segment = c[depth + 1 + i] + const segment = c.segments[depth + 1 + i] return ( !segment || segment.type !== s.type || @@ -259,12 +245,12 @@ describe('work in progress', () => { ) }), ) - if (skipDepth === -1) skipDepth = routeSegments.length - depth - 1 + if (skipDepth === -1) skipDepth = route.segments.length - depth - 1 const lCondition = skipDepth || depth > initialDepth ? `l > ${depth + skipDepth}` : '' const skipConditions = Array.from({ length: skipDepth + 1 }, (_, i) => { - const segment = candidates[0]![depth + i]! + const segment = candidates[0]!.segments[depth + i]! if (segment.type === SEGMENT_TYPE_PARAM) { const conditions = [] if (segment.prefixSegment) { @@ -291,35 +277,43 @@ describe('work in progress', () => { if (hasCondition) { fn += `\n${indent}if (${lCondition}${lCondition && skipConditions ? ' && ' : ''}${skipConditions}) {` } - const deeper = candidates.filter( - (c) => c.length > depth + 1 + skipDepth, - ) - const leaves = candidates.filter( - (c) => c.length <= depth + 1 + skipDepth, - ) - if (deeper.length + leaves.length !== candidates.length) { - throw new Error('Implementation error: this should not happen') + const deeperBefore: Array = [] + const deeperAfter: Array = [] + let leaf: ParsedRoute | undefined + for (const c of candidates) { + const isLeaf = c.segments.length <= depth + 1 + skipDepth + if (isLeaf && !leaf) { + leaf = c + continue + } + if (isLeaf) { + continue // ignore subsequent leaves, they can never be matched + } + if (!leaf) { + deeperBefore.push(c) + } else { + deeperAfter.push(c) + } } - if (deeper.length > 0) { + if (deeperBefore.length > 0) { recursiveStaticMatch( - deeper, + deeperBefore, depth + 1 + skipDepth, hasCondition ? indent + ' ' : indent, ) } - if (leaves.length > 1) { - // WARN: we should probably support "multiple leaves" - // 1. user error: it's possible that a user created both `/a/$id` and `/a/$foo`, they'd be both matched, just use the 1st one - // 2. wildcards: if a user created both `/a/$` and `/a/b`, we could have 2 leaves. the order in `leaves` will be `[/a/b, /a/$]` which is correct, try to match `/a/b` first, then `/a/$` - throw new Error( - `Multiple candidates found for depth ${depth} with type ${routeSegments[depth]!.type} and value ${routeSegments[depth]!.value}: ${leaves.map(logParsed).join(', ')}`, - ) - } else if (leaves.length === 1) { - // WARN: is it ok that the leaf is matched last? - fn += `\n${indent} if (l === ${leaves[0]!.length}) {` - fn += `\n${indent} return '${rebuildPath(leaves[0]!)}';` + if (leaf) { + fn += `\n${indent} if (l === ${leaf.segments.length}) {` + fn += `\n${indent} return '${leaf.path}';` fn += `\n${indent} }` } + if (deeperAfter.length > 0) { + recursiveStaticMatch( + deeperAfter, + depth + 1 + skipDepth, + hasCondition ? indent + ' ' : indent, + ) + } if (hasCondition) { fn += `\n${indent}}` } @@ -327,12 +321,12 @@ describe('work in progress', () => { const leaf = candidates[0]! // Check if this route contains a wildcard segment - const wildcardIndex = leaf.findIndex((s) => s && s.type === SEGMENT_TYPE_WILDCARD) + const wildcardIndex = leaf.segments.findIndex((s) => s && s.type === SEGMENT_TYPE_WILDCARD) if (wildcardIndex !== -1 && wildcardIndex >= depth) { // This route has a wildcard at or after the current depth - const wildcardSegment = leaf[wildcardIndex]! - const done = `return '${rebuildPath(leaf)}';` + const wildcardSegment = leaf.segments[wildcardIndex]! + const done = `return '${leaf.path}';` // For wildcards, we need to check: // 1. All static/param segments before the wildcard match @@ -343,7 +337,7 @@ describe('work in progress', () => { // Add conditions for all segments before the wildcard for (let i = depth; i < wildcardIndex; i++) { - const segment = leaf[i]! + const segment = leaf.segments[i]! const value = `baseSegments[${i}].value` if (segment.type === SEGMENT_TYPE_PARAM) { @@ -383,10 +377,10 @@ describe('work in progress', () => { fn += `\n${indent}}` } else { // No wildcard in this route, use the original logic - const done = `return '${rebuildPath(leaf)}';` - fn += `\n${indent}if (l === ${leaf.length}` - for (let i = depth; i < leaf.length; i++) { - const segment = leaf[i]! + const done = `return '${leaf.path}';` + fn += `\n${indent}if (l === ${leaf.segments.length}` + for (let i = depth; i < leaf.segments.length; i++) { + const segment = leaf.segments[i]! const value = `baseSegments[${i}].value` // For SEGMENT_TYPE_PARAM (type 1), check if base has static segment (type 0) that satisfies constraints @@ -602,6 +596,9 @@ describe('work in progress', () => { ) { return '/about'; } + if (l === 1) { + return '/'; + } if (l > 1) { if (l === 4 && baseSegments[2].value === 'bar' @@ -616,9 +613,6 @@ describe('work in progress', () => { return '/$id/foo/bar'; } } - if (l === 1) { - return '/'; - } }" `) }) From 7d2b299c0490ffd069873a798ca745ebf1b80cf8 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 11:05:34 +0200 Subject: [PATCH 19/57] alternate implementation --- packages/router-core/tests/built2.test.ts | 714 ++++++++++++++++++++++ 1 file changed, 714 insertions(+) create mode 100644 packages/router-core/tests/built2.test.ts diff --git a/packages/router-core/tests/built2.test.ts b/packages/router-core/tests/built2.test.ts new file mode 100644 index 00000000000..b03d707c739 --- /dev/null +++ b/packages/router-core/tests/built2.test.ts @@ -0,0 +1,714 @@ +import { describe, expect, it, test } from 'vitest' +import { + joinPaths, + matchPathname, + parsePathname, + processRouteTree, +} from '../src' +import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD } from "../src/path" +import { format } from 'prettier' + +interface TestRoute { + id: string + isRoot?: boolean + path?: string + fullPath: string + rank?: number + parentRoute?: TestRoute + children?: Array + options?: { + caseSensitive?: boolean + } +} + +type PathOrChildren = string | [string, Array] + +function createRoute( + pathOrChildren: Array, + parentPath: string, +): Array { + return pathOrChildren.map((route) => { + if (Array.isArray(route)) { + const fullPath = joinPaths([parentPath, route[0]]) + const children = createRoute(route[1], fullPath) + const r = { + id: fullPath, + path: route[0], + fullPath, + children: children, + } + children.forEach((child) => { + child.parentRoute = r + }) + + return r + } + + const fullPath = joinPaths([parentPath, route]) + + return { + id: fullPath, + path: route, + fullPath, + } + }) +} + +function createRouteTree(pathOrChildren: Array): TestRoute { + return { + id: '__root__', + fullPath: '', + isRoot: true, + path: undefined, + children: createRoute(pathOrChildren, ''), + } +} + +const routeTree = createRouteTree([ + '/', + '/users/profile/settings', // static-deep (longest static path) + '/users/profile', // static-medium (medium static path) + '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) + '/users/$id', // param-simple (plain param) + '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) + '/files/$', // wildcard (lowest priority) + '/about', // static-shallow (shorter static path) + '/a/profile/settings', + '/a/profile', + '/a/user-{$id}', + '/a/$id', + '/a/{-$slug}', + '/a/$', + '/a', + '/b/profile/settings', + '/b/profile', + '/b/user-{$id}', + '/b/$id', + '/b/{-$slug}', + '/b/$', + '/b', + '/foo/bar/$id', + '/foo/$id/bar', + '/foo/$bar', + '/foo/{-$bar}/qux', + '/$id/bar/foo', + '/$id/foo/bar', + '/a/b/c/d/e/f', + '/beep/boop', + '/one/two', + '/one', + '/z/y/x/w', + '/z/y/x/v', + '/z/y/x/u', + '/z/y/x', + '/images/thumb_{$}', // wildcard with prefix + '/logs/{$}.txt', // wildcard with suffix + '/cache/temp_{$}.log', // wildcard with prefix and suffix +]) + +const result = processRouteTree({ routeTree }) + +function originalMatcher(from: string): string | undefined { + const match = result.flatRoutes.find((r) => + matchPathname('/', from, { to: r.fullPath }), + ) + return match?.fullPath +} + +describe('work in progress', () => { + it('is ordrered', () => { + expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` + [ + "/a/b/c/d/e/f", + "/z/y/x/u", + "/z/y/x/v", + "/z/y/x/w", + "/a/profile/settings", + "/b/profile/settings", + "/users/profile/settings", + "/z/y/x", + "/foo/bar/$id", + "/a/profile", + "/b/profile", + "/beep/boop", + "/one/two", + "/users/profile", + "/foo/$id/bar", + "/foo/{-$bar}/qux", + "/a/user-{$id}", + "/api/user-{$id}", + "/b/user-{$id}", + "/a/$id", + "/b/$id", + "/foo/$bar", + "/users/$id", + "/a/{-$slug}", + "/b/{-$slug}", + "/posts/{-$slug}", + "/cache/temp_{$}.log", + "/images/thumb_{$}", + "/logs/{$}.txt", + "/a/$", + "/b/$", + "/files/$", + "/a", + "/about", + "/b", + "/one", + "/", + "/$id/bar/foo", + "/$id/foo/bar", + ] + `) + }) + + const parsedRoutes = result.flatRoutes.map((route): ParsedRoute => ({ + path: route.fullPath, + segments: parsePathname(route.fullPath), + rank: route.rank! + })) + + type ParsedRoute = { path: string, segments: ReturnType, rank: number } + + let fn = 'const baseSegments = parsePathname(from).map(s => s.value);' + fn += '\nconst l = baseSegments.length;' + + type WithConditions = ParsedRoute & { + conditions: Array + length: { min: number; max: number } + } + + function conditionToString(condition: Condition) { + if (condition.kind === 'static') { + if (condition.index === 0 && condition.value === '/') return undefined // root segment is always `/` + return `baseSegments[${condition.index}] === '${condition.value}'` + } else if (condition.kind === 'startsWith') { + return `baseSegments[${condition.index}].startsWith('${condition.value}')` + } else if (condition.kind === 'endsWith') { + return `baseSegments[${condition.index}].endsWith('${condition.value}')` + } else if (condition.kind === 'wildcardEndsWith') { + return `baseSegments[l - 1].endsWith('${condition.value}')` + } + return undefined + } + + function outputRoute( + route: WithConditions, + length: { min: number; max: number }, + preconditions: Array = [], + ) { + const flags: Array = [] + let min = length.min + if (route.length.min > length.min) min = route.length.min + let max = length.max + if (route.length.max < length.max) max = route.length.max + for (const condition of route.conditions) { + if (condition.kind === 'static' && condition.index + 1 < min) { + min = condition.index + 1 + } else if (condition.kind === 'startsWith' && condition.index + 1 < min) { + min = condition.index + 1 + } else if (condition.kind === 'endsWith' && condition.index + 1 < min) { + min = condition.index + 1 + } + } + + if (min > length.min && max < length.max && min === max) { + flags.push(`l === ${min}`) + } else { + if (min > length.min) { + flags.push(`l >= ${min}`) + } + if (max < length.max) { + flags.push(`l <= ${max}`) + } + } + for (const condition of route.conditions) { + if (!preconditions.includes(condition.key)) { + const str = conditionToString(condition) + if (str) { + flags.push(str) + } + } + } + if (flags.length) { + fn += `if (${flags.join(' && ')}) {` + } + fn += `propose(${route.rank}, '${route.path}');` + if (flags.length) { + fn += '}' + } + } + + function recursiveStaticMatch( + parsedRoutes: Array, + length: { min: number; max: number } = { min: 0, max: Infinity }, + preconditions: Array = [], + ) { + // count all conditions by `key` + // determine the condition that would match as close to 50% of the routes as possible + const conditionCounts: Record = {} + parsedRoutes.forEach((r) => { + r.conditions.forEach((c) => { + conditionCounts[c.key] = (conditionCounts[c.key] || 0) + 1 + }) + }) + const total = parsedRoutes.length + const target = total / 2 + let bestKey + let bestScore = Infinity + for (const key in conditionCounts) { + if (preconditions.includes(key)) continue + const score = Math.abs(conditionCounts[key]! - target) + if (score < bestScore) { + bestScore = score + bestKey = key + } + } + // console.log(`Best condition key: ${bestKey} with score: ${conditionCounts[bestKey]} / ${total}`) + + // look at all minLengths and maxLengths + // determine a minLength and a maxLength that would match as close to 50% of the routes as possible + const minLengths: Record = {} + const maxLengths: Record = {} + parsedRoutes.forEach((r) => { + const minLength = r.length.min + if (minLength > length.min) { + minLengths[minLength] = (minLengths[minLength] || 0) + 1 + } + const maxLength = r.length.max + if (maxLength !== Infinity && maxLength < length.max) { + maxLengths[maxLength] = (maxLengths[maxLength] || 0) + 1 + } + }) + const allMinLengths = Object.keys(minLengths).sort((a, b) => Number(a) - Number(b)) + for (let i = 0; i < allMinLengths.length; i++) { + for (let j = i + 1; j < allMinLengths.length; j++) { + minLengths[Number(allMinLengths[i]!)]! += minLengths[Number(allMinLengths[j]!)]! + } + } + const allMaxLengths = Object.keys(maxLengths).sort((a, b) => Number(b) - Number(a)) + for (let i = 0; i < allMaxLengths.length; i++) { + for (let j = i + 1; j < allMaxLengths.length; j++) { + maxLengths[Number(allMaxLengths[i]!)]! += maxLengths[Number(allMaxLengths[j]!)]! + } + } + let bestMinLength + let bestMaxLength + let bestMinScore = Infinity + for (const minLength in minLengths) { + const minScore = Math.abs(minLengths[minLength]! - target) + if (minScore < bestMinScore) { + bestMinScore = minScore + bestMinLength = Number(minLength) + } + } + for (const maxLength in maxLengths) { + const maxScore = Math.abs(maxLengths[maxLength]! - target) + if (maxScore < bestMinScore) { + bestMinScore = maxScore + bestMaxLength = Number(maxLength) + } + } + // console.log(`Best minLength: ${bestMinLength} with score: ${minLengths[bestMinLength!]} / ${total}`) + // console.log(`Best maxLength: ${bestMaxLength} with score: ${maxLengths[bestMaxLength!]} / ${total}`) + + // determine which of the 3 discriminants to use (condition, minLength, maxLength) to match as close to 50% of the routes as possible + const discriminant = bestKey && (!bestMinLength || conditionCounts[bestKey] > minLengths[bestMinLength!]) && (!bestMaxLength || conditionCounts[bestKey] > maxLengths[bestMaxLength!]) + ? { key: bestKey, type: 'condition', } as const + : bestMinLength && (!bestMaxLength || minLengths[bestMinLength!] > maxLengths[bestMaxLength!]) && (!bestKey || minLengths[bestMinLength!] > conditionCounts[bestKey]) + ? { key: bestMinLength!, type: 'minLength' } as const + : bestMaxLength + ? { key: bestMaxLength!, type: 'maxLength' } as const + : undefined + + if (discriminant) { + // split all routes into 2 groups (matching and not matching) based on the discriminant + const matchingRoutes: Array = [] + const nonMatchingRoutes: Array = [] + for (const route of parsedRoutes) { + if (discriminant.type === 'condition') { + const condition = route.conditions.find(c => c.key === discriminant.key) + if (condition) { + matchingRoutes.push(route) + } else { + nonMatchingRoutes.push(route) + } + } else if (discriminant.type === 'minLength') { + if (route.length.min >= discriminant.key) { + matchingRoutes.push(route) + } else { + nonMatchingRoutes.push(route) + } + } else if (discriminant.type === 'maxLength') { + if (route.length.max <= discriminant.key) { + matchingRoutes.push(route) + } else { + nonMatchingRoutes.push(route) + } + } + } + if (matchingRoutes.length === 1) { + outputRoute(matchingRoutes[0]!, length, preconditions) + } else if (matchingRoutes.length) { + // add `if` for the discriminant + if (discriminant.type === 'condition') { + const condition = matchingRoutes[0]!.conditions.find(c => c.key === discriminant.key)! + fn += `if (${conditionToString(condition) || 'true'}) {` + } else if (discriminant.type === 'minLength') { + if (discriminant.key === length.max) { + fn += `if (l === ${discriminant.key}) {` + } else { + fn += `if (l >= ${discriminant.key}) {` + } + } else if (discriminant.type === 'maxLength') { + if (discriminant.key === length.min) { + fn += `if (l === ${discriminant.key}) {` + } else { + fn += `if (l <= ${discriminant.key}) {` + } + } else { + throw new Error(`Unknown discriminant type: ${JSON.stringify(discriminant)}`) + } + // recurse + recursiveStaticMatch( + matchingRoutes, + { min: discriminant.type === 'minLength' ? discriminant.key : length.min, max: discriminant.type === 'maxLength' ? discriminant.key : length.max }, + discriminant.type === 'condition' ? [...preconditions, discriminant.key] : preconditions + ) + fn += '}' + } + if (nonMatchingRoutes.length === 1) { + outputRoute(nonMatchingRoutes[0]!, length, preconditions) + } else if (nonMatchingRoutes.length) { + // recurse + recursiveStaticMatch( + nonMatchingRoutes, + length, + preconditions, + ) + } + } else { + for (const route of parsedRoutes) { + outputRoute(route, length, preconditions) + } + } + } + + function prepareOptionalParams(parsedRoutes: Array): Array { + const result: Array = [] + for (const route of parsedRoutes) { + const index = route.segments.findIndex((s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM) + if (index === -1) { + result.push(route) + continue + } + // for every optional param in the route, we need to push a version of the route without it, and a version of the route with it as a regular param + // example: + // /foo/{-$bar}/qux => [/foo/qux, /foo/$bar/qux] + // /a/{-$b}/c/{-$d} => [/a/c, /a/c/$d, /a/$b/c, /a/$b/c/$d] + const withoutOptional: ParsedRoute = { + ...route, + segments: route.segments.filter((_, i) => i !== index), + } + const withRegular: ParsedRoute = { + ...route, + segments: route.segments.map((s, i) => i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s), + } + const chunk = prepareOptionalParams([withRegular, withoutOptional]) + result.push(...chunk) + } + return result + } + + type Condition = + | { key: string, kind: 'static'; index: number; value: string } + | { key: string, kind: 'startsWith'; index: number; value: string } + | { key: string, kind: 'endsWith'; index: number; value: string } + | { key: string, kind: 'wildcardEndsWith'; value: string } + + const withConditions: Array = prepareOptionalParams(parsedRoutes).map(r => { + let minLength = 0 + let maxLength = 0 + const conditions: Array = r.segments.flatMap((s, i) => { + if (s.type === SEGMENT_TYPE_PATHNAME) { + minLength += 1 + maxLength += 1 + if (i === 0 && s.value === '/') { + return [] + } + return [ + { kind: 'static', index: i, value: s.value, key: `static-${i}-${s.value}` }, + ] + } else if (s.type === SEGMENT_TYPE_PARAM) { + minLength += 1 + maxLength += 1 + const conds: Array = [] + if (s.prefixSegment) { + conds.push({ kind: 'startsWith', index: i, value: s.prefixSegment, key: `startsWith-${i}-${s.prefixSegment}` }) + } + if (s.suffixSegment) { + conds.push({ kind: 'endsWith', index: i, value: s.suffixSegment, key: `endsWith-${i}-${s.suffixSegment}` }) + } + return conds + } else if (s.type === SEGMENT_TYPE_WILDCARD) { + maxLength += Infinity + const conds: Array = [] + if (s.prefixSegment || s.suffixSegment) { + minLength += 1 + } + if (s.prefixSegment) { + conds.push({ kind: 'startsWith', index: i, value: s.prefixSegment, key: `startsWith-${i}-${s.prefixSegment}` }) + } + if (s.suffixSegment) { + conds.push({ kind: 'wildcardEndsWith', value: s.suffixSegment, key: `wildcardEndsWith-${s.suffixSegment}` }) + } + return conds + } + return [] + }) + return { ...r, conditions, length: { min: minLength, max: maxLength } } + }) + + recursiveStaticMatch(withConditions) + + it('generates a matching function', async () => { + expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` + "const baseSegments = parsePathname(from).map((s) => s.value); + const l = baseSegments.length; + if (l <= 3) { + if (l === 3) { + if (baseSegments[1] === "a") { + if (baseSegments[2] === "profile") { + propose(9, "/a/profile"); + } + if (baseSegments[2].startsWith("user-")) { + propose(16, "/a/user-{$id}"); + } + propose(19, "/a/$id"); + propose(23, "/a/{-$slug}"); + } + if (baseSegments[1] === "b") { + if (baseSegments[2] === "profile") { + propose(10, "/b/profile"); + } + if (baseSegments[2].startsWith("user-")) { + propose(18, "/b/user-{$id}"); + } + propose(20, "/b/$id"); + propose(24, "/b/{-$slug}"); + } + if (baseSegments[1] === "users") { + if (baseSegments[2] === "profile") { + propose(13, "/users/profile"); + } + propose(22, "/users/$id"); + } + if (baseSegments[1] === "foo") { + if (baseSegments[2] === "qux") { + propose(15, "/foo/{-$bar}/qux"); + } + propose(21, "/foo/$bar"); + } + if (baseSegments[1] === "beep" && baseSegments[2] === "boop") { + propose(11, "/beep/boop"); + } + if (baseSegments[1] === "one" && baseSegments[2] === "two") { + propose(12, "/one/two"); + } + if (baseSegments[1] === "api" && baseSegments[2].startsWith("user-")) { + propose(17, "/api/user-{$id}"); + } + if (baseSegments[1] === "posts") { + propose(25, "/posts/{-$slug}"); + } + } + if (l >= 2) { + if (l === 2) { + if (baseSegments[1] === "a") { + propose(23, "/a/{-$slug}"); + propose(32, "/a"); + } + if (baseSegments[1] === "b") { + propose(24, "/b/{-$slug}"); + propose(34, "/b"); + } + if (baseSegments[1] === "posts") { + propose(25, "/posts/{-$slug}"); + } + if (baseSegments[1] === "about") { + propose(33, "/about"); + } + if (baseSegments[1] === "one") { + propose(35, "/one"); + } + } + } + if (l === 1) { + propose(36, "/"); + } + } + if (l >= 4) { + if ( + l <= 7 && + baseSegments[1] === "a" && + baseSegments[2] === "b" && + baseSegments[3] === "c" && + baseSegments[4] === "d" && + baseSegments[5] === "e" && + baseSegments[6] === "f" + ) { + propose(0, "/a/b/c/d/e/f"); + } + if ( + l <= 5 && + baseSegments[1] === "z" && + baseSegments[2] === "y" && + baseSegments[3] === "x" && + baseSegments[4] === "u" + ) { + propose(1, "/z/y/x/u"); + } + if ( + l <= 5 && + baseSegments[1] === "z" && + baseSegments[2] === "y" && + baseSegments[3] === "x" && + baseSegments[4] === "v" + ) { + propose(2, "/z/y/x/v"); + } + if ( + l <= 5 && + baseSegments[1] === "z" && + baseSegments[2] === "y" && + baseSegments[3] === "x" && + baseSegments[4] === "w" + ) { + propose(3, "/z/y/x/w"); + } + if ( + l <= 4 && + baseSegments[1] === "a" && + baseSegments[2] === "profile" && + baseSegments[3] === "settings" + ) { + propose(4, "/a/profile/settings"); + } + if ( + l <= 4 && + baseSegments[1] === "b" && + baseSegments[2] === "profile" && + baseSegments[3] === "settings" + ) { + propose(5, "/b/profile/settings"); + } + if ( + l <= 4 && + baseSegments[1] === "users" && + baseSegments[2] === "profile" && + baseSegments[3] === "settings" + ) { + propose(6, "/users/profile/settings"); + } + if ( + l <= 4 && + baseSegments[1] === "z" && + baseSegments[2] === "y" && + baseSegments[3] === "x" + ) { + propose(7, "/z/y/x"); + } + if (l <= 4 && baseSegments[1] === "foo" && baseSegments[2] === "bar") { + propose(8, "/foo/bar/$id"); + } + if (l <= 4 && baseSegments[1] === "foo" && baseSegments[3] === "bar") { + propose(14, "/foo/$id/bar"); + } + if (l <= 4 && baseSegments[1] === "foo" && baseSegments[3] === "qux") { + propose(15, "/foo/{-$bar}/qux"); + } + if (l <= 4 && baseSegments[2] === "bar" && baseSegments[3] === "foo") { + propose(37, "/$id/bar/foo"); + } + if (l <= 4 && baseSegments[2] === "foo" && baseSegments[3] === "bar") { + propose(38, "/$id/foo/bar"); + } + } + if (l >= 3) { + if ( + baseSegments[1] === "cache" && + baseSegments[2].startsWith("temp_") && + baseSegments[l - 1].endsWith(".log") + ) { + propose(26, "/cache/temp_{$}.log"); + } + if (baseSegments[1] === "images" && baseSegments[2].startsWith("thumb_")) { + propose(27, "/images/thumb_{$}"); + } + if (baseSegments[1] === "logs" && baseSegments[l - 1].endsWith(".txt")) { + propose(28, "/logs/{$}.txt"); + } + } + if (l >= 2) { + if (baseSegments[1] === "a") { + propose(29, "/a/$"); + } + if (baseSegments[1] === "b") { + propose(30, "/b/$"); + } + if (baseSegments[1] === "files") { + propose(31, "/files/$"); + } + } + " + `) + }) + + const buildMatcher = new Function('parsePathname', 'propose', 'from', fn) as ( + parser: typeof parsePathname, + propose: (rank: number, path: string) => void, + from: string, + ) => string | undefined + + const wrappedMatcher = (from: string): string | undefined => { + let bestRank = Infinity + let bestPath: string | undefined = undefined + const propose = (rank: number, path: string) => { + if (rank < bestRank) { + bestRank = rank + bestPath = path + } + } + buildMatcher(parsePathname, propose, from) + return bestPath + } + + // WARN: some of these don't work yet, they're just here to show the differences + test.each([ + '/', + '/users/profile/settings', + '/foo/123', + '/b/123', + '/foo/qux', + '/foo/123/qux', + '/foo/qux', + '/a/user-123', + '/a/123', + '/a/123/more', + '/files', + '/files/hello-world.txt', + '/something/foo/bar', + '/files/deep/nested/file.json', + '/files/', + '/images/thumb_200x300.jpg', + '/logs/error.txt', + '/cache/temp_user456.log', + ])('matching %s', (s) => { + const originalMatch = originalMatcher(s) + const buildMatch = wrappedMatcher(s) + console.log( + `matching: ${s}, originalMatch: ${originalMatch}, buildMatch: ${buildMatch}`, + ) + expect(buildMatch).toBe(originalMatch) + }) +}) \ No newline at end of file From 31700cbb96db55b48bfb8397139c50ca21089d85 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 11:20:52 +0200 Subject: [PATCH 20/57] better condition grouping --- packages/router-core/tests/built.test.ts | 33 ++- packages/router-core/tests/built2.test.ts | 287 +++++++++++++--------- 2 files changed, 192 insertions(+), 128 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 5f22bde5c3d..ca5e6770f00 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -5,7 +5,12 @@ import { parsePathname, processRouteTree, } from '../src' -import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD } from "../src/path" +import { + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, +} from '../src/path' interface TestRoute { id: string @@ -170,7 +175,10 @@ describe('work in progress', () => { let fn = 'const baseSegments = parsePathname(from);' fn += '\nconst l = baseSegments.length;' - type ParsedRoute = { path: string, segments: ReturnType } + type ParsedRoute = { + path: string + segments: ReturnType + } function recursiveStaticMatch( parsedRoutes: Array, @@ -181,19 +189,14 @@ describe('work in progress', () => { for (const route of parsedRoutes) { if (resolved.has(route)) continue // already resolved console.log('\n') - console.log( - 'resolving: depth=', - depth, - 'parsed=', - route.path, - ) + console.log('resolving: depth=', depth, 'parsed=', route.path) console.log('\u001b[34m' + fn + '\u001b[0m') const currentSegment = route.segments[depth] if (!currentSegment) { throw new Error( 'Implementation error: this should not happen, depth=' + - depth + - `, route=${route.path}`, + depth + + `, route=${route.path}`, ) } const candidates = parsedRoutes.filter((r) => { @@ -227,7 +230,10 @@ describe('work in progress', () => { rParsed.suffixSegment === currentSegment.suffixSegment ) }) - console.log('candidates:', candidates.map(r => r.path)) + console.log( + 'candidates:', + candidates.map((r) => r.path), + ) if (candidates.length === 0) { throw new Error('Implementation error: this should not happen') } @@ -321,7 +327,9 @@ describe('work in progress', () => { const leaf = candidates[0]! // Check if this route contains a wildcard segment - const wildcardIndex = leaf.segments.findIndex((s) => s && s.type === SEGMENT_TYPE_WILDCARD) + const wildcardIndex = leaf.segments.findIndex( + (s) => s && s.type === SEGMENT_TYPE_WILDCARD, + ) if (wildcardIndex !== -1 && wildcardIndex >= depth) { // This route has a wildcard at or after the current depth @@ -642,6 +650,7 @@ describe('work in progress', () => { '/images/thumb_200x300.jpg', '/logs/error.txt', '/cache/temp_user456.log', + '/a/b/c/d/e', ])('matching %s', (s) => { const originalMatch = originalMatcher(s) const buildMatch = buildMatcher(parsePathname, s) diff --git a/packages/router-core/tests/built2.test.ts b/packages/router-core/tests/built2.test.ts index b03d707c739..eac11fc411c 100644 --- a/packages/router-core/tests/built2.test.ts +++ b/packages/router-core/tests/built2.test.ts @@ -5,7 +5,12 @@ import { parsePathname, processRouteTree, } from '../src' -import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD } from "../src/path" +import { + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, +} from '../src/path' import { format } from 'prettier' interface TestRoute { @@ -162,13 +167,19 @@ describe('work in progress', () => { `) }) - const parsedRoutes = result.flatRoutes.map((route): ParsedRoute => ({ - path: route.fullPath, - segments: parsePathname(route.fullPath), - rank: route.rank! - })) + const parsedRoutes = result.flatRoutes.map( + (route): ParsedRoute => ({ + path: route.fullPath, + segments: parsePathname(route.fullPath), + rank: route.rank!, + }), + ) - type ParsedRoute = { path: string, segments: ReturnType, rank: number } + type ParsedRoute = { + path: string + segments: ReturnType + rank: number + } let fn = 'const baseSegments = parsePathname(from).map(s => s.value);' fn += '\nconst l = baseSegments.length;' @@ -280,16 +291,22 @@ describe('work in progress', () => { maxLengths[maxLength] = (maxLengths[maxLength] || 0) + 1 } }) - const allMinLengths = Object.keys(minLengths).sort((a, b) => Number(a) - Number(b)) + const allMinLengths = Object.keys(minLengths).sort( + (a, b) => Number(a) - Number(b), + ) for (let i = 0; i < allMinLengths.length; i++) { for (let j = i + 1; j < allMinLengths.length; j++) { - minLengths[Number(allMinLengths[i]!)]! += minLengths[Number(allMinLengths[j]!)]! + minLengths[Number(allMinLengths[i]!)]! += + minLengths[Number(allMinLengths[j]!)]! } } - const allMaxLengths = Object.keys(maxLengths).sort((a, b) => Number(b) - Number(a)) + const allMaxLengths = Object.keys(maxLengths).sort( + (a, b) => Number(b) - Number(a), + ) for (let i = 0; i < allMaxLengths.length; i++) { for (let j = i + 1; j < allMaxLengths.length; j++) { - maxLengths[Number(allMaxLengths[i]!)]! += maxLengths[Number(allMaxLengths[j]!)]! + maxLengths[Number(allMaxLengths[i]!)]! += + maxLengths[Number(allMaxLengths[j]!)]! } } let bestMinLength @@ -313,13 +330,20 @@ describe('work in progress', () => { // console.log(`Best maxLength: ${bestMaxLength} with score: ${maxLengths[bestMaxLength!]} / ${total}`) // determine which of the 3 discriminants to use (condition, minLength, maxLength) to match as close to 50% of the routes as possible - const discriminant = bestKey && (!bestMinLength || conditionCounts[bestKey] > minLengths[bestMinLength!]) && (!bestMaxLength || conditionCounts[bestKey] > maxLengths[bestMaxLength!]) - ? { key: bestKey, type: 'condition', } as const - : bestMinLength && (!bestMaxLength || minLengths[bestMinLength!] > maxLengths[bestMaxLength!]) && (!bestKey || minLengths[bestMinLength!] > conditionCounts[bestKey]) - ? { key: bestMinLength!, type: 'minLength' } as const - : bestMaxLength - ? { key: bestMaxLength!, type: 'maxLength' } as const - : undefined + const discriminant = + bestKey && + (!bestMinLength || + conditionCounts[bestKey] > minLengths[bestMinLength!]) && + (!bestMaxLength || conditionCounts[bestKey] > maxLengths[bestMaxLength!]) + ? ({ key: bestKey, type: 'condition' } as const) + : bestMinLength && + (!bestMaxLength || + minLengths[bestMinLength!] > maxLengths[bestMaxLength!]) && + (!bestKey || minLengths[bestMinLength!] > conditionCounts[bestKey]) + ? ({ key: bestMinLength!, type: 'minLength' } as const) + : bestMaxLength + ? ({ key: bestMaxLength!, type: 'maxLength' } as const) + : undefined if (discriminant) { // split all routes into 2 groups (matching and not matching) based on the discriminant @@ -327,7 +351,9 @@ describe('work in progress', () => { const nonMatchingRoutes: Array = [] for (const route of parsedRoutes) { if (discriminant.type === 'condition') { - const condition = route.conditions.find(c => c.key === discriminant.key) + const condition = route.conditions.find( + (c) => c.key === discriminant.key, + ) if (condition) { matchingRoutes.push(route) } else { @@ -352,7 +378,9 @@ describe('work in progress', () => { } else if (matchingRoutes.length) { // add `if` for the discriminant if (discriminant.type === 'condition') { - const condition = matchingRoutes[0]!.conditions.find(c => c.key === discriminant.key)! + const condition = matchingRoutes[0]!.conditions.find( + (c) => c.key === discriminant.key, + )! fn += `if (${conditionToString(condition) || 'true'}) {` } else if (discriminant.type === 'minLength') { if (discriminant.key === length.max) { @@ -367,13 +395,22 @@ describe('work in progress', () => { fn += `if (l <= ${discriminant.key}) {` } } else { - throw new Error(`Unknown discriminant type: ${JSON.stringify(discriminant)}`) + throw new Error( + `Unknown discriminant type: ${JSON.stringify(discriminant)}`, + ) } // recurse recursiveStaticMatch( matchingRoutes, - { min: discriminant.type === 'minLength' ? discriminant.key : length.min, max: discriminant.type === 'maxLength' ? discriminant.key : length.max }, - discriminant.type === 'condition' ? [...preconditions, discriminant.key] : preconditions + { + min: + discriminant.type === 'minLength' ? discriminant.key : length.min, + max: + discriminant.type === 'maxLength' ? discriminant.key : length.max, + }, + discriminant.type === 'condition' + ? [...preconditions, discriminant.key] + : preconditions, ) fn += '}' } @@ -381,23 +418,26 @@ describe('work in progress', () => { outputRoute(nonMatchingRoutes[0]!, length, preconditions) } else if (nonMatchingRoutes.length) { // recurse - recursiveStaticMatch( - nonMatchingRoutes, - length, - preconditions, - ) + recursiveStaticMatch(nonMatchingRoutes, length, preconditions) } } else { - for (const route of parsedRoutes) { - outputRoute(route, length, preconditions) + const [route, ...rest] = parsedRoutes + if (route) outputRoute(route, length, preconditions) + if (rest.length) { + // try again w/ 1 fewer route, it might find a good discriminant now + recursiveStaticMatch(rest, length, preconditions) } } } - function prepareOptionalParams(parsedRoutes: Array): Array { + function prepareOptionalParams( + parsedRoutes: Array, + ): Array { const result: Array = [] for (const route of parsedRoutes) { - const index = route.segments.findIndex((s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM) + const index = route.segments.findIndex( + (s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM, + ) if (index === -1) { result.push(route) continue @@ -412,7 +452,9 @@ describe('work in progress', () => { } const withRegular: ParsedRoute = { ...route, - segments: route.segments.map((s, i) => i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s), + segments: route.segments.map((s, i) => + i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s, + ), } const chunk = prepareOptionalParams([withRegular, withoutOptional]) result.push(...chunk) @@ -421,12 +463,14 @@ describe('work in progress', () => { } type Condition = - | { key: string, kind: 'static'; index: number; value: string } - | { key: string, kind: 'startsWith'; index: number; value: string } - | { key: string, kind: 'endsWith'; index: number; value: string } - | { key: string, kind: 'wildcardEndsWith'; value: string } + | { key: string; kind: 'static'; index: number; value: string } + | { key: string; kind: 'startsWith'; index: number; value: string } + | { key: string; kind: 'endsWith'; index: number; value: string } + | { key: string; kind: 'wildcardEndsWith'; value: string } - const withConditions: Array = prepareOptionalParams(parsedRoutes).map(r => { + const withConditions: Array = prepareOptionalParams( + parsedRoutes, + ).map((r) => { let minLength = 0 let maxLength = 0 const conditions: Array = r.segments.flatMap((s, i) => { @@ -437,17 +481,32 @@ describe('work in progress', () => { return [] } return [ - { kind: 'static', index: i, value: s.value, key: `static-${i}-${s.value}` }, + { + kind: 'static', + index: i, + value: s.value, + key: `static-${i}-${s.value}`, + }, ] } else if (s.type === SEGMENT_TYPE_PARAM) { minLength += 1 maxLength += 1 const conds: Array = [] if (s.prefixSegment) { - conds.push({ kind: 'startsWith', index: i, value: s.prefixSegment, key: `startsWith-${i}-${s.prefixSegment}` }) + conds.push({ + kind: 'startsWith', + index: i, + value: s.prefixSegment, + key: `startsWith-${i}-${s.prefixSegment}`, + }) } if (s.suffixSegment) { - conds.push({ kind: 'endsWith', index: i, value: s.suffixSegment, key: `endsWith-${i}-${s.suffixSegment}` }) + conds.push({ + kind: 'endsWith', + index: i, + value: s.suffixSegment, + key: `endsWith-${i}-${s.suffixSegment}`, + }) } return conds } else if (s.type === SEGMENT_TYPE_WILDCARD) { @@ -457,10 +516,19 @@ describe('work in progress', () => { minLength += 1 } if (s.prefixSegment) { - conds.push({ kind: 'startsWith', index: i, value: s.prefixSegment, key: `startsWith-${i}-${s.prefixSegment}` }) + conds.push({ + kind: 'startsWith', + index: i, + value: s.prefixSegment, + key: `startsWith-${i}-${s.prefixSegment}`, + }) } if (s.suffixSegment) { - conds.push({ kind: 'wildcardEndsWith', value: s.suffixSegment, key: `wildcardEndsWith-${s.suffixSegment}` }) + conds.push({ + kind: 'wildcardEndsWith', + value: s.suffixSegment, + key: `wildcardEndsWith-${s.suffixSegment}`, + }) } return conds } @@ -559,79 +627,65 @@ describe('work in progress', () => { ) { propose(0, "/a/b/c/d/e/f"); } - if ( - l <= 5 && - baseSegments[1] === "z" && - baseSegments[2] === "y" && - baseSegments[3] === "x" && - baseSegments[4] === "u" - ) { - propose(1, "/z/y/x/u"); - } - if ( - l <= 5 && - baseSegments[1] === "z" && - baseSegments[2] === "y" && - baseSegments[3] === "x" && - baseSegments[4] === "v" - ) { - propose(2, "/z/y/x/v"); - } - if ( - l <= 5 && - baseSegments[1] === "z" && - baseSegments[2] === "y" && - baseSegments[3] === "x" && - baseSegments[4] === "w" - ) { - propose(3, "/z/y/x/w"); - } - if ( - l <= 4 && - baseSegments[1] === "a" && - baseSegments[2] === "profile" && - baseSegments[3] === "settings" - ) { - propose(4, "/a/profile/settings"); - } - if ( - l <= 4 && - baseSegments[1] === "b" && - baseSegments[2] === "profile" && - baseSegments[3] === "settings" - ) { - propose(5, "/b/profile/settings"); - } - if ( - l <= 4 && - baseSegments[1] === "users" && - baseSegments[2] === "profile" && - baseSegments[3] === "settings" - ) { - propose(6, "/users/profile/settings"); - } - if ( - l <= 4 && - baseSegments[1] === "z" && - baseSegments[2] === "y" && - baseSegments[3] === "x" - ) { - propose(7, "/z/y/x"); - } - if (l <= 4 && baseSegments[1] === "foo" && baseSegments[2] === "bar") { - propose(8, "/foo/bar/$id"); - } - if (l <= 4 && baseSegments[1] === "foo" && baseSegments[3] === "bar") { - propose(14, "/foo/$id/bar"); - } - if (l <= 4 && baseSegments[1] === "foo" && baseSegments[3] === "qux") { - propose(15, "/foo/{-$bar}/qux"); - } - if (l <= 4 && baseSegments[2] === "bar" && baseSegments[3] === "foo") { - propose(37, "/$id/bar/foo"); + if (baseSegments[1] === "z") { + if (l >= 5) { + if (l === 5) { + if ( + baseSegments[2] === "y" && + baseSegments[3] === "x" && + baseSegments[4] === "u" + ) { + propose(1, "/z/y/x/u"); + } + if ( + baseSegments[2] === "y" && + baseSegments[3] === "x" && + baseSegments[4] === "v" + ) { + propose(2, "/z/y/x/v"); + } + if ( + baseSegments[2] === "y" && + baseSegments[3] === "x" && + baseSegments[4] === "w" + ) { + propose(3, "/z/y/x/w"); + } + } + } + if (l <= 4 && baseSegments[2] === "y" && baseSegments[3] === "x") { + propose(7, "/z/y/x"); + } } - if (l <= 4 && baseSegments[2] === "foo" && baseSegments[3] === "bar") { - propose(38, "/$id/foo/bar"); + if (l === 4) { + if (baseSegments[2] === "profile") { + if (baseSegments[1] === "a" && baseSegments[3] === "settings") { + propose(4, "/a/profile/settings"); + } + if (baseSegments[1] === "b" && baseSegments[3] === "settings") { + propose(5, "/b/profile/settings"); + } + if (baseSegments[1] === "users" && baseSegments[3] === "settings") { + propose(6, "/users/profile/settings"); + } + } + if (baseSegments[1] === "foo") { + if (baseSegments[2] === "bar") { + propose(8, "/foo/bar/$id"); + } + if (baseSegments[3] === "bar") { + propose(14, "/foo/$id/bar"); + } + if (baseSegments[3] === "qux") { + propose(15, "/foo/{-$bar}/qux"); + } + } + if (baseSegments[2] === "bar" && baseSegments[3] === "foo") { + propose(37, "/$id/bar/foo"); + } + if (baseSegments[2] === "foo" && baseSegments[3] === "bar") { + propose(38, "/$id/foo/bar"); + } } } if (l >= 3) { @@ -703,6 +757,7 @@ describe('work in progress', () => { '/images/thumb_200x300.jpg', '/logs/error.txt', '/cache/temp_user456.log', + '/a/b/c/d/e', ])('matching %s', (s) => { const originalMatch = originalMatcher(s) const buildMatch = wrappedMatcher(s) @@ -711,4 +766,4 @@ describe('work in progress', () => { ) expect(buildMatch).toBe(originalMatch) }) -}) \ No newline at end of file +}) From 80e334354ee44df0bad5e1ba7344480fe7a5dec0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 12:26:20 +0200 Subject: [PATCH 21/57] skip length check if constraint applies to all children --- packages/router-core/tests/built2.test.ts | 111 ++++++++++++---------- 1 file changed, 61 insertions(+), 50 deletions(-) diff --git a/packages/router-core/tests/built2.test.ts b/packages/router-core/tests/built2.test.ts index eac11fc411c..337923ebcd4 100644 --- a/packages/router-core/tests/built2.test.ts +++ b/packages/router-core/tests/built2.test.ts @@ -377,6 +377,12 @@ describe('work in progress', () => { outputRoute(matchingRoutes[0]!, length, preconditions) } else if (matchingRoutes.length) { // add `if` for the discriminant + const nextLength = { + min: + discriminant.type === 'minLength' ? discriminant.key : length.min, + max: + discriminant.type === 'maxLength' ? discriminant.key : length.max, + } if (discriminant.type === 'condition') { const condition = matchingRoutes[0]!.conditions.find( (c) => c.key === discriminant.key, @@ -386,13 +392,27 @@ describe('work in progress', () => { if (discriminant.key === length.max) { fn += `if (l === ${discriminant.key}) {` } else { - fn += `if (l >= ${discriminant.key}) {` + if ( + matchingRoutes.every((r) => r.length.max === discriminant.key) + ) { + nextLength.max = discriminant.key + fn += `if (l === ${discriminant.key}) {` + } else { + fn += `if (l >= ${discriminant.key}) {` + } } } else if (discriminant.type === 'maxLength') { if (discriminant.key === length.min) { fn += `if (l === ${discriminant.key}) {` } else { - fn += `if (l <= ${discriminant.key}) {` + if ( + matchingRoutes.every((r) => r.length.min === discriminant.key) + ) { + nextLength.min = discriminant.key + fn += `if (l === ${discriminant.key}) {` + } else { + fn += `if (l <= ${discriminant.key}) {` + } } } else { throw new Error( @@ -402,12 +422,7 @@ describe('work in progress', () => { // recurse recursiveStaticMatch( matchingRoutes, - { - min: - discriminant.type === 'minLength' ? discriminant.key : length.min, - max: - discriminant.type === 'maxLength' ? discriminant.key : length.max, - }, + nextLength, discriminant.type === 'condition' ? [...preconditions, discriminant.key] : preconditions, @@ -590,25 +605,23 @@ describe('work in progress', () => { propose(25, "/posts/{-$slug}"); } } - if (l >= 2) { - if (l === 2) { - if (baseSegments[1] === "a") { - propose(23, "/a/{-$slug}"); - propose(32, "/a"); - } - if (baseSegments[1] === "b") { - propose(24, "/b/{-$slug}"); - propose(34, "/b"); - } - if (baseSegments[1] === "posts") { - propose(25, "/posts/{-$slug}"); - } - if (baseSegments[1] === "about") { - propose(33, "/about"); - } - if (baseSegments[1] === "one") { - propose(35, "/one"); - } + if (l === 2) { + if (baseSegments[1] === "a") { + propose(23, "/a/{-$slug}"); + propose(32, "/a"); + } + if (baseSegments[1] === "b") { + propose(24, "/b/{-$slug}"); + propose(34, "/b"); + } + if (baseSegments[1] === "posts") { + propose(25, "/posts/{-$slug}"); + } + if (baseSegments[1] === "about") { + propose(33, "/about"); + } + if (baseSegments[1] === "one") { + propose(35, "/one"); } } if (l === 1) { @@ -628,29 +641,27 @@ describe('work in progress', () => { propose(0, "/a/b/c/d/e/f"); } if (baseSegments[1] === "z") { - if (l >= 5) { - if (l === 5) { - if ( - baseSegments[2] === "y" && - baseSegments[3] === "x" && - baseSegments[4] === "u" - ) { - propose(1, "/z/y/x/u"); - } - if ( - baseSegments[2] === "y" && - baseSegments[3] === "x" && - baseSegments[4] === "v" - ) { - propose(2, "/z/y/x/v"); - } - if ( - baseSegments[2] === "y" && - baseSegments[3] === "x" && - baseSegments[4] === "w" - ) { - propose(3, "/z/y/x/w"); - } + if (l === 5) { + if ( + baseSegments[2] === "y" && + baseSegments[3] === "x" && + baseSegments[4] === "u" + ) { + propose(1, "/z/y/x/u"); + } + if ( + baseSegments[2] === "y" && + baseSegments[3] === "x" && + baseSegments[4] === "v" + ) { + propose(2, "/z/y/x/v"); + } + if ( + baseSegments[2] === "y" && + baseSegments[3] === "x" && + baseSegments[4] === "w" + ) { + propose(3, "/z/y/x/w"); } } if (l <= 4 && baseSegments[2] === "y" && baseSegments[3] === "x") { From 0adb15734581738412ed19a059ff84483c844c7c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 13:14:16 +0200 Subject: [PATCH 22/57] reduce array access --- packages/router-core/tests/built2.test.ts | 134 ++++++++++++---------- 1 file changed, 72 insertions(+), 62 deletions(-) diff --git a/packages/router-core/tests/built2.test.ts b/packages/router-core/tests/built2.test.ts index 337923ebcd4..c4bec57c018 100644 --- a/packages/router-core/tests/built2.test.ts +++ b/packages/router-core/tests/built2.test.ts @@ -192,11 +192,11 @@ describe('work in progress', () => { function conditionToString(condition: Condition) { if (condition.kind === 'static') { if (condition.index === 0 && condition.value === '/') return undefined // root segment is always `/` - return `baseSegments[${condition.index}] === '${condition.value}'` + return `s${condition.index} === '${condition.value}'` } else if (condition.kind === 'startsWith') { - return `baseSegments[${condition.index}].startsWith('${condition.value}')` + return `s${condition.index}.startsWith('${condition.value}')` } else if (condition.kind === 'endsWith') { - return `baseSegments[${condition.index}].endsWith('${condition.value}')` + return `s${condition.index}.endsWith('${condition.value}')` } else if (condition.kind === 'wildcardEndsWith') { return `baseSegments[l - 1].endsWith('${condition.value}')` } @@ -552,75 +552,97 @@ describe('work in progress', () => { return { ...r, conditions, length: { min: minLength, max: maxLength } } }) + const max = withConditions.reduce( + (max, r) => + Math.max( + max, + r.conditions.reduce((max, c) => { + if ( + c.kind === 'static' || + c.kind === 'startsWith' || + c.kind === 'endsWith' + ) { + return Math.max(max, c.index) + } + return max + }, 0), + ), + 0, + ) + + if (max > 0) + fn += `const [${Array.from({ length: max + 1 }, (_, i) => `s${i}`).join(', ')}] = baseSegments;\n` + recursiveStaticMatch(withConditions) it('generates a matching function', async () => { expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` "const baseSegments = parsePathname(from).map((s) => s.value); const l = baseSegments.length; + const [s0, s1, s2, s3, s4, s5, s6] = baseSegments; if (l <= 3) { if (l === 3) { - if (baseSegments[1] === "a") { - if (baseSegments[2] === "profile") { + if (s1 === "a") { + if (s2 === "profile") { propose(9, "/a/profile"); } - if (baseSegments[2].startsWith("user-")) { + if (s2.startsWith("user-")) { propose(16, "/a/user-{$id}"); } propose(19, "/a/$id"); propose(23, "/a/{-$slug}"); } - if (baseSegments[1] === "b") { - if (baseSegments[2] === "profile") { + if (s1 === "b") { + if (s2 === "profile") { propose(10, "/b/profile"); } - if (baseSegments[2].startsWith("user-")) { + if (s2.startsWith("user-")) { propose(18, "/b/user-{$id}"); } propose(20, "/b/$id"); propose(24, "/b/{-$slug}"); } - if (baseSegments[1] === "users") { - if (baseSegments[2] === "profile") { + if (s1 === "users") { + if (s2 === "profile") { propose(13, "/users/profile"); } propose(22, "/users/$id"); } - if (baseSegments[1] === "foo") { - if (baseSegments[2] === "qux") { + if (s1 === "foo") { + if (s2 === "qux") { propose(15, "/foo/{-$bar}/qux"); } propose(21, "/foo/$bar"); } - if (baseSegments[1] === "beep" && baseSegments[2] === "boop") { + if (s1 === "beep" && s2 === "boop") { propose(11, "/beep/boop"); } - if (baseSegments[1] === "one" && baseSegments[2] === "two") { + if (s1 === "one" && s2 === "two") { propose(12, "/one/two"); } - if (baseSegments[1] === "api" && baseSegments[2].startsWith("user-")) { + if (s1 === "api" && s2.startsWith("user-")) { propose(17, "/api/user-{$id}"); } - if (baseSegments[1] === "posts") { + if (s1 === "posts") { propose(25, "/posts/{-$slug}"); } } if (l === 2) { - if (baseSegments[1] === "a") { + if (s1 === "a") { propose(23, "/a/{-$slug}"); propose(32, "/a"); } - if (baseSegments[1] === "b") { + if (s1 === "b") { propose(24, "/b/{-$slug}"); propose(34, "/b"); } - if (baseSegments[1] === "posts") { + if (s1 === "posts") { propose(25, "/posts/{-$slug}"); } - if (baseSegments[1] === "about") { + if (s1 === "about") { propose(33, "/about"); } - if (baseSegments[1] === "one") { + if (s1 === "one") { propose(35, "/one"); } } @@ -631,97 +653,85 @@ describe('work in progress', () => { if (l >= 4) { if ( l <= 7 && - baseSegments[1] === "a" && - baseSegments[2] === "b" && - baseSegments[3] === "c" && - baseSegments[4] === "d" && - baseSegments[5] === "e" && - baseSegments[6] === "f" + s1 === "a" && + s2 === "b" && + s3 === "c" && + s4 === "d" && + s5 === "e" && + s6 === "f" ) { propose(0, "/a/b/c/d/e/f"); } - if (baseSegments[1] === "z") { + if (s1 === "z") { if (l === 5) { - if ( - baseSegments[2] === "y" && - baseSegments[3] === "x" && - baseSegments[4] === "u" - ) { + if (s2 === "y" && s3 === "x" && s4 === "u") { propose(1, "/z/y/x/u"); } - if ( - baseSegments[2] === "y" && - baseSegments[3] === "x" && - baseSegments[4] === "v" - ) { + if (s2 === "y" && s3 === "x" && s4 === "v") { propose(2, "/z/y/x/v"); } - if ( - baseSegments[2] === "y" && - baseSegments[3] === "x" && - baseSegments[4] === "w" - ) { + if (s2 === "y" && s3 === "x" && s4 === "w") { propose(3, "/z/y/x/w"); } } - if (l <= 4 && baseSegments[2] === "y" && baseSegments[3] === "x") { + if (l <= 4 && s2 === "y" && s3 === "x") { propose(7, "/z/y/x"); } } if (l === 4) { - if (baseSegments[2] === "profile") { - if (baseSegments[1] === "a" && baseSegments[3] === "settings") { + if (s2 === "profile") { + if (s1 === "a" && s3 === "settings") { propose(4, "/a/profile/settings"); } - if (baseSegments[1] === "b" && baseSegments[3] === "settings") { + if (s1 === "b" && s3 === "settings") { propose(5, "/b/profile/settings"); } - if (baseSegments[1] === "users" && baseSegments[3] === "settings") { + if (s1 === "users" && s3 === "settings") { propose(6, "/users/profile/settings"); } } - if (baseSegments[1] === "foo") { - if (baseSegments[2] === "bar") { + if (s1 === "foo") { + if (s2 === "bar") { propose(8, "/foo/bar/$id"); } - if (baseSegments[3] === "bar") { + if (s3 === "bar") { propose(14, "/foo/$id/bar"); } - if (baseSegments[3] === "qux") { + if (s3 === "qux") { propose(15, "/foo/{-$bar}/qux"); } } - if (baseSegments[2] === "bar" && baseSegments[3] === "foo") { + if (s2 === "bar" && s3 === "foo") { propose(37, "/$id/bar/foo"); } - if (baseSegments[2] === "foo" && baseSegments[3] === "bar") { + if (s2 === "foo" && s3 === "bar") { propose(38, "/$id/foo/bar"); } } } if (l >= 3) { if ( - baseSegments[1] === "cache" && - baseSegments[2].startsWith("temp_") && + s1 === "cache" && + s2.startsWith("temp_") && baseSegments[l - 1].endsWith(".log") ) { propose(26, "/cache/temp_{$}.log"); } - if (baseSegments[1] === "images" && baseSegments[2].startsWith("thumb_")) { + if (s1 === "images" && s2.startsWith("thumb_")) { propose(27, "/images/thumb_{$}"); } - if (baseSegments[1] === "logs" && baseSegments[l - 1].endsWith(".txt")) { + if (s1 === "logs" && baseSegments[l - 1].endsWith(".txt")) { propose(28, "/logs/{$}.txt"); } } if (l >= 2) { - if (baseSegments[1] === "a") { + if (s1 === "a") { propose(29, "/a/$"); } - if (baseSegments[1] === "b") { + if (s1 === "b") { propose(30, "/b/$"); } - if (baseSegments[1] === "files") { + if (s1 === "files") { propose(31, "/files/$"); } } From d3712b4b85e51b8a6753b7c621b47933d3ae2de9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 13:41:34 +0200 Subject: [PATCH 23/57] add rank conditions --- packages/router-core/tests/built2.test.ts | 85 +++++++++++++++-------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/packages/router-core/tests/built2.test.ts b/packages/router-core/tests/built2.test.ts index c4bec57c018..7e81a3074e8 100644 --- a/packages/router-core/tests/built2.test.ts +++ b/packages/router-core/tests/built2.test.ts @@ -183,6 +183,14 @@ describe('work in progress', () => { let fn = 'const baseSegments = parsePathname(from).map(s => s.value);' fn += '\nconst l = baseSegments.length;' + fn += `\nlet rank = Infinity;` + fn += `\nlet path = undefined;` + fn += `\nconst propose = (r, p) => {` + fn += `\n if (r < rank) {` + fn += `\n rank = r;` + fn += `\n path = p;` + fn += `\n }` + fn += `\n};` type WithConditions = ParsedRoute & { conditions: Array @@ -244,7 +252,11 @@ describe('work in progress', () => { if (flags.length) { fn += `if (${flags.join(' && ')}) {` } - fn += `propose(${route.rank}, '${route.path}');` + if (route.rank === 0) { + fn += `return '${route.path}';` + } else { + fn += `propose(${route.rank}, '${route.path}');` + } if (flags.length) { fn += '}' } @@ -254,6 +266,7 @@ describe('work in progress', () => { parsedRoutes: Array, length: { min: number; max: number } = { min: 0, max: Infinity }, preconditions: Array = [], + lastRank?: number, ) { // count all conditions by `key` // determine the condition that would match as close to 50% of the routes as possible @@ -377,6 +390,16 @@ describe('work in progress', () => { outputRoute(matchingRoutes[0]!, length, preconditions) } else if (matchingRoutes.length) { // add `if` for the discriminant + const bestChildRank = matchingRoutes.reduce( + (min, r) => Math.min(min, r.rank), + Infinity, + ) + const rankTest = + matchingRoutes.length > 2 && + bestChildRank !== Infinity && + bestChildRank !== lastRank + ? ` && rank > ${bestChildRank}` + : '' const nextLength = { min: discriminant.type === 'minLength' ? discriminant.key : length.min, @@ -387,31 +410,31 @@ describe('work in progress', () => { const condition = matchingRoutes[0]!.conditions.find( (c) => c.key === discriminant.key, )! - fn += `if (${conditionToString(condition) || 'true'}) {` + fn += `if (${conditionToString(condition) || 'true'}${rankTest}) {` } else if (discriminant.type === 'minLength') { if (discriminant.key === length.max) { - fn += `if (l === ${discriminant.key}) {` + fn += `if (l === ${discriminant.key}${rankTest}) {` } else { if ( matchingRoutes.every((r) => r.length.max === discriminant.key) ) { nextLength.max = discriminant.key - fn += `if (l === ${discriminant.key}) {` + fn += `if (l === ${discriminant.key}${rankTest}) {` } else { - fn += `if (l >= ${discriminant.key}) {` + fn += `if (l >= ${discriminant.key}${rankTest}) {` } } } else if (discriminant.type === 'maxLength') { if (discriminant.key === length.min) { - fn += `if (l === ${discriminant.key}) {` + fn += `if (l === ${discriminant.key}${rankTest}) {` } else { if ( matchingRoutes.every((r) => r.length.min === discriminant.key) ) { nextLength.min = discriminant.key - fn += `if (l === ${discriminant.key}) {` + fn += `if (l === ${discriminant.key}${rankTest}) {` } else { - fn += `if (l <= ${discriminant.key}) {` + fn += `if (l <= ${discriminant.key}${rankTest}) {` } } } else { @@ -426,6 +449,7 @@ describe('work in progress', () => { discriminant.type === 'condition' ? [...preconditions, discriminant.key] : preconditions, + rankTest ? bestChildRank : lastRank, ) fn += '}' } @@ -575,12 +599,22 @@ describe('work in progress', () => { recursiveStaticMatch(withConditions) + fn += `return path;` + it('generates a matching function', async () => { expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` "const baseSegments = parsePathname(from).map((s) => s.value); const l = baseSegments.length; + let rank = Infinity; + let path = undefined; + const propose = (r, p) => { + if (r < rank) { + rank = r; + path = p; + } + }; const [s0, s1, s2, s3, s4, s5, s6] = baseSegments; - if (l <= 3) { + if (l <= 3 && rank > 9) { if (l === 3) { if (s1 === "a") { if (s2 === "profile") { @@ -592,7 +626,7 @@ describe('work in progress', () => { propose(19, "/a/$id"); propose(23, "/a/{-$slug}"); } - if (s1 === "b") { + if (s1 === "b" && rank > 10) { if (s2 === "profile") { propose(10, "/b/profile"); } @@ -627,7 +661,7 @@ describe('work in progress', () => { propose(25, "/posts/{-$slug}"); } } - if (l === 2) { + if (l === 2 && rank > 23) { if (s1 === "a") { propose(23, "/a/{-$slug}"); propose(32, "/a"); @@ -650,7 +684,7 @@ describe('work in progress', () => { propose(36, "/"); } } - if (l >= 4) { + if (l >= 4 && rank > 0) { if ( l <= 7 && s1 === "a" && @@ -660,9 +694,9 @@ describe('work in progress', () => { s5 === "e" && s6 === "f" ) { - propose(0, "/a/b/c/d/e/f"); + return "/a/b/c/d/e/f"; } - if (s1 === "z") { + if (s1 === "z" && rank > 1) { if (l === 5) { if (s2 === "y" && s3 === "x" && s4 === "u") { propose(1, "/z/y/x/u"); @@ -678,7 +712,7 @@ describe('work in progress', () => { propose(7, "/z/y/x"); } } - if (l === 4) { + if (l === 4 && rank > 4) { if (s2 === "profile") { if (s1 === "a" && s3 === "settings") { propose(4, "/a/profile/settings"); @@ -690,7 +724,7 @@ describe('work in progress', () => { propose(6, "/users/profile/settings"); } } - if (s1 === "foo") { + if (s1 === "foo" && rank > 8) { if (s2 === "bar") { propose(8, "/foo/bar/$id"); } @@ -709,7 +743,7 @@ describe('work in progress', () => { } } } - if (l >= 3) { + if (l >= 3 && rank > 26) { if ( s1 === "cache" && s2.startsWith("temp_") && @@ -724,7 +758,7 @@ describe('work in progress', () => { propose(28, "/logs/{$}.txt"); } } - if (l >= 2) { + if (l >= 2 && rank > 29) { if (s1 === "a") { propose(29, "/a/$"); } @@ -735,27 +769,18 @@ describe('work in progress', () => { propose(31, "/files/$"); } } + return path; " `) }) - const buildMatcher = new Function('parsePathname', 'propose', 'from', fn) as ( + const buildMatcher = new Function('parsePathname', 'from', fn) as ( parser: typeof parsePathname, - propose: (rank: number, path: string) => void, from: string, ) => string | undefined const wrappedMatcher = (from: string): string | undefined => { - let bestRank = Infinity - let bestPath: string | undefined = undefined - const propose = (rank: number, path: string) => { - if (rank < bestRank) { - bestRank = rank - bestPath = path - } - } - buildMatcher(parsePathname, propose, from) - return bestPath + return buildMatcher(parsePathname, from) } // WARN: some of these don't work yet, they're just here to show the differences From c058759fb8bc059b0a175290598efa00e7355a9e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 14:41:37 +0200 Subject: [PATCH 24/57] fix some rank and length checks --- packages/router-core/tests/built2.test.ts | 35 ++++++++++++----------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/router-core/tests/built2.test.ts b/packages/router-core/tests/built2.test.ts index 7e81a3074e8..48673ffa942 100644 --- a/packages/router-core/tests/built2.test.ts +++ b/packages/router-core/tests/built2.test.ts @@ -218,20 +218,20 @@ describe('work in progress', () => { ) { const flags: Array = [] let min = length.min - if (route.length.min > length.min) min = route.length.min + if (route.length.min > min) min = route.length.min let max = length.max - if (route.length.max < length.max) max = route.length.max + if (route.length.max < max) max = route.length.max for (const condition of route.conditions) { - if (condition.kind === 'static' && condition.index + 1 < min) { + if (condition.kind === 'static' && condition.index + 1 > min) { min = condition.index + 1 - } else if (condition.kind === 'startsWith' && condition.index + 1 < min) { + } else if (condition.kind === 'startsWith' && condition.index + 1 > min) { min = condition.index + 1 - } else if (condition.kind === 'endsWith' && condition.index + 1 < min) { + } else if (condition.kind === 'endsWith' && condition.index + 1 > min) { min = condition.index + 1 } } - if (min > length.min && max < length.max && min === max) { + if (min === max && (min !== length.min || max !== length.max)) { flags.push(`l === ${min}`) } else { if (min > length.min) { @@ -345,14 +345,14 @@ describe('work in progress', () => { // determine which of the 3 discriminants to use (condition, minLength, maxLength) to match as close to 50% of the routes as possible const discriminant = bestKey && - (!bestMinLength || - conditionCounts[bestKey] > minLengths[bestMinLength!]) && - (!bestMaxLength || conditionCounts[bestKey] > maxLengths[bestMaxLength!]) + (!bestMinLength || + conditionCounts[bestKey] > minLengths[bestMinLength!]) && + (!bestMaxLength || conditionCounts[bestKey] > maxLengths[bestMaxLength!]) ? ({ key: bestKey, type: 'condition' } as const) : bestMinLength && - (!bestMaxLength || - minLengths[bestMinLength!] > maxLengths[bestMaxLength!]) && - (!bestKey || minLengths[bestMinLength!] > conditionCounts[bestKey]) + (!bestMaxLength || + minLengths[bestMinLength!] > maxLengths[bestMaxLength!]) && + (!bestKey || minLengths[bestMinLength!] > conditionCounts[bestKey]) ? ({ key: bestMinLength!, type: 'minLength' } as const) : bestMaxLength ? ({ key: bestMaxLength!, type: 'maxLength' } as const) @@ -396,8 +396,9 @@ describe('work in progress', () => { ) const rankTest = matchingRoutes.length > 2 && - bestChildRank !== Infinity && - bestChildRank !== lastRank + bestChildRank !== 0 && + bestChildRank !== Infinity && + bestChildRank !== lastRank ? ` && rank > ${bestChildRank}` : '' const nextLength = { @@ -684,9 +685,9 @@ describe('work in progress', () => { propose(36, "/"); } } - if (l >= 4 && rank > 0) { + if (l >= 4) { if ( - l <= 7 && + l === 7 && s1 === "a" && s2 === "b" && s3 === "c" && @@ -708,7 +709,7 @@ describe('work in progress', () => { propose(3, "/z/y/x/w"); } } - if (l <= 4 && s2 === "y" && s3 === "x") { + if (l === 4 && s2 === "y" && s3 === "x") { propose(7, "/z/y/x"); } } From 815c3d0a17e1454666e0e49b99f192b9b9d50833 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 14:58:38 +0200 Subject: [PATCH 25/57] use rank for early return opportunities --- packages/router-core/tests/built2.test.ts | 43 +++++++++++++++-------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/router-core/tests/built2.test.ts b/packages/router-core/tests/built2.test.ts index 48673ffa942..81e3b2d77e4 100644 --- a/packages/router-core/tests/built2.test.ts +++ b/packages/router-core/tests/built2.test.ts @@ -215,6 +215,7 @@ describe('work in progress', () => { route: WithConditions, length: { min: number; max: number }, preconditions: Array = [], + allSeenRanks: Array = [], ) { const flags: Array = [] let min = length.min @@ -252,8 +253,20 @@ describe('work in progress', () => { if (flags.length) { fn += `if (${flags.join(' && ')}) {` } - if (route.rank === 0) { + allSeenRanks.sort((a, b) => a - b) + let maxContinuousRank = -1 + for (let i = 0; i < allSeenRanks.length; i++) { + if (allSeenRanks[i] === i) { + maxContinuousRank = i + } else { + break + } + } + allSeenRanks.push(route.rank) + if (route.rank === maxContinuousRank + 1) { + // if we know at this point of the function, we can't do better than this, return it directly fn += `return '${route.path}';` + console.log('maxContinuousRank', maxContinuousRank, 'all', allSeenRanks.join(',')) } else { fn += `propose(${route.rank}, '${route.path}');` } @@ -267,6 +280,7 @@ describe('work in progress', () => { length: { min: number; max: number } = { min: 0, max: Infinity }, preconditions: Array = [], lastRank?: number, + allSeenRanks: Array = [], ) { // count all conditions by `key` // determine the condition that would match as close to 50% of the routes as possible @@ -387,7 +401,7 @@ describe('work in progress', () => { } } if (matchingRoutes.length === 1) { - outputRoute(matchingRoutes[0]!, length, preconditions) + outputRoute(matchingRoutes[0]!, length, preconditions, allSeenRanks) } else if (matchingRoutes.length) { // add `if` for the discriminant const bestChildRank = matchingRoutes.reduce( @@ -451,21 +465,22 @@ describe('work in progress', () => { ? [...preconditions, discriminant.key] : preconditions, rankTest ? bestChildRank : lastRank, + allSeenRanks ) fn += '}' } if (nonMatchingRoutes.length === 1) { - outputRoute(nonMatchingRoutes[0]!, length, preconditions) + outputRoute(nonMatchingRoutes[0]!, length, preconditions, allSeenRanks) } else if (nonMatchingRoutes.length) { // recurse - recursiveStaticMatch(nonMatchingRoutes, length, preconditions) + recursiveStaticMatch(nonMatchingRoutes, length, preconditions, undefined, allSeenRanks) } } else { const [route, ...rest] = parsedRoutes - if (route) outputRoute(route, length, preconditions) + if (route) outputRoute(route, length, preconditions, allSeenRanks) if (rest.length) { // try again w/ 1 fewer route, it might find a good discriminant now - recursiveStaticMatch(rest, length, preconditions) + recursiveStaticMatch(rest, length, preconditions, undefined, allSeenRanks) } } } @@ -700,13 +715,13 @@ describe('work in progress', () => { if (s1 === "z" && rank > 1) { if (l === 5) { if (s2 === "y" && s3 === "x" && s4 === "u") { - propose(1, "/z/y/x/u"); + return "/z/y/x/u"; } if (s2 === "y" && s3 === "x" && s4 === "v") { - propose(2, "/z/y/x/v"); + return "/z/y/x/v"; } if (s2 === "y" && s3 === "x" && s4 === "w") { - propose(3, "/z/y/x/w"); + return "/z/y/x/w"; } } if (l === 4 && s2 === "y" && s3 === "x") { @@ -716,21 +731,21 @@ describe('work in progress', () => { if (l === 4 && rank > 4) { if (s2 === "profile") { if (s1 === "a" && s3 === "settings") { - propose(4, "/a/profile/settings"); + return "/a/profile/settings"; } if (s1 === "b" && s3 === "settings") { - propose(5, "/b/profile/settings"); + return "/b/profile/settings"; } if (s1 === "users" && s3 === "settings") { - propose(6, "/users/profile/settings"); + return "/users/profile/settings"; } } if (s1 === "foo" && rank > 8) { if (s2 === "bar") { - propose(8, "/foo/bar/$id"); + return "/foo/bar/$id"; } if (s3 === "bar") { - propose(14, "/foo/$id/bar"); + return "/foo/$id/bar"; } if (s3 === "qux") { propose(15, "/foo/{-$bar}/qux"); From 225ebaa4e41260f94d22d84042c7cf6da3666bf5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 15:13:47 +0200 Subject: [PATCH 26/57] global 'caseSensitive' --- packages/router-core/tests/built2.test.ts | 39 +++++++++++++++-------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/router-core/tests/built2.test.ts b/packages/router-core/tests/built2.test.ts index 81e3b2d77e4..58e34d3aba6 100644 --- a/packages/router-core/tests/built2.test.ts +++ b/packages/router-core/tests/built2.test.ts @@ -181,6 +181,8 @@ describe('work in progress', () => { rank: number } + const caseSensitive = true + let fn = 'const baseSegments = parsePathname(from).map(s => s.value);' fn += '\nconst l = baseSegments.length;' fn += `\nlet rank = Infinity;` @@ -197,10 +199,12 @@ describe('work in progress', () => { length: { min: number; max: number } } - function conditionToString(condition: Condition) { + function conditionToString(condition: Condition, caseSensitive = true) { if (condition.kind === 'static') { if (condition.index === 0 && condition.value === '/') return undefined // root segment is always `/` - return `s${condition.index} === '${condition.value}'` + return caseSensitive + ? `s${condition.index} === '${condition.value}'` + : `sc${condition.index} === '${condition.value}'` } else if (condition.kind === 'startsWith') { return `s${condition.index}.startsWith('${condition.value}')` } else if (condition.kind === 'endsWith') { @@ -213,6 +217,7 @@ describe('work in progress', () => { function outputRoute( route: WithConditions, + caseSensitive = true, length: { min: number; max: number }, preconditions: Array = [], allSeenRanks: Array = [], @@ -244,7 +249,7 @@ describe('work in progress', () => { } for (const condition of route.conditions) { if (!preconditions.includes(condition.key)) { - const str = conditionToString(condition) + const str = conditionToString(condition, caseSensitive) if (str) { flags.push(str) } @@ -266,7 +271,6 @@ describe('work in progress', () => { if (route.rank === maxContinuousRank + 1) { // if we know at this point of the function, we can't do better than this, return it directly fn += `return '${route.path}';` - console.log('maxContinuousRank', maxContinuousRank, 'all', allSeenRanks.join(',')) } else { fn += `propose(${route.rank}, '${route.path}');` } @@ -277,6 +281,7 @@ describe('work in progress', () => { function recursiveStaticMatch( parsedRoutes: Array, + caseSensitive = true, length: { min: number; max: number } = { min: 0, max: Infinity }, preconditions: Array = [], lastRank?: number, @@ -401,7 +406,7 @@ describe('work in progress', () => { } } if (matchingRoutes.length === 1) { - outputRoute(matchingRoutes[0]!, length, preconditions, allSeenRanks) + outputRoute(matchingRoutes[0]!, caseSensitive, length, preconditions, allSeenRanks) } else if (matchingRoutes.length) { // add `if` for the discriminant const bestChildRank = matchingRoutes.reduce( @@ -425,7 +430,7 @@ describe('work in progress', () => { const condition = matchingRoutes[0]!.conditions.find( (c) => c.key === discriminant.key, )! - fn += `if (${conditionToString(condition) || 'true'}${rankTest}) {` + fn += `if (${conditionToString(condition, caseSensitive) || 'true'}${rankTest}) {` } else if (discriminant.type === 'minLength') { if (discriminant.key === length.max) { fn += `if (l === ${discriminant.key}${rankTest}) {` @@ -460,6 +465,7 @@ describe('work in progress', () => { // recurse recursiveStaticMatch( matchingRoutes, + caseSensitive, nextLength, discriminant.type === 'condition' ? [...preconditions, discriminant.key] @@ -470,17 +476,17 @@ describe('work in progress', () => { fn += '}' } if (nonMatchingRoutes.length === 1) { - outputRoute(nonMatchingRoutes[0]!, length, preconditions, allSeenRanks) + outputRoute(nonMatchingRoutes[0]!, caseSensitive, length, preconditions, allSeenRanks) } else if (nonMatchingRoutes.length) { // recurse - recursiveStaticMatch(nonMatchingRoutes, length, preconditions, undefined, allSeenRanks) + recursiveStaticMatch(nonMatchingRoutes, caseSensitive, length, preconditions, undefined, allSeenRanks) } } else { const [route, ...rest] = parsedRoutes - if (route) outputRoute(route, length, preconditions, allSeenRanks) + if (route) outputRoute(route, caseSensitive, length, preconditions, allSeenRanks) if (rest.length) { // try again w/ 1 fewer route, it might find a good discriminant now - recursiveStaticMatch(rest, length, preconditions, undefined, allSeenRanks) + recursiveStaticMatch(rest, caseSensitive, length, preconditions, undefined, allSeenRanks) } } } @@ -539,7 +545,7 @@ describe('work in progress', () => { { kind: 'static', index: i, - value: s.value, + value: caseSensitive ? s.value : s.value.toLowerCase(), key: `static-${i}-${s.value}`, }, ] @@ -610,10 +616,17 @@ describe('work in progress', () => { 0, ) - if (max > 0) + if (max > 0) { fn += `const [${Array.from({ length: max + 1 }, (_, i) => `s${i}`).join(', ')}] = baseSegments;\n` + if (!caseSensitive) { + for (let i = 0; i <= max; i++) { + fn += `const sc${i} = s${i}?.toLowerCase();\n` + } + } + } + - recursiveStaticMatch(withConditions) + recursiveStaticMatch(withConditions, caseSensitive) fn += `return path;` From 7808d9718f6ac6232d27bbabf87cb3d38a2acd20 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 21:26:09 +0200 Subject: [PATCH 27/57] fix index routes --- packages/router-core/tests/built2.test.ts | 115 +++++++++++++--------- 1 file changed, 71 insertions(+), 44 deletions(-) diff --git a/packages/router-core/tests/built2.test.ts b/packages/router-core/tests/built2.test.ts index 58e34d3aba6..262af3c03c1 100644 --- a/packages/router-core/tests/built2.test.ts +++ b/packages/router-core/tests/built2.test.ts @@ -95,6 +95,7 @@ const routeTree = createRouteTree([ '/foo/bar/$id', '/foo/$id/bar', '/foo/$bar', + '/foo/$bar/', '/foo/{-$bar}/qux', '/$id/bar/foo', '/$id/foo/bar', @@ -143,6 +144,7 @@ describe('work in progress', () => { "/a/user-{$id}", "/api/user-{$id}", "/b/user-{$id}", + "/foo/$bar/", "/a/$id", "/b/$id", "/foo/$bar", @@ -491,6 +493,24 @@ describe('work in progress', () => { } } + function prepareIndexRoutes( + parsedRoutes: Array, + ): Array { + const result: Array = [] + for (const route of parsedRoutes) { + result.push(route) + const last = route.segments.at(-1)! + if (route.segments.length > 1 && last.type === SEGMENT_TYPE_PATHNAME && last.value === '/') { + const clone: ParsedRoute = { + ...route, + segments: route.segments.slice(0, -1), + } + result.push(clone) + } + } + return result + } + function prepareOptionalParams( parsedRoutes: Array, ): Array { @@ -530,7 +550,9 @@ describe('work in progress', () => { | { key: string; kind: 'wildcardEndsWith'; value: string } const withConditions: Array = prepareOptionalParams( - parsedRoutes, + prepareIndexRoutes( + parsedRoutes + ), ).map((r) => { let minLength = 0 let maxLength = 0 @@ -652,8 +674,8 @@ describe('work in progress', () => { if (s2.startsWith("user-")) { propose(16, "/a/user-{$id}"); } - propose(19, "/a/$id"); - propose(23, "/a/{-$slug}"); + propose(20, "/a/$id"); + propose(24, "/a/{-$slug}"); } if (s1 === "b" && rank > 10) { if (s2 === "profile") { @@ -662,20 +684,21 @@ describe('work in progress', () => { if (s2.startsWith("user-")) { propose(18, "/b/user-{$id}"); } - propose(20, "/b/$id"); - propose(24, "/b/{-$slug}"); + propose(21, "/b/$id"); + propose(25, "/b/{-$slug}"); + } + if (s1 === "foo" && rank > 15) { + if (s2 === "qux") { + propose(15, "/foo/{-$bar}/qux"); + } + propose(19, "/foo/$bar/"); + propose(22, "/foo/$bar"); } if (s1 === "users") { if (s2 === "profile") { propose(13, "/users/profile"); } - propose(22, "/users/$id"); - } - if (s1 === "foo") { - if (s2 === "qux") { - propose(15, "/foo/{-$bar}/qux"); - } - propose(21, "/foo/$bar"); + propose(23, "/users/$id"); } if (s1 === "beep" && s2 === "boop") { propose(11, "/beep/boop"); @@ -687,30 +710,30 @@ describe('work in progress', () => { propose(17, "/api/user-{$id}"); } if (s1 === "posts") { - propose(25, "/posts/{-$slug}"); + propose(26, "/posts/{-$slug}"); } } - if (l === 2 && rank > 23) { + if (l === 2 && rank > 24) { if (s1 === "a") { - propose(23, "/a/{-$slug}"); - propose(32, "/a"); + propose(24, "/a/{-$slug}"); + propose(33, "/a"); } if (s1 === "b") { - propose(24, "/b/{-$slug}"); - propose(34, "/b"); + propose(25, "/b/{-$slug}"); + propose(35, "/b"); } if (s1 === "posts") { - propose(25, "/posts/{-$slug}"); + propose(26, "/posts/{-$slug}"); } if (s1 === "about") { - propose(33, "/about"); + propose(34, "/about"); } if (s1 === "one") { - propose(35, "/one"); + propose(36, "/one"); } } if (l === 1) { - propose(36, "/"); + propose(37, "/"); } } if (l >= 4) { @@ -742,7 +765,21 @@ describe('work in progress', () => { } } if (l === 4 && rank > 4) { - if (s2 === "profile") { + if (s1 === "foo" && rank > 8) { + if (s2 === "bar") { + propose(8, "/foo/bar/$id"); + } + if (s3 === "bar") { + propose(14, "/foo/$id/bar"); + } + if (s3 === "qux") { + propose(15, "/foo/{-$bar}/qux"); + } + if (s3 === "/") { + propose(19, "/foo/$bar/"); + } + } + if (s2 === "profile" && rank > 4) { if (s1 === "a" && s3 === "settings") { return "/a/profile/settings"; } @@ -753,49 +790,38 @@ describe('work in progress', () => { return "/users/profile/settings"; } } - if (s1 === "foo" && rank > 8) { - if (s2 === "bar") { - return "/foo/bar/$id"; - } - if (s3 === "bar") { - return "/foo/$id/bar"; - } - if (s3 === "qux") { - propose(15, "/foo/{-$bar}/qux"); - } - } if (s2 === "bar" && s3 === "foo") { - propose(37, "/$id/bar/foo"); + propose(38, "/$id/bar/foo"); } if (s2 === "foo" && s3 === "bar") { - propose(38, "/$id/foo/bar"); + propose(39, "/$id/foo/bar"); } } } - if (l >= 3 && rank > 26) { + if (l >= 3 && rank > 27) { if ( s1 === "cache" && s2.startsWith("temp_") && baseSegments[l - 1].endsWith(".log") ) { - propose(26, "/cache/temp_{$}.log"); + propose(27, "/cache/temp_{$}.log"); } if (s1 === "images" && s2.startsWith("thumb_")) { - propose(27, "/images/thumb_{$}"); + propose(28, "/images/thumb_{$}"); } if (s1 === "logs" && baseSegments[l - 1].endsWith(".txt")) { - propose(28, "/logs/{$}.txt"); + propose(29, "/logs/{$}.txt"); } } - if (l >= 2 && rank > 29) { + if (l >= 2 && rank > 30) { if (s1 === "a") { - propose(29, "/a/$"); + propose(30, "/a/$"); } if (s1 === "b") { - propose(30, "/b/$"); + propose(31, "/b/$"); } if (s1 === "files") { - propose(31, "/files/$"); + propose(32, "/files/$"); } } return path; @@ -817,6 +843,7 @@ describe('work in progress', () => { '/', '/users/profile/settings', '/foo/123', + '/foo/123/', '/b/123', '/foo/qux', '/foo/123/qux', From b22924eefe25e2e65a8edeedfa7f1422099c1a90 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 20 Jul 2025 22:06:34 +0200 Subject: [PATCH 28/57] handle empty case --- packages/router-core/tests/built2.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/router-core/tests/built2.test.ts b/packages/router-core/tests/built2.test.ts index 262af3c03c1..daeb58cb8a6 100644 --- a/packages/router-core/tests/built2.test.ts +++ b/packages/router-core/tests/built2.test.ts @@ -185,7 +185,7 @@ describe('work in progress', () => { const caseSensitive = true - let fn = 'const baseSegments = parsePathname(from).map(s => s.value);' + let fn = 'const baseSegments = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' fn += '\nconst l = baseSegments.length;' fn += `\nlet rank = Infinity;` fn += `\nlet path = undefined;` @@ -654,7 +654,9 @@ describe('work in progress', () => { it('generates a matching function', async () => { expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` - "const baseSegments = parsePathname(from).map((s) => s.value); + "const baseSegments = parsePathname(from[0] === "/" ? from : "/" + from).map( + (s) => s.value, + ); const l = baseSegments.length; let rank = Infinity; let path = undefined; @@ -840,6 +842,7 @@ describe('work in progress', () => { // WARN: some of these don't work yet, they're just here to show the differences test.each([ + '', '/', '/users/profile/settings', '/foo/123', From a25fc836c21acfa4384ed827bd8e67f9331796e5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 21 Jul 2025 20:23:09 +0200 Subject: [PATCH 29/57] rewrite 1st implem so I actually understand it --- packages/router-core/tests/built.test.ts | 751 +++++++++++------------ 1 file changed, 354 insertions(+), 397 deletions(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index ca5e6770f00..2c9e427fdd5 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -10,7 +10,9 @@ import { SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, + type Segment, } from '../src/path' +import { format } from "prettier" interface TestRoute { id: string @@ -94,6 +96,7 @@ const routeTree = createRouteTree([ '/foo/bar/$id', '/foo/$id/bar', '/foo/$bar', + '/foo/$bar/', '/foo/{-$bar}/qux', '/$id/bar/foo', '/$id/foo/bar', @@ -142,6 +145,7 @@ describe('work in progress', () => { "/a/user-{$id}", "/api/user-{$id}", "/b/user-{$id}", + "/foo/$bar/", "/a/$id", "/b/$id", "/foo/$bar", @@ -171,457 +175,409 @@ describe('work in progress', () => { segments: parsePathname(route.fullPath), })) - const initialDepth = 0 - let fn = 'const baseSegments = parsePathname(from);' - fn += '\nconst l = baseSegments.length;' - type ParsedRoute = { path: string segments: ReturnType } + const segmentToConditions = (segment: Segment, index: number): Array => { + if (segment.type === SEGMENT_TYPE_WILDCARD) { + const conditions = [] + if (segment.prefixSegment) { + conditions.push(`s${index}.startsWith('${segment.prefixSegment}')`) + } + if (segment.suffixSegment) { + conditions.push(`baseSegments[l - 1].endsWith('${segment.suffixSegment}')`) + } + return conditions + } + if (segment.type === SEGMENT_TYPE_PARAM || segment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { + const conditions = [] + if (segment.prefixSegment) { + conditions.push(`s${index}.startsWith('${segment.prefixSegment}')`) + } + if (segment.suffixSegment) { + conditions.push(`s${index}.endsWith('${segment.suffixSegment}')`) + } + return conditions + } + if (segment.type === SEGMENT_TYPE_PATHNAME) { + if (index === 0 && segment.value === '/') return [] + return [`s${index} === '${segment.value}'`] + } + throw new Error(`Unknown segment type: ${segment.type}`) + } + + const routeSegmentsToConditions = (segments: ReadonlyArray, startIndex: number, additionalSegments: number = 0) => { + let hasWildcard = false + return Array.from({ length: additionalSegments + 1 }).flatMap((_, i) => { + if (hasWildcard) return '' // Wildcards consume all remaining segments, no check needed + const segment = segments[startIndex + i]! + if (segment.type === SEGMENT_TYPE_WILDCARD) { + hasWildcard = true + } + return segmentToConditions(segment, startIndex + i) + }) + .filter(Boolean) + .join(' && ') + } + + const needsSameSegment = (a: Segment, b?: Segment) => { + if (!b) return false + const sameStructure = a.type === b.type && + a.prefixSegment === b.prefixSegment && + a.suffixSegment === b.suffixSegment + if (a.type === SEGMENT_TYPE_PATHNAME) { + return sameStructure && a.value === b.value + } + return sameStructure + } + + const groupRoutesInOrder = (candidates: Array, length: number) => { + // find which (if any) of the routes will be considered fully matched after these checks + // and sort the other routes into "before the leaf" and "after the leaf", so we can print them in the right order + const deeperBefore: Array = [] + const deeperAfter: Array = [] + let leaf: ParsedRoute | undefined + for (const c of candidates) { + const isLeaf = c.segments.length <= length + if (isLeaf && !leaf) { + leaf = c + continue + } + if (isLeaf) { + continue // ignore subsequent leaves, they can never be matched + } + if (!leaf) { + deeperBefore.push(c) + } else { + deeperAfter.push(c) + } + } + return [deeperBefore, leaf, deeperAfter] as const + } + function recursiveStaticMatch( parsedRoutes: Array, - depth = initialDepth, - indent = '', + depth = 0, + fixedLength = false, ) { const resolved = new Set() - for (const route of parsedRoutes) { + for (let i = 0; i < parsedRoutes.length; i++) { + const route = parsedRoutes[i]! if (resolved.has(route)) continue // already resolved - console.log('\n') - console.log('resolving: depth=', depth, 'parsed=', route.path) - console.log('\u001b[34m' + fn + '\u001b[0m') const currentSegment = route.segments[depth] if (!currentSegment) { throw new Error( 'Implementation error: this should not happen, depth=' + - depth + - `, route=${route.path}`, + depth + + `, route=${route.path}`, ) } - const candidates = parsedRoutes.filter((r) => { - const rParsed = r.segments[depth] - if (!rParsed) return false - - // For SEGMENT_TYPE_PARAM (type 1), match only on type and prefix/suffix constraints - if (currentSegment.type === SEGMENT_TYPE_PARAM) { - return ( - rParsed.type === SEGMENT_TYPE_PARAM && - rParsed.prefixSegment === currentSegment.prefixSegment && - rParsed.suffixSegment === currentSegment.suffixSegment - ) - } - // For SEGMENT_TYPE_WILDCARD (type 2), match only on type and prefix/suffix constraints - if (currentSegment.type === SEGMENT_TYPE_WILDCARD) { - return ( - rParsed.type === SEGMENT_TYPE_WILDCARD && - rParsed.prefixSegment === currentSegment.prefixSegment && - rParsed.suffixSegment === currentSegment.suffixSegment - ) + // group together all subsequent routes that require the same "next-segment conditions" + const candidates = [route] + for (let j = i + 1; j < parsedRoutes.length; j++) { + const nextRoute = parsedRoutes[j]! + if (resolved.has(nextRoute)) continue // already resolved + const routeSegment = nextRoute.segments[depth] + if (needsSameSegment(currentSegment, routeSegment)) { + candidates.push(nextRoute) + } else { + break // no more candidates in this group } - - // For all other segment types (SEGMENT_TYPE_PATHNAME, etc.), use exact matching - return ( - rParsed.type === currentSegment.type && - rParsed.value === currentSegment.value && - rParsed.hasStaticAfter === currentSegment.hasStaticAfter && - rParsed.prefixSegment === currentSegment.prefixSegment && - rParsed.suffixSegment === currentSegment.suffixSegment - ) - }) - console.log( - 'candidates:', - candidates.map((r) => r.path), - ) - if (candidates.length === 0) { - throw new Error('Implementation error: this should not happen') } - if (candidates.length > 1) { + + outputConditionGroup: if (candidates.length > 1) { + // Determine how many segments the routes in this group have in common let skipDepth = route.segments.slice(depth + 1).findIndex((s, i) => - candidates.some((c) => { - const segment = c.segments[depth + 1 + i] - return ( - !segment || - segment.type !== s.type || - segment.value !== s.value || - segment.hasStaticAfter !== s.hasStaticAfter || - segment.prefixSegment !== s.prefixSegment || - segment.suffixSegment !== s.suffixSegment - ) - }), + candidates.some((c) => !needsSameSegment(s, c.segments[depth + 1 + i])), ) + // If no segment differ at all, match everything if (skipDepth === -1) skipDepth = route.segments.length - depth - 1 + const lCondition = - skipDepth || depth > initialDepth ? `l > ${depth + skipDepth}` : '' - const skipConditions = - Array.from({ length: skipDepth + 1 }, (_, i) => { - const segment = candidates[0]!.segments[depth + i]! - if (segment.type === SEGMENT_TYPE_PARAM) { - const conditions = [] - if (segment.prefixSegment) { - conditions.push( - `baseSegments[${depth + i}].value.startsWith('${segment.prefixSegment}')`, - ) - } - if (segment.suffixSegment) { - conditions.push( - `baseSegments[${depth + i}].value.endsWith('${segment.suffixSegment}')`, - ) - } - return conditions.join(' && ') - } - if (segment.type === SEGMENT_TYPE_WILDCARD) { - // Wildcards consume all remaining segments, no checking needed - return '' - } - return `baseSegments[${depth + i}].value === '${segment.value}'` - }) - .filter(Boolean) - .join(`\n${indent} && `) + (skipDepth ? `\n${indent}` : '') - const hasCondition = Boolean(lCondition || skipConditions) - if (hasCondition) { - fn += `\n${indent}if (${lCondition}${lCondition && skipConditions ? ' && ' : ''}${skipConditions}) {` + !fixedLength && (skipDepth || (depth > 0)) ? `l > ${depth + skipDepth}` : '' + + // Accumulate all the conditions for all segments in the group + const skipConditions = routeSegmentsToConditions(candidates[0]!.segments, depth, skipDepth) + if (!skipConditions) { // we grouped by "next-segment conditions", but this didn't result in any conditions, bail out + candidates.length = 1 + break outputConditionGroup } - const deeperBefore: Array = [] - const deeperAfter: Array = [] - let leaf: ParsedRoute | undefined - for (const c of candidates) { - const isLeaf = c.segments.length <= depth + 1 + skipDepth - if (isLeaf && !leaf) { - leaf = c - continue - } - if (isLeaf) { - continue // ignore subsequent leaves, they can never be matched - } - if (!leaf) { - deeperBefore.push(c) - } else { - deeperAfter.push(c) - } + + const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, depth + 1 + skipDepth) + const hasCondition = Boolean(lCondition || skipConditions) && (deeperBefore.length > 0 || deeperAfter.length > 0) + if (hasCondition) { + fn += `if (${lCondition}${lCondition && skipConditions ? ' && ' : ''}${skipConditions}) {` } if (deeperBefore.length > 0) { recursiveStaticMatch( deeperBefore, depth + 1 + skipDepth, - hasCondition ? indent + ' ' : indent, + fixedLength, ) } if (leaf) { - fn += `\n${indent} if (l === ${leaf.segments.length}) {` - fn += `\n${indent} return '${leaf.path}';` - fn += `\n${indent} }` + if (fixedLength) { + fn += `return '${leaf.path}';` + } else { + fn += `if (l === ${leaf.segments.length}) return '${leaf.path}';` + } } - if (deeperAfter.length > 0) { + if (deeperAfter.length > 0 && !(leaf && fixedLength)) { recursiveStaticMatch( deeperAfter, depth + 1 + skipDepth, - hasCondition ? indent + ' ' : indent, + fixedLength, ) } if (hasCondition) { - fn += `\n${indent}}` + fn += '}' } - } else { - const leaf = candidates[0]! + candidates.forEach((c) => resolved.add(c)) + continue + } + - // Check if this route contains a wildcard segment - const wildcardIndex = leaf.segments.findIndex( - (s) => s && s.type === SEGMENT_TYPE_WILDCARD, + const wildcardIndex = route.segments.findIndex( + (s) => s && s.type === SEGMENT_TYPE_WILDCARD, + ) + if (wildcardIndex > -1 && wildcardIndex < depth) { + throw new Error( + `Implementation error: wildcard segment at index ${wildcardIndex} cannot be before depth ${depth} in route ${route.path}`, ) + } - if (wildcardIndex !== -1 && wildcardIndex >= depth) { - // This route has a wildcard at or after the current depth - const wildcardSegment = leaf.segments[wildcardIndex]! - const done = `return '${leaf.path}';` - - // For wildcards, we need to check: - // 1. All static/param segments before the wildcard match - // 2. There are remaining segments for the wildcard to consume (l >= wildcardIndex) - // 3. Handle prefix/suffix constraints for the wildcard if present - - const conditions = [`l >= ${wildcardIndex}`] - - // Add conditions for all segments before the wildcard - for (let i = depth; i < wildcardIndex; i++) { - const segment = leaf.segments[i]! - const value = `baseSegments[${i}].value` - - if (segment.type === SEGMENT_TYPE_PARAM) { - // Parameter segment - if (segment.prefixSegment) { - conditions.push( - `${value}.startsWith('${segment.prefixSegment}')`, - ) - } - if (segment.suffixSegment) { - conditions.push(`${value}.endsWith('${segment.suffixSegment}')`) - } - } else if (segment.type === SEGMENT_TYPE_PATHNAME) { - // Static segment - conditions.push(`${value} === '${segment.value}'`) - } + // couldn't group by segment, try grouping by length (exclude wildcard routes because of their variable length) + if (wildcardIndex === -1 && !fixedLength) { + for (let j = i + 1; j < parsedRoutes.length; j++) { + const nextRoute = parsedRoutes[j]! + if (resolved.has(nextRoute)) continue // already resolved + if (nextRoute.segments.length === route.segments.length && !nextRoute.segments.some((s) => s.type === SEGMENT_TYPE_WILDCARD)) { + candidates.push(nextRoute) + } else { + break // no more candidates in this group } - - // Handle prefix/suffix for the wildcard itself - if (wildcardSegment.prefixSegment || wildcardSegment.suffixSegment) { - const wildcardValue = `baseSegments[${wildcardIndex}].value` - if (wildcardSegment.prefixSegment) { - conditions.push( - `${wildcardValue}.startsWith('${wildcardSegment.prefixSegment}')`, - ) - } - if (wildcardSegment.suffixSegment) { - // For suffix wildcard, we need to check the last segment - conditions.push( - `baseSegments[l - 1].value.endsWith('${wildcardSegment.suffixSegment}')`, - ) - } + } + if (candidates.length > 2) { + const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, route.segments.length) + if (leaf && deeperBefore.length || leaf && deeperAfter.length) { + throw new Error(`Implementation error: length-based leaf route ${leaf.path} should not have deeper routes, but has ${deeperBefore.length} before and ${deeperAfter.length} after`) } + fn += `if (l === ${route.segments.length}) {` + recursiveStaticMatch( + candidates, + depth, + true + ) + fn += '}' + candidates.forEach((c) => resolved.add(c)) + continue + } + } - fn += `\n${indent}if (${conditions.join(' && ')}) {` - fn += `\n${indent} ${done}` - fn += `\n${indent}}` - } else { - // No wildcard in this route, use the original logic - const done = `return '${leaf.path}';` - fn += `\n${indent}if (l === ${leaf.segments.length}` - for (let i = depth; i < leaf.segments.length; i++) { - const segment = leaf.segments[i]! - const value = `baseSegments[${i}].value` - - // For SEGMENT_TYPE_PARAM (type 1), check if base has static segment (type 0) that satisfies constraints - if (segment.type === SEGMENT_TYPE_PARAM) { - if (segment.prefixSegment || segment.suffixSegment) { - fn += `\n${indent} ` - } - // Add prefix/suffix checks for parameters with prefix/suffix - if (segment.prefixSegment) { - fn += ` && ${value}.startsWith('${segment.prefixSegment}')` - } - if (segment.suffixSegment) { - fn += ` && ${value}.endsWith('${segment.suffixSegment}')` - } - } else { - // For other segment types, use exact matching - fn += `\n${indent} && ${value} === '${segment.value}'` - } + // try grouping wildcard routes that would have the same base length + if (wildcardIndex > -1 && !fixedLength) { + for (let j = i + 1; j < parsedRoutes.length; j++) { + const nextRoute = parsedRoutes[j]! + if (resolved.has(nextRoute)) continue // already resolved + if (nextRoute.segments.length === route.segments.length && wildcardIndex === nextRoute.segments.findIndex((s) => s.type === SEGMENT_TYPE_WILDCARD)) { + candidates.push(nextRoute) + } else { + break // no more candidates in this group } - fn += `\n${indent}) {` - fn += `\n${indent} ${done}` - fn += `\n${indent}}` } + if (candidates.length > 2) { + const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, route.segments.length) + if (leaf && deeperBefore.length || leaf && deeperAfter.length) { + throw new Error(`Implementation error: wildcard-based leaf route ${leaf.path} should not have deeper routes, but has ${deeperBefore.length} before and ${deeperAfter.length} after`) + } + fn += `if (l >= ${wildcardIndex}) {` + recursiveStaticMatch( + candidates, + depth, + true + ) + fn += '}' + candidates.forEach((c) => resolved.add(c)) + continue + } + } + + // couldn't group at all, just output a single-route match, and let the next iteration handle the rest + if (wildcardIndex === -1) { + const conditions = routeSegmentsToConditions(route.segments, depth, route.segments.length - depth - 1) + const lCondition = fixedLength ? '' : `l === ${route.segments.length}` + fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) return '${route.path}';` + } else { + const conditions = routeSegmentsToConditions(route.segments, depth, wildcardIndex - depth) + const lCondition = fixedLength ? '' : `l >= ${wildcardIndex}` + fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) return '${route.path}';` } - candidates.forEach((c) => resolved.add(c)) + resolved.add(route) } } - recursiveStaticMatch(parsedRoutes) + let fn = 'const baseSegments = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' + fn += '\nconst l = baseSegments.length;' - it('generates a matching function', () => { - expect(fn).toMatchInlineSnapshot(` - "const baseSegments = parsePathname(from); - const l = baseSegments.length; - if (baseSegments[0].value === '/') { - if (l > 1 && baseSegments[1].value === 'a') { - if (l === 7 - && baseSegments[2].value === 'b' - && baseSegments[3].value === 'c' - && baseSegments[4].value === 'd' - && baseSegments[5].value === 'e' - && baseSegments[6].value === 'f' - ) { - return '/a/b/c/d/e/f'; - } - if (l > 2 && baseSegments[2].value === 'profile') { - if (l === 4 - && baseSegments[3].value === 'settings' - ) { - return '/a/profile/settings'; - } - if (l === 3) { - return '/a/profile'; - } - } - if (l === 3 - && baseSegments[2].value.startsWith('user-') - ) { - return '/a/user-{$id}'; - } - if (l === 3 - ) { - return '/a/$id'; - } - if (l === 3 - && baseSegments[2].value === '$slug' - ) { - return '/a/{-$slug}'; - } - if (l >= 2) { - return '/a/$'; - } - if (l === 2) { - return '/a'; - } - } - if (l > 3 && baseSegments[1].value === 'z' - && baseSegments[2].value === 'y' - && baseSegments[3].value === 'x' - ) { - if (l === 5 - && baseSegments[4].value === 'u' - ) { - return '/z/y/x/u'; - } - if (l === 5 - && baseSegments[4].value === 'v' - ) { - return '/z/y/x/v'; - } - if (l === 5 - && baseSegments[4].value === 'w' - ) { - return '/z/y/x/w'; - } - if (l === 4) { - return '/z/y/x'; - } - } - if (l > 1 && baseSegments[1].value === 'b') { - if (l > 2 && baseSegments[2].value === 'profile') { - if (l === 4 - && baseSegments[3].value === 'settings' - ) { - return '/b/profile/settings'; - } - if (l === 3) { - return '/b/profile'; - } - } - if (l === 3 - && baseSegments[2].value.startsWith('user-') - ) { - return '/b/user-{$id}'; - } - if (l === 3 - ) { - return '/b/$id'; - } - if (l === 3 - && baseSegments[2].value === '$slug' - ) { - return '/b/{-$slug}'; - } - if (l >= 2) { - return '/b/$'; - } - if (l === 2) { - return '/b'; - } - } - if (l > 1 && baseSegments[1].value === 'users') { - if (l > 2 && baseSegments[2].value === 'profile') { - if (l === 4 - && baseSegments[3].value === 'settings' - ) { - return '/users/profile/settings'; - } - if (l === 3) { - return '/users/profile'; - } - } - if (l === 3 - ) { - return '/users/$id'; - } - } - if (l > 1 && baseSegments[1].value === 'foo') { - if (l === 4 - && baseSegments[2].value === 'bar' - ) { - return '/foo/bar/$id'; - } - if (l > 2) { - if (l === 4 - && baseSegments[3].value === 'bar' - ) { - return '/foo/$id/bar'; - } - if (l === 3) { - return '/foo/$bar'; - } - } - if (l === 4 - && baseSegments[2].value === '$bar' - && baseSegments[3].value === 'qux' - ) { - return '/foo/{-$bar}/qux'; - } - } - if (l === 3 - && baseSegments[1].value === 'beep' - && baseSegments[2].value === 'boop' - ) { - return '/beep/boop'; - } - if (l > 1 && baseSegments[1].value === 'one') { - if (l === 3 - && baseSegments[2].value === 'two' - ) { - return '/one/two'; - } - if (l === 2) { - return '/one'; - } - } - if (l === 3 - && baseSegments[1].value === 'api' - && baseSegments[2].value.startsWith('user-') - ) { - return '/api/user-{$id}'; - } - if (l === 3 - && baseSegments[1].value === 'posts' - && baseSegments[2].value === '$slug' - ) { - return '/posts/{-$slug}'; - } - if (l >= 2 && baseSegments[1].value === 'cache' && baseSegments[2].value.startsWith('temp_') && baseSegments[l - 1].value.endsWith('.log')) { - return '/cache/temp_{$}.log'; - } - if (l >= 2 && baseSegments[1].value === 'images' && baseSegments[2].value.startsWith('thumb_')) { - return '/images/thumb_{$}'; - } - if (l >= 2 && baseSegments[1].value === 'logs' && baseSegments[l - 1].value.endsWith('.txt')) { - return '/logs/{$}.txt'; - } - if (l >= 2 && baseSegments[1].value === 'files') { - return '/files/$'; - } - if (l === 2 - && baseSegments[1].value === 'about' - ) { - return '/about'; - } - if (l === 1) { - return '/'; + const max = parsedRoutes.reduce( + (max, r) => Math.max(max, r.segments.length), + 0, + ) + if (max > 0) fn += `\nconst [,${Array.from({ length: max }, (_, i) => `s${i + 1}`).join(', ')}] = baseSegments;` + + + // we duplicate routes that end in a static `/`, so they're also matched if that final `/` is not present + function prepareIndexRoutes( + parsedRoutes: Array, + ): Array { + const result: Array = [] + for (const route of parsedRoutes) { + result.push(route) + const last = route.segments.at(-1)! + if (route.segments.length > 1 && last.type === SEGMENT_TYPE_PATHNAME && last.value === '/') { + const clone: ParsedRoute = { + ...route, + segments: route.segments.slice(0, -1), } - if (l > 1) { - if (l === 4 - && baseSegments[2].value === 'bar' - && baseSegments[3].value === 'foo' - ) { - return '/$id/bar/foo'; - } - if (l === 4 - && baseSegments[2].value === 'foo' - && baseSegments[3].value === 'bar' - ) { - return '/$id/foo/bar'; - } + result.push(clone) + } + } + return result + } + + // we replace routes w/ optional params, with + // - 1 version where it's a regular param + // - 1 version where it's removed entirely + function prepareOptionalParams( + parsedRoutes: Array, + ): Array { + const result: Array = [] + for (const route of parsedRoutes) { + const index = route.segments.findIndex( + (s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM, + ) + if (index === -1) { + result.push(route) + continue + } + // for every optional param in the route, we need to push a version of the route without it, and a version of the route with it as a regular param + // example: + // /foo/{-$bar}/qux => [/foo/qux, /foo/$bar/qux] + // /a/{-$b}/c/{-$d} => [/a/c, /a/c/$d, /a/$b/c, /a/$b/c/$d] + const withRegular: ParsedRoute = { + ...route, + segments: route.segments.map((s, i) => + i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s, + ), + } + const withoutOptional: ParsedRoute = { + ...route, + segments: route.segments.filter((_, i) => i !== index), + } + const chunk = prepareOptionalParams([withRegular, withoutOptional]) + result.push(...chunk) + } + return result + } + + const all = prepareOptionalParams( + prepareIndexRoutes( + parsedRoutes + ), + ) + + recursiveStaticMatch(all) + + it('generates a matching function', async () => { + expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` + "const baseSegments = parsePathname(from[0] === "/" ? from : "/" + from).map( + (s) => s.value, + ); + const l = baseSegments.length; + const [, s1, s2, s3, s4, s5, s6, s7] = baseSegments; + if ( + l === 7 && + s1 === "a" && + s2 === "b" && + s3 === "c" && + s4 === "d" && + s5 === "e" && + s6 === "f" + ) + return "/a/b/c/d/e/f"; + if (l === 5) { + if (s1 === "z" && s2 === "y" && s3 === "x") { + if (s4 === "u") return "/z/y/x/u"; + if (s4 === "v") return "/z/y/x/v"; + if (s4 === "w") return "/z/y/x/w"; } - }" + } + if (l === 4) { + if (s1 === "a" && s2 === "profile" && s3 === "settings") + return "/a/profile/settings"; + if (s1 === "b" && s2 === "profile" && s3 === "settings") + return "/b/profile/settings"; + if (s1 === "users" && s2 === "profile" && s3 === "settings") + return "/users/profile/settings"; + if (s1 === "z" && s2 === "y" && s3 === "x") return "/z/y/x"; + if (s1 === "foo" && s2 === "bar") return "/foo/bar/$id"; + } + if (l === 3) { + if (s1 === "a" && s2 === "profile") return "/a/profile"; + if (s1 === "b" && s2 === "profile") return "/b/profile"; + if (s1 === "beep" && s2 === "boop") return "/beep/boop"; + if (s1 === "one" && s2 === "two") return "/one/two"; + if (s1 === "users" && s2 === "profile") return "/users/profile"; + } + if (l === 4 && s1 === "foo" && s3 === "bar") return "/foo/$id/bar"; + if (l === 4 && s1 === "foo" && s3 === "qux") return "/foo/{-$bar}/qux"; + if (l === 3) { + if (s1 === "foo" && s2 === "qux") return "/foo/{-$bar}/qux"; + if (s1 === "a" && s2.startsWith("user-")) return "/a/user-{$id}"; + if (s1 === "api" && s2.startsWith("user-")) return "/api/user-{$id}"; + if (s1 === "b" && s2.startsWith("user-")) return "/b/user-{$id}"; + } + if (l === 4 && s1 === "foo" && s3 === "/") return "/foo/$bar/"; + if (l === 3) { + if (s1 === "foo") return "/foo/$bar/"; + if (s1 === "a") return "/a/$id"; + if (s1 === "b") return "/b/$id"; + if (s1 === "foo") return "/foo/$bar"; + if (s1 === "users") return "/users/$id"; + if (s1 === "a") return "/a/{-$slug}"; + } + if (l === 2 && s1 === "a") return "/a/{-$slug}"; + if (l === 3 && s1 === "b") return "/b/{-$slug}"; + if (l === 2 && s1 === "b") return "/b/{-$slug}"; + if (l === 3 && s1 === "posts") return "/posts/{-$slug}"; + if (l === 2 && s1 === "posts") return "/posts/{-$slug}"; + if (l >= 2) { + if ( + s1 === "cache" && + s2.startsWith("temp_") && + baseSegments[l - 1].endsWith(".log") + ) + return "/cache/temp_{$}.log"; + if (s1 === "images" && s2.startsWith("thumb_")) return "/images/thumb_{$}"; + if (s1 === "logs" && baseSegments[l - 1].endsWith(".txt")) + return "/logs/{$}.txt"; + if (s1 === "a") return "/a/$"; + if (s1 === "b") return "/b/$"; + if (s1 === "files") return "/files/$"; + } + if (l === 2) { + if (s1 === "a") return "/a"; + if (s1 === "about") return "/about"; + if (s1 === "b") return "/b"; + if (s1 === "one") return "/one"; + } + if (l === 1) return "/"; + if (l === 4 && s2 === "bar" && s3 === "foo") return "/$id/bar/foo"; + if (l === 4 && s2 === "foo" && s3 === "bar") return "/$id/foo/bar"; + " `) }) @@ -632,13 +588,14 @@ describe('work in progress', () => { // WARN: some of these don't work yet, they're just here to show the differences test.each([ + '', '/', '/users/profile/settings', '/foo/123', + '/foo/123/', '/b/123', '/foo/qux', '/foo/123/qux', - '/foo/qux', '/a/user-123', '/a/123', '/a/123/more', From 9797c77e9fe615602f5890787824b393811579d8 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 21 Jul 2025 20:28:25 +0200 Subject: [PATCH 30/57] better wildstar route test --- packages/router-core/tests/built.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 2c9e427fdd5..59443335b2c 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -605,7 +605,7 @@ describe('work in progress', () => { '/files/deep/nested/file.json', '/files/', '/images/thumb_200x300.jpg', - '/logs/error.txt', + '/logs/2020/01/01/error.txt', '/cache/temp_user456.log', '/a/b/c/d/e', ])('matching %s', (s) => { From 171353fce3d79dd8fa6a775e16f7ffd6a9215c50 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 21 Jul 2025 20:57:31 +0200 Subject: [PATCH 31/57] no need to limit length grouping --- packages/router-core/tests/built.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts index 59443335b2c..39ec26ff3f5 100644 --- a/packages/router-core/tests/built.test.ts +++ b/packages/router-core/tests/built.test.ts @@ -360,7 +360,7 @@ describe('work in progress', () => { break // no more candidates in this group } } - if (candidates.length > 2) { + if (candidates.length > 1) { const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, route.segments.length) if (leaf && deeperBefore.length || leaf && deeperAfter.length) { throw new Error(`Implementation error: length-based leaf route ${leaf.path} should not have deeper routes, but has ${deeperBefore.length} before and ${deeperAfter.length} after`) From d836b810c649ebdf32dedf5d6b9f06748e057e07 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 22 Jul 2025 00:17:23 +0200 Subject: [PATCH 32/57] param retrieval code gen --- .../router-core/tests/pathToParams.test.ts | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 packages/router-core/tests/pathToParams.test.ts diff --git a/packages/router-core/tests/pathToParams.test.ts b/packages/router-core/tests/pathToParams.test.ts new file mode 100644 index 00000000000..b9644869547 --- /dev/null +++ b/packages/router-core/tests/pathToParams.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it, test } from 'vitest' +import { + joinPaths, + matchPathname, + parsePathname, + processRouteTree, +} from '../src' +import { + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, + type Segment, +} from '../src/path' +import { format } from "prettier" + +interface TestRoute { + id: string + isRoot?: boolean + path?: string + fullPath: string + rank?: number + parentRoute?: TestRoute + children?: Array + options?: { + caseSensitive?: boolean + } +} + +type PathOrChildren = string | [string, Array] + +function createRoute( + pathOrChildren: Array, + parentPath: string, +): Array { + return pathOrChildren.map((route) => { + if (Array.isArray(route)) { + const fullPath = joinPaths([parentPath, route[0]]) + const children = createRoute(route[1], fullPath) + const r = { + id: fullPath, + path: route[0], + fullPath, + children: children, + } + children.forEach((child) => { + child.parentRoute = r + }) + + return r + } + + const fullPath = joinPaths([parentPath, route]) + + return { + id: fullPath, + path: route, + fullPath, + } + }) +} + +function createRouteTree(pathOrChildren: Array): TestRoute { + return { + id: '__root__', + fullPath: '', + isRoot: true, + path: undefined, + children: createRoute(pathOrChildren, ''), + } +} + +const routeTree = createRouteTree([ + '/', + '/users/profile/settings', // static-deep (longest static path) + '/users/profile', // static-medium (medium static path) + '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) + '/users/$id', // param-simple (plain param) + '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) + '/files/$', // wildcard (lowest priority) + '/about', // static-shallow (shorter static path) + '/a/profile/settings', + '/a/profile', + '/a/user-{$id}', + '/a/$id', + '/a/{-$slug}', + '/a/$', + '/a', + '/b/profile/settings', + '/b/profile', + '/b/user-{$id}', + '/b/$id', + '/b/{-$slug}', + '/b/$', + '/b', + '/foo/bar/$id', + '/foo/$id/bar', + '/foo/$bar', + '/foo/$bar/', + '/foo/{-$bar}/qux', + '/foo/{-$bar}/$baz/qux', + '/$id/bar/foo', + '/$id/foo/bar', + '/a/b/c/d/e/f', + '/beep/boop', + '/one/two', + '/one', + '/z/y/x/w', + '/z/y/x/v', + '/z/y/x/u', + '/z/y/x', + '/images/thumb_{$}', // wildcard with prefix + '/logs/{$}.txt', // wildcard with suffix + '/cache/temp_{$}.log', // wildcard with prefix and suffix +]) + +const result = processRouteTree({ routeTree }) + +function originalMatcher(from: string): string | undefined { + const match = result.flatRoutes.find((r) => + matchPathname('/', from, { to: r.fullPath }), + ) + return match?.fullPath +} + +describe('work in progress', () => { + it('is ordrered', () => { + expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` + [ + "/a/b/c/d/e/f", + "/z/y/x/u", + "/z/y/x/v", + "/z/y/x/w", + "/a/profile/settings", + "/b/profile/settings", + "/users/profile/settings", + "/z/y/x", + "/foo/bar/$id", + "/a/profile", + "/b/profile", + "/beep/boop", + "/one/two", + "/users/profile", + "/foo/$id/bar", + "/foo/{-$bar}/qux", + "/foo/{-$bar}/$baz/qux", + "/a/user-{$id}", + "/api/user-{$id}", + "/b/user-{$id}", + "/foo/$bar/", + "/a/$id", + "/b/$id", + "/foo/$bar", + "/users/$id", + "/a/{-$slug}", + "/b/{-$slug}", + "/posts/{-$slug}", + "/cache/temp_{$}.log", + "/images/thumb_{$}", + "/logs/{$}.txt", + "/a/$", + "/b/$", + "/files/$", + "/a", + "/about", + "/b", + "/one", + "/", + "/$id/bar/foo", + "/$id/foo/bar", + ] + `) + }) + + function segmentsToRegex(segments: ReadonlyArray): string | undefined { + if (segments.every((s) => s.type === SEGMENT_TYPE_PATHNAME)) return + let re = '' + for (let i = 0; i < segments.length; i++) { + const s = segments[i]! + if (s.type === SEGMENT_TYPE_PATHNAME) { + if (s.value === '/') { + if (i === segments.length - 1) { + re += '/?' + } + } else { + let skip = 0 + for (let j = i + 1; j < segments.length; j++) { + if (segments[j]!.type !== SEGMENT_TYPE_PATHNAME || segments[j]!.value === '/') { + break + } + skip++ + } + if (skip) { + re += `(/[^/]*){${skip + 1}}` + } else { + re += '/[^/]*' + } + } + } else if (s.type === SEGMENT_TYPE_PARAM) { + const prefix = s.prefixSegment ? RegExp.escape(s.prefixSegment) : '' + const suffix = s.suffixSegment ? RegExp.escape(s.suffixSegment) : '' + const name = s.value.replace(/^\$/, '') + const param = `(?<${name}>[^/]*)` + re += `/${prefix}${param}${suffix}` + } else if (s.type === SEGMENT_TYPE_OPTIONAL_PARAM) { + const name = s.value.replace(/^\$/, '') + const param = `(?<${name}>[^/]*)` + re += `(?:/${param})?` + } else if (s.type === SEGMENT_TYPE_WILDCARD) { + const prefix = s.prefixSegment ? RegExp.escape(s.prefixSegment) : '' + const suffix = s.suffixSegment ? RegExp.escape(s.suffixSegment) : '' + const param = `(?<_splat>.*)` + if (prefix || suffix) { + re += `/${prefix}${param}${suffix}` + } else { + re += `/?${param}` + } + break + } else { + throw new Error(`Unknown segment type: ${s.type}`) + } + } + return `^${re}$` + } + + const obj: Record = {} + + for (const route of result.flatRoutes) { + const segments = parsePathname(route.fullPath) + const re = segmentsToRegex(segments) + if (!re) continue + obj[route.fullPath] = re + } + + it('works', () => { + console.log(obj) + expect(obj).toMatchInlineSnapshot(` + { + "/$id/bar/foo": "^/(?[^/]*)(/[^/]*){2}/[^/]*$", + "/$id/foo/bar": "^/(?[^/]*)(/[^/]*){2}/[^/]*$", + "/a/$": "^/[^/]*/?(?<_splat>.*)$", + "/a/$id": "^/[^/]*/(?[^/]*)$", + "/a/user-{$id}": "^/[^/]*/\\x75ser\\x2d(?[^/]*)$", + "/a/{-$slug}": "^/[^/]*(?:/(?[^/]*))?$", + "/api/user-{$id}": "^/[^/]*/\\x75ser\\x2d(?[^/]*)$", + "/b/$": "^/[^/]*/?(?<_splat>.*)$", + "/b/$id": "^/[^/]*/(?[^/]*)$", + "/b/user-{$id}": "^/[^/]*/\\x75ser\\x2d(?[^/]*)$", + "/b/{-$slug}": "^/[^/]*(?:/(?[^/]*))?$", + "/cache/temp_{$}.log": "^/[^/]*/\\x74emp_(?<_splat>.*)\\.log$", + "/files/$": "^/[^/]*/?(?<_splat>.*)$", + "/foo/$bar": "^/[^/]*/(?[^/]*)$", + "/foo/$bar/": "^/[^/]*/(?[^/]*)/?$", + "/foo/$id/bar": "^/[^/]*/(?[^/]*)/[^/]*$", + "/foo/bar/$id": "^(/[^/]*){2}/[^/]*/(?[^/]*)$", + "/foo/{-$bar}/$baz/qux": "^/[^/]*(?:/(?[^/]*))?/(?[^/]*)/[^/]*$", + "/foo/{-$bar}/qux": "^/[^/]*(?:/(?[^/]*))?/[^/]*$", + "/images/thumb_{$}": "^/[^/]*/\\x74humb_(?<_splat>.*)$", + "/logs/{$}.txt": "^/[^/]*/(?<_splat>.*)\\.txt$", + "/posts/{-$slug}": "^/[^/]*(?:/(?[^/]*))?$", + "/users/$id": "^/[^/]*/(?[^/]*)$", + } + `) + }) + + function getParams(path: string, input: string) { + const str = obj[path] + if (!str) return {} + const match = new RegExp(str).exec(input) + return match?.groups || {} + } + + function isMatched(path: string, input: string) { + const str = obj[path] + if (!str) return false + return new RegExp(str).test(input) + } + + describe.each([ + ['/a/$id', '/a/123', { id: '123' }], + ['/a/{-$slug}', '/a/hello', { slug: 'hello' }], + ['/a/{-$slug}', '/a/', { slug: '' }], + ['/a/{-$slug}', '/a', { slug: undefined }], + ['/b/user-{$id}', '/b/user-123', { id: '123' }], + ['/logs/{$}.txt', '/logs/2022/01/01/error.txt', { _splat: '2022/01/01/error' }], + ['/foo/{-$bar}/qux', '/foo/hello/qux', { bar: 'hello' }], + ['/foo/{-$bar}/qux', '/foo/qux', { bar: undefined }], + ['/foo/{-$bar}/$baz/qux', '/foo/qux/qux', { bar: undefined, baz: 'qux' }], + ['/foo/$bar/', '/foo/qux/', { bar: 'qux' }], + ])('getParams(%s, %s) === %j', (path, input, expected) => { + it('matches', () => expect(isMatched(path, input)).toBeTruthy()) + it('returns', () => expect(getParams(path, input)).toEqual(expected)) + }) +}) \ No newline at end of file From e07330f57b57e6bdf30a2ead1aca0998f793b641 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 22 Jul 2025 10:48:38 +0200 Subject: [PATCH 33/57] add direct param extraction --- .../router-core/tests/builtWithParams.test.ts | 704 ++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100644 packages/router-core/tests/builtWithParams.test.ts diff --git a/packages/router-core/tests/builtWithParams.test.ts b/packages/router-core/tests/builtWithParams.test.ts new file mode 100644 index 00000000000..7d2095f6b03 --- /dev/null +++ b/packages/router-core/tests/builtWithParams.test.ts @@ -0,0 +1,704 @@ +import { describe, expect, it, test } from 'vitest' +import { + joinPaths, + matchPathname, + parsePathname, + processRouteTree, +} from '../src' +import { + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, + type Segment, +} from '../src/path' +import { format } from "prettier" + +interface TestRoute { + id: string + isRoot?: boolean + path?: string + fullPath: string + rank?: number + parentRoute?: TestRoute + children?: Array + options?: { + caseSensitive?: boolean + } +} + +type PathOrChildren = string | [string, Array] + +function createRoute( + pathOrChildren: Array, + parentPath: string, +): Array { + return pathOrChildren.map((route) => { + if (Array.isArray(route)) { + const fullPath = joinPaths([parentPath, route[0]]) + const children = createRoute(route[1], fullPath) + const r = { + id: fullPath, + path: route[0], + fullPath, + children: children, + } + children.forEach((child) => { + child.parentRoute = r + }) + + return r + } + + const fullPath = joinPaths([parentPath, route]) + + return { + id: fullPath, + path: route, + fullPath, + } + }) +} + +function createRouteTree(pathOrChildren: Array): TestRoute { + return { + id: '__root__', + fullPath: '', + isRoot: true, + path: undefined, + children: createRoute(pathOrChildren, ''), + } +} + +const routeTree = createRouteTree([ + '/', + '/users/profile/settings', // static-deep (longest static path) + '/users/profile', // static-medium (medium static path) + '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) + '/users/$id', // param-simple (plain param) + '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) + '/files/$', // wildcard (lowest priority) + '/about', // static-shallow (shorter static path) + '/a/profile/settings', + '/a/profile', + '/a/user-{$id}', + '/a/$id', + '/a/{-$slug}', + '/a/$', + '/a', + '/b/profile/settings', + '/b/profile', + '/b/user-{$id}', + '/b/$id', + '/b/{-$slug}', + '/b/$', + '/b', + '/foo/bar/$id', + '/foo/$id/bar', + '/foo/$bar', + '/foo/$bar/', + '/foo/{-$bar}/qux', + '/$id/bar/foo', + '/$id/foo/bar', + '/a/b/c/d/e/f', + '/beep/boop', + '/one/two', + '/one', + '/z/y/x/w', + '/z/y/x/v', + '/z/y/x/u', + '/z/y/x', + '/images/thumb_{$}', // wildcard with prefix + '/logs/{$}.txt', // wildcard with suffix + '/cache/temp_{$}.log', // wildcard with prefix and suffix +]) + +const result = processRouteTree({ routeTree }) + +function originalMatcher(from: string): readonly [string, Record] | undefined { + let match + for (const route of result.flatRoutes) { + const result = matchPathname('/', from, { to: route.fullPath }) + if (result) { + match = [route.fullPath, result] as const + break + } + } + return match +} + +describe('work in progress', () => { + it('is ordrered', () => { + expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` + [ + "/a/b/c/d/e/f", + "/z/y/x/u", + "/z/y/x/v", + "/z/y/x/w", + "/a/profile/settings", + "/b/profile/settings", + "/users/profile/settings", + "/z/y/x", + "/foo/bar/$id", + "/a/profile", + "/b/profile", + "/beep/boop", + "/one/two", + "/users/profile", + "/foo/$id/bar", + "/foo/{-$bar}/qux", + "/a/user-{$id}", + "/api/user-{$id}", + "/b/user-{$id}", + "/foo/$bar/", + "/a/$id", + "/b/$id", + "/foo/$bar", + "/users/$id", + "/a/{-$slug}", + "/b/{-$slug}", + "/posts/{-$slug}", + "/cache/temp_{$}.log", + "/images/thumb_{$}", + "/logs/{$}.txt", + "/a/$", + "/b/$", + "/files/$", + "/a", + "/about", + "/b", + "/one", + "/", + "/$id/bar/foo", + "/$id/foo/bar", + ] + `) + }) + + const parsedRoutes = result.flatRoutes.map((route) => ({ + path: route.fullPath, + segments: parsePathname(route.fullPath), + })) + + type ParsedRoute = { + path: string + segments: ReturnType + } + + const segmentToConditions = (segment: Segment, index: number): Array => { + if (segment.type === SEGMENT_TYPE_WILDCARD) { + const conditions = [] + if (segment.prefixSegment) { + conditions.push(`s${index}.startsWith('${segment.prefixSegment}')`) + } + if (segment.suffixSegment) { + conditions.push(`s[l - 1].endsWith('${segment.suffixSegment}')`) + } + return conditions + } + if (segment.type === SEGMENT_TYPE_PARAM || segment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { + const conditions = [] + if (segment.prefixSegment) { + conditions.push(`s${index}.startsWith('${segment.prefixSegment}')`) + } + if (segment.suffixSegment) { + conditions.push(`s${index}.endsWith('${segment.suffixSegment}')`) + } + return conditions + } + if (segment.type === SEGMENT_TYPE_PATHNAME) { + if (index === 0 && segment.value === '/') return [] + return [`s${index} === '${segment.value}'`] + } + throw new Error(`Unknown segment type: ${segment.type}`) + } + + const routeSegmentsToConditions = (segments: ReadonlyArray, startIndex: number, additionalSegments: number = 0) => { + let hasWildcard = false + return Array.from({ length: additionalSegments + 1 }).flatMap((_, i) => { + if (hasWildcard) return '' // Wildcards consume all remaining segments, no check needed + const segment = segments[startIndex + i]! + if (segment.type === SEGMENT_TYPE_WILDCARD) { + hasWildcard = true + } + return segmentToConditions(segment, startIndex + i) + }) + .filter(Boolean) + .join(' && ') + } + + const needsSameSegment = (a: Segment, b?: Segment) => { + if (!b) return false + const sameStructure = a.type === b.type && + a.prefixSegment === b.prefixSegment && + a.suffixSegment === b.suffixSegment + if (a.type === SEGMENT_TYPE_PATHNAME) { + return sameStructure && a.value === b.value + } + return sameStructure + } + + const groupRoutesInOrder = (candidates: Array, length: number) => { + // find which (if any) of the routes will be considered fully matched after these checks + // and sort the other routes into "before the leaf" and "after the leaf", so we can print them in the right order + const deeperBefore: Array = [] + const deeperAfter: Array = [] + let leaf: ParsedRoute | undefined + for (const c of candidates) { + const isLeaf = c.segments.length <= length + if (isLeaf && !leaf) { + leaf = c + continue + } + if (isLeaf) { + continue // ignore subsequent leaves, they can never be matched + } + if (!leaf) { + deeperBefore.push(c) + } else { + deeperAfter.push(c) + } + } + return [deeperBefore, leaf, deeperAfter] as const + } + + function outputRoute(route: ParsedRoute) { + /** + * return [ + * route.path, + * { foo: s2, bar: s4 } + * ] + */ + let result = `return ['${route.path}', {` + for (let i = 0; i < route.segments.length; i++) { + const segment = route.segments[i]! + if (segment.type === SEGMENT_TYPE_PARAM) { + const name = segment.value.replace(/^\$/, '') + const value = `s${i}` + if (segment.prefixSegment && segment.suffixSegment) { + result += `${name}: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + } else if (segment.prefixSegment) { + result += `${name}: ${value}.slice(${segment.prefixSegment.length}), ` + } else if (segment.suffixSegment) { + result += `${name}: ${value}.slice(0, -${segment.suffixSegment.length}), ` + } else { + result += `${name}: ${value}, ` + } + } else if (segment.type === SEGMENT_TYPE_WILDCARD) { + const value = `s.slice(${i}).join('/')` + if (segment.prefixSegment && segment.suffixSegment) { + result += `_splat: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + result += `'*': ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + } else if (segment.prefixSegment) { + result += `_splat: ${value}.slice(${segment.prefixSegment.length}), ` + result += `'*': ${value}.slice(${segment.prefixSegment.length}), ` + } else if (segment.suffixSegment) { + result += `_splat: ${value}.slice(0, -${segment.suffixSegment.length}), ` + result += `'*': ${value}.slice(0, -${segment.suffixSegment.length}), ` + } else { + result += `_splat: ${value}, ` + result += `'*': ${value}, ` + } + break + } + } + result += '}];' + return result + } + + function recursiveStaticMatch( + parsedRoutes: Array, + depth = 0, + fixedLength = false, + ) { + const resolved = new Set() + for (let i = 0; i < parsedRoutes.length; i++) { + const route = parsedRoutes[i]! + if (resolved.has(route)) continue // already resolved + const currentSegment = route.segments[depth] + if (!currentSegment) { + throw new Error( + 'Implementation error: this should not happen, depth=' + + depth + + `, route=${route.path}`, + ) + } + + // group together all subsequent routes that require the same "next-segment conditions" + const candidates = [route] + for (let j = i + 1; j < parsedRoutes.length; j++) { + const nextRoute = parsedRoutes[j]! + if (resolved.has(nextRoute)) continue // already resolved + const routeSegment = nextRoute.segments[depth] + if (needsSameSegment(currentSegment, routeSegment)) { + candidates.push(nextRoute) + } else { + break // no more candidates in this group + } + } + + outputConditionGroup: if (candidates.length > 1) { + // Determine how many segments the routes in this group have in common + let skipDepth = route.segments.slice(depth + 1).findIndex((s, i) => + candidates.some((c) => !needsSameSegment(s, c.segments[depth + 1 + i])), + ) + // If no segment differ at all, match everything + if (skipDepth === -1) skipDepth = route.segments.length - depth - 1 + + const lCondition = + !fixedLength && (skipDepth || (depth > 0)) ? `l > ${depth + skipDepth}` : '' + + // Accumulate all the conditions for all segments in the group + const skipConditions = routeSegmentsToConditions(candidates[0]!.segments, depth, skipDepth) + if (!skipConditions) { // we grouped by "next-segment conditions", but this didn't result in any conditions, bail out + candidates.length = 1 + break outputConditionGroup + } + + const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, depth + 1 + skipDepth) + const hasCondition = Boolean(lCondition || skipConditions) && (deeperBefore.length > 0 || deeperAfter.length > 0) + if (hasCondition) { + fn += `if (${lCondition}${lCondition && skipConditions ? ' && ' : ''}${skipConditions}) {` + } + if (deeperBefore.length > 0) { + recursiveStaticMatch( + deeperBefore, + depth + 1 + skipDepth, + fixedLength, + ) + } + if (leaf) { + if (fixedLength) { + fn += outputRoute(leaf) + } else { + fn += `if (l === ${leaf.segments.length}) ${outputRoute(leaf)}` + } + } + if (deeperAfter.length > 0 && !(leaf && fixedLength)) { + recursiveStaticMatch( + deeperAfter, + depth + 1 + skipDepth, + fixedLength, + ) + } + if (hasCondition) { + fn += '}' + } + candidates.forEach((c) => resolved.add(c)) + continue + } + + + const wildcardIndex = route.segments.findIndex( + (s) => s && s.type === SEGMENT_TYPE_WILDCARD, + ) + if (wildcardIndex > -1 && wildcardIndex < depth) { + throw new Error( + `Implementation error: wildcard segment at index ${wildcardIndex} cannot be before depth ${depth} in route ${route.path}`, + ) + } + + // couldn't group by segment, try grouping by length (exclude wildcard routes because of their variable length) + if (wildcardIndex === -1 && !fixedLength) { + for (let j = i + 1; j < parsedRoutes.length; j++) { + const nextRoute = parsedRoutes[j]! + if (resolved.has(nextRoute)) continue // already resolved + if (nextRoute.segments.length === route.segments.length && !nextRoute.segments.some((s) => s.type === SEGMENT_TYPE_WILDCARD)) { + candidates.push(nextRoute) + } else { + break // no more candidates in this group + } + } + if (candidates.length > 1) { + const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, route.segments.length) + if (leaf && deeperBefore.length || leaf && deeperAfter.length) { + throw new Error(`Implementation error: length-based leaf route ${leaf.path} should not have deeper routes, but has ${deeperBefore.length} before and ${deeperAfter.length} after`) + } + fn += `if (l === ${route.segments.length}) {` + recursiveStaticMatch( + candidates, + depth, + true + ) + fn += '}' + candidates.forEach((c) => resolved.add(c)) + continue + } + } + + // try grouping wildcard routes that would have the same base length + if (wildcardIndex > -1 && !fixedLength) { + for (let j = i + 1; j < parsedRoutes.length; j++) { + const nextRoute = parsedRoutes[j]! + if (resolved.has(nextRoute)) continue // already resolved + if (nextRoute.segments.length === route.segments.length && wildcardIndex === nextRoute.segments.findIndex((s) => s.type === SEGMENT_TYPE_WILDCARD)) { + candidates.push(nextRoute) + } else { + break // no more candidates in this group + } + } + if (candidates.length > 2) { + const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, route.segments.length) + if (leaf && deeperBefore.length || leaf && deeperAfter.length) { + throw new Error(`Implementation error: wildcard-based leaf route ${leaf.path} should not have deeper routes, but has ${deeperBefore.length} before and ${deeperAfter.length} after`) + } + fn += `if (l >= ${wildcardIndex}) {` + recursiveStaticMatch( + candidates, + depth, + true + ) + fn += '}' + candidates.forEach((c) => resolved.add(c)) + continue + } + } + + // couldn't group at all, just output a single-route match, and let the next iteration handle the rest + if (wildcardIndex === -1) { + const conditions = routeSegmentsToConditions(route.segments, depth, route.segments.length - depth - 1) + const lCondition = fixedLength ? '' : `l === ${route.segments.length}` + fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) ${outputRoute(route)}` + } else { + const conditions = routeSegmentsToConditions(route.segments, depth, wildcardIndex - depth) + const lCondition = fixedLength ? '' : `l >= ${wildcardIndex}` + fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) ${outputRoute(route)}` + } + resolved.add(route) + } + } + + let fn = 'const s = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' + fn += '\nconst l = s.length;' + + const max = parsedRoutes.reduce( + (max, r) => Math.max(max, r.segments.length), + 0, + ) + if (max > 0) fn += `\nconst [,${Array.from({ length: max }, (_, i) => `s${i + 1}`).join(', ')}] = s;` + + + // we duplicate routes that end in a static `/`, so they're also matched if that final `/` is not present + function prepareIndexRoutes( + parsedRoutes: Array, + ): Array { + const result: Array = [] + for (const route of parsedRoutes) { + result.push(route) + const last = route.segments.at(-1)! + if (route.segments.length > 1 && last.type === SEGMENT_TYPE_PATHNAME && last.value === '/') { + const clone: ParsedRoute = { + ...route, + segments: route.segments.slice(0, -1), + } + result.push(clone) + } + } + return result + } + + // we replace routes w/ optional params, with + // - 1 version where it's a regular param + // - 1 version where it's removed entirely + function prepareOptionalParams( + parsedRoutes: Array, + ): Array { + const result: Array = [] + for (const route of parsedRoutes) { + const index = route.segments.findIndex( + (s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM, + ) + if (index === -1) { + result.push(route) + continue + } + // for every optional param in the route, we need to push a version of the route without it, and a version of the route with it as a regular param + // example: + // /foo/{-$bar}/qux => [/foo/qux, /foo/$bar/qux] + // /a/{-$b}/c/{-$d} => [/a/c, /a/c/$d, /a/$b/c, /a/$b/c/$d] + const withRegular: ParsedRoute = { + ...route, + segments: route.segments.map((s, i) => + i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s, + ), + } + const withoutOptional: ParsedRoute = { + ...route, + segments: route.segments.filter((_, i) => i !== index), + } + const chunk = prepareOptionalParams([withRegular, withoutOptional]) + result.push(...chunk) + } + return result + } + + const all = prepareOptionalParams( + prepareIndexRoutes( + parsedRoutes + ), + ) + + recursiveStaticMatch(all) + + it('generates a matching function', async () => { + expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` + "const s = parsePathname(from[0] === "/" ? from : "/" + from).map( + (s) => s.value, + ); + const l = s.length; + const [, s1, s2, s3, s4, s5, s6, s7] = s; + if ( + l === 7 && + s1 === "a" && + s2 === "b" && + s3 === "c" && + s4 === "d" && + s5 === "e" && + s6 === "f" + ) + return ["/a/b/c/d/e/f", {}]; + if (l === 5) { + if (s1 === "z" && s2 === "y" && s3 === "x") { + if (s4 === "u") return ["/z/y/x/u", {}]; + if (s4 === "v") return ["/z/y/x/v", {}]; + if (s4 === "w") return ["/z/y/x/w", {}]; + } + } + if (l === 4) { + if (s1 === "a" && s2 === "profile" && s3 === "settings") + return ["/a/profile/settings", {}]; + if (s1 === "b" && s2 === "profile" && s3 === "settings") + return ["/b/profile/settings", {}]; + if (s1 === "users" && s2 === "profile" && s3 === "settings") + return ["/users/profile/settings", {}]; + if (s1 === "z" && s2 === "y" && s3 === "x") return ["/z/y/x", {}]; + if (s1 === "foo" && s2 === "bar") return ["/foo/bar/$id", { id: s3 }]; + } + if (l === 3) { + if (s1 === "a" && s2 === "profile") return ["/a/profile", {}]; + if (s1 === "b" && s2 === "profile") return ["/b/profile", {}]; + if (s1 === "beep" && s2 === "boop") return ["/beep/boop", {}]; + if (s1 === "one" && s2 === "two") return ["/one/two", {}]; + if (s1 === "users" && s2 === "profile") return ["/users/profile", {}]; + } + if (l === 4) { + if (s1 === "foo") { + if (s3 === "bar") return ["/foo/$id/bar", { id: s2 }]; + if (s3 === "qux") return ["/foo/{-$bar}/qux", { bar: s2 }]; + } + } + if (l === 3) { + if (s1 === "foo" && s2 === "qux") return ["/foo/{-$bar}/qux", {}]; + if (s1 === "a" && s2.startsWith("user-")) + return ["/a/user-{$id}", { id: s2.slice(5) }]; + if (s1 === "api" && s2.startsWith("user-")) + return ["/api/user-{$id}", { id: s2.slice(5) }]; + if (s1 === "b" && s2.startsWith("user-")) + return ["/b/user-{$id}", { id: s2.slice(5) }]; + } + if (l === 4 && s1 === "foo" && s3 === "/") return ["/foo/$bar/", { bar: s2 }]; + if (l === 3) { + if (s1 === "foo") return ["/foo/$bar/", { bar: s2 }]; + if (s1 === "a") return ["/a/$id", { id: s2 }]; + if (s1 === "b") return ["/b/$id", { id: s2 }]; + if (s1 === "foo") return ["/foo/$bar", { bar: s2 }]; + if (s1 === "users") return ["/users/$id", { id: s2 }]; + if (s1 === "a") return ["/a/{-$slug}", { slug: s2 }]; + } + if (l === 2 && s1 === "a") return ["/a/{-$slug}", {}]; + if (l === 3 && s1 === "b") return ["/b/{-$slug}", { slug: s2 }]; + if (l === 2 && s1 === "b") return ["/b/{-$slug}", {}]; + if (l === 3 && s1 === "posts") return ["/posts/{-$slug}", { slug: s2 }]; + if (l === 2 && s1 === "posts") return ["/posts/{-$slug}", {}]; + if (l >= 2) { + if (s1 === "cache" && s2.startsWith("temp_") && s[l - 1].endsWith(".log")) + return [ + "/cache/temp_{$}.log", + { + _splat: s.slice(2).join("/").slice(5, -4), + "*": s.slice(2).join("/").slice(5, -4), + }, + ]; + if (s1 === "images" && s2.startsWith("thumb_")) + return [ + "/images/thumb_{$}", + { + _splat: s.slice(2).join("/").slice(6), + "*": s.slice(2).join("/").slice(6), + }, + ]; + if (s1 === "logs" && s[l - 1].endsWith(".txt")) + return [ + "/logs/{$}.txt", + { + _splat: s.slice(2).join("/").slice(0, -4), + "*": s.slice(2).join("/").slice(0, -4), + }, + ]; + if (s1 === "a") + return [ + "/a/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + if (s1 === "b") + return [ + "/b/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + if (s1 === "files") + return [ + "/files/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + } + if (l === 2) { + if (s1 === "a") return ["/a", {}]; + if (s1 === "about") return ["/about", {}]; + if (s1 === "b") return ["/b", {}]; + if (s1 === "one") return ["/one", {}]; + } + if (l === 1) return ["/", {}]; + if (l === 4) { + if (s2 === "bar" && s3 === "foo") return ["/$id/bar/foo", { id: s1 }]; + if (s2 === "foo" && s3 === "bar") return ["/$id/foo/bar", { id: s1 }]; + } + " + `) + }) + + const buildMatcher = new Function('parsePathname', 'from', fn) as ( + parser: typeof parsePathname, + from: string, + ) => readonly [path: string, params: Record] | undefined + + // WARN: some of these don't work yet, they're just here to show the differences + test.each([ + '', + '/', + '/users/profile/settings', + '/foo/123', + '/foo/123/', + '/b/123', + '/foo/qux', + '/foo/123/qux', + '/a/user-123', + '/a/123', + '/a/123/more', + '/files', + '/files/hello-world.txt', + '/something/foo/bar', + '/files/deep/nested/file.json', + '/files/', + '/images/thumb_200x300.jpg', + '/logs/2020/01/01/error.txt', + '/cache/temp_user456.log', + '/a/b/c/d/e', + ])('matching %s', (s) => { + const originalMatch = originalMatcher(s) + const buildMatch = buildMatcher(parsePathname, s) + console.log( + `matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]}`, + ) + expect(buildMatch).toEqual(originalMatch) + }) +}) From 844df5e364c5d38b07db8d9f70036dd5aff7f005 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 23 Jul 2025 18:12:36 +0200 Subject: [PATCH 34/57] support runtime fuzzy options --- .../router-core/tests/builtWithParams.test.ts | 158 +++++++++++------- 1 file changed, 96 insertions(+), 62 deletions(-) diff --git a/packages/router-core/tests/builtWithParams.test.ts b/packages/router-core/tests/builtWithParams.test.ts index 7d2095f6b03..9c4187c50f7 100644 --- a/packages/router-core/tests/builtWithParams.test.ts +++ b/packages/router-core/tests/builtWithParams.test.ts @@ -115,10 +115,10 @@ const routeTree = createRouteTree([ const result = processRouteTree({ routeTree }) -function originalMatcher(from: string): readonly [string, Record] | undefined { +function originalMatcher(from: string, fuzzy?: boolean): readonly [string, Record] | undefined { let match for (const route of result.flatRoutes) { - const result = matchPathname('/', from, { to: route.fullPath }) + const result = matchPathname('/', from, { to: route.fullPath, fuzzy }) if (result) { match = [route.fullPath, result] as const break @@ -262,14 +262,15 @@ describe('work in progress', () => { return [deeperBefore, leaf, deeperAfter] as const } - function outputRoute(route: ParsedRoute) { + function outputRoute(route: ParsedRoute, length: number) { /** * return [ * route.path, * { foo: s2, bar: s4 } * ] */ - let result = `return ['${route.path}', {` + let result = `{` + let hasWildcard = false for (let i = 0; i < route.segments.length; i++) { const segment = route.segments[i]! if (segment.type === SEGMENT_TYPE_PARAM) { @@ -285,6 +286,7 @@ describe('work in progress', () => { result += `${name}: ${value}, ` } } else if (segment.type === SEGMENT_TYPE_WILDCARD) { + hasWildcard = true const value = `s.slice(${i}).join('/')` if (segment.prefixSegment && segment.suffixSegment) { result += `_splat: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` @@ -302,8 +304,10 @@ describe('work in progress', () => { break } } - result += '}];' - return result + result += `}` + return hasWildcard + ? `return ['${route.path}', ${result}];` + : `return ['${route.path}', params(${result}, ${length})];` } function recursiveStaticMatch( @@ -369,9 +373,9 @@ describe('work in progress', () => { } if (leaf) { if (fixedLength) { - fn += outputRoute(leaf) + fn += outputRoute(leaf, leaf.segments.length) } else { - fn += `if (l === ${leaf.segments.length}) ${outputRoute(leaf)}` + fn += `if (length(${leaf.segments.length})) ${outputRoute(leaf, leaf.segments.length)}` } } if (deeperAfter.length > 0 && !(leaf && fixedLength)) { @@ -414,7 +418,7 @@ describe('work in progress', () => { if (leaf && deeperBefore.length || leaf && deeperAfter.length) { throw new Error(`Implementation error: length-based leaf route ${leaf.path} should not have deeper routes, but has ${deeperBefore.length} before and ${deeperAfter.length} after`) } - fn += `if (l === ${route.segments.length}) {` + fn += `if (length(${route.segments.length})) {` recursiveStaticMatch( candidates, depth, @@ -457,12 +461,12 @@ describe('work in progress', () => { // couldn't group at all, just output a single-route match, and let the next iteration handle the rest if (wildcardIndex === -1) { const conditions = routeSegmentsToConditions(route.segments, depth, route.segments.length - depth - 1) - const lCondition = fixedLength ? '' : `l === ${route.segments.length}` - fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) ${outputRoute(route)}` + const lCondition = fixedLength ? '' : `length(${route.segments.length})` + fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) ${outputRoute(route, route.segments.length)}` } else { const conditions = routeSegmentsToConditions(route.segments, depth, wildcardIndex - depth) const lCondition = fixedLength ? '' : `l >= ${wildcardIndex}` - fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) ${outputRoute(route)}` + fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) ${outputRoute(route, route.segments.length)}` } resolved.add(route) } @@ -470,6 +474,8 @@ describe('work in progress', () => { let fn = 'const s = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' fn += '\nconst l = s.length;' + fn += '\nconst length = fuzzy ? (n) => l >= n : (n) => l === n;' + fn += '\nconst params = fuzzy ? (p, n) => { if (n && l > n) p[\'**\'] = s.slice(n).join(\'/\'); return p } : (p) => p' const max = parsedRoutes.reduce( (max, r) => Math.max(max, r.segments.length), @@ -546,9 +552,16 @@ describe('work in progress', () => { (s) => s.value, ); const l = s.length; + const length = fuzzy ? (n) => l >= n : (n) => l === n; + const params = fuzzy + ? (p, n) => { + if (n && l > n) p["**"] = s.slice(n).join("/"); + return p; + } + : (p) => p; const [, s1, s2, s3, s4, s5, s6, s7] = s; if ( - l === 7 && + length(7) && s1 === "a" && s2 === "b" && s3 === "c" && @@ -556,60 +569,64 @@ describe('work in progress', () => { s5 === "e" && s6 === "f" ) - return ["/a/b/c/d/e/f", {}]; - if (l === 5) { + return ["/a/b/c/d/e/f", params({}, 7)]; + if (length(5)) { if (s1 === "z" && s2 === "y" && s3 === "x") { - if (s4 === "u") return ["/z/y/x/u", {}]; - if (s4 === "v") return ["/z/y/x/v", {}]; - if (s4 === "w") return ["/z/y/x/w", {}]; + if (s4 === "u") return ["/z/y/x/u", params({}, 5)]; + if (s4 === "v") return ["/z/y/x/v", params({}, 5)]; + if (s4 === "w") return ["/z/y/x/w", params({}, 5)]; } } - if (l === 4) { + if (length(4)) { if (s1 === "a" && s2 === "profile" && s3 === "settings") - return ["/a/profile/settings", {}]; + return ["/a/profile/settings", params({}, 4)]; if (s1 === "b" && s2 === "profile" && s3 === "settings") - return ["/b/profile/settings", {}]; + return ["/b/profile/settings", params({}, 4)]; if (s1 === "users" && s2 === "profile" && s3 === "settings") - return ["/users/profile/settings", {}]; - if (s1 === "z" && s2 === "y" && s3 === "x") return ["/z/y/x", {}]; - if (s1 === "foo" && s2 === "bar") return ["/foo/bar/$id", { id: s3 }]; + return ["/users/profile/settings", params({}, 4)]; + if (s1 === "z" && s2 === "y" && s3 === "x") return ["/z/y/x", params({}, 4)]; + if (s1 === "foo" && s2 === "bar") + return ["/foo/bar/$id", params({ id: s3 }, 4)]; } - if (l === 3) { - if (s1 === "a" && s2 === "profile") return ["/a/profile", {}]; - if (s1 === "b" && s2 === "profile") return ["/b/profile", {}]; - if (s1 === "beep" && s2 === "boop") return ["/beep/boop", {}]; - if (s1 === "one" && s2 === "two") return ["/one/two", {}]; - if (s1 === "users" && s2 === "profile") return ["/users/profile", {}]; + if (length(3)) { + if (s1 === "a" && s2 === "profile") return ["/a/profile", params({}, 3)]; + if (s1 === "b" && s2 === "profile") return ["/b/profile", params({}, 3)]; + if (s1 === "beep" && s2 === "boop") return ["/beep/boop", params({}, 3)]; + if (s1 === "one" && s2 === "two") return ["/one/two", params({}, 3)]; + if (s1 === "users" && s2 === "profile") + return ["/users/profile", params({}, 3)]; } - if (l === 4) { + if (length(4)) { if (s1 === "foo") { - if (s3 === "bar") return ["/foo/$id/bar", { id: s2 }]; - if (s3 === "qux") return ["/foo/{-$bar}/qux", { bar: s2 }]; + if (s3 === "bar") return ["/foo/$id/bar", params({ id: s2 }, 4)]; + if (s3 === "qux") return ["/foo/{-$bar}/qux", params({ bar: s2 }, 4)]; } } - if (l === 3) { - if (s1 === "foo" && s2 === "qux") return ["/foo/{-$bar}/qux", {}]; + if (length(3)) { + if (s1 === "foo" && s2 === "qux") return ["/foo/{-$bar}/qux", params({}, 3)]; if (s1 === "a" && s2.startsWith("user-")) - return ["/a/user-{$id}", { id: s2.slice(5) }]; + return ["/a/user-{$id}", params({ id: s2.slice(5) }, 3)]; if (s1 === "api" && s2.startsWith("user-")) - return ["/api/user-{$id}", { id: s2.slice(5) }]; + return ["/api/user-{$id}", params({ id: s2.slice(5) }, 3)]; if (s1 === "b" && s2.startsWith("user-")) - return ["/b/user-{$id}", { id: s2.slice(5) }]; + return ["/b/user-{$id}", params({ id: s2.slice(5) }, 3)]; } - if (l === 4 && s1 === "foo" && s3 === "/") return ["/foo/$bar/", { bar: s2 }]; - if (l === 3) { - if (s1 === "foo") return ["/foo/$bar/", { bar: s2 }]; - if (s1 === "a") return ["/a/$id", { id: s2 }]; - if (s1 === "b") return ["/b/$id", { id: s2 }]; - if (s1 === "foo") return ["/foo/$bar", { bar: s2 }]; - if (s1 === "users") return ["/users/$id", { id: s2 }]; - if (s1 === "a") return ["/a/{-$slug}", { slug: s2 }]; + if (length(4) && s1 === "foo" && s3 === "/") + return ["/foo/$bar/", params({ bar: s2 }, 4)]; + if (length(3)) { + if (s1 === "foo") return ["/foo/$bar/", params({ bar: s2 }, 3)]; + if (s1 === "a") return ["/a/$id", params({ id: s2 }, 3)]; + if (s1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; + if (s1 === "foo") return ["/foo/$bar", params({ bar: s2 }, 3)]; + if (s1 === "users") return ["/users/$id", params({ id: s2 }, 3)]; + if (s1 === "a") return ["/a/{-$slug}", params({ slug: s2 }, 3)]; } - if (l === 2 && s1 === "a") return ["/a/{-$slug}", {}]; - if (l === 3 && s1 === "b") return ["/b/{-$slug}", { slug: s2 }]; - if (l === 2 && s1 === "b") return ["/b/{-$slug}", {}]; - if (l === 3 && s1 === "posts") return ["/posts/{-$slug}", { slug: s2 }]; - if (l === 2 && s1 === "posts") return ["/posts/{-$slug}", {}]; + if (length(2) && s1 === "a") return ["/a/{-$slug}", params({}, 2)]; + if (length(3) && s1 === "b") return ["/b/{-$slug}", params({ slug: s2 }, 3)]; + if (length(2) && s1 === "b") return ["/b/{-$slug}", params({}, 2)]; + if (length(3) && s1 === "posts") + return ["/posts/{-$slug}", params({ slug: s2 }, 3)]; + if (length(2) && s1 === "posts") return ["/posts/{-$slug}", params({}, 2)]; if (l >= 2) { if (s1 === "cache" && s2.startsWith("temp_") && s[l - 1].endsWith(".log")) return [ @@ -651,27 +668,29 @@ describe('work in progress', () => { { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, ]; } - if (l === 2) { - if (s1 === "a") return ["/a", {}]; - if (s1 === "about") return ["/about", {}]; - if (s1 === "b") return ["/b", {}]; - if (s1 === "one") return ["/one", {}]; + if (length(2)) { + if (s1 === "a") return ["/a", params({}, 2)]; + if (s1 === "about") return ["/about", params({}, 2)]; + if (s1 === "b") return ["/b", params({}, 2)]; + if (s1 === "one") return ["/one", params({}, 2)]; } - if (l === 1) return ["/", {}]; - if (l === 4) { - if (s2 === "bar" && s3 === "foo") return ["/$id/bar/foo", { id: s1 }]; - if (s2 === "foo" && s3 === "bar") return ["/$id/foo/bar", { id: s1 }]; + if (length(1)) return ["/", params({}, 1)]; + if (length(4)) { + if (s2 === "bar" && s3 === "foo") + return ["/$id/bar/foo", params({ id: s1 }, 4)]; + if (s2 === "foo" && s3 === "bar") + return ["/$id/foo/bar", params({ id: s1 }, 4)]; } " `) }) - const buildMatcher = new Function('parsePathname', 'from', fn) as ( + const buildMatcher = new Function('parsePathname', 'from', 'fuzzy', fn) as ( parser: typeof parsePathname, from: string, + fuzzy?: boolean ) => readonly [path: string, params: Record] | undefined - // WARN: some of these don't work yet, they're just here to show the differences test.each([ '', '/', @@ -701,4 +720,19 @@ describe('work in progress', () => { ) expect(buildMatch).toEqual(originalMatch) }) + + // WARN: some of these don't work yet, they're just here to show the differences + test.each([ + '/users/profile/settings/hello', + '/a/b/c/d/e/f/g', + '/foo/bar/baz', + '/foo/bar/baz/qux', + ])('fuzzy matching %s', (s) => { + const originalMatch = originalMatcher(s, true) + const buildMatch = buildMatcher(parsePathname, s, true) + console.log( + `fuzzy matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]} ${JSON.stringify(buildMatch?.[1])}`, + ) + expect(buildMatch).toEqual(originalMatch) + }) }) From b4f7d74c7f37dc5529bce7cd592b7deaa599dee9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 23 Jul 2025 18:42:44 +0200 Subject: [PATCH 35/57] fix level 0 grouping --- .../router-core/tests/builtWithParams.test.ts | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/router-core/tests/builtWithParams.test.ts b/packages/router-core/tests/builtWithParams.test.ts index 9c4187c50f7..47d59436686 100644 --- a/packages/router-core/tests/builtWithParams.test.ts +++ b/packages/router-core/tests/builtWithParams.test.ts @@ -319,8 +319,7 @@ describe('work in progress', () => { for (let i = 0; i < parsedRoutes.length; i++) { const route = parsedRoutes[i]! if (resolved.has(route)) continue // already resolved - const currentSegment = route.segments[depth] - if (!currentSegment) { + if (route.segments.length <= depth) { throw new Error( 'Implementation error: this should not happen, depth=' + depth + @@ -330,11 +329,13 @@ describe('work in progress', () => { // group together all subsequent routes that require the same "next-segment conditions" const candidates = [route] + const compareIndex = route.segments.length > 1 ? depth || 1 : depth + const compare = route.segments[compareIndex]! for (let j = i + 1; j < parsedRoutes.length; j++) { const nextRoute = parsedRoutes[j]! if (resolved.has(nextRoute)) continue // already resolved - const routeSegment = nextRoute.segments[depth] - if (needsSameSegment(currentSegment, routeSegment)) { + const routeSegment = nextRoute.segments[compareIndex] + if (needsSameSegment(compare, routeSegment)) { candidates.push(nextRoute) } else { break // no more candidates in this group @@ -570,8 +571,8 @@ describe('work in progress', () => { s6 === "f" ) return ["/a/b/c/d/e/f", params({}, 7)]; - if (length(5)) { - if (s1 === "z" && s2 === "y" && s3 === "x") { + if (l > 3 && s1 === "z" && s2 === "y" && s3 === "x") { + if (length(5)) { if (s4 === "u") return ["/z/y/x/u", params({}, 5)]; if (s4 === "v") return ["/z/y/x/v", params({}, 5)]; if (s4 === "w") return ["/z/y/x/w", params({}, 5)]; @@ -596,14 +597,14 @@ describe('work in progress', () => { if (s1 === "users" && s2 === "profile") return ["/users/profile", params({}, 3)]; } - if (length(4)) { - if (s1 === "foo") { + if (l > 1 && s1 === "foo") { + if (length(4)) { if (s3 === "bar") return ["/foo/$id/bar", params({ id: s2 }, 4)]; if (s3 === "qux") return ["/foo/{-$bar}/qux", params({ bar: s2 }, 4)]; } + if (length(3) && s2 === "qux") return ["/foo/{-$bar}/qux", params({}, 3)]; } if (length(3)) { - if (s1 === "foo" && s2 === "qux") return ["/foo/{-$bar}/qux", params({}, 3)]; if (s1 === "a" && s2.startsWith("user-")) return ["/a/user-{$id}", params({ id: s2.slice(5) }, 3)]; if (s1 === "api" && s2.startsWith("user-")) @@ -611,10 +612,11 @@ describe('work in progress', () => { if (s1 === "b" && s2.startsWith("user-")) return ["/b/user-{$id}", params({ id: s2.slice(5) }, 3)]; } - if (length(4) && s1 === "foo" && s3 === "/") - return ["/foo/$bar/", params({ bar: s2 }, 4)]; + if (l > 2 && s1 === "foo") { + if (length(4) && s3 === "/") return ["/foo/$bar/", params({ bar: s2 }, 4)]; + if (length(3)) return ["/foo/$bar/", params({ bar: s2 }, 3)]; + } if (length(3)) { - if (s1 === "foo") return ["/foo/$bar/", params({ bar: s2 }, 3)]; if (s1 === "a") return ["/a/$id", params({ id: s2 }, 3)]; if (s1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; if (s1 === "foo") return ["/foo/$bar", params({ bar: s2 }, 3)]; @@ -622,11 +624,14 @@ describe('work in progress', () => { if (s1 === "a") return ["/a/{-$slug}", params({ slug: s2 }, 3)]; } if (length(2) && s1 === "a") return ["/a/{-$slug}", params({}, 2)]; - if (length(3) && s1 === "b") return ["/b/{-$slug}", params({ slug: s2 }, 3)]; - if (length(2) && s1 === "b") return ["/b/{-$slug}", params({}, 2)]; - if (length(3) && s1 === "posts") - return ["/posts/{-$slug}", params({ slug: s2 }, 3)]; - if (length(2) && s1 === "posts") return ["/posts/{-$slug}", params({}, 2)]; + if (l > 1 && s1 === "b") { + if (length(3)) return ["/b/{-$slug}", params({ slug: s2 }, 3)]; + if (length(2)) return ["/b/{-$slug}", params({}, 2)]; + } + if (l > 1 && s1 === "posts") { + if (length(3)) return ["/posts/{-$slug}", params({ slug: s2 }, 3)]; + if (length(2)) return ["/posts/{-$slug}", params({}, 2)]; + } if (l >= 2) { if (s1 === "cache" && s2.startsWith("temp_") && s[l - 1].endsWith(".log")) return [ From 7391bad8c41b48648136297140d19d614863442b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 23 Jul 2025 23:58:54 +0200 Subject: [PATCH 36/57] cleanup --- .../tests/builtWithParams3.test.ts | 812 ++++++++++++++++++ 1 file changed, 812 insertions(+) create mode 100644 packages/router-core/tests/builtWithParams3.test.ts diff --git a/packages/router-core/tests/builtWithParams3.test.ts b/packages/router-core/tests/builtWithParams3.test.ts new file mode 100644 index 00000000000..b05573b5370 --- /dev/null +++ b/packages/router-core/tests/builtWithParams3.test.ts @@ -0,0 +1,812 @@ +import { describe, expect, it, test } from 'vitest' +import { format } from "prettier" +import { + joinPaths, + matchPathname, + parsePathname, + processRouteTree, +} from '../src' +import { + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, +} from '../src/path' + +interface TestRoute { + id: string + isRoot?: boolean + path?: string + fullPath: string + rank?: number + parentRoute?: TestRoute + children?: Array + options?: { + caseSensitive?: boolean + } +} + +type PathOrChildren = string | [string, Array] + +function createRoute( + pathOrChildren: Array, + parentPath: string, +): Array { + return pathOrChildren.map((route) => { + if (Array.isArray(route)) { + const fullPath = joinPaths([parentPath, route[0]]) + const children = createRoute(route[1], fullPath) + const r = { + id: fullPath, + path: route[0], + fullPath, + children: children, + } + children.forEach((child) => { + child.parentRoute = r + }) + + return r + } + + const fullPath = joinPaths([parentPath, route]) + + return { + id: fullPath, + path: route, + fullPath, + } + }) +} + +function createRouteTree(pathOrChildren: Array): TestRoute { + return { + id: '__root__', + fullPath: '', + isRoot: true, + path: undefined, + children: createRoute(pathOrChildren, ''), + } +} + +const routeTree = createRouteTree([ + '/', + '/users/profile/settings', // static-deep (longest static path) + '/users/profile', // static-medium (medium static path) + '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) + '/users/$id', // param-simple (plain param) + '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) + '/files/$', // wildcard (lowest priority) + '/about', // static-shallow (shorter static path) + '/a/profile/settings', + '/a/profile', + '/a/user-{$id}', + '/a/$id', + '/a/{-$slug}', + '/a/$', + '/a', + '/b/profile/settings', + '/b/profile', + '/b/user-{$id}', + '/b/$id', + '/b/{-$slug}', + '/b/$', + '/b', + '/foo/bar/$id', + '/foo/$id/bar', + '/foo/$bar', + '/foo/$bar/', + '/foo/{-$bar}/qux', + '/$id/bar/foo', + '/$id/foo/bar', + '/a/b/c/d/e/f', + '/beep/boop', + '/one/two', + '/one', + '/z/y/x/w', + '/z/y/x/v', + '/z/y/x/u', + '/z/y/x', + '/images/thumb_{$}', // wildcard with prefix + '/logs/{$}.txt', // wildcard with suffix + '/cache/temp_{$}.log', // wildcard with prefix and suffix +]) + +const result = processRouteTree({ routeTree }) + +function originalMatcher(from: string, fuzzy?: boolean): readonly [string, Record] | undefined { + let match + for (const route of result.flatRoutes) { + const result = matchPathname('/', from, { to: route.fullPath, fuzzy }) + if (result) { + match = [route.fullPath, result] as const + break + } + } + return match +} + +describe('work in progress', () => { + it('is ordered', () => { + expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` + [ + "/a/b/c/d/e/f", + "/z/y/x/u", + "/z/y/x/v", + "/z/y/x/w", + "/a/profile/settings", + "/b/profile/settings", + "/users/profile/settings", + "/z/y/x", + "/foo/bar/$id", + "/a/profile", + "/b/profile", + "/beep/boop", + "/one/two", + "/users/profile", + "/foo/$id/bar", + "/foo/{-$bar}/qux", + "/a/user-{$id}", + "/api/user-{$id}", + "/b/user-{$id}", + "/foo/$bar/", + "/a/$id", + "/b/$id", + "/foo/$bar", + "/users/$id", + "/a/{-$slug}", + "/b/{-$slug}", + "/posts/{-$slug}", + "/cache/temp_{$}.log", + "/images/thumb_{$}", + "/logs/{$}.txt", + "/a/$", + "/b/$", + "/files/$", + "/a", + "/about", + "/b", + "/one", + "/", + "/$id/bar/foo", + "/$id/foo/bar", + ] + `) + }) + + const parsedRoutes = result.flatRoutes.map((route) => ({ + path: route.fullPath, + segments: parsePathname(route.fullPath), + })) + + type ParsedRoute = { + path: string + segments: ReturnType + } + + + // we duplicate routes that end in a static `/`, so they're also matched if that final `/` is not present + function prepareIndexRoutes( + parsedRoutes: Array, + ): Array { + const result: Array = [] + for (const route of parsedRoutes) { + result.push(route) + const last = route.segments.at(-1)! + if (route.segments.length > 1 && last.type === SEGMENT_TYPE_PATHNAME && last.value === '/') { + const clone: ParsedRoute = { + ...route, + segments: route.segments.slice(0, -1), + } + result.push(clone) + } + } + return result + } + + // we replace routes w/ optional params, with + // - 1 version where it's a regular param + // - 1 version where it's removed entirely + function prepareOptionalParams( + parsedRoutes: Array, + ): Array { + const result: Array = [] + for (const route of parsedRoutes) { + const index = route.segments.findIndex( + (s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM, + ) + if (index === -1) { + result.push(route) + continue + } + // for every optional param in the route, we need to push a version of the route without it, and a version of the route with it as a regular param + // example: + // /foo/{-$bar}/qux => [/foo/qux, /foo/$bar/qux] + // /a/{-$b}/c/{-$d} => [/a/c, /a/c/$d, /a/$b/c, /a/$b/c/$d] + const withRegular: ParsedRoute = { + ...route, + segments: route.segments.map((s, i) => + i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s, + ), + } + const withoutOptional: ParsedRoute = { + ...route, + segments: route.segments.filter((_, i) => i !== index), + } + const chunk = prepareOptionalParams([withRegular, withoutOptional]) + result.push(...chunk) + } + return result + } + + type Condition = + | { key: string, type: 'static', index: number, value: string } + | { key: string, type: 'length', direction: 'eq' | 'gte' | 'lte', value: number } + | { key: string, type: 'startsWith', index: number, value: string } + | { key: string, type: 'endsWith', index: number, value: string } + | { key: string, type: 'globalEndsWith', value: string } + + function toConditions(routes: Array) { + return routes.map((route) => { + const conditions: Array = [] + + let hasWildcard = false + let minLength = 0 + for (let i = 0; i < route.segments.length; i++) { + const segment = route.segments[i]! + if (segment.type === SEGMENT_TYPE_PATHNAME) { + minLength += 1 + if (i === 0 && segment.value === '/') continue // skip leading slash + conditions.push({ type: 'static', index: i, value: segment.value, key: `static_${i}_${segment.value}` }) + continue + } + if (segment.type === SEGMENT_TYPE_PARAM) { + minLength += 1 + if (segment.prefixSegment) { + conditions.push({ type: 'startsWith', index: i, value: segment.prefixSegment, key: `startsWith_${i}_${segment.prefixSegment}` }) + } + if (segment.suffixSegment) { + conditions.push({ type: 'endsWith', index: i, value: segment.suffixSegment, key: `endsWith_${i}_${segment.suffixSegment}` }) + } + continue + } + if (segment.type === SEGMENT_TYPE_WILDCARD) { + hasWildcard = true + if (segment.prefixSegment) { + conditions.push({ type: 'startsWith', index: i, value: segment.prefixSegment, key: `startsWith_${i}_${segment.prefixSegment}` }) + } + if (segment.suffixSegment) { + conditions.push({ type: 'globalEndsWith', value: segment.suffixSegment, key: `globalEndsWith_${i}_${segment.suffixSegment}` }) + } + if (segment.suffixSegment || segment.prefixSegment) { + minLength += 1 + } + continue + } + throw new Error(`Unhandled segment type: ${segment.type}`) + } + + if (hasWildcard) { + conditions.push({ type: 'length', direction: 'gte', value: minLength, key: `length_gte_${minLength}` }) + } else { + conditions.push({ type: 'length', direction: 'eq', value: minLength, key: `length_eq_${minLength}` }) + } + + return { + ...route, + conditions, + } + }) + } + + + // ////////////////////////////////// + // build a flat tree of all routes // + // ////////////////////////////////// + + type LeafNode = { type: 'leaf', conditions: Array, route: ParsedRoute, parent: BranchNode | RootNode } + type RootNode = { type: 'root', children: Array } + type BranchNode = { type: 'branch', conditions: Array, children: Array, parent: BranchNode | RootNode } + + const tree: RootNode = { type: 'root', children: [] } + const all = toConditions(prepareOptionalParams(prepareIndexRoutes(parsedRoutes))) + for (const { conditions, path, segments } of all) { + tree.children.push({ type: 'leaf', route: { path, segments }, parent: tree, conditions }) + } + + expandTree(tree) + contractTree(tree) + + + + + console.log('Tree built with', all.length, 'routes and', tree.children.length, 'top-level nodes') + + /** + * recursively expand each node of the tree until there is only one child left + */ + function expandTree(tree: RootNode) { + const stack: Array = [tree] + while (stack.length > 0) { + const node = stack.shift()! + if (node.children.length <= 1) continue + + const resolved = new Set() + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]! + if (resolved.has(child)) continue + + // segment-based conditions should try to group as many children as possible + const bestCondition = findBestCondition(node, i, node.children.length - i - 1) + // length-based conditions should try to group as few children as possible + const bestLength = findBestLength(node, i, 0) + + if (bestCondition.score === Infinity && bestLength.score === Infinity) { + // no grouping possible, just add the child as is + resolved.add(child) + continue + } + + const selected = bestCondition.score < bestLength.score ? bestCondition : bestLength + const condition = selected.condition! + const newNode: BranchNode = { + type: 'branch', + conditions: [condition], + children: selected.candidates, + parent: node, + } + node.children.splice(i, selected.candidates.length, newNode) + stack.push(newNode) + resolved.add(newNode) + for (const c of selected.candidates) { + c.conditions = c.conditions.filter((sc) => sc.key !== condition.key) + } + + // find all conditions that are shared by all candidates, and lift them to the new node + for (const condition of newNode.children[0]!.conditions) { + if (newNode.children.every((c) => c.conditions.some((sc) => sc.key === condition.key))) { + newNode.conditions.push(condition) + } + } + for (let i = 1; i < newNode.conditions.length; i++) { + const condition = newNode.conditions[i]! + for (const c of newNode.children) { + c.conditions = c.conditions.filter((sc) => sc.key !== condition.key) + } + } + } + } + } + + /** + * recursively shorten branches that have a single child into a leaf node + */ + function contractTree(tree: RootNode) { + const stack = tree.children.filter((c) => c.type === 'branch') + while (stack.length > 0) { + const node = stack.pop()! + if (node.children.length === 1) { + const child = node.children[0]! + node.parent.children.splice(node.parent.children.indexOf(node), 1, child) + child.parent = node.parent + child.conditions = [...node.conditions, ...child.conditions] + + // reduce length-based conditions into a single condition + const lengthConditions = child.conditions.filter(c => c.type === 'length') + if (lengthConditions.some(c => c.direction === 'eq')) { + for (const c of lengthConditions) { + if (c.direction === 'gte') { + child.conditions = child.conditions.filter(sc => sc.key !== c.key) + } + } + } else if (lengthConditions.length > 0) { + const minLength = Math.min(...lengthConditions.map(c => c.value)) + child.conditions = child.conditions.filter(c => c.type !== 'length') + child.conditions.push({ type: 'length', direction: 'eq', value: minLength, key: `length_eq_${minLength}` }) + } + } + for (const child of node.children) { + if (child.type === 'branch') { + stack.push(child) + } + } + } + } + + function printTree(node: RootNode | BranchNode | LeafNode) { + let str = '' + if (node.type === 'root') { + for (const child of node.children) { + str += printTree(child) + } + return str + } + if (node.conditions.length) { + str += 'if (' + str += printConditions(node.conditions) + str += ')' + } + if (node.type === 'branch') { + if (node.conditions.length && node.children.length) str += `{` + for (const child of node.children) { + str += printTree(child) + } + if (node.conditions.length && node.children.length) str += `}` + } else { + str += printRoute(node.route) + } + return str + } + + function printConditions(conditions: Array) { + const lengths = conditions.filter((c) => c.type === 'length') + const segment = conditions.filter((c) => c.type !== 'length') + const results: Array = [] + if (lengths.length > 1) { + const exact = lengths.find((c) => c.direction === 'eq') + if (exact) { + results.push(printCondition(exact)) + } else { + results.push(printCondition(lengths[0]!)) + } + } else if (lengths.length === 1) { + results.push(printCondition(lengths[0]!)) + } + for (const c of segment) { + results.push(printCondition(c)) + } + return results.join(' && ') + } + + function printCondition(condition: Condition) { + switch (condition.type) { + case 'static': + return `s${condition.index} === '${condition.value}'` + case 'length': + if (condition.direction === 'eq') { + return `length(${condition.value})` + } else if (condition.direction === 'gte') { + return `l >= ${condition.value}` + } + break + case 'startsWith': + return `s${condition.index}.startsWith('${condition.value}')` + case 'endsWith': + return `s${condition.index}.endsWith('${condition.value}')` + case 'globalEndsWith': + return `s[l - 1].endsWith('${condition.value}')` + } + throw new Error(`Unhandled condition type: ${condition.type}`) + } + + function printRoute(route: ParsedRoute) { + const length = route.segments.length + /** + * return [ + * route.path, + * { foo: s2, bar: s4 } + * ] + */ + let result = `{` + let hasWildcard = false + for (let i = 0; i < route.segments.length; i++) { + const segment = route.segments[i]! + if (segment.type === SEGMENT_TYPE_PARAM) { + const name = segment.value.replace(/^\$/, '') + const value = `s${i}` + if (segment.prefixSegment && segment.suffixSegment) { + result += `${name}: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + } else if (segment.prefixSegment) { + result += `${name}: ${value}.slice(${segment.prefixSegment.length}), ` + } else if (segment.suffixSegment) { + result += `${name}: ${value}.slice(0, -${segment.suffixSegment.length}), ` + } else { + result += `${name}: ${value}, ` + } + } else if (segment.type === SEGMENT_TYPE_WILDCARD) { + hasWildcard = true + const value = `s.slice(${i}).join('/')` + if (segment.prefixSegment && segment.suffixSegment) { + result += `_splat: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + result += `'*': ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + } else if (segment.prefixSegment) { + result += `_splat: ${value}.slice(${segment.prefixSegment.length}), ` + result += `'*': ${value}.slice(${segment.prefixSegment.length}), ` + } else if (segment.suffixSegment) { + result += `_splat: ${value}.slice(0, -${segment.suffixSegment.length}), ` + result += `'*': ${value}.slice(0, -${segment.suffixSegment.length}), ` + } else { + result += `_splat: ${value}, ` + result += `'*': ${value}, ` + } + break + } + } + result += `}` + return hasWildcard + ? `return ['${route.path}', ${result}];` + : `return ['${route.path}', params(${result}, ${length})];` + } + + function findBestCondition(node: RootNode | BranchNode, i: number, target: number) { + const child = node.children[i]! + let bestCondition: Condition | undefined + let bestMatchScore = Infinity + let bestCandidates = [child] + for (const c of child.conditions) { + const candidates = [child] + for (let j = i + 1; j < node.children.length; j++) { + const sibling = node.children[j]! + if (sibling.conditions.some((sc) => sc.key === c.key)) { + candidates.push(sibling) + } else { + break + } + } + const score = Math.abs(candidates.length - target) + if (score < bestMatchScore) { + bestMatchScore = score + bestCondition = c + bestCandidates = candidates + } + } + + return { score: bestMatchScore, condition: bestCondition, candidates: bestCandidates } + } + + function findBestLength(node: RootNode | BranchNode, i: number, target: number) { + const child = node.children[i]! + const childLengthCondition = child.conditions.find(c => c.type === 'length') + if (!childLengthCondition) { + return { score: Infinity, condition: undefined, candidates: [child] } + } + let currentMinLength = 1 + let exactLength = false + if (node.type !== 'root') { + let n = node + do { + const lengthCondition = n.conditions.find(c => c.type === 'length') + if (!lengthCondition) continue + if (lengthCondition.direction === 'eq') { + exactLength = true + break + } + if (lengthCondition.direction === 'gte') { + currentMinLength = lengthCondition.value + break + } + } while (n.parent.type === 'branch' && (n = n.parent)) + } + if (exactLength || currentMinLength >= childLengthCondition.value) { + return { score: Infinity, condition: undefined, candidates: [child] } + } + let bestMatchScore = Infinity + let bestLength: number | undefined + let bestCandidates = [child] + let bestExact = false + for (let l = currentMinLength + 1; l <= childLengthCondition.value; l++) { + const candidates = [child] + let exact = childLengthCondition.direction === 'eq' && l === childLengthCondition.value + for (let j = i + 1; j < node.children.length; j++) { + const sibling = node.children[j]! + const lengthCondition = sibling.conditions.find(c => c.type === 'length') + if (!lengthCondition) break + if (lengthCondition.value < l) break + candidates.push(sibling) + exact &&= lengthCondition.direction === 'eq' && lengthCondition.value === l + } + const score = Math.abs(candidates.length - target) + if (score < bestMatchScore) { + bestMatchScore = score + bestLength = l + bestCandidates = candidates + bestExact = exact + } + } + const condition: Condition = { type: 'length', direction: bestExact ? 'eq' : 'gte', value: bestLength!, key: `length_${bestExact ? 'eq' : 'gte'}_${bestLength}` } + return { score: bestMatchScore, condition, candidates: bestCandidates } + } + + let fn = 'const s = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' + fn += 'const l = s.length;' + fn += 'const length = fuzzy ? (n) => l >= n : (n) => l === n;' + fn += 'const params = fuzzy ? (p, n) => { if (n && l > n) p[\'**\'] = s.slice(n).join(\'/\'); return p } : (p) => p;' + const max = parsedRoutes.reduce( + (max, r) => Math.max(max, r.segments.length), + 0, + ) + if (max > 0) fn += `const [,${Array.from({ length: max }, (_, i) => `s${i + 1}`).join(', ')}] = s;` + fn += printTree(tree) + + + + it('generates a matching function', async () => { + expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` + "const s = parsePathname(from[0] === "/" ? from : "/" + from).map( + (s) => s.value, + ); + const l = s.length; + const length = fuzzy ? (n) => l >= n : (n) => l === n; + const params = fuzzy + ? (p, n) => { + if (n && l > n) p["**"] = s.slice(n).join("/"); + return p; + } + : (p) => p; + const [, s1, s2, s3, s4, s5, s6, s7] = s; + if ( + length(7) && + s1 === "a" && + s2 === "b" && + s3 === "c" && + s4 === "d" && + s5 === "e" && + s6 === "f" + ) + return ["/a/b/c/d/e/f", params({}, 7)]; + if (length(5) && s1 === "z" && s2 === "y" && s3 === "x") { + if (s4 === "u") return ["/z/y/x/u", params({}, 5)]; + if (s4 === "v") return ["/z/y/x/v", params({}, 5)]; + if (s4 === "w") return ["/z/y/x/w", params({}, 5)]; + } + if (length(4)) { + if (s2 === "profile" && s3 === "settings") { + if (s1 === "a") return ["/a/profile/settings", params({}, 4)]; + if (s1 === "b") return ["/b/profile/settings", params({}, 4)]; + if (s1 === "users") return ["/users/profile/settings", params({}, 4)]; + } + if (s1 === "z" && s2 === "y" && s3 === "x") return ["/z/y/x", params({}, 4)]; + if (s1 === "foo" && s2 === "bar") + return ["/foo/bar/$id", params({ id: s3 }, 4)]; + } + if (l >= 3) { + if (length(3)) { + if (s2 === "profile") { + if (s1 === "a") return ["/a/profile", params({}, 3)]; + if (s1 === "b") return ["/b/profile", params({}, 3)]; + } + if (s1 === "beep" && s2 === "boop") return ["/beep/boop", params({}, 3)]; + if (s1 === "one" && s2 === "two") return ["/one/two", params({}, 3)]; + if (s1 === "users" && s2 === "profile") + return ["/users/profile", params({}, 3)]; + } + if (length(4) && s1 === "foo") { + if (s3 === "bar") return ["/foo/$id/bar", params({ id: s2 }, 4)]; + if (s3 === "qux") return ["/foo/{-$bar}/qux", params({ bar: s2 }, 4)]; + } + if (length(3)) { + if (s1 === "foo" && s2 === "qux") + return ["/foo/{-$bar}/qux", params({}, 3)]; + if (s1 === "a" && s2.startsWith("user-")) + return ["/a/user-{$id}", params({ id: s2.slice(5) }, 3)]; + if (s1 === "api" && s2.startsWith("user-")) + return ["/api/user-{$id}", params({ id: s2.slice(5) }, 3)]; + if (s1 === "b" && s2.startsWith("user-")) + return ["/b/user-{$id}", params({ id: s2.slice(5) }, 3)]; + } + if (length(4) && s1 === "foo" && s3 === "/") + return ["/foo/$bar/", params({ bar: s2 }, 4)]; + if (length(3)) { + if (s1 === "foo") return ["/foo/$bar/", params({ bar: s2 }, 3)]; + if (s1 === "a") return ["/a/$id", params({ id: s2 }, 3)]; + if (s1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; + if (s1 === "foo") return ["/foo/$bar", params({ bar: s2 }, 3)]; + if (s1 === "users") return ["/users/$id", params({ id: s2 }, 3)]; + if (s1 === "a") return ["/a/{-$slug}", params({ slug: s2 }, 3)]; + } + } + if (l >= 2) { + if (length(2) && s1 === "a") return ["/a/{-$slug}", params({}, 2)]; + if (length(3) && s1 === "b") return ["/b/{-$slug}", params({ slug: s2 }, 3)]; + if (length(2) && s1 === "b") return ["/b/{-$slug}", params({}, 2)]; + if (length(3) && s1 === "posts") + return ["/posts/{-$slug}", params({ slug: s2 }, 3)]; + if (length(2) && s1 === "posts") return ["/posts/{-$slug}", params({}, 2)]; + if (l >= 3) { + if (s1 === "cache" && s2.startsWith("temp_") && s[l - 1].endsWith(".log")) + return [ + "/cache/temp_{$}.log", + { + _splat: s.slice(2).join("/").slice(5, -4), + "*": s.slice(2).join("/").slice(5, -4), + }, + ]; + if (s1 === "images" && s2.startsWith("thumb_")) + return [ + "/images/thumb_{$}", + { + _splat: s.slice(2).join("/").slice(6), + "*": s.slice(2).join("/").slice(6), + }, + ]; + if (s1 === "logs" && s[l - 1].endsWith(".txt")) + return [ + "/logs/{$}.txt", + { + _splat: s.slice(2).join("/").slice(0, -4), + "*": s.slice(2).join("/").slice(0, -4), + }, + ]; + } + if (s1 === "a") + return [ + "/a/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + if (s1 === "b") + return [ + "/b/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + if (s1 === "files") + return [ + "/files/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + if (length(2)) { + if (s1 === "a") return ["/a", params({}, 2)]; + if (s1 === "about") return ["/about", params({}, 2)]; + if (s1 === "b") return ["/b", params({}, 2)]; + if (s1 === "one") return ["/one", params({}, 2)]; + } + } + if (length(1)) return ["/", params({}, 1)]; + if (length(4) && s2 === "bar" && s3 === "foo") + return ["/$id/bar/foo", params({ id: s1 }, 4)]; + if (length(4) && s2 === "foo" && s3 === "bar") + return ["/$id/foo/bar", params({ id: s1 }, 4)]; + " + `) + }) + + const buildMatcher = new Function('parsePathname', 'from', 'fuzzy', fn) as ( + parser: typeof parsePathname, + from: string, + fuzzy?: boolean + ) => readonly [path: string, params: Record] | undefined + + test.each([ + '', + '/', + '/users/profile/settings', + '/foo/123', + '/foo/123/', + '/b/123', + '/foo/qux', + '/foo/123/qux', + '/a/user-123', + '/a/123', + '/a/123/more', + '/files', + '/files/hello-world.txt', + '/something/foo/bar', + '/files/deep/nested/file.json', + '/files/', + '/images/thumb_200x300.jpg', + '/logs/2020/01/01/error.txt', + '/cache/temp_user456.log', + '/a/b/c/d/e', + ])('matching %s', (s) => { + const originalMatch = originalMatcher(s) + const buildMatch = buildMatcher(parsePathname, s) + console.log( + `matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]}`, + ) + expect(buildMatch).toEqual(originalMatch) + }) + + // WARN: some of these don't work yet, they're just here to show the differences + test.each([ + '/users/profile/settings/hello', + '/a/b/c/d/e/f/g', + '/foo/bar/baz', + '/foo/bar/baz/qux', + ])('fuzzy matching %s', (s) => { + const originalMatch = originalMatcher(s, true) + const buildMatch = buildMatcher(parsePathname, s, true) + console.log( + `fuzzy matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]} ${JSON.stringify(buildMatch?.[1])}`, + ) + expect(buildMatch).toEqual(originalMatch) + }) +}) From 173ad099fc504f8479ba669e842ba03c82ee57eb Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 11:45:31 +0200 Subject: [PATCH 37/57] support case insensitive maatching --- .../tests/builtWithParams3.test.ts | 181 +++++++++++------- 1 file changed, 111 insertions(+), 70 deletions(-) diff --git a/packages/router-core/tests/builtWithParams3.test.ts b/packages/router-core/tests/builtWithParams3.test.ts index b05573b5370..4d001ea1b2b 100644 --- a/packages/router-core/tests/builtWithParams3.test.ts +++ b/packages/router-core/tests/builtWithParams3.test.ts @@ -240,7 +240,8 @@ describe('work in progress', () => { } type Condition = - | { key: string, type: 'static', index: number, value: string } + | { key: string, type: 'static-insensitive', index: number, value: string } + | { key: string, type: 'static-sensitive', index: number, value: string } | { key: string, type: 'length', direction: 'eq' | 'gte' | 'lte', value: number } | { key: string, type: 'startsWith', index: number, value: string } | { key: string, type: 'endsWith', index: number, value: string } @@ -257,7 +258,12 @@ describe('work in progress', () => { if (segment.type === SEGMENT_TYPE_PATHNAME) { minLength += 1 if (i === 0 && segment.value === '/') continue // skip leading slash - conditions.push({ type: 'static', index: i, value: segment.value, key: `static_${i}_${segment.value}` }) + const value = segment.value + if (route.caseSensitive) { + conditions.push({ type: 'static-sensitive', index: i, value, key: `static_sensitive_${i}_${value}` }) + } else { + conditions.push({ type: 'static-insensitive', index: i, value: value.toLowerCase(), key: `static_insensitive_${i}_${value.toLowerCase()}` }) + } continue } if (segment.type === SEGMENT_TYPE_PARAM) { @@ -317,8 +323,9 @@ describe('work in progress', () => { expandTree(tree) contractTree(tree) - - + let fn = '' + fn += printHead(all) + fn += printTree(tree) console.log('Tree built with', all.length, 'routes and', tree.children.length, 'top-level nodes') @@ -460,8 +467,10 @@ describe('work in progress', () => { function printCondition(condition: Condition) { switch (condition.type) { - case 'static': + case 'static-sensitive': return `s${condition.index} === '${condition.value}'` + case 'static-insensitive': + return `sc${condition.index} === '${condition.value}'` case 'length': if (condition.direction === 'eq') { return `length(${condition.value})` @@ -528,6 +537,41 @@ describe('work in progress', () => { : `return ['${route.path}', params(${result}, ${length})];` } + function printHead(routes: Array }>) { + let head = 'const s = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' + head += 'const l = s.length;' + + // the `length()` function does exact match by default, but greater-than-or-equal match if `fuzzy` is true + head += 'const length = fuzzy ? (n) => l >= n : (n) => l === n;' + + // the `params()` function returns the params object, and if `fuzzy` is true, it also adds a `**` property with the remaining segments + head += 'const params = fuzzy ? (p, n) => { if (n && l > n) p[\'**\'] = s.slice(n).join(\'/\'); return p } : (p) => p;' + + // extract all segments from the input + // const [, s1, s2, s3] = s; + const max = routes.reduce( + (max, r) => Math.max(max, r.segments.length), + 0, + ) + if (max > 0) head += `const [,${Array.from({ length: max - 1 }, (_, i) => `s${i + 1}`).join(', ')}] = s;` + + // add toLowerCase version of each segment that is needed in a case-insensitive match + // const sc1 = s1?.toLowerCase(); + const caseInsensitiveSegments = new Set() + for (const route of routes) { + for (const condition of route.conditions) { + if (condition.type === 'static-insensitive') { + caseInsensitiveSegments.add(condition.index) + } + } + } + for (const index of caseInsensitiveSegments) { + head += `const sc${index} = s${index}?.toLowerCase();` + } + + return head + } + function findBestCondition(node: RootNode | BranchNode, i: number, target: number) { const child = node.children[i]! let bestCondition: Condition | undefined @@ -607,17 +651,6 @@ describe('work in progress', () => { return { score: bestMatchScore, condition, candidates: bestCandidates } } - let fn = 'const s = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' - fn += 'const l = s.length;' - fn += 'const length = fuzzy ? (n) => l >= n : (n) => l === n;' - fn += 'const params = fuzzy ? (p, n) => { if (n && l > n) p[\'**\'] = s.slice(n).join(\'/\'); return p } : (p) => p;' - const max = parsedRoutes.reduce( - (max, r) => Math.max(max, r.segments.length), - 0, - ) - if (max > 0) fn += `const [,${Array.from({ length: max }, (_, i) => `s${i + 1}`).join(', ')}] = s;` - fn += printTree(tree) - it('generates a matching function', async () => { @@ -633,77 +666,84 @@ describe('work in progress', () => { return p; } : (p) => p; - const [, s1, s2, s3, s4, s5, s6, s7] = s; + const [, s1, s2, s3, s4, s5, s6] = s; + const sc1 = s1?.toLowerCase(); + const sc2 = s2?.toLowerCase(); + const sc3 = s3?.toLowerCase(); + const sc4 = s4?.toLowerCase(); + const sc5 = s5?.toLowerCase(); + const sc6 = s6?.toLowerCase(); if ( length(7) && - s1 === "a" && - s2 === "b" && - s3 === "c" && - s4 === "d" && - s5 === "e" && - s6 === "f" + sc1 === "a" && + sc2 === "b" && + sc3 === "c" && + sc4 === "d" && + sc5 === "e" && + sc6 === "f" ) return ["/a/b/c/d/e/f", params({}, 7)]; - if (length(5) && s1 === "z" && s2 === "y" && s3 === "x") { - if (s4 === "u") return ["/z/y/x/u", params({}, 5)]; - if (s4 === "v") return ["/z/y/x/v", params({}, 5)]; - if (s4 === "w") return ["/z/y/x/w", params({}, 5)]; + if (length(5) && sc1 === "z" && sc2 === "y" && sc3 === "x") { + if (sc4 === "u") return ["/z/y/x/u", params({}, 5)]; + if (sc4 === "v") return ["/z/y/x/v", params({}, 5)]; + if (sc4 === "w") return ["/z/y/x/w", params({}, 5)]; } if (length(4)) { - if (s2 === "profile" && s3 === "settings") { - if (s1 === "a") return ["/a/profile/settings", params({}, 4)]; - if (s1 === "b") return ["/b/profile/settings", params({}, 4)]; - if (s1 === "users") return ["/users/profile/settings", params({}, 4)]; + if (sc2 === "profile" && sc3 === "settings") { + if (sc1 === "a") return ["/a/profile/settings", params({}, 4)]; + if (sc1 === "b") return ["/b/profile/settings", params({}, 4)]; + if (sc1 === "users") return ["/users/profile/settings", params({}, 4)]; } - if (s1 === "z" && s2 === "y" && s3 === "x") return ["/z/y/x", params({}, 4)]; - if (s1 === "foo" && s2 === "bar") + if (sc1 === "z" && sc2 === "y" && sc3 === "x") + return ["/z/y/x", params({}, 4)]; + if (sc1 === "foo" && sc2 === "bar") return ["/foo/bar/$id", params({ id: s3 }, 4)]; } if (l >= 3) { if (length(3)) { - if (s2 === "profile") { - if (s1 === "a") return ["/a/profile", params({}, 3)]; - if (s1 === "b") return ["/b/profile", params({}, 3)]; + if (sc2 === "profile") { + if (sc1 === "a") return ["/a/profile", params({}, 3)]; + if (sc1 === "b") return ["/b/profile", params({}, 3)]; } - if (s1 === "beep" && s2 === "boop") return ["/beep/boop", params({}, 3)]; - if (s1 === "one" && s2 === "two") return ["/one/two", params({}, 3)]; - if (s1 === "users" && s2 === "profile") + if (sc1 === "beep" && sc2 === "boop") return ["/beep/boop", params({}, 3)]; + if (sc1 === "one" && sc2 === "two") return ["/one/two", params({}, 3)]; + if (sc1 === "users" && sc2 === "profile") return ["/users/profile", params({}, 3)]; } - if (length(4) && s1 === "foo") { - if (s3 === "bar") return ["/foo/$id/bar", params({ id: s2 }, 4)]; - if (s3 === "qux") return ["/foo/{-$bar}/qux", params({ bar: s2 }, 4)]; + if (length(4) && sc1 === "foo") { + if (sc3 === "bar") return ["/foo/$id/bar", params({ id: s2 }, 4)]; + if (sc3 === "qux") return ["/foo/{-$bar}/qux", params({ bar: s2 }, 4)]; } if (length(3)) { - if (s1 === "foo" && s2 === "qux") + if (sc1 === "foo" && sc2 === "qux") return ["/foo/{-$bar}/qux", params({}, 3)]; - if (s1 === "a" && s2.startsWith("user-")) + if (sc1 === "a" && s2.startsWith("user-")) return ["/a/user-{$id}", params({ id: s2.slice(5) }, 3)]; - if (s1 === "api" && s2.startsWith("user-")) + if (sc1 === "api" && s2.startsWith("user-")) return ["/api/user-{$id}", params({ id: s2.slice(5) }, 3)]; - if (s1 === "b" && s2.startsWith("user-")) + if (sc1 === "b" && s2.startsWith("user-")) return ["/b/user-{$id}", params({ id: s2.slice(5) }, 3)]; } - if (length(4) && s1 === "foo" && s3 === "/") + if (length(4) && sc1 === "foo" && sc3 === "/") return ["/foo/$bar/", params({ bar: s2 }, 4)]; if (length(3)) { - if (s1 === "foo") return ["/foo/$bar/", params({ bar: s2 }, 3)]; - if (s1 === "a") return ["/a/$id", params({ id: s2 }, 3)]; - if (s1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; - if (s1 === "foo") return ["/foo/$bar", params({ bar: s2 }, 3)]; - if (s1 === "users") return ["/users/$id", params({ id: s2 }, 3)]; - if (s1 === "a") return ["/a/{-$slug}", params({ slug: s2 }, 3)]; + if (sc1 === "foo") return ["/foo/$bar/", params({ bar: s2 }, 3)]; + if (sc1 === "a") return ["/a/$id", params({ id: s2 }, 3)]; + if (sc1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; + if (sc1 === "foo") return ["/foo/$bar", params({ bar: s2 }, 3)]; + if (sc1 === "users") return ["/users/$id", params({ id: s2 }, 3)]; + if (sc1 === "a") return ["/a/{-$slug}", params({ slug: s2 }, 3)]; } } if (l >= 2) { - if (length(2) && s1 === "a") return ["/a/{-$slug}", params({}, 2)]; - if (length(3) && s1 === "b") return ["/b/{-$slug}", params({ slug: s2 }, 3)]; - if (length(2) && s1 === "b") return ["/b/{-$slug}", params({}, 2)]; - if (length(3) && s1 === "posts") + if (length(2) && sc1 === "a") return ["/a/{-$slug}", params({}, 2)]; + if (length(3) && sc1 === "b") return ["/b/{-$slug}", params({ slug: s2 }, 3)]; + if (length(2) && sc1 === "b") return ["/b/{-$slug}", params({}, 2)]; + if (length(3) && sc1 === "posts") return ["/posts/{-$slug}", params({ slug: s2 }, 3)]; - if (length(2) && s1 === "posts") return ["/posts/{-$slug}", params({}, 2)]; + if (length(2) && sc1 === "posts") return ["/posts/{-$slug}", params({}, 2)]; if (l >= 3) { - if (s1 === "cache" && s2.startsWith("temp_") && s[l - 1].endsWith(".log")) + if (sc1 === "cache" && s2.startsWith("temp_") && s[l - 1].endsWith(".log")) return [ "/cache/temp_{$}.log", { @@ -711,7 +751,7 @@ describe('work in progress', () => { "*": s.slice(2).join("/").slice(5, -4), }, ]; - if (s1 === "images" && s2.startsWith("thumb_")) + if (sc1 === "images" && s2.startsWith("thumb_")) return [ "/images/thumb_{$}", { @@ -719,7 +759,7 @@ describe('work in progress', () => { "*": s.slice(2).join("/").slice(6), }, ]; - if (s1 === "logs" && s[l - 1].endsWith(".txt")) + if (sc1 === "logs" && s[l - 1].endsWith(".txt")) return [ "/logs/{$}.txt", { @@ -728,32 +768,32 @@ describe('work in progress', () => { }, ]; } - if (s1 === "a") + if (sc1 === "a") return [ "/a/$", { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, ]; - if (s1 === "b") + if (sc1 === "b") return [ "/b/$", { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, ]; - if (s1 === "files") + if (sc1 === "files") return [ "/files/$", { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, ]; if (length(2)) { - if (s1 === "a") return ["/a", params({}, 2)]; - if (s1 === "about") return ["/about", params({}, 2)]; - if (s1 === "b") return ["/b", params({}, 2)]; - if (s1 === "one") return ["/one", params({}, 2)]; + if (sc1 === "a") return ["/a", params({}, 2)]; + if (sc1 === "about") return ["/about", params({}, 2)]; + if (sc1 === "b") return ["/b", params({}, 2)]; + if (sc1 === "one") return ["/one", params({}, 2)]; } } if (length(1)) return ["/", params({}, 1)]; - if (length(4) && s2 === "bar" && s3 === "foo") + if (length(4) && sc2 === "bar" && sc3 === "foo") return ["/$id/bar/foo", params({ id: s1 }, 4)]; - if (length(4) && s2 === "foo" && s3 === "bar") + if (length(4) && sc2 === "foo" && sc3 === "bar") return ["/$id/foo/bar", params({ id: s1 }, 4)]; " `) @@ -770,6 +810,7 @@ describe('work in progress', () => { '/', '/users/profile/settings', '/foo/123', + '/FOO/123', '/foo/123/', '/b/123', '/foo/qux', From 50635c5f9a3a9dd77e081d2fe67b3b06a5fb5406 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 16:08:40 +0200 Subject: [PATCH 38/57] code comments --- .../tests/builtWithParams3.test.ts | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/router-core/tests/builtWithParams3.test.ts b/packages/router-core/tests/builtWithParams3.test.ts index 4d001ea1b2b..ac090debb97 100644 --- a/packages/router-core/tests/builtWithParams3.test.ts +++ b/packages/router-core/tests/builtWithParams3.test.ts @@ -112,6 +112,13 @@ const routeTree = createRouteTree([ '/cache/temp_{$}.log', // wildcard with prefix and suffix ]) +// required keys on a `route` object for `processRouteTree` to correctly generate `flatRoutes` +// - id +// - children +// - isRoot +// - path +// - fullPath + const result = processRouteTree({ routeTree }) function originalMatcher(from: string, fuzzy?: boolean): readonly [string, Record] | undefined { @@ -247,6 +254,7 @@ describe('work in progress', () => { | { key: string, type: 'endsWith', index: number, value: string } | { key: string, type: 'globalEndsWith', value: string } + // each segment of a route can have zero or more conditions that needs to be met for the route to match function toConditions(routes: Array) { return routes.map((route) => { const conditions: Array = [] @@ -305,17 +313,14 @@ describe('work in progress', () => { }) } - - // ////////////////////////////////// - // build a flat tree of all routes // - // ////////////////////////////////// - type LeafNode = { type: 'leaf', conditions: Array, route: ParsedRoute, parent: BranchNode | RootNode } type RootNode = { type: 'root', children: Array } type BranchNode = { type: 'branch', conditions: Array, children: Array, parent: BranchNode | RootNode } - const tree: RootNode = { type: 'root', children: [] } const all = toConditions(prepareOptionalParams(prepareIndexRoutes(parsedRoutes))) + + // We start by building a flat tree with all routes as leaf nodes, all children of the root node. + const tree: RootNode = { type: 'root', children: [] } for (const { conditions, path, segments } of all) { tree.children.push({ type: 'leaf', route: { path, segments }, parent: tree, conditions }) } @@ -330,7 +335,26 @@ describe('work in progress', () => { console.log('Tree built with', all.length, 'routes and', tree.children.length, 'top-level nodes') /** - * recursively expand each node of the tree until there is only one child left + * Recursively expand each node of the tree until there is only one child left + * + * For each child node in a parent node, we try to find subsequent siblings that would share the same condition to be matched. + * If we find any, we group them together into a new branch node that replaces the original child node and the grouped siblings in the parent node. + * + * We repeat the process in each newly created branch node until there is only one child left in each branch node. + * + * This turns + * ``` + * if (a && b && c && d) return route1; + * if (a && b && e && f) return route2; + * ``` + * into + * ``` + * if (a && b) { + * if (c) { if (d) return route1; } + * if (e) { if (f) return route2; } + * } + * ``` + * */ function expandTree(tree: RootNode) { const stack: Array = [tree] @@ -386,7 +410,15 @@ describe('work in progress', () => { } /** - * recursively shorten branches that have a single child into a leaf node + * Recursively shorten branches that have a single child into a leaf node. + * + * For each branch node in the tree, if it has only one child, we can replace the branch node with that child node, + * and merge the conditions of the branch node into the child node. + * + * This turns + * `if (condition1) { if (condition2) { return route } }` + * into + * `if (condition1 && condition2) { return route }` */ function contractTree(tree: RootNode) { const stack = tree.children.filter((c) => c.type === 'branch') From 99678b51efc090d050c713732e61c235bea3dcf4 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 20:52:29 +0200 Subject: [PATCH 39/57] productize --- packages/router-core/src/compile-matcher.ts | 513 ++++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 packages/router-core/src/compile-matcher.ts diff --git a/packages/router-core/src/compile-matcher.ts b/packages/router-core/src/compile-matcher.ts new file mode 100644 index 00000000000..0c98b735e9f --- /dev/null +++ b/packages/router-core/src/compile-matcher.ts @@ -0,0 +1,513 @@ +import { parsePathname, SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD } from "./path" +import type { processRouteTree } from "./router" + + + +export function compileMatcher(flatRoutes: ReturnType['flatRoutes']) { + const parsedRoutes = flatRoutes.map((route) => ({ + path: route.fullPath, + segments: parsePathname(route.fullPath), + })) + + const all = toConditions(prepareOptionalParams(prepareIndexRoutes(parsedRoutes))) + + // We start by building a flat tree with all routes as leaf nodes, all children of the root node. + const tree: RootNode = { type: 'root', children: [] } + for (const { conditions, path, segments } of all) { + tree.children.push({ type: 'leaf', route: { path, segments }, parent: tree, conditions }) + } + + expandTree(tree) + contractTree(tree) + + let fn = '' + fn += printHead(all) + fn += printTree(tree) + + return `(parsePathname, from, fuzzy) => {${fn}}` +} + +type ParsedRoute = { + path: string + segments: ReturnType +} + + +// we duplicate routes that end in a static `/`, so they're also matched if that final `/` is not present +function prepareIndexRoutes( + parsedRoutes: Array, +): Array { + const result: Array = [] + for (const route of parsedRoutes) { + result.push(route) + const last = route.segments.at(-1)! + if (route.segments.length > 1 && last.type === SEGMENT_TYPE_PATHNAME && last.value === '/') { + const clone: ParsedRoute = { + ...route, + segments: route.segments.slice(0, -1), + } + result.push(clone) + } + } + return result +} + +// we replace routes w/ optional params, with +// - 1 version where it's a regular param +// - 1 version where it's removed entirely +function prepareOptionalParams( + parsedRoutes: Array, +): Array { + const result: Array = [] + for (const route of parsedRoutes) { + const index = route.segments.findIndex( + (s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM, + ) + if (index === -1) { + result.push(route) + continue + } + // for every optional param in the route, we need to push a version of the route without it, and a version of the route with it as a regular param + // example: + // /foo/{-$bar}/qux => [/foo/qux, /foo/$bar/qux] + // /a/{-$b}/c/{-$d} => [/a/c, /a/c/$d, /a/$b/c, /a/$b/c/$d] + const withRegular: ParsedRoute = { + ...route, + segments: route.segments.map((s, i) => + i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s, + ), + } + const withoutOptional: ParsedRoute = { + ...route, + segments: route.segments.filter((_, i) => i !== index), + } + const chunk = prepareOptionalParams([withRegular, withoutOptional]) + result.push(...chunk) + } + return result +} + +type Condition = + | { key: string, type: 'static-insensitive', index: number, value: string } + | { key: string, type: 'static-sensitive', index: number, value: string } + | { key: string, type: 'length', direction: 'eq' | 'gte' | 'lte', value: number } + | { key: string, type: 'startsWith', index: number, value: string } + | { key: string, type: 'endsWith', index: number, value: string } + | { key: string, type: 'globalEndsWith', value: string } + +// each segment of a route can have zero or more conditions that needs to be met for the route to match +function toConditions(routes: Array) { + return routes.map((route) => { + const conditions: Array = [] + + let hasWildcard = false + let minLength = 0 + for (let i = 0; i < route.segments.length; i++) { + const segment = route.segments[i]! + if (segment.type === SEGMENT_TYPE_PATHNAME) { + minLength += 1 + if (i === 0 && segment.value === '/') continue // skip leading slash + const value = segment.value + // @ts-expect-error -- not typed yet, i don't know how I'm gonna get this value here + if (route.caseSensitive) { + conditions.push({ type: 'static-sensitive', index: i, value, key: `static_sensitive_${i}_${value}` }) + } else { + conditions.push({ type: 'static-insensitive', index: i, value: value.toLowerCase(), key: `static_insensitive_${i}_${value.toLowerCase()}` }) + } + continue + } + if (segment.type === SEGMENT_TYPE_PARAM) { + minLength += 1 + if (segment.prefixSegment) { + conditions.push({ type: 'startsWith', index: i, value: segment.prefixSegment, key: `startsWith_${i}_${segment.prefixSegment}` }) + } + if (segment.suffixSegment) { + conditions.push({ type: 'endsWith', index: i, value: segment.suffixSegment, key: `endsWith_${i}_${segment.suffixSegment}` }) + } + continue + } + if (segment.type === SEGMENT_TYPE_WILDCARD) { + hasWildcard = true + if (segment.prefixSegment) { + conditions.push({ type: 'startsWith', index: i, value: segment.prefixSegment, key: `startsWith_${i}_${segment.prefixSegment}` }) + } + if (segment.suffixSegment) { + conditions.push({ type: 'globalEndsWith', value: segment.suffixSegment, key: `globalEndsWith_${i}_${segment.suffixSegment}` }) + } + if (segment.suffixSegment || segment.prefixSegment) { + minLength += 1 + } + continue + } + throw new Error(`Unhandled segment type: ${segment.type}`) + } + + if (hasWildcard) { + conditions.push({ type: 'length', direction: 'gte', value: minLength, key: `length_gte_${minLength}` }) + } else { + conditions.push({ type: 'length', direction: 'eq', value: minLength, key: `length_eq_${minLength}` }) + } + + return { + ...route, + conditions, + } + }) +} + +type LeafNode = { type: 'leaf', conditions: Array, route: ParsedRoute, parent: BranchNode | RootNode } +type RootNode = { type: 'root', children: Array } +type BranchNode = { type: 'branch', conditions: Array, children: Array, parent: BranchNode | RootNode } + + +/** + * Recursively expand each node of the tree until there is only one child left + * + * For each child node in a parent node, we try to find subsequent siblings that would share the same condition to be matched. + * If we find any, we group them together into a new branch node that replaces the original child node and the grouped siblings in the parent node. + * + * We repeat the process in each newly created branch node until there is only one child left in each branch node. + * + * This turns + * ``` + * if (a && b && c && d) return route1; + * if (a && b && e && f) return route2; + * ``` + * into + * ``` + * if (a && b) { + * if (c) { if (d) return route1; } + * if (e) { if (f) return route2; } + * } + * ``` + * + */ +function expandTree(tree: RootNode) { + const stack: Array = [tree] + while (stack.length > 0) { + const node = stack.shift()! + if (node.children.length <= 1) continue + + const resolved = new Set() + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]! + if (resolved.has(child)) continue + + // segment-based conditions should try to group as many children as possible + const bestCondition = findBestCondition(node, i, node.children.length - i - 1) + // length-based conditions should try to group as few children as possible + const bestLength = findBestLength(node, i, 0) + + if (bestCondition.score === Infinity && bestLength.score === Infinity) { + // no grouping possible, just add the child as is + resolved.add(child) + continue + } + + const selected = bestCondition.score < bestLength.score ? bestCondition : bestLength + const condition = selected.condition! + const newNode: BranchNode = { + type: 'branch', + conditions: [condition], + children: selected.candidates, + parent: node, + } + node.children.splice(i, selected.candidates.length, newNode) + stack.push(newNode) + resolved.add(newNode) + for (const c of selected.candidates) { + c.conditions = c.conditions.filter((sc) => sc.key !== condition.key) + } + + // find all conditions that are shared by all candidates, and lift them to the new node + for (const condition of newNode.children[0]!.conditions) { + if (newNode.children.every((c) => c.conditions.some((sc) => sc.key === condition.key))) { + newNode.conditions.push(condition) + } + } + for (let i = 1; i < newNode.conditions.length; i++) { + const condition = newNode.conditions[i]! + for (const c of newNode.children) { + c.conditions = c.conditions.filter((sc) => sc.key !== condition.key) + } + } + } + } +} + +/** + * Recursively shorten branches that have a single child into a leaf node. + * + * For each branch node in the tree, if it has only one child, we can replace the branch node with that child node, + * and merge the conditions of the branch node into the child node. + * + * This turns + * `if (condition1) { if (condition2) { return route } }` + * into + * `if (condition1 && condition2) { return route }` + */ +function contractTree(tree: RootNode) { + const stack = tree.children.filter((c) => c.type === 'branch') + while (stack.length > 0) { + const node = stack.pop()! + if (node.children.length === 1) { + const child = node.children[0]! + node.parent.children.splice(node.parent.children.indexOf(node), 1, child) + child.parent = node.parent + child.conditions = [...node.conditions, ...child.conditions] + + // reduce length-based conditions into a single condition + const lengthConditions = child.conditions.filter(c => c.type === 'length') + if (lengthConditions.some(c => c.direction === 'eq')) { + for (const c of lengthConditions) { + if (c.direction === 'gte') { + child.conditions = child.conditions.filter(sc => sc.key !== c.key) + } + } + } else if (lengthConditions.length > 0) { + const minLength = Math.min(...lengthConditions.map(c => c.value)) + child.conditions = child.conditions.filter(c => c.type !== 'length') + child.conditions.push({ type: 'length', direction: 'eq', value: minLength, key: `length_eq_${minLength}` }) + } + } + for (const child of node.children) { + if (child.type === 'branch') { + stack.push(child) + } + } + } +} + +function printTree(node: RootNode | BranchNode | LeafNode) { + let str = '' + if (node.type === 'root') { + for (const child of node.children) { + str += printTree(child) + } + return str + } + if (node.conditions.length) { + str += 'if (' + str += printConditions(node.conditions) + str += ')' + } + if (node.type === 'branch') { + if (node.conditions.length && node.children.length) str += `{` + for (const child of node.children) { + str += printTree(child) + } + if (node.conditions.length && node.children.length) str += `}` + } else { + str += printRoute(node.route) + } + return str +} + +function printConditions(conditions: Array) { + const lengths = conditions.filter((c) => c.type === 'length') + const segment = conditions.filter((c) => c.type !== 'length') + const results: Array = [] + if (lengths.length > 1) { + const exact = lengths.find((c) => c.direction === 'eq') + if (exact) { + results.push(printCondition(exact)) + } else { + results.push(printCondition(lengths[0]!)) + } + } else if (lengths.length === 1) { + results.push(printCondition(lengths[0]!)) + } + for (const c of segment) { + results.push(printCondition(c)) + } + return results.join(' && ') +} + +function printCondition(condition: Condition) { + switch (condition.type) { + case 'static-sensitive': + return `s${condition.index} === '${condition.value}'` + case 'static-insensitive': + return `sc${condition.index} === '${condition.value}'` + case 'length': + if (condition.direction === 'eq') { + return `length(${condition.value})` + } else if (condition.direction === 'gte') { + return `l >= ${condition.value}` + } + break + case 'startsWith': + return `s${condition.index}.startsWith('${condition.value}')` + case 'endsWith': + return `s${condition.index}.endsWith('${condition.value}')` + case 'globalEndsWith': + return `s[l - 1].endsWith('${condition.value}')` + } + throw new Error(`Unhandled condition type: ${condition.type}`) +} + +function printRoute(route: ParsedRoute) { + const length = route.segments.length + /** + * return [ + * route.path, + * { foo: s2, bar: s4 } + * ] + */ + let result = `{` + let hasWildcard = false + for (let i = 0; i < route.segments.length; i++) { + const segment = route.segments[i]! + if (segment.type === SEGMENT_TYPE_PARAM) { + const name = segment.value.replace(/^\$/, '') + const value = `s${i}` + if (segment.prefixSegment && segment.suffixSegment) { + result += `${name}: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + } else if (segment.prefixSegment) { + result += `${name}: ${value}.slice(${segment.prefixSegment.length}), ` + } else if (segment.suffixSegment) { + result += `${name}: ${value}.slice(0, -${segment.suffixSegment.length}), ` + } else { + result += `${name}: ${value}, ` + } + } else if (segment.type === SEGMENT_TYPE_WILDCARD) { + hasWildcard = true + const value = `s.slice(${i}).join('/')` + if (segment.prefixSegment && segment.suffixSegment) { + result += `_splat: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + result += `'*': ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + } else if (segment.prefixSegment) { + result += `_splat: ${value}.slice(${segment.prefixSegment.length}), ` + result += `'*': ${value}.slice(${segment.prefixSegment.length}), ` + } else if (segment.suffixSegment) { + result += `_splat: ${value}.slice(0, -${segment.suffixSegment.length}), ` + result += `'*': ${value}.slice(0, -${segment.suffixSegment.length}), ` + } else { + result += `_splat: ${value}, ` + result += `'*': ${value}, ` + } + break + } + } + result += `}` + return hasWildcard + ? `return ['${route.path}', ${result}];` + : `return ['${route.path}', params(${result}, ${length})];` +} + +function printHead(routes: Array }>) { + let head = 'const s = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' + head += 'const l = s.length;' + + // the `length()` function does exact match by default, but greater-than-or-equal match if `fuzzy` is true + head += 'const length = fuzzy ? (n) => l >= n : (n) => l === n;' + + // the `params()` function returns the params object, and if `fuzzy` is true, it also adds a `**` property with the remaining segments + head += 'const params = fuzzy ? (p, n) => { if (n && l > n) p[\'**\'] = s.slice(n).join(\'/\'); return p } : (p) => p;' + + // extract all segments from the input + // const [, s1, s2, s3] = s; + const max = routes.reduce( + (max, r) => Math.max(max, r.segments.length), + 0, + ) + if (max > 0) head += `const [,${Array.from({ length: max - 1 }, (_, i) => `s${i + 1}`).join(', ')}] = s;` + + // add toLowerCase version of each segment that is needed in a case-insensitive match + // const sc1 = s1?.toLowerCase(); + const caseInsensitiveSegments = new Set() + for (const route of routes) { + for (const condition of route.conditions) { + if (condition.type === 'static-insensitive') { + caseInsensitiveSegments.add(condition.index) + } + } + } + for (const index of caseInsensitiveSegments) { + head += `const sc${index} = s${index}?.toLowerCase();` + } + + return head +} + +function findBestCondition(node: RootNode | BranchNode, i: number, target: number) { + const child = node.children[i]! + let bestCondition: Condition | undefined + let bestMatchScore = Infinity + let bestCandidates = [child] + for (const c of child.conditions) { + const candidates = [child] + for (let j = i + 1; j < node.children.length; j++) { + const sibling = node.children[j]! + if (sibling.conditions.some((sc) => sc.key === c.key)) { + candidates.push(sibling) + } else { + break + } + } + const score = Math.abs(candidates.length - target) + if (score < bestMatchScore) { + bestMatchScore = score + bestCondition = c + bestCandidates = candidates + } + } + + return { score: bestMatchScore, condition: bestCondition, candidates: bestCandidates } +} + +function findBestLength(node: RootNode | BranchNode, i: number, target: number) { + const child = node.children[i]! + const childLengthCondition = child.conditions.find(c => c.type === 'length') + if (!childLengthCondition) { + return { score: Infinity, condition: undefined, candidates: [child] } + } + let currentMinLength = 1 + let exactLength = false + if (node.type !== 'root') { + let n: BranchNode | null = node + do { + const lengthCondition = n.conditions.find(c => c.type === 'length') + if (!lengthCondition) continue + if (lengthCondition.direction === 'eq') { + exactLength = true + break + } + if (lengthCondition.direction === 'gte') { + currentMinLength = lengthCondition.value + break + } + if (n.parent.type === 'branch') + n = n.parent + else n = null + } while (n) + } + if (exactLength || currentMinLength >= childLengthCondition.value) { + return { score: Infinity, condition: undefined, candidates: [child] } + } + let bestMatchScore = Infinity + let bestLength: number | undefined + let bestCandidates = [child] + let bestExact = false + for (let l = currentMinLength + 1; l <= childLengthCondition.value; l++) { + const candidates = [child] + let exact = childLengthCondition.direction === 'eq' && l === childLengthCondition.value + for (let j = i + 1; j < node.children.length; j++) { + const sibling = node.children[j]! + const lengthCondition = sibling.conditions.find(c => c.type === 'length') + if (!lengthCondition) break + if (lengthCondition.value < l) break + candidates.push(sibling) + exact &&= lengthCondition.direction === 'eq' && lengthCondition.value === l + } + const score = Math.abs(candidates.length - target) + if (score < bestMatchScore) { + bestMatchScore = score + bestLength = l + bestCandidates = candidates + bestExact = exact + } + } + const condition: Condition = { type: 'length', direction: bestExact ? 'eq' : 'gte', value: bestLength!, key: `length_${bestExact ? 'eq' : 'gte'}_${bestLength}` } + return { score: bestMatchScore, condition, candidates: bestCandidates } +} \ No newline at end of file From f282ab2da84e2529ce3846004518fafc5b53ff95 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 22:08:58 +0200 Subject: [PATCH 40/57] cleanup --- packages/router-core/src/compile-matcher.ts | 257 +++-- packages/router-core/src/index.ts | 1 + packages/router-core/tests/built.test.ts | 619 ------------- packages/router-core/tests/built2.test.ts | 874 ------------------ .../router-core/tests/builtWithParams.test.ts | 743 --------------- .../tests/builtWithParams3.test.ts | 534 +---------- .../router-core/tests/pathToParams.test.ts | 294 ------ 7 files changed, 212 insertions(+), 3110 deletions(-) delete mode 100644 packages/router-core/tests/built.test.ts delete mode 100644 packages/router-core/tests/built2.test.ts delete mode 100644 packages/router-core/tests/builtWithParams.test.ts delete mode 100644 packages/router-core/tests/pathToParams.test.ts diff --git a/packages/router-core/src/compile-matcher.ts b/packages/router-core/src/compile-matcher.ts index 0c98b735e9f..a8597697f9e 100644 --- a/packages/router-core/src/compile-matcher.ts +++ b/packages/router-core/src/compile-matcher.ts @@ -1,20 +1,58 @@ -import { parsePathname, SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD } from "./path" -import type { processRouteTree } from "./router" +import { + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, + parsePathname, +} from './path' +import type { LRUCache } from './lru-cache' +import type { Segment } from './path' +import type { processRouteTree } from './router' + +export type CompiledMatcher = ( + parser: typeof parsePathname, + from: string, + fuzzy?: boolean, + cache?: LRUCache>, +) => readonly [path: string, params: Record] | undefined - - -export function compileMatcher(flatRoutes: ReturnType['flatRoutes']) { +/** + * Compiles a sorted list of routes (as returned by `processRouteTree().flatRoutes`) + * into a matcher function. + * + * Run-time use (requires eval permissions): + * ```ts + * const fn = compileMatcher(processRouteTree({ routeTree }).flatRoutes) + * const matcher = new Function('parsePathname', 'from', 'fuzzy', 'cache', fn) as CompiledMatcher + * ``` + * + * Build-time use: + * ```ts + * const fn = compileMatcher(processRouteTree({ routeTree }).flatRoutes) + * sourceCode += `const matcher = (parsePathname, from, fuzzy, cache) => { ${fn} }` + * ``` + */ +export function compileMatcher( + flatRoutes: ReturnType['flatRoutes'], +) { const parsedRoutes = flatRoutes.map((route) => ({ path: route.fullPath, segments: parsePathname(route.fullPath), })) - const all = toConditions(prepareOptionalParams(prepareIndexRoutes(parsedRoutes))) + const all = toConditions( + prepareOptionalParams(prepareIndexRoutes(parsedRoutes)), + ) // We start by building a flat tree with all routes as leaf nodes, all children of the root node. const tree: RootNode = { type: 'root', children: [] } for (const { conditions, path, segments } of all) { - tree.children.push({ type: 'leaf', route: { path, segments }, parent: tree, conditions }) + tree.children.push({ + type: 'leaf', + route: { path, segments }, + parent: tree, + conditions, + }) } expandTree(tree) @@ -24,7 +62,7 @@ export function compileMatcher(flatRoutes: ReturnType[' fn += printHead(all) fn += printTree(tree) - return `(parsePathname, from, fuzzy) => {${fn}}` + return fn } type ParsedRoute = { @@ -32,7 +70,6 @@ type ParsedRoute = { segments: ReturnType } - // we duplicate routes that end in a static `/`, so they're also matched if that final `/` is not present function prepareIndexRoutes( parsedRoutes: Array, @@ -41,7 +78,11 @@ function prepareIndexRoutes( for (const route of parsedRoutes) { result.push(route) const last = route.segments.at(-1)! - if (route.segments.length > 1 && last.type === SEGMENT_TYPE_PATHNAME && last.value === '/') { + if ( + route.segments.length > 1 && + last.type === SEGMENT_TYPE_PATHNAME && + last.value === '/' + ) { const clone: ParsedRoute = { ...route, segments: route.segments.slice(0, -1), @@ -88,12 +129,17 @@ function prepareOptionalParams( } type Condition = - | { key: string, type: 'static-insensitive', index: number, value: string } - | { key: string, type: 'static-sensitive', index: number, value: string } - | { key: string, type: 'length', direction: 'eq' | 'gte' | 'lte', value: number } - | { key: string, type: 'startsWith', index: number, value: string } - | { key: string, type: 'endsWith', index: number, value: string } - | { key: string, type: 'globalEndsWith', value: string } + | { key: string; type: 'static-insensitive'; index: number; value: string } + | { key: string; type: 'static-sensitive'; index: number; value: string } + | { + key: string + type: 'length' + direction: 'eq' | 'gte' | 'lte' + value: number + } + | { key: string; type: 'startsWith'; index: number; value: string } + | { key: string; type: 'endsWith'; index: number; value: string } + | { key: string; type: 'globalEndsWith'; value: string } // each segment of a route can have zero or more conditions that needs to be met for the route to match function toConditions(routes: Array) { @@ -110,29 +156,58 @@ function toConditions(routes: Array) { const value = segment.value // @ts-expect-error -- not typed yet, i don't know how I'm gonna get this value here if (route.caseSensitive) { - conditions.push({ type: 'static-sensitive', index: i, value, key: `static_sensitive_${i}_${value}` }) + conditions.push({ + type: 'static-sensitive', + index: i, + value, + key: `static_sensitive_${i}_${value}`, + }) } else { - conditions.push({ type: 'static-insensitive', index: i, value: value.toLowerCase(), key: `static_insensitive_${i}_${value.toLowerCase()}` }) + conditions.push({ + type: 'static-insensitive', + index: i, + value: value.toLowerCase(), + key: `static_insensitive_${i}_${value.toLowerCase()}`, + }) } continue } if (segment.type === SEGMENT_TYPE_PARAM) { minLength += 1 if (segment.prefixSegment) { - conditions.push({ type: 'startsWith', index: i, value: segment.prefixSegment, key: `startsWith_${i}_${segment.prefixSegment}` }) + conditions.push({ + type: 'startsWith', + index: i, + value: segment.prefixSegment, + key: `startsWith_${i}_${segment.prefixSegment}`, + }) } if (segment.suffixSegment) { - conditions.push({ type: 'endsWith', index: i, value: segment.suffixSegment, key: `endsWith_${i}_${segment.suffixSegment}` }) + conditions.push({ + type: 'endsWith', + index: i, + value: segment.suffixSegment, + key: `endsWith_${i}_${segment.suffixSegment}`, + }) } continue } if (segment.type === SEGMENT_TYPE_WILDCARD) { hasWildcard = true if (segment.prefixSegment) { - conditions.push({ type: 'startsWith', index: i, value: segment.prefixSegment, key: `startsWith_${i}_${segment.prefixSegment}` }) + conditions.push({ + type: 'startsWith', + index: i, + value: segment.prefixSegment, + key: `startsWith_${i}_${segment.prefixSegment}`, + }) } if (segment.suffixSegment) { - conditions.push({ type: 'globalEndsWith', value: segment.suffixSegment, key: `globalEndsWith_${i}_${segment.suffixSegment}` }) + conditions.push({ + type: 'globalEndsWith', + value: segment.suffixSegment, + key: `globalEndsWith_${i}_${segment.suffixSegment}`, + }) } if (segment.suffixSegment || segment.prefixSegment) { minLength += 1 @@ -143,9 +218,19 @@ function toConditions(routes: Array) { } if (hasWildcard) { - conditions.push({ type: 'length', direction: 'gte', value: minLength, key: `length_gte_${minLength}` }) + conditions.push({ + type: 'length', + direction: 'gte', + value: minLength, + key: `length_gte_${minLength}`, + }) } else { - conditions.push({ type: 'length', direction: 'eq', value: minLength, key: `length_eq_${minLength}` }) + conditions.push({ + type: 'length', + direction: 'eq', + value: minLength, + key: `length_eq_${minLength}`, + }) } return { @@ -155,19 +240,28 @@ function toConditions(routes: Array) { }) } -type LeafNode = { type: 'leaf', conditions: Array, route: ParsedRoute, parent: BranchNode | RootNode } -type RootNode = { type: 'root', children: Array } -type BranchNode = { type: 'branch', conditions: Array, children: Array, parent: BranchNode | RootNode } - +type LeafNode = { + type: 'leaf' + conditions: Array + route: ParsedRoute + parent: BranchNode | RootNode +} +type RootNode = { type: 'root'; children: Array } +type BranchNode = { + type: 'branch' + conditions: Array + children: Array + parent: BranchNode | RootNode +} /** * Recursively expand each node of the tree until there is only one child left - * + * * For each child node in a parent node, we try to find subsequent siblings that would share the same condition to be matched. * If we find any, we group them together into a new branch node that replaces the original child node and the grouped siblings in the parent node. - * + * * We repeat the process in each newly created branch node until there is only one child left in each branch node. - * + * * This turns * ``` * if (a && b && c && d) return route1; @@ -180,7 +274,7 @@ type BranchNode = { type: 'branch', conditions: Array, children: Arra * if (e) { if (f) return route2; } * } * ``` - * + * */ function expandTree(tree: RootNode) { const stack: Array = [tree] @@ -194,7 +288,11 @@ function expandTree(tree: RootNode) { if (resolved.has(child)) continue // segment-based conditions should try to group as many children as possible - const bestCondition = findBestCondition(node, i, node.children.length - i - 1) + const bestCondition = findBestCondition( + node, + i, + node.children.length - i - 1, + ) // length-based conditions should try to group as few children as possible const bestLength = findBestLength(node, i, 0) @@ -204,7 +302,8 @@ function expandTree(tree: RootNode) { continue } - const selected = bestCondition.score < bestLength.score ? bestCondition : bestLength + const selected = + bestCondition.score < bestLength.score ? bestCondition : bestLength const condition = selected.condition! const newNode: BranchNode = { type: 'branch', @@ -221,7 +320,11 @@ function expandTree(tree: RootNode) { // find all conditions that are shared by all candidates, and lift them to the new node for (const condition of newNode.children[0]!.conditions) { - if (newNode.children.every((c) => c.conditions.some((sc) => sc.key === condition.key))) { + if ( + newNode.children.every((c) => + c.conditions.some((sc) => sc.key === condition.key), + ) + ) { newNode.conditions.push(condition) } } @@ -240,7 +343,7 @@ function expandTree(tree: RootNode) { * * For each branch node in the tree, if it has only one child, we can replace the branch node with that child node, * and merge the conditions of the branch node into the child node. - * + * * This turns * `if (condition1) { if (condition2) { return route } }` * into @@ -257,17 +360,24 @@ function contractTree(tree: RootNode) { child.conditions = [...node.conditions, ...child.conditions] // reduce length-based conditions into a single condition - const lengthConditions = child.conditions.filter(c => c.type === 'length') - if (lengthConditions.some(c => c.direction === 'eq')) { + const lengthConditions = child.conditions.filter( + (c) => c.type === 'length', + ) + if (lengthConditions.some((c) => c.direction === 'eq')) { for (const c of lengthConditions) { if (c.direction === 'gte') { - child.conditions = child.conditions.filter(sc => sc.key !== c.key) + child.conditions = child.conditions.filter((sc) => sc.key !== c.key) } } } else if (lengthConditions.length > 0) { - const minLength = Math.min(...lengthConditions.map(c => c.value)) - child.conditions = child.conditions.filter(c => c.type !== 'length') - child.conditions.push({ type: 'length', direction: 'eq', value: minLength, key: `length_eq_${minLength}` }) + const minLength = Math.min(...lengthConditions.map((c) => c.value)) + child.conditions = child.conditions.filter((c) => c.type !== 'length') + child.conditions.push({ + type: 'length', + direction: 'eq', + value: minLength, + key: `length_eq_${minLength}`, + }) } } for (const child of node.children) { @@ -395,23 +505,25 @@ function printRoute(route: ParsedRoute) { : `return ['${route.path}', params(${result}, ${length})];` } -function printHead(routes: Array }>) { - let head = 'const s = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' +function printHead( + routes: Array }>, +) { + let head = + 'const s = parsePathname(from[0] === "/" ? from : "/" + from, cache).map((s) => s.value);' head += 'const l = s.length;' // the `length()` function does exact match by default, but greater-than-or-equal match if `fuzzy` is true head += 'const length = fuzzy ? (n) => l >= n : (n) => l === n;' // the `params()` function returns the params object, and if `fuzzy` is true, it also adds a `**` property with the remaining segments - head += 'const params = fuzzy ? (p, n) => { if (n && l > n) p[\'**\'] = s.slice(n).join(\'/\'); return p } : (p) => p;' + head += + "const params = fuzzy ? (p, n) => { if (n && l > n) p['**'] = s.slice(n).join('/'); return p } : (p) => p;" // extract all segments from the input // const [, s1, s2, s3] = s; - const max = routes.reduce( - (max, r) => Math.max(max, r.segments.length), - 0, - ) - if (max > 0) head += `const [,${Array.from({ length: max - 1 }, (_, i) => `s${i + 1}`).join(', ')}] = s;` + const max = routes.reduce((max, r) => Math.max(max, r.segments.length), 0) + if (max > 0) + head += `const [,${Array.from({ length: max - 1 }, (_, i) => `s${i + 1}`).join(', ')}] = s;` // add toLowerCase version of each segment that is needed in a case-insensitive match // const sc1 = s1?.toLowerCase(); @@ -430,7 +542,11 @@ function printHead(routes: Array }> return head } -function findBestCondition(node: RootNode | BranchNode, i: number, target: number) { +function findBestCondition( + node: RootNode | BranchNode, + i: number, + target: number, +) { const child = node.children[i]! let bestCondition: Condition | undefined let bestMatchScore = Infinity @@ -453,12 +569,20 @@ function findBestCondition(node: RootNode | BranchNode, i: number, target: numbe } } - return { score: bestMatchScore, condition: bestCondition, candidates: bestCandidates } + return { + score: bestMatchScore, + condition: bestCondition, + candidates: bestCandidates, + } } -function findBestLength(node: RootNode | BranchNode, i: number, target: number) { +function findBestLength( + node: RootNode | BranchNode, + i: number, + target: number, +) { const child = node.children[i]! - const childLengthCondition = child.conditions.find(c => c.type === 'length') + const childLengthCondition = child.conditions.find((c) => c.type === 'length') if (!childLengthCondition) { return { score: Infinity, condition: undefined, candidates: [child] } } @@ -467,7 +591,7 @@ function findBestLength(node: RootNode | BranchNode, i: number, target: number) if (node.type !== 'root') { let n: BranchNode | null = node do { - const lengthCondition = n.conditions.find(c => c.type === 'length') + const lengthCondition = n.conditions.find((c) => c.type === 'length') if (!lengthCondition) continue if (lengthCondition.direction === 'eq') { exactLength = true @@ -477,8 +601,7 @@ function findBestLength(node: RootNode | BranchNode, i: number, target: number) currentMinLength = lengthCondition.value break } - if (n.parent.type === 'branch') - n = n.parent + if (n.parent.type === 'branch') n = n.parent else n = null } while (n) } @@ -491,14 +614,19 @@ function findBestLength(node: RootNode | BranchNode, i: number, target: number) let bestExact = false for (let l = currentMinLength + 1; l <= childLengthCondition.value; l++) { const candidates = [child] - let exact = childLengthCondition.direction === 'eq' && l === childLengthCondition.value + let exact = + childLengthCondition.direction === 'eq' && + l === childLengthCondition.value for (let j = i + 1; j < node.children.length; j++) { const sibling = node.children[j]! - const lengthCondition = sibling.conditions.find(c => c.type === 'length') + const lengthCondition = sibling.conditions.find( + (c) => c.type === 'length', + ) if (!lengthCondition) break if (lengthCondition.value < l) break candidates.push(sibling) - exact &&= lengthCondition.direction === 'eq' && lengthCondition.value === l + exact &&= + lengthCondition.direction === 'eq' && lengthCondition.value === l } const score = Math.abs(candidates.length - target) if (score < bestMatchScore) { @@ -508,6 +636,11 @@ function findBestLength(node: RootNode | BranchNode, i: number, target: number) bestExact = exact } } - const condition: Condition = { type: 'length', direction: bestExact ? 'eq' : 'gte', value: bestLength!, key: `length_${bestExact ? 'eq' : 'gte'}_${bestLength}` } + const condition: Condition = { + type: 'length', + direction: bestExact ? 'eq' : 'gte', + value: bestLength!, + key: `length_${bestExact ? 'eq' : 'gte'}_${bestLength}`, + } return { score: bestMatchScore, condition, candidates: bestCandidates } -} \ No newline at end of file +} diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 1b6f4dea873..5e2737f0878 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -104,6 +104,7 @@ export { removeBasepath, matchByPath, } from './path' +export { compileMatcher } from './compile-matcher' export type { Segment } from './path' export { encode, decode } from './qss' export { rootRouteId } from './root' diff --git a/packages/router-core/tests/built.test.ts b/packages/router-core/tests/built.test.ts deleted file mode 100644 index 39ec26ff3f5..00000000000 --- a/packages/router-core/tests/built.test.ts +++ /dev/null @@ -1,619 +0,0 @@ -import { describe, expect, it, test } from 'vitest' -import { - joinPaths, - matchPathname, - parsePathname, - processRouteTree, -} from '../src' -import { - SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PARAM, - SEGMENT_TYPE_PATHNAME, - SEGMENT_TYPE_WILDCARD, - type Segment, -} from '../src/path' -import { format } from "prettier" - -interface TestRoute { - id: string - isRoot?: boolean - path?: string - fullPath: string - rank?: number - parentRoute?: TestRoute - children?: Array - options?: { - caseSensitive?: boolean - } -} - -type PathOrChildren = string | [string, Array] - -function createRoute( - pathOrChildren: Array, - parentPath: string, -): Array { - return pathOrChildren.map((route) => { - if (Array.isArray(route)) { - const fullPath = joinPaths([parentPath, route[0]]) - const children = createRoute(route[1], fullPath) - const r = { - id: fullPath, - path: route[0], - fullPath, - children: children, - } - children.forEach((child) => { - child.parentRoute = r - }) - - return r - } - - const fullPath = joinPaths([parentPath, route]) - - return { - id: fullPath, - path: route, - fullPath, - } - }) -} - -function createRouteTree(pathOrChildren: Array): TestRoute { - return { - id: '__root__', - fullPath: '', - isRoot: true, - path: undefined, - children: createRoute(pathOrChildren, ''), - } -} - -const routeTree = createRouteTree([ - '/', - '/users/profile/settings', // static-deep (longest static path) - '/users/profile', // static-medium (medium static path) - '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) - '/users/$id', // param-simple (plain param) - '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) - '/files/$', // wildcard (lowest priority) - '/about', // static-shallow (shorter static path) - '/a/profile/settings', - '/a/profile', - '/a/user-{$id}', - '/a/$id', - '/a/{-$slug}', - '/a/$', - '/a', - '/b/profile/settings', - '/b/profile', - '/b/user-{$id}', - '/b/$id', - '/b/{-$slug}', - '/b/$', - '/b', - '/foo/bar/$id', - '/foo/$id/bar', - '/foo/$bar', - '/foo/$bar/', - '/foo/{-$bar}/qux', - '/$id/bar/foo', - '/$id/foo/bar', - '/a/b/c/d/e/f', - '/beep/boop', - '/one/two', - '/one', - '/z/y/x/w', - '/z/y/x/v', - '/z/y/x/u', - '/z/y/x', - '/images/thumb_{$}', // wildcard with prefix - '/logs/{$}.txt', // wildcard with suffix - '/cache/temp_{$}.log', // wildcard with prefix and suffix -]) - -const result = processRouteTree({ routeTree }) - -function originalMatcher(from: string): string | undefined { - const match = result.flatRoutes.find((r) => - matchPathname('/', from, { to: r.fullPath }), - ) - return match?.fullPath -} - -describe('work in progress', () => { - it('is ordrered', () => { - expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` - [ - "/a/b/c/d/e/f", - "/z/y/x/u", - "/z/y/x/v", - "/z/y/x/w", - "/a/profile/settings", - "/b/profile/settings", - "/users/profile/settings", - "/z/y/x", - "/foo/bar/$id", - "/a/profile", - "/b/profile", - "/beep/boop", - "/one/two", - "/users/profile", - "/foo/$id/bar", - "/foo/{-$bar}/qux", - "/a/user-{$id}", - "/api/user-{$id}", - "/b/user-{$id}", - "/foo/$bar/", - "/a/$id", - "/b/$id", - "/foo/$bar", - "/users/$id", - "/a/{-$slug}", - "/b/{-$slug}", - "/posts/{-$slug}", - "/cache/temp_{$}.log", - "/images/thumb_{$}", - "/logs/{$}.txt", - "/a/$", - "/b/$", - "/files/$", - "/a", - "/about", - "/b", - "/one", - "/", - "/$id/bar/foo", - "/$id/foo/bar", - ] - `) - }) - - const parsedRoutes = result.flatRoutes.map((route) => ({ - path: route.fullPath, - segments: parsePathname(route.fullPath), - })) - - type ParsedRoute = { - path: string - segments: ReturnType - } - - const segmentToConditions = (segment: Segment, index: number): Array => { - if (segment.type === SEGMENT_TYPE_WILDCARD) { - const conditions = [] - if (segment.prefixSegment) { - conditions.push(`s${index}.startsWith('${segment.prefixSegment}')`) - } - if (segment.suffixSegment) { - conditions.push(`baseSegments[l - 1].endsWith('${segment.suffixSegment}')`) - } - return conditions - } - if (segment.type === SEGMENT_TYPE_PARAM || segment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { - const conditions = [] - if (segment.prefixSegment) { - conditions.push(`s${index}.startsWith('${segment.prefixSegment}')`) - } - if (segment.suffixSegment) { - conditions.push(`s${index}.endsWith('${segment.suffixSegment}')`) - } - return conditions - } - if (segment.type === SEGMENT_TYPE_PATHNAME) { - if (index === 0 && segment.value === '/') return [] - return [`s${index} === '${segment.value}'`] - } - throw new Error(`Unknown segment type: ${segment.type}`) - } - - const routeSegmentsToConditions = (segments: ReadonlyArray, startIndex: number, additionalSegments: number = 0) => { - let hasWildcard = false - return Array.from({ length: additionalSegments + 1 }).flatMap((_, i) => { - if (hasWildcard) return '' // Wildcards consume all remaining segments, no check needed - const segment = segments[startIndex + i]! - if (segment.type === SEGMENT_TYPE_WILDCARD) { - hasWildcard = true - } - return segmentToConditions(segment, startIndex + i) - }) - .filter(Boolean) - .join(' && ') - } - - const needsSameSegment = (a: Segment, b?: Segment) => { - if (!b) return false - const sameStructure = a.type === b.type && - a.prefixSegment === b.prefixSegment && - a.suffixSegment === b.suffixSegment - if (a.type === SEGMENT_TYPE_PATHNAME) { - return sameStructure && a.value === b.value - } - return sameStructure - } - - const groupRoutesInOrder = (candidates: Array, length: number) => { - // find which (if any) of the routes will be considered fully matched after these checks - // and sort the other routes into "before the leaf" and "after the leaf", so we can print them in the right order - const deeperBefore: Array = [] - const deeperAfter: Array = [] - let leaf: ParsedRoute | undefined - for (const c of candidates) { - const isLeaf = c.segments.length <= length - if (isLeaf && !leaf) { - leaf = c - continue - } - if (isLeaf) { - continue // ignore subsequent leaves, they can never be matched - } - if (!leaf) { - deeperBefore.push(c) - } else { - deeperAfter.push(c) - } - } - return [deeperBefore, leaf, deeperAfter] as const - } - - function recursiveStaticMatch( - parsedRoutes: Array, - depth = 0, - fixedLength = false, - ) { - const resolved = new Set() - for (let i = 0; i < parsedRoutes.length; i++) { - const route = parsedRoutes[i]! - if (resolved.has(route)) continue // already resolved - const currentSegment = route.segments[depth] - if (!currentSegment) { - throw new Error( - 'Implementation error: this should not happen, depth=' + - depth + - `, route=${route.path}`, - ) - } - - // group together all subsequent routes that require the same "next-segment conditions" - const candidates = [route] - for (let j = i + 1; j < parsedRoutes.length; j++) { - const nextRoute = parsedRoutes[j]! - if (resolved.has(nextRoute)) continue // already resolved - const routeSegment = nextRoute.segments[depth] - if (needsSameSegment(currentSegment, routeSegment)) { - candidates.push(nextRoute) - } else { - break // no more candidates in this group - } - } - - outputConditionGroup: if (candidates.length > 1) { - // Determine how many segments the routes in this group have in common - let skipDepth = route.segments.slice(depth + 1).findIndex((s, i) => - candidates.some((c) => !needsSameSegment(s, c.segments[depth + 1 + i])), - ) - // If no segment differ at all, match everything - if (skipDepth === -1) skipDepth = route.segments.length - depth - 1 - - const lCondition = - !fixedLength && (skipDepth || (depth > 0)) ? `l > ${depth + skipDepth}` : '' - - // Accumulate all the conditions for all segments in the group - const skipConditions = routeSegmentsToConditions(candidates[0]!.segments, depth, skipDepth) - if (!skipConditions) { // we grouped by "next-segment conditions", but this didn't result in any conditions, bail out - candidates.length = 1 - break outputConditionGroup - } - - const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, depth + 1 + skipDepth) - const hasCondition = Boolean(lCondition || skipConditions) && (deeperBefore.length > 0 || deeperAfter.length > 0) - if (hasCondition) { - fn += `if (${lCondition}${lCondition && skipConditions ? ' && ' : ''}${skipConditions}) {` - } - if (deeperBefore.length > 0) { - recursiveStaticMatch( - deeperBefore, - depth + 1 + skipDepth, - fixedLength, - ) - } - if (leaf) { - if (fixedLength) { - fn += `return '${leaf.path}';` - } else { - fn += `if (l === ${leaf.segments.length}) return '${leaf.path}';` - } - } - if (deeperAfter.length > 0 && !(leaf && fixedLength)) { - recursiveStaticMatch( - deeperAfter, - depth + 1 + skipDepth, - fixedLength, - ) - } - if (hasCondition) { - fn += '}' - } - candidates.forEach((c) => resolved.add(c)) - continue - } - - - const wildcardIndex = route.segments.findIndex( - (s) => s && s.type === SEGMENT_TYPE_WILDCARD, - ) - if (wildcardIndex > -1 && wildcardIndex < depth) { - throw new Error( - `Implementation error: wildcard segment at index ${wildcardIndex} cannot be before depth ${depth} in route ${route.path}`, - ) - } - - // couldn't group by segment, try grouping by length (exclude wildcard routes because of their variable length) - if (wildcardIndex === -1 && !fixedLength) { - for (let j = i + 1; j < parsedRoutes.length; j++) { - const nextRoute = parsedRoutes[j]! - if (resolved.has(nextRoute)) continue // already resolved - if (nextRoute.segments.length === route.segments.length && !nextRoute.segments.some((s) => s.type === SEGMENT_TYPE_WILDCARD)) { - candidates.push(nextRoute) - } else { - break // no more candidates in this group - } - } - if (candidates.length > 1) { - const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, route.segments.length) - if (leaf && deeperBefore.length || leaf && deeperAfter.length) { - throw new Error(`Implementation error: length-based leaf route ${leaf.path} should not have deeper routes, but has ${deeperBefore.length} before and ${deeperAfter.length} after`) - } - fn += `if (l === ${route.segments.length}) {` - recursiveStaticMatch( - candidates, - depth, - true - ) - fn += '}' - candidates.forEach((c) => resolved.add(c)) - continue - } - } - - // try grouping wildcard routes that would have the same base length - if (wildcardIndex > -1 && !fixedLength) { - for (let j = i + 1; j < parsedRoutes.length; j++) { - const nextRoute = parsedRoutes[j]! - if (resolved.has(nextRoute)) continue // already resolved - if (nextRoute.segments.length === route.segments.length && wildcardIndex === nextRoute.segments.findIndex((s) => s.type === SEGMENT_TYPE_WILDCARD)) { - candidates.push(nextRoute) - } else { - break // no more candidates in this group - } - } - if (candidates.length > 2) { - const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, route.segments.length) - if (leaf && deeperBefore.length || leaf && deeperAfter.length) { - throw new Error(`Implementation error: wildcard-based leaf route ${leaf.path} should not have deeper routes, but has ${deeperBefore.length} before and ${deeperAfter.length} after`) - } - fn += `if (l >= ${wildcardIndex}) {` - recursiveStaticMatch( - candidates, - depth, - true - ) - fn += '}' - candidates.forEach((c) => resolved.add(c)) - continue - } - } - - // couldn't group at all, just output a single-route match, and let the next iteration handle the rest - if (wildcardIndex === -1) { - const conditions = routeSegmentsToConditions(route.segments, depth, route.segments.length - depth - 1) - const lCondition = fixedLength ? '' : `l === ${route.segments.length}` - fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) return '${route.path}';` - } else { - const conditions = routeSegmentsToConditions(route.segments, depth, wildcardIndex - depth) - const lCondition = fixedLength ? '' : `l >= ${wildcardIndex}` - fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) return '${route.path}';` - } - resolved.add(route) - } - } - - let fn = 'const baseSegments = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' - fn += '\nconst l = baseSegments.length;' - - const max = parsedRoutes.reduce( - (max, r) => Math.max(max, r.segments.length), - 0, - ) - if (max > 0) fn += `\nconst [,${Array.from({ length: max }, (_, i) => `s${i + 1}`).join(', ')}] = baseSegments;` - - - // we duplicate routes that end in a static `/`, so they're also matched if that final `/` is not present - function prepareIndexRoutes( - parsedRoutes: Array, - ): Array { - const result: Array = [] - for (const route of parsedRoutes) { - result.push(route) - const last = route.segments.at(-1)! - if (route.segments.length > 1 && last.type === SEGMENT_TYPE_PATHNAME && last.value === '/') { - const clone: ParsedRoute = { - ...route, - segments: route.segments.slice(0, -1), - } - result.push(clone) - } - } - return result - } - - // we replace routes w/ optional params, with - // - 1 version where it's a regular param - // - 1 version where it's removed entirely - function prepareOptionalParams( - parsedRoutes: Array, - ): Array { - const result: Array = [] - for (const route of parsedRoutes) { - const index = route.segments.findIndex( - (s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM, - ) - if (index === -1) { - result.push(route) - continue - } - // for every optional param in the route, we need to push a version of the route without it, and a version of the route with it as a regular param - // example: - // /foo/{-$bar}/qux => [/foo/qux, /foo/$bar/qux] - // /a/{-$b}/c/{-$d} => [/a/c, /a/c/$d, /a/$b/c, /a/$b/c/$d] - const withRegular: ParsedRoute = { - ...route, - segments: route.segments.map((s, i) => - i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s, - ), - } - const withoutOptional: ParsedRoute = { - ...route, - segments: route.segments.filter((_, i) => i !== index), - } - const chunk = prepareOptionalParams([withRegular, withoutOptional]) - result.push(...chunk) - } - return result - } - - const all = prepareOptionalParams( - prepareIndexRoutes( - parsedRoutes - ), - ) - - recursiveStaticMatch(all) - - it('generates a matching function', async () => { - expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` - "const baseSegments = parsePathname(from[0] === "/" ? from : "/" + from).map( - (s) => s.value, - ); - const l = baseSegments.length; - const [, s1, s2, s3, s4, s5, s6, s7] = baseSegments; - if ( - l === 7 && - s1 === "a" && - s2 === "b" && - s3 === "c" && - s4 === "d" && - s5 === "e" && - s6 === "f" - ) - return "/a/b/c/d/e/f"; - if (l === 5) { - if (s1 === "z" && s2 === "y" && s3 === "x") { - if (s4 === "u") return "/z/y/x/u"; - if (s4 === "v") return "/z/y/x/v"; - if (s4 === "w") return "/z/y/x/w"; - } - } - if (l === 4) { - if (s1 === "a" && s2 === "profile" && s3 === "settings") - return "/a/profile/settings"; - if (s1 === "b" && s2 === "profile" && s3 === "settings") - return "/b/profile/settings"; - if (s1 === "users" && s2 === "profile" && s3 === "settings") - return "/users/profile/settings"; - if (s1 === "z" && s2 === "y" && s3 === "x") return "/z/y/x"; - if (s1 === "foo" && s2 === "bar") return "/foo/bar/$id"; - } - if (l === 3) { - if (s1 === "a" && s2 === "profile") return "/a/profile"; - if (s1 === "b" && s2 === "profile") return "/b/profile"; - if (s1 === "beep" && s2 === "boop") return "/beep/boop"; - if (s1 === "one" && s2 === "two") return "/one/two"; - if (s1 === "users" && s2 === "profile") return "/users/profile"; - } - if (l === 4 && s1 === "foo" && s3 === "bar") return "/foo/$id/bar"; - if (l === 4 && s1 === "foo" && s3 === "qux") return "/foo/{-$bar}/qux"; - if (l === 3) { - if (s1 === "foo" && s2 === "qux") return "/foo/{-$bar}/qux"; - if (s1 === "a" && s2.startsWith("user-")) return "/a/user-{$id}"; - if (s1 === "api" && s2.startsWith("user-")) return "/api/user-{$id}"; - if (s1 === "b" && s2.startsWith("user-")) return "/b/user-{$id}"; - } - if (l === 4 && s1 === "foo" && s3 === "/") return "/foo/$bar/"; - if (l === 3) { - if (s1 === "foo") return "/foo/$bar/"; - if (s1 === "a") return "/a/$id"; - if (s1 === "b") return "/b/$id"; - if (s1 === "foo") return "/foo/$bar"; - if (s1 === "users") return "/users/$id"; - if (s1 === "a") return "/a/{-$slug}"; - } - if (l === 2 && s1 === "a") return "/a/{-$slug}"; - if (l === 3 && s1 === "b") return "/b/{-$slug}"; - if (l === 2 && s1 === "b") return "/b/{-$slug}"; - if (l === 3 && s1 === "posts") return "/posts/{-$slug}"; - if (l === 2 && s1 === "posts") return "/posts/{-$slug}"; - if (l >= 2) { - if ( - s1 === "cache" && - s2.startsWith("temp_") && - baseSegments[l - 1].endsWith(".log") - ) - return "/cache/temp_{$}.log"; - if (s1 === "images" && s2.startsWith("thumb_")) return "/images/thumb_{$}"; - if (s1 === "logs" && baseSegments[l - 1].endsWith(".txt")) - return "/logs/{$}.txt"; - if (s1 === "a") return "/a/$"; - if (s1 === "b") return "/b/$"; - if (s1 === "files") return "/files/$"; - } - if (l === 2) { - if (s1 === "a") return "/a"; - if (s1 === "about") return "/about"; - if (s1 === "b") return "/b"; - if (s1 === "one") return "/one"; - } - if (l === 1) return "/"; - if (l === 4 && s2 === "bar" && s3 === "foo") return "/$id/bar/foo"; - if (l === 4 && s2 === "foo" && s3 === "bar") return "/$id/foo/bar"; - " - `) - }) - - const buildMatcher = new Function('parsePathname', 'from', fn) as ( - parser: typeof parsePathname, - from: string, - ) => string | undefined - - // WARN: some of these don't work yet, they're just here to show the differences - test.each([ - '', - '/', - '/users/profile/settings', - '/foo/123', - '/foo/123/', - '/b/123', - '/foo/qux', - '/foo/123/qux', - '/a/user-123', - '/a/123', - '/a/123/more', - '/files', - '/files/hello-world.txt', - '/something/foo/bar', - '/files/deep/nested/file.json', - '/files/', - '/images/thumb_200x300.jpg', - '/logs/2020/01/01/error.txt', - '/cache/temp_user456.log', - '/a/b/c/d/e', - ])('matching %s', (s) => { - const originalMatch = originalMatcher(s) - const buildMatch = buildMatcher(parsePathname, s) - console.log( - `matching: ${s}, originalMatch: ${originalMatch}, buildMatch: ${buildMatch}`, - ) - expect(buildMatch).toBe(originalMatch) - }) -}) diff --git a/packages/router-core/tests/built2.test.ts b/packages/router-core/tests/built2.test.ts deleted file mode 100644 index daeb58cb8a6..00000000000 --- a/packages/router-core/tests/built2.test.ts +++ /dev/null @@ -1,874 +0,0 @@ -import { describe, expect, it, test } from 'vitest' -import { - joinPaths, - matchPathname, - parsePathname, - processRouteTree, -} from '../src' -import { - SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PARAM, - SEGMENT_TYPE_PATHNAME, - SEGMENT_TYPE_WILDCARD, -} from '../src/path' -import { format } from 'prettier' - -interface TestRoute { - id: string - isRoot?: boolean - path?: string - fullPath: string - rank?: number - parentRoute?: TestRoute - children?: Array - options?: { - caseSensitive?: boolean - } -} - -type PathOrChildren = string | [string, Array] - -function createRoute( - pathOrChildren: Array, - parentPath: string, -): Array { - return pathOrChildren.map((route) => { - if (Array.isArray(route)) { - const fullPath = joinPaths([parentPath, route[0]]) - const children = createRoute(route[1], fullPath) - const r = { - id: fullPath, - path: route[0], - fullPath, - children: children, - } - children.forEach((child) => { - child.parentRoute = r - }) - - return r - } - - const fullPath = joinPaths([parentPath, route]) - - return { - id: fullPath, - path: route, - fullPath, - } - }) -} - -function createRouteTree(pathOrChildren: Array): TestRoute { - return { - id: '__root__', - fullPath: '', - isRoot: true, - path: undefined, - children: createRoute(pathOrChildren, ''), - } -} - -const routeTree = createRouteTree([ - '/', - '/users/profile/settings', // static-deep (longest static path) - '/users/profile', // static-medium (medium static path) - '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) - '/users/$id', // param-simple (plain param) - '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) - '/files/$', // wildcard (lowest priority) - '/about', // static-shallow (shorter static path) - '/a/profile/settings', - '/a/profile', - '/a/user-{$id}', - '/a/$id', - '/a/{-$slug}', - '/a/$', - '/a', - '/b/profile/settings', - '/b/profile', - '/b/user-{$id}', - '/b/$id', - '/b/{-$slug}', - '/b/$', - '/b', - '/foo/bar/$id', - '/foo/$id/bar', - '/foo/$bar', - '/foo/$bar/', - '/foo/{-$bar}/qux', - '/$id/bar/foo', - '/$id/foo/bar', - '/a/b/c/d/e/f', - '/beep/boop', - '/one/two', - '/one', - '/z/y/x/w', - '/z/y/x/v', - '/z/y/x/u', - '/z/y/x', - '/images/thumb_{$}', // wildcard with prefix - '/logs/{$}.txt', // wildcard with suffix - '/cache/temp_{$}.log', // wildcard with prefix and suffix -]) - -const result = processRouteTree({ routeTree }) - -function originalMatcher(from: string): string | undefined { - const match = result.flatRoutes.find((r) => - matchPathname('/', from, { to: r.fullPath }), - ) - return match?.fullPath -} - -describe('work in progress', () => { - it('is ordrered', () => { - expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` - [ - "/a/b/c/d/e/f", - "/z/y/x/u", - "/z/y/x/v", - "/z/y/x/w", - "/a/profile/settings", - "/b/profile/settings", - "/users/profile/settings", - "/z/y/x", - "/foo/bar/$id", - "/a/profile", - "/b/profile", - "/beep/boop", - "/one/two", - "/users/profile", - "/foo/$id/bar", - "/foo/{-$bar}/qux", - "/a/user-{$id}", - "/api/user-{$id}", - "/b/user-{$id}", - "/foo/$bar/", - "/a/$id", - "/b/$id", - "/foo/$bar", - "/users/$id", - "/a/{-$slug}", - "/b/{-$slug}", - "/posts/{-$slug}", - "/cache/temp_{$}.log", - "/images/thumb_{$}", - "/logs/{$}.txt", - "/a/$", - "/b/$", - "/files/$", - "/a", - "/about", - "/b", - "/one", - "/", - "/$id/bar/foo", - "/$id/foo/bar", - ] - `) - }) - - const parsedRoutes = result.flatRoutes.map( - (route): ParsedRoute => ({ - path: route.fullPath, - segments: parsePathname(route.fullPath), - rank: route.rank!, - }), - ) - - type ParsedRoute = { - path: string - segments: ReturnType - rank: number - } - - const caseSensitive = true - - let fn = 'const baseSegments = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' - fn += '\nconst l = baseSegments.length;' - fn += `\nlet rank = Infinity;` - fn += `\nlet path = undefined;` - fn += `\nconst propose = (r, p) => {` - fn += `\n if (r < rank) {` - fn += `\n rank = r;` - fn += `\n path = p;` - fn += `\n }` - fn += `\n};` - - type WithConditions = ParsedRoute & { - conditions: Array - length: { min: number; max: number } - } - - function conditionToString(condition: Condition, caseSensitive = true) { - if (condition.kind === 'static') { - if (condition.index === 0 && condition.value === '/') return undefined // root segment is always `/` - return caseSensitive - ? `s${condition.index} === '${condition.value}'` - : `sc${condition.index} === '${condition.value}'` - } else if (condition.kind === 'startsWith') { - return `s${condition.index}.startsWith('${condition.value}')` - } else if (condition.kind === 'endsWith') { - return `s${condition.index}.endsWith('${condition.value}')` - } else if (condition.kind === 'wildcardEndsWith') { - return `baseSegments[l - 1].endsWith('${condition.value}')` - } - return undefined - } - - function outputRoute( - route: WithConditions, - caseSensitive = true, - length: { min: number; max: number }, - preconditions: Array = [], - allSeenRanks: Array = [], - ) { - const flags: Array = [] - let min = length.min - if (route.length.min > min) min = route.length.min - let max = length.max - if (route.length.max < max) max = route.length.max - for (const condition of route.conditions) { - if (condition.kind === 'static' && condition.index + 1 > min) { - min = condition.index + 1 - } else if (condition.kind === 'startsWith' && condition.index + 1 > min) { - min = condition.index + 1 - } else if (condition.kind === 'endsWith' && condition.index + 1 > min) { - min = condition.index + 1 - } - } - - if (min === max && (min !== length.min || max !== length.max)) { - flags.push(`l === ${min}`) - } else { - if (min > length.min) { - flags.push(`l >= ${min}`) - } - if (max < length.max) { - flags.push(`l <= ${max}`) - } - } - for (const condition of route.conditions) { - if (!preconditions.includes(condition.key)) { - const str = conditionToString(condition, caseSensitive) - if (str) { - flags.push(str) - } - } - } - if (flags.length) { - fn += `if (${flags.join(' && ')}) {` - } - allSeenRanks.sort((a, b) => a - b) - let maxContinuousRank = -1 - for (let i = 0; i < allSeenRanks.length; i++) { - if (allSeenRanks[i] === i) { - maxContinuousRank = i - } else { - break - } - } - allSeenRanks.push(route.rank) - if (route.rank === maxContinuousRank + 1) { - // if we know at this point of the function, we can't do better than this, return it directly - fn += `return '${route.path}';` - } else { - fn += `propose(${route.rank}, '${route.path}');` - } - if (flags.length) { - fn += '}' - } - } - - function recursiveStaticMatch( - parsedRoutes: Array, - caseSensitive = true, - length: { min: number; max: number } = { min: 0, max: Infinity }, - preconditions: Array = [], - lastRank?: number, - allSeenRanks: Array = [], - ) { - // count all conditions by `key` - // determine the condition that would match as close to 50% of the routes as possible - const conditionCounts: Record = {} - parsedRoutes.forEach((r) => { - r.conditions.forEach((c) => { - conditionCounts[c.key] = (conditionCounts[c.key] || 0) + 1 - }) - }) - const total = parsedRoutes.length - const target = total / 2 - let bestKey - let bestScore = Infinity - for (const key in conditionCounts) { - if (preconditions.includes(key)) continue - const score = Math.abs(conditionCounts[key]! - target) - if (score < bestScore) { - bestScore = score - bestKey = key - } - } - // console.log(`Best condition key: ${bestKey} with score: ${conditionCounts[bestKey]} / ${total}`) - - // look at all minLengths and maxLengths - // determine a minLength and a maxLength that would match as close to 50% of the routes as possible - const minLengths: Record = {} - const maxLengths: Record = {} - parsedRoutes.forEach((r) => { - const minLength = r.length.min - if (minLength > length.min) { - minLengths[minLength] = (minLengths[minLength] || 0) + 1 - } - const maxLength = r.length.max - if (maxLength !== Infinity && maxLength < length.max) { - maxLengths[maxLength] = (maxLengths[maxLength] || 0) + 1 - } - }) - const allMinLengths = Object.keys(minLengths).sort( - (a, b) => Number(a) - Number(b), - ) - for (let i = 0; i < allMinLengths.length; i++) { - for (let j = i + 1; j < allMinLengths.length; j++) { - minLengths[Number(allMinLengths[i]!)]! += - minLengths[Number(allMinLengths[j]!)]! - } - } - const allMaxLengths = Object.keys(maxLengths).sort( - (a, b) => Number(b) - Number(a), - ) - for (let i = 0; i < allMaxLengths.length; i++) { - for (let j = i + 1; j < allMaxLengths.length; j++) { - maxLengths[Number(allMaxLengths[i]!)]! += - maxLengths[Number(allMaxLengths[j]!)]! - } - } - let bestMinLength - let bestMaxLength - let bestMinScore = Infinity - for (const minLength in minLengths) { - const minScore = Math.abs(minLengths[minLength]! - target) - if (minScore < bestMinScore) { - bestMinScore = minScore - bestMinLength = Number(minLength) - } - } - for (const maxLength in maxLengths) { - const maxScore = Math.abs(maxLengths[maxLength]! - target) - if (maxScore < bestMinScore) { - bestMinScore = maxScore - bestMaxLength = Number(maxLength) - } - } - // console.log(`Best minLength: ${bestMinLength} with score: ${minLengths[bestMinLength!]} / ${total}`) - // console.log(`Best maxLength: ${bestMaxLength} with score: ${maxLengths[bestMaxLength!]} / ${total}`) - - // determine which of the 3 discriminants to use (condition, minLength, maxLength) to match as close to 50% of the routes as possible - const discriminant = - bestKey && - (!bestMinLength || - conditionCounts[bestKey] > minLengths[bestMinLength!]) && - (!bestMaxLength || conditionCounts[bestKey] > maxLengths[bestMaxLength!]) - ? ({ key: bestKey, type: 'condition' } as const) - : bestMinLength && - (!bestMaxLength || - minLengths[bestMinLength!] > maxLengths[bestMaxLength!]) && - (!bestKey || minLengths[bestMinLength!] > conditionCounts[bestKey]) - ? ({ key: bestMinLength!, type: 'minLength' } as const) - : bestMaxLength - ? ({ key: bestMaxLength!, type: 'maxLength' } as const) - : undefined - - if (discriminant) { - // split all routes into 2 groups (matching and not matching) based on the discriminant - const matchingRoutes: Array = [] - const nonMatchingRoutes: Array = [] - for (const route of parsedRoutes) { - if (discriminant.type === 'condition') { - const condition = route.conditions.find( - (c) => c.key === discriminant.key, - ) - if (condition) { - matchingRoutes.push(route) - } else { - nonMatchingRoutes.push(route) - } - } else if (discriminant.type === 'minLength') { - if (route.length.min >= discriminant.key) { - matchingRoutes.push(route) - } else { - nonMatchingRoutes.push(route) - } - } else if (discriminant.type === 'maxLength') { - if (route.length.max <= discriminant.key) { - matchingRoutes.push(route) - } else { - nonMatchingRoutes.push(route) - } - } - } - if (matchingRoutes.length === 1) { - outputRoute(matchingRoutes[0]!, caseSensitive, length, preconditions, allSeenRanks) - } else if (matchingRoutes.length) { - // add `if` for the discriminant - const bestChildRank = matchingRoutes.reduce( - (min, r) => Math.min(min, r.rank), - Infinity, - ) - const rankTest = - matchingRoutes.length > 2 && - bestChildRank !== 0 && - bestChildRank !== Infinity && - bestChildRank !== lastRank - ? ` && rank > ${bestChildRank}` - : '' - const nextLength = { - min: - discriminant.type === 'minLength' ? discriminant.key : length.min, - max: - discriminant.type === 'maxLength' ? discriminant.key : length.max, - } - if (discriminant.type === 'condition') { - const condition = matchingRoutes[0]!.conditions.find( - (c) => c.key === discriminant.key, - )! - fn += `if (${conditionToString(condition, caseSensitive) || 'true'}${rankTest}) {` - } else if (discriminant.type === 'minLength') { - if (discriminant.key === length.max) { - fn += `if (l === ${discriminant.key}${rankTest}) {` - } else { - if ( - matchingRoutes.every((r) => r.length.max === discriminant.key) - ) { - nextLength.max = discriminant.key - fn += `if (l === ${discriminant.key}${rankTest}) {` - } else { - fn += `if (l >= ${discriminant.key}${rankTest}) {` - } - } - } else if (discriminant.type === 'maxLength') { - if (discriminant.key === length.min) { - fn += `if (l === ${discriminant.key}${rankTest}) {` - } else { - if ( - matchingRoutes.every((r) => r.length.min === discriminant.key) - ) { - nextLength.min = discriminant.key - fn += `if (l === ${discriminant.key}${rankTest}) {` - } else { - fn += `if (l <= ${discriminant.key}${rankTest}) {` - } - } - } else { - throw new Error( - `Unknown discriminant type: ${JSON.stringify(discriminant)}`, - ) - } - // recurse - recursiveStaticMatch( - matchingRoutes, - caseSensitive, - nextLength, - discriminant.type === 'condition' - ? [...preconditions, discriminant.key] - : preconditions, - rankTest ? bestChildRank : lastRank, - allSeenRanks - ) - fn += '}' - } - if (nonMatchingRoutes.length === 1) { - outputRoute(nonMatchingRoutes[0]!, caseSensitive, length, preconditions, allSeenRanks) - } else if (nonMatchingRoutes.length) { - // recurse - recursiveStaticMatch(nonMatchingRoutes, caseSensitive, length, preconditions, undefined, allSeenRanks) - } - } else { - const [route, ...rest] = parsedRoutes - if (route) outputRoute(route, caseSensitive, length, preconditions, allSeenRanks) - if (rest.length) { - // try again w/ 1 fewer route, it might find a good discriminant now - recursiveStaticMatch(rest, caseSensitive, length, preconditions, undefined, allSeenRanks) - } - } - } - - function prepareIndexRoutes( - parsedRoutes: Array, - ): Array { - const result: Array = [] - for (const route of parsedRoutes) { - result.push(route) - const last = route.segments.at(-1)! - if (route.segments.length > 1 && last.type === SEGMENT_TYPE_PATHNAME && last.value === '/') { - const clone: ParsedRoute = { - ...route, - segments: route.segments.slice(0, -1), - } - result.push(clone) - } - } - return result - } - - function prepareOptionalParams( - parsedRoutes: Array, - ): Array { - const result: Array = [] - for (const route of parsedRoutes) { - const index = route.segments.findIndex( - (s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM, - ) - if (index === -1) { - result.push(route) - continue - } - // for every optional param in the route, we need to push a version of the route without it, and a version of the route with it as a regular param - // example: - // /foo/{-$bar}/qux => [/foo/qux, /foo/$bar/qux] - // /a/{-$b}/c/{-$d} => [/a/c, /a/c/$d, /a/$b/c, /a/$b/c/$d] - const withoutOptional: ParsedRoute = { - ...route, - segments: route.segments.filter((_, i) => i !== index), - } - const withRegular: ParsedRoute = { - ...route, - segments: route.segments.map((s, i) => - i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s, - ), - } - const chunk = prepareOptionalParams([withRegular, withoutOptional]) - result.push(...chunk) - } - return result - } - - type Condition = - | { key: string; kind: 'static'; index: number; value: string } - | { key: string; kind: 'startsWith'; index: number; value: string } - | { key: string; kind: 'endsWith'; index: number; value: string } - | { key: string; kind: 'wildcardEndsWith'; value: string } - - const withConditions: Array = prepareOptionalParams( - prepareIndexRoutes( - parsedRoutes - ), - ).map((r) => { - let minLength = 0 - let maxLength = 0 - const conditions: Array = r.segments.flatMap((s, i) => { - if (s.type === SEGMENT_TYPE_PATHNAME) { - minLength += 1 - maxLength += 1 - if (i === 0 && s.value === '/') { - return [] - } - return [ - { - kind: 'static', - index: i, - value: caseSensitive ? s.value : s.value.toLowerCase(), - key: `static-${i}-${s.value}`, - }, - ] - } else if (s.type === SEGMENT_TYPE_PARAM) { - minLength += 1 - maxLength += 1 - const conds: Array = [] - if (s.prefixSegment) { - conds.push({ - kind: 'startsWith', - index: i, - value: s.prefixSegment, - key: `startsWith-${i}-${s.prefixSegment}`, - }) - } - if (s.suffixSegment) { - conds.push({ - kind: 'endsWith', - index: i, - value: s.suffixSegment, - key: `endsWith-${i}-${s.suffixSegment}`, - }) - } - return conds - } else if (s.type === SEGMENT_TYPE_WILDCARD) { - maxLength += Infinity - const conds: Array = [] - if (s.prefixSegment || s.suffixSegment) { - minLength += 1 - } - if (s.prefixSegment) { - conds.push({ - kind: 'startsWith', - index: i, - value: s.prefixSegment, - key: `startsWith-${i}-${s.prefixSegment}`, - }) - } - if (s.suffixSegment) { - conds.push({ - kind: 'wildcardEndsWith', - value: s.suffixSegment, - key: `wildcardEndsWith-${s.suffixSegment}`, - }) - } - return conds - } - return [] - }) - return { ...r, conditions, length: { min: minLength, max: maxLength } } - }) - - const max = withConditions.reduce( - (max, r) => - Math.max( - max, - r.conditions.reduce((max, c) => { - if ( - c.kind === 'static' || - c.kind === 'startsWith' || - c.kind === 'endsWith' - ) { - return Math.max(max, c.index) - } - return max - }, 0), - ), - 0, - ) - - if (max > 0) { - fn += `const [${Array.from({ length: max + 1 }, (_, i) => `s${i}`).join(', ')}] = baseSegments;\n` - if (!caseSensitive) { - for (let i = 0; i <= max; i++) { - fn += `const sc${i} = s${i}?.toLowerCase();\n` - } - } - } - - - recursiveStaticMatch(withConditions, caseSensitive) - - fn += `return path;` - - it('generates a matching function', async () => { - expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` - "const baseSegments = parsePathname(from[0] === "/" ? from : "/" + from).map( - (s) => s.value, - ); - const l = baseSegments.length; - let rank = Infinity; - let path = undefined; - const propose = (r, p) => { - if (r < rank) { - rank = r; - path = p; - } - }; - const [s0, s1, s2, s3, s4, s5, s6] = baseSegments; - if (l <= 3 && rank > 9) { - if (l === 3) { - if (s1 === "a") { - if (s2 === "profile") { - propose(9, "/a/profile"); - } - if (s2.startsWith("user-")) { - propose(16, "/a/user-{$id}"); - } - propose(20, "/a/$id"); - propose(24, "/a/{-$slug}"); - } - if (s1 === "b" && rank > 10) { - if (s2 === "profile") { - propose(10, "/b/profile"); - } - if (s2.startsWith("user-")) { - propose(18, "/b/user-{$id}"); - } - propose(21, "/b/$id"); - propose(25, "/b/{-$slug}"); - } - if (s1 === "foo" && rank > 15) { - if (s2 === "qux") { - propose(15, "/foo/{-$bar}/qux"); - } - propose(19, "/foo/$bar/"); - propose(22, "/foo/$bar"); - } - if (s1 === "users") { - if (s2 === "profile") { - propose(13, "/users/profile"); - } - propose(23, "/users/$id"); - } - if (s1 === "beep" && s2 === "boop") { - propose(11, "/beep/boop"); - } - if (s1 === "one" && s2 === "two") { - propose(12, "/one/two"); - } - if (s1 === "api" && s2.startsWith("user-")) { - propose(17, "/api/user-{$id}"); - } - if (s1 === "posts") { - propose(26, "/posts/{-$slug}"); - } - } - if (l === 2 && rank > 24) { - if (s1 === "a") { - propose(24, "/a/{-$slug}"); - propose(33, "/a"); - } - if (s1 === "b") { - propose(25, "/b/{-$slug}"); - propose(35, "/b"); - } - if (s1 === "posts") { - propose(26, "/posts/{-$slug}"); - } - if (s1 === "about") { - propose(34, "/about"); - } - if (s1 === "one") { - propose(36, "/one"); - } - } - if (l === 1) { - propose(37, "/"); - } - } - if (l >= 4) { - if ( - l === 7 && - s1 === "a" && - s2 === "b" && - s3 === "c" && - s4 === "d" && - s5 === "e" && - s6 === "f" - ) { - return "/a/b/c/d/e/f"; - } - if (s1 === "z" && rank > 1) { - if (l === 5) { - if (s2 === "y" && s3 === "x" && s4 === "u") { - return "/z/y/x/u"; - } - if (s2 === "y" && s3 === "x" && s4 === "v") { - return "/z/y/x/v"; - } - if (s2 === "y" && s3 === "x" && s4 === "w") { - return "/z/y/x/w"; - } - } - if (l === 4 && s2 === "y" && s3 === "x") { - propose(7, "/z/y/x"); - } - } - if (l === 4 && rank > 4) { - if (s1 === "foo" && rank > 8) { - if (s2 === "bar") { - propose(8, "/foo/bar/$id"); - } - if (s3 === "bar") { - propose(14, "/foo/$id/bar"); - } - if (s3 === "qux") { - propose(15, "/foo/{-$bar}/qux"); - } - if (s3 === "/") { - propose(19, "/foo/$bar/"); - } - } - if (s2 === "profile" && rank > 4) { - if (s1 === "a" && s3 === "settings") { - return "/a/profile/settings"; - } - if (s1 === "b" && s3 === "settings") { - return "/b/profile/settings"; - } - if (s1 === "users" && s3 === "settings") { - return "/users/profile/settings"; - } - } - if (s2 === "bar" && s3 === "foo") { - propose(38, "/$id/bar/foo"); - } - if (s2 === "foo" && s3 === "bar") { - propose(39, "/$id/foo/bar"); - } - } - } - if (l >= 3 && rank > 27) { - if ( - s1 === "cache" && - s2.startsWith("temp_") && - baseSegments[l - 1].endsWith(".log") - ) { - propose(27, "/cache/temp_{$}.log"); - } - if (s1 === "images" && s2.startsWith("thumb_")) { - propose(28, "/images/thumb_{$}"); - } - if (s1 === "logs" && baseSegments[l - 1].endsWith(".txt")) { - propose(29, "/logs/{$}.txt"); - } - } - if (l >= 2 && rank > 30) { - if (s1 === "a") { - propose(30, "/a/$"); - } - if (s1 === "b") { - propose(31, "/b/$"); - } - if (s1 === "files") { - propose(32, "/files/$"); - } - } - return path; - " - `) - }) - - const buildMatcher = new Function('parsePathname', 'from', fn) as ( - parser: typeof parsePathname, - from: string, - ) => string | undefined - - const wrappedMatcher = (from: string): string | undefined => { - return buildMatcher(parsePathname, from) - } - - // WARN: some of these don't work yet, they're just here to show the differences - test.each([ - '', - '/', - '/users/profile/settings', - '/foo/123', - '/foo/123/', - '/b/123', - '/foo/qux', - '/foo/123/qux', - '/foo/qux', - '/a/user-123', - '/a/123', - '/a/123/more', - '/files', - '/files/hello-world.txt', - '/something/foo/bar', - '/files/deep/nested/file.json', - '/files/', - '/images/thumb_200x300.jpg', - '/logs/error.txt', - '/cache/temp_user456.log', - '/a/b/c/d/e', - ])('matching %s', (s) => { - const originalMatch = originalMatcher(s) - const buildMatch = wrappedMatcher(s) - console.log( - `matching: ${s}, originalMatch: ${originalMatch}, buildMatch: ${buildMatch}`, - ) - expect(buildMatch).toBe(originalMatch) - }) -}) diff --git a/packages/router-core/tests/builtWithParams.test.ts b/packages/router-core/tests/builtWithParams.test.ts deleted file mode 100644 index 47d59436686..00000000000 --- a/packages/router-core/tests/builtWithParams.test.ts +++ /dev/null @@ -1,743 +0,0 @@ -import { describe, expect, it, test } from 'vitest' -import { - joinPaths, - matchPathname, - parsePathname, - processRouteTree, -} from '../src' -import { - SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PARAM, - SEGMENT_TYPE_PATHNAME, - SEGMENT_TYPE_WILDCARD, - type Segment, -} from '../src/path' -import { format } from "prettier" - -interface TestRoute { - id: string - isRoot?: boolean - path?: string - fullPath: string - rank?: number - parentRoute?: TestRoute - children?: Array - options?: { - caseSensitive?: boolean - } -} - -type PathOrChildren = string | [string, Array] - -function createRoute( - pathOrChildren: Array, - parentPath: string, -): Array { - return pathOrChildren.map((route) => { - if (Array.isArray(route)) { - const fullPath = joinPaths([parentPath, route[0]]) - const children = createRoute(route[1], fullPath) - const r = { - id: fullPath, - path: route[0], - fullPath, - children: children, - } - children.forEach((child) => { - child.parentRoute = r - }) - - return r - } - - const fullPath = joinPaths([parentPath, route]) - - return { - id: fullPath, - path: route, - fullPath, - } - }) -} - -function createRouteTree(pathOrChildren: Array): TestRoute { - return { - id: '__root__', - fullPath: '', - isRoot: true, - path: undefined, - children: createRoute(pathOrChildren, ''), - } -} - -const routeTree = createRouteTree([ - '/', - '/users/profile/settings', // static-deep (longest static path) - '/users/profile', // static-medium (medium static path) - '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) - '/users/$id', // param-simple (plain param) - '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) - '/files/$', // wildcard (lowest priority) - '/about', // static-shallow (shorter static path) - '/a/profile/settings', - '/a/profile', - '/a/user-{$id}', - '/a/$id', - '/a/{-$slug}', - '/a/$', - '/a', - '/b/profile/settings', - '/b/profile', - '/b/user-{$id}', - '/b/$id', - '/b/{-$slug}', - '/b/$', - '/b', - '/foo/bar/$id', - '/foo/$id/bar', - '/foo/$bar', - '/foo/$bar/', - '/foo/{-$bar}/qux', - '/$id/bar/foo', - '/$id/foo/bar', - '/a/b/c/d/e/f', - '/beep/boop', - '/one/two', - '/one', - '/z/y/x/w', - '/z/y/x/v', - '/z/y/x/u', - '/z/y/x', - '/images/thumb_{$}', // wildcard with prefix - '/logs/{$}.txt', // wildcard with suffix - '/cache/temp_{$}.log', // wildcard with prefix and suffix -]) - -const result = processRouteTree({ routeTree }) - -function originalMatcher(from: string, fuzzy?: boolean): readonly [string, Record] | undefined { - let match - for (const route of result.flatRoutes) { - const result = matchPathname('/', from, { to: route.fullPath, fuzzy }) - if (result) { - match = [route.fullPath, result] as const - break - } - } - return match -} - -describe('work in progress', () => { - it('is ordrered', () => { - expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` - [ - "/a/b/c/d/e/f", - "/z/y/x/u", - "/z/y/x/v", - "/z/y/x/w", - "/a/profile/settings", - "/b/profile/settings", - "/users/profile/settings", - "/z/y/x", - "/foo/bar/$id", - "/a/profile", - "/b/profile", - "/beep/boop", - "/one/two", - "/users/profile", - "/foo/$id/bar", - "/foo/{-$bar}/qux", - "/a/user-{$id}", - "/api/user-{$id}", - "/b/user-{$id}", - "/foo/$bar/", - "/a/$id", - "/b/$id", - "/foo/$bar", - "/users/$id", - "/a/{-$slug}", - "/b/{-$slug}", - "/posts/{-$slug}", - "/cache/temp_{$}.log", - "/images/thumb_{$}", - "/logs/{$}.txt", - "/a/$", - "/b/$", - "/files/$", - "/a", - "/about", - "/b", - "/one", - "/", - "/$id/bar/foo", - "/$id/foo/bar", - ] - `) - }) - - const parsedRoutes = result.flatRoutes.map((route) => ({ - path: route.fullPath, - segments: parsePathname(route.fullPath), - })) - - type ParsedRoute = { - path: string - segments: ReturnType - } - - const segmentToConditions = (segment: Segment, index: number): Array => { - if (segment.type === SEGMENT_TYPE_WILDCARD) { - const conditions = [] - if (segment.prefixSegment) { - conditions.push(`s${index}.startsWith('${segment.prefixSegment}')`) - } - if (segment.suffixSegment) { - conditions.push(`s[l - 1].endsWith('${segment.suffixSegment}')`) - } - return conditions - } - if (segment.type === SEGMENT_TYPE_PARAM || segment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { - const conditions = [] - if (segment.prefixSegment) { - conditions.push(`s${index}.startsWith('${segment.prefixSegment}')`) - } - if (segment.suffixSegment) { - conditions.push(`s${index}.endsWith('${segment.suffixSegment}')`) - } - return conditions - } - if (segment.type === SEGMENT_TYPE_PATHNAME) { - if (index === 0 && segment.value === '/') return [] - return [`s${index} === '${segment.value}'`] - } - throw new Error(`Unknown segment type: ${segment.type}`) - } - - const routeSegmentsToConditions = (segments: ReadonlyArray, startIndex: number, additionalSegments: number = 0) => { - let hasWildcard = false - return Array.from({ length: additionalSegments + 1 }).flatMap((_, i) => { - if (hasWildcard) return '' // Wildcards consume all remaining segments, no check needed - const segment = segments[startIndex + i]! - if (segment.type === SEGMENT_TYPE_WILDCARD) { - hasWildcard = true - } - return segmentToConditions(segment, startIndex + i) - }) - .filter(Boolean) - .join(' && ') - } - - const needsSameSegment = (a: Segment, b?: Segment) => { - if (!b) return false - const sameStructure = a.type === b.type && - a.prefixSegment === b.prefixSegment && - a.suffixSegment === b.suffixSegment - if (a.type === SEGMENT_TYPE_PATHNAME) { - return sameStructure && a.value === b.value - } - return sameStructure - } - - const groupRoutesInOrder = (candidates: Array, length: number) => { - // find which (if any) of the routes will be considered fully matched after these checks - // and sort the other routes into "before the leaf" and "after the leaf", so we can print them in the right order - const deeperBefore: Array = [] - const deeperAfter: Array = [] - let leaf: ParsedRoute | undefined - for (const c of candidates) { - const isLeaf = c.segments.length <= length - if (isLeaf && !leaf) { - leaf = c - continue - } - if (isLeaf) { - continue // ignore subsequent leaves, they can never be matched - } - if (!leaf) { - deeperBefore.push(c) - } else { - deeperAfter.push(c) - } - } - return [deeperBefore, leaf, deeperAfter] as const - } - - function outputRoute(route: ParsedRoute, length: number) { - /** - * return [ - * route.path, - * { foo: s2, bar: s4 } - * ] - */ - let result = `{` - let hasWildcard = false - for (let i = 0; i < route.segments.length; i++) { - const segment = route.segments[i]! - if (segment.type === SEGMENT_TYPE_PARAM) { - const name = segment.value.replace(/^\$/, '') - const value = `s${i}` - if (segment.prefixSegment && segment.suffixSegment) { - result += `${name}: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` - } else if (segment.prefixSegment) { - result += `${name}: ${value}.slice(${segment.prefixSegment.length}), ` - } else if (segment.suffixSegment) { - result += `${name}: ${value}.slice(0, -${segment.suffixSegment.length}), ` - } else { - result += `${name}: ${value}, ` - } - } else if (segment.type === SEGMENT_TYPE_WILDCARD) { - hasWildcard = true - const value = `s.slice(${i}).join('/')` - if (segment.prefixSegment && segment.suffixSegment) { - result += `_splat: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` - result += `'*': ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` - } else if (segment.prefixSegment) { - result += `_splat: ${value}.slice(${segment.prefixSegment.length}), ` - result += `'*': ${value}.slice(${segment.prefixSegment.length}), ` - } else if (segment.suffixSegment) { - result += `_splat: ${value}.slice(0, -${segment.suffixSegment.length}), ` - result += `'*': ${value}.slice(0, -${segment.suffixSegment.length}), ` - } else { - result += `_splat: ${value}, ` - result += `'*': ${value}, ` - } - break - } - } - result += `}` - return hasWildcard - ? `return ['${route.path}', ${result}];` - : `return ['${route.path}', params(${result}, ${length})];` - } - - function recursiveStaticMatch( - parsedRoutes: Array, - depth = 0, - fixedLength = false, - ) { - const resolved = new Set() - for (let i = 0; i < parsedRoutes.length; i++) { - const route = parsedRoutes[i]! - if (resolved.has(route)) continue // already resolved - if (route.segments.length <= depth) { - throw new Error( - 'Implementation error: this should not happen, depth=' + - depth + - `, route=${route.path}`, - ) - } - - // group together all subsequent routes that require the same "next-segment conditions" - const candidates = [route] - const compareIndex = route.segments.length > 1 ? depth || 1 : depth - const compare = route.segments[compareIndex]! - for (let j = i + 1; j < parsedRoutes.length; j++) { - const nextRoute = parsedRoutes[j]! - if (resolved.has(nextRoute)) continue // already resolved - const routeSegment = nextRoute.segments[compareIndex] - if (needsSameSegment(compare, routeSegment)) { - candidates.push(nextRoute) - } else { - break // no more candidates in this group - } - } - - outputConditionGroup: if (candidates.length > 1) { - // Determine how many segments the routes in this group have in common - let skipDepth = route.segments.slice(depth + 1).findIndex((s, i) => - candidates.some((c) => !needsSameSegment(s, c.segments[depth + 1 + i])), - ) - // If no segment differ at all, match everything - if (skipDepth === -1) skipDepth = route.segments.length - depth - 1 - - const lCondition = - !fixedLength && (skipDepth || (depth > 0)) ? `l > ${depth + skipDepth}` : '' - - // Accumulate all the conditions for all segments in the group - const skipConditions = routeSegmentsToConditions(candidates[0]!.segments, depth, skipDepth) - if (!skipConditions) { // we grouped by "next-segment conditions", but this didn't result in any conditions, bail out - candidates.length = 1 - break outputConditionGroup - } - - const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, depth + 1 + skipDepth) - const hasCondition = Boolean(lCondition || skipConditions) && (deeperBefore.length > 0 || deeperAfter.length > 0) - if (hasCondition) { - fn += `if (${lCondition}${lCondition && skipConditions ? ' && ' : ''}${skipConditions}) {` - } - if (deeperBefore.length > 0) { - recursiveStaticMatch( - deeperBefore, - depth + 1 + skipDepth, - fixedLength, - ) - } - if (leaf) { - if (fixedLength) { - fn += outputRoute(leaf, leaf.segments.length) - } else { - fn += `if (length(${leaf.segments.length})) ${outputRoute(leaf, leaf.segments.length)}` - } - } - if (deeperAfter.length > 0 && !(leaf && fixedLength)) { - recursiveStaticMatch( - deeperAfter, - depth + 1 + skipDepth, - fixedLength, - ) - } - if (hasCondition) { - fn += '}' - } - candidates.forEach((c) => resolved.add(c)) - continue - } - - - const wildcardIndex = route.segments.findIndex( - (s) => s && s.type === SEGMENT_TYPE_WILDCARD, - ) - if (wildcardIndex > -1 && wildcardIndex < depth) { - throw new Error( - `Implementation error: wildcard segment at index ${wildcardIndex} cannot be before depth ${depth} in route ${route.path}`, - ) - } - - // couldn't group by segment, try grouping by length (exclude wildcard routes because of their variable length) - if (wildcardIndex === -1 && !fixedLength) { - for (let j = i + 1; j < parsedRoutes.length; j++) { - const nextRoute = parsedRoutes[j]! - if (resolved.has(nextRoute)) continue // already resolved - if (nextRoute.segments.length === route.segments.length && !nextRoute.segments.some((s) => s.type === SEGMENT_TYPE_WILDCARD)) { - candidates.push(nextRoute) - } else { - break // no more candidates in this group - } - } - if (candidates.length > 1) { - const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, route.segments.length) - if (leaf && deeperBefore.length || leaf && deeperAfter.length) { - throw new Error(`Implementation error: length-based leaf route ${leaf.path} should not have deeper routes, but has ${deeperBefore.length} before and ${deeperAfter.length} after`) - } - fn += `if (length(${route.segments.length})) {` - recursiveStaticMatch( - candidates, - depth, - true - ) - fn += '}' - candidates.forEach((c) => resolved.add(c)) - continue - } - } - - // try grouping wildcard routes that would have the same base length - if (wildcardIndex > -1 && !fixedLength) { - for (let j = i + 1; j < parsedRoutes.length; j++) { - const nextRoute = parsedRoutes[j]! - if (resolved.has(nextRoute)) continue // already resolved - if (nextRoute.segments.length === route.segments.length && wildcardIndex === nextRoute.segments.findIndex((s) => s.type === SEGMENT_TYPE_WILDCARD)) { - candidates.push(nextRoute) - } else { - break // no more candidates in this group - } - } - if (candidates.length > 2) { - const [deeperBefore, leaf, deeperAfter] = groupRoutesInOrder(candidates, route.segments.length) - if (leaf && deeperBefore.length || leaf && deeperAfter.length) { - throw new Error(`Implementation error: wildcard-based leaf route ${leaf.path} should not have deeper routes, but has ${deeperBefore.length} before and ${deeperAfter.length} after`) - } - fn += `if (l >= ${wildcardIndex}) {` - recursiveStaticMatch( - candidates, - depth, - true - ) - fn += '}' - candidates.forEach((c) => resolved.add(c)) - continue - } - } - - // couldn't group at all, just output a single-route match, and let the next iteration handle the rest - if (wildcardIndex === -1) { - const conditions = routeSegmentsToConditions(route.segments, depth, route.segments.length - depth - 1) - const lCondition = fixedLength ? '' : `length(${route.segments.length})` - fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) ${outputRoute(route, route.segments.length)}` - } else { - const conditions = routeSegmentsToConditions(route.segments, depth, wildcardIndex - depth) - const lCondition = fixedLength ? '' : `l >= ${wildcardIndex}` - fn += `if (${lCondition}${lCondition && conditions ? ' && ' : ''}${conditions}) ${outputRoute(route, route.segments.length)}` - } - resolved.add(route) - } - } - - let fn = 'const s = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' - fn += '\nconst l = s.length;' - fn += '\nconst length = fuzzy ? (n) => l >= n : (n) => l === n;' - fn += '\nconst params = fuzzy ? (p, n) => { if (n && l > n) p[\'**\'] = s.slice(n).join(\'/\'); return p } : (p) => p' - - const max = parsedRoutes.reduce( - (max, r) => Math.max(max, r.segments.length), - 0, - ) - if (max > 0) fn += `\nconst [,${Array.from({ length: max }, (_, i) => `s${i + 1}`).join(', ')}] = s;` - - - // we duplicate routes that end in a static `/`, so they're also matched if that final `/` is not present - function prepareIndexRoutes( - parsedRoutes: Array, - ): Array { - const result: Array = [] - for (const route of parsedRoutes) { - result.push(route) - const last = route.segments.at(-1)! - if (route.segments.length > 1 && last.type === SEGMENT_TYPE_PATHNAME && last.value === '/') { - const clone: ParsedRoute = { - ...route, - segments: route.segments.slice(0, -1), - } - result.push(clone) - } - } - return result - } - - // we replace routes w/ optional params, with - // - 1 version where it's a regular param - // - 1 version where it's removed entirely - function prepareOptionalParams( - parsedRoutes: Array, - ): Array { - const result: Array = [] - for (const route of parsedRoutes) { - const index = route.segments.findIndex( - (s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM, - ) - if (index === -1) { - result.push(route) - continue - } - // for every optional param in the route, we need to push a version of the route without it, and a version of the route with it as a regular param - // example: - // /foo/{-$bar}/qux => [/foo/qux, /foo/$bar/qux] - // /a/{-$b}/c/{-$d} => [/a/c, /a/c/$d, /a/$b/c, /a/$b/c/$d] - const withRegular: ParsedRoute = { - ...route, - segments: route.segments.map((s, i) => - i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s, - ), - } - const withoutOptional: ParsedRoute = { - ...route, - segments: route.segments.filter((_, i) => i !== index), - } - const chunk = prepareOptionalParams([withRegular, withoutOptional]) - result.push(...chunk) - } - return result - } - - const all = prepareOptionalParams( - prepareIndexRoutes( - parsedRoutes - ), - ) - - recursiveStaticMatch(all) - - it('generates a matching function', async () => { - expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` - "const s = parsePathname(from[0] === "/" ? from : "/" + from).map( - (s) => s.value, - ); - const l = s.length; - const length = fuzzy ? (n) => l >= n : (n) => l === n; - const params = fuzzy - ? (p, n) => { - if (n && l > n) p["**"] = s.slice(n).join("/"); - return p; - } - : (p) => p; - const [, s1, s2, s3, s4, s5, s6, s7] = s; - if ( - length(7) && - s1 === "a" && - s2 === "b" && - s3 === "c" && - s4 === "d" && - s5 === "e" && - s6 === "f" - ) - return ["/a/b/c/d/e/f", params({}, 7)]; - if (l > 3 && s1 === "z" && s2 === "y" && s3 === "x") { - if (length(5)) { - if (s4 === "u") return ["/z/y/x/u", params({}, 5)]; - if (s4 === "v") return ["/z/y/x/v", params({}, 5)]; - if (s4 === "w") return ["/z/y/x/w", params({}, 5)]; - } - } - if (length(4)) { - if (s1 === "a" && s2 === "profile" && s3 === "settings") - return ["/a/profile/settings", params({}, 4)]; - if (s1 === "b" && s2 === "profile" && s3 === "settings") - return ["/b/profile/settings", params({}, 4)]; - if (s1 === "users" && s2 === "profile" && s3 === "settings") - return ["/users/profile/settings", params({}, 4)]; - if (s1 === "z" && s2 === "y" && s3 === "x") return ["/z/y/x", params({}, 4)]; - if (s1 === "foo" && s2 === "bar") - return ["/foo/bar/$id", params({ id: s3 }, 4)]; - } - if (length(3)) { - if (s1 === "a" && s2 === "profile") return ["/a/profile", params({}, 3)]; - if (s1 === "b" && s2 === "profile") return ["/b/profile", params({}, 3)]; - if (s1 === "beep" && s2 === "boop") return ["/beep/boop", params({}, 3)]; - if (s1 === "one" && s2 === "two") return ["/one/two", params({}, 3)]; - if (s1 === "users" && s2 === "profile") - return ["/users/profile", params({}, 3)]; - } - if (l > 1 && s1 === "foo") { - if (length(4)) { - if (s3 === "bar") return ["/foo/$id/bar", params({ id: s2 }, 4)]; - if (s3 === "qux") return ["/foo/{-$bar}/qux", params({ bar: s2 }, 4)]; - } - if (length(3) && s2 === "qux") return ["/foo/{-$bar}/qux", params({}, 3)]; - } - if (length(3)) { - if (s1 === "a" && s2.startsWith("user-")) - return ["/a/user-{$id}", params({ id: s2.slice(5) }, 3)]; - if (s1 === "api" && s2.startsWith("user-")) - return ["/api/user-{$id}", params({ id: s2.slice(5) }, 3)]; - if (s1 === "b" && s2.startsWith("user-")) - return ["/b/user-{$id}", params({ id: s2.slice(5) }, 3)]; - } - if (l > 2 && s1 === "foo") { - if (length(4) && s3 === "/") return ["/foo/$bar/", params({ bar: s2 }, 4)]; - if (length(3)) return ["/foo/$bar/", params({ bar: s2 }, 3)]; - } - if (length(3)) { - if (s1 === "a") return ["/a/$id", params({ id: s2 }, 3)]; - if (s1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; - if (s1 === "foo") return ["/foo/$bar", params({ bar: s2 }, 3)]; - if (s1 === "users") return ["/users/$id", params({ id: s2 }, 3)]; - if (s1 === "a") return ["/a/{-$slug}", params({ slug: s2 }, 3)]; - } - if (length(2) && s1 === "a") return ["/a/{-$slug}", params({}, 2)]; - if (l > 1 && s1 === "b") { - if (length(3)) return ["/b/{-$slug}", params({ slug: s2 }, 3)]; - if (length(2)) return ["/b/{-$slug}", params({}, 2)]; - } - if (l > 1 && s1 === "posts") { - if (length(3)) return ["/posts/{-$slug}", params({ slug: s2 }, 3)]; - if (length(2)) return ["/posts/{-$slug}", params({}, 2)]; - } - if (l >= 2) { - if (s1 === "cache" && s2.startsWith("temp_") && s[l - 1].endsWith(".log")) - return [ - "/cache/temp_{$}.log", - { - _splat: s.slice(2).join("/").slice(5, -4), - "*": s.slice(2).join("/").slice(5, -4), - }, - ]; - if (s1 === "images" && s2.startsWith("thumb_")) - return [ - "/images/thumb_{$}", - { - _splat: s.slice(2).join("/").slice(6), - "*": s.slice(2).join("/").slice(6), - }, - ]; - if (s1 === "logs" && s[l - 1].endsWith(".txt")) - return [ - "/logs/{$}.txt", - { - _splat: s.slice(2).join("/").slice(0, -4), - "*": s.slice(2).join("/").slice(0, -4), - }, - ]; - if (s1 === "a") - return [ - "/a/$", - { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, - ]; - if (s1 === "b") - return [ - "/b/$", - { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, - ]; - if (s1 === "files") - return [ - "/files/$", - { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, - ]; - } - if (length(2)) { - if (s1 === "a") return ["/a", params({}, 2)]; - if (s1 === "about") return ["/about", params({}, 2)]; - if (s1 === "b") return ["/b", params({}, 2)]; - if (s1 === "one") return ["/one", params({}, 2)]; - } - if (length(1)) return ["/", params({}, 1)]; - if (length(4)) { - if (s2 === "bar" && s3 === "foo") - return ["/$id/bar/foo", params({ id: s1 }, 4)]; - if (s2 === "foo" && s3 === "bar") - return ["/$id/foo/bar", params({ id: s1 }, 4)]; - } - " - `) - }) - - const buildMatcher = new Function('parsePathname', 'from', 'fuzzy', fn) as ( - parser: typeof parsePathname, - from: string, - fuzzy?: boolean - ) => readonly [path: string, params: Record] | undefined - - test.each([ - '', - '/', - '/users/profile/settings', - '/foo/123', - '/foo/123/', - '/b/123', - '/foo/qux', - '/foo/123/qux', - '/a/user-123', - '/a/123', - '/a/123/more', - '/files', - '/files/hello-world.txt', - '/something/foo/bar', - '/files/deep/nested/file.json', - '/files/', - '/images/thumb_200x300.jpg', - '/logs/2020/01/01/error.txt', - '/cache/temp_user456.log', - '/a/b/c/d/e', - ])('matching %s', (s) => { - const originalMatch = originalMatcher(s) - const buildMatch = buildMatcher(parsePathname, s) - console.log( - `matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]}`, - ) - expect(buildMatch).toEqual(originalMatch) - }) - - // WARN: some of these don't work yet, they're just here to show the differences - test.each([ - '/users/profile/settings/hello', - '/a/b/c/d/e/f/g', - '/foo/bar/baz', - '/foo/bar/baz/qux', - ])('fuzzy matching %s', (s) => { - const originalMatch = originalMatcher(s, true) - const buildMatch = buildMatcher(parsePathname, s, true) - console.log( - `fuzzy matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]} ${JSON.stringify(buildMatch?.[1])}`, - ) - expect(buildMatch).toEqual(originalMatch) - }) -}) diff --git a/packages/router-core/tests/builtWithParams3.test.ts b/packages/router-core/tests/builtWithParams3.test.ts index ac090debb97..b103116a053 100644 --- a/packages/router-core/tests/builtWithParams3.test.ts +++ b/packages/router-core/tests/builtWithParams3.test.ts @@ -1,17 +1,13 @@ import { describe, expect, it, test } from 'vitest' -import { format } from "prettier" +import { format } from 'prettier' import { joinPaths, matchPathname, parsePathname, processRouteTree, } from '../src' -import { - SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PARAM, - SEGMENT_TYPE_PATHNAME, - SEGMENT_TYPE_WILDCARD, -} from '../src/path' +import { compileMatcher } from '../src/compile-matcher' +import type { CompiledMatcher } from '../src/compile-matcher' interface TestRoute { id: string @@ -121,7 +117,10 @@ const routeTree = createRouteTree([ const result = processRouteTree({ routeTree }) -function originalMatcher(from: string, fuzzy?: boolean): readonly [string, Record] | undefined { +function originalMatcher( + from: string, + fuzzy?: boolean, +): readonly [string, Record] | undefined { let match for (const route of result.flatRoutes) { const result = matchPathname('/', from, { to: route.fullPath, fuzzy }) @@ -181,513 +180,11 @@ describe('work in progress', () => { `) }) - const parsedRoutes = result.flatRoutes.map((route) => ({ - path: route.fullPath, - segments: parsePathname(route.fullPath), - })) - - type ParsedRoute = { - path: string - segments: ReturnType - } - - - // we duplicate routes that end in a static `/`, so they're also matched if that final `/` is not present - function prepareIndexRoutes( - parsedRoutes: Array, - ): Array { - const result: Array = [] - for (const route of parsedRoutes) { - result.push(route) - const last = route.segments.at(-1)! - if (route.segments.length > 1 && last.type === SEGMENT_TYPE_PATHNAME && last.value === '/') { - const clone: ParsedRoute = { - ...route, - segments: route.segments.slice(0, -1), - } - result.push(clone) - } - } - return result - } - - // we replace routes w/ optional params, with - // - 1 version where it's a regular param - // - 1 version where it's removed entirely - function prepareOptionalParams( - parsedRoutes: Array, - ): Array { - const result: Array = [] - for (const route of parsedRoutes) { - const index = route.segments.findIndex( - (s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM, - ) - if (index === -1) { - result.push(route) - continue - } - // for every optional param in the route, we need to push a version of the route without it, and a version of the route with it as a regular param - // example: - // /foo/{-$bar}/qux => [/foo/qux, /foo/$bar/qux] - // /a/{-$b}/c/{-$d} => [/a/c, /a/c/$d, /a/$b/c, /a/$b/c/$d] - const withRegular: ParsedRoute = { - ...route, - segments: route.segments.map((s, i) => - i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s, - ), - } - const withoutOptional: ParsedRoute = { - ...route, - segments: route.segments.filter((_, i) => i !== index), - } - const chunk = prepareOptionalParams([withRegular, withoutOptional]) - result.push(...chunk) - } - return result - } - - type Condition = - | { key: string, type: 'static-insensitive', index: number, value: string } - | { key: string, type: 'static-sensitive', index: number, value: string } - | { key: string, type: 'length', direction: 'eq' | 'gte' | 'lte', value: number } - | { key: string, type: 'startsWith', index: number, value: string } - | { key: string, type: 'endsWith', index: number, value: string } - | { key: string, type: 'globalEndsWith', value: string } - - // each segment of a route can have zero or more conditions that needs to be met for the route to match - function toConditions(routes: Array) { - return routes.map((route) => { - const conditions: Array = [] - - let hasWildcard = false - let minLength = 0 - for (let i = 0; i < route.segments.length; i++) { - const segment = route.segments[i]! - if (segment.type === SEGMENT_TYPE_PATHNAME) { - minLength += 1 - if (i === 0 && segment.value === '/') continue // skip leading slash - const value = segment.value - if (route.caseSensitive) { - conditions.push({ type: 'static-sensitive', index: i, value, key: `static_sensitive_${i}_${value}` }) - } else { - conditions.push({ type: 'static-insensitive', index: i, value: value.toLowerCase(), key: `static_insensitive_${i}_${value.toLowerCase()}` }) - } - continue - } - if (segment.type === SEGMENT_TYPE_PARAM) { - minLength += 1 - if (segment.prefixSegment) { - conditions.push({ type: 'startsWith', index: i, value: segment.prefixSegment, key: `startsWith_${i}_${segment.prefixSegment}` }) - } - if (segment.suffixSegment) { - conditions.push({ type: 'endsWith', index: i, value: segment.suffixSegment, key: `endsWith_${i}_${segment.suffixSegment}` }) - } - continue - } - if (segment.type === SEGMENT_TYPE_WILDCARD) { - hasWildcard = true - if (segment.prefixSegment) { - conditions.push({ type: 'startsWith', index: i, value: segment.prefixSegment, key: `startsWith_${i}_${segment.prefixSegment}` }) - } - if (segment.suffixSegment) { - conditions.push({ type: 'globalEndsWith', value: segment.suffixSegment, key: `globalEndsWith_${i}_${segment.suffixSegment}` }) - } - if (segment.suffixSegment || segment.prefixSegment) { - minLength += 1 - } - continue - } - throw new Error(`Unhandled segment type: ${segment.type}`) - } - - if (hasWildcard) { - conditions.push({ type: 'length', direction: 'gte', value: minLength, key: `length_gte_${minLength}` }) - } else { - conditions.push({ type: 'length', direction: 'eq', value: minLength, key: `length_eq_${minLength}` }) - } - - return { - ...route, - conditions, - } - }) - } - - type LeafNode = { type: 'leaf', conditions: Array, route: ParsedRoute, parent: BranchNode | RootNode } - type RootNode = { type: 'root', children: Array } - type BranchNode = { type: 'branch', conditions: Array, children: Array, parent: BranchNode | RootNode } - - const all = toConditions(prepareOptionalParams(prepareIndexRoutes(parsedRoutes))) - - // We start by building a flat tree with all routes as leaf nodes, all children of the root node. - const tree: RootNode = { type: 'root', children: [] } - for (const { conditions, path, segments } of all) { - tree.children.push({ type: 'leaf', route: { path, segments }, parent: tree, conditions }) - } - - expandTree(tree) - contractTree(tree) - - let fn = '' - fn += printHead(all) - fn += printTree(tree) - - console.log('Tree built with', all.length, 'routes and', tree.children.length, 'top-level nodes') - - /** - * Recursively expand each node of the tree until there is only one child left - * - * For each child node in a parent node, we try to find subsequent siblings that would share the same condition to be matched. - * If we find any, we group them together into a new branch node that replaces the original child node and the grouped siblings in the parent node. - * - * We repeat the process in each newly created branch node until there is only one child left in each branch node. - * - * This turns - * ``` - * if (a && b && c && d) return route1; - * if (a && b && e && f) return route2; - * ``` - * into - * ``` - * if (a && b) { - * if (c) { if (d) return route1; } - * if (e) { if (f) return route2; } - * } - * ``` - * - */ - function expandTree(tree: RootNode) { - const stack: Array = [tree] - while (stack.length > 0) { - const node = stack.shift()! - if (node.children.length <= 1) continue - - const resolved = new Set() - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]! - if (resolved.has(child)) continue - - // segment-based conditions should try to group as many children as possible - const bestCondition = findBestCondition(node, i, node.children.length - i - 1) - // length-based conditions should try to group as few children as possible - const bestLength = findBestLength(node, i, 0) - - if (bestCondition.score === Infinity && bestLength.score === Infinity) { - // no grouping possible, just add the child as is - resolved.add(child) - continue - } - - const selected = bestCondition.score < bestLength.score ? bestCondition : bestLength - const condition = selected.condition! - const newNode: BranchNode = { - type: 'branch', - conditions: [condition], - children: selected.candidates, - parent: node, - } - node.children.splice(i, selected.candidates.length, newNode) - stack.push(newNode) - resolved.add(newNode) - for (const c of selected.candidates) { - c.conditions = c.conditions.filter((sc) => sc.key !== condition.key) - } - - // find all conditions that are shared by all candidates, and lift them to the new node - for (const condition of newNode.children[0]!.conditions) { - if (newNode.children.every((c) => c.conditions.some((sc) => sc.key === condition.key))) { - newNode.conditions.push(condition) - } - } - for (let i = 1; i < newNode.conditions.length; i++) { - const condition = newNode.conditions[i]! - for (const c of newNode.children) { - c.conditions = c.conditions.filter((sc) => sc.key !== condition.key) - } - } - } - } - } - - /** - * Recursively shorten branches that have a single child into a leaf node. - * - * For each branch node in the tree, if it has only one child, we can replace the branch node with that child node, - * and merge the conditions of the branch node into the child node. - * - * This turns - * `if (condition1) { if (condition2) { return route } }` - * into - * `if (condition1 && condition2) { return route }` - */ - function contractTree(tree: RootNode) { - const stack = tree.children.filter((c) => c.type === 'branch') - while (stack.length > 0) { - const node = stack.pop()! - if (node.children.length === 1) { - const child = node.children[0]! - node.parent.children.splice(node.parent.children.indexOf(node), 1, child) - child.parent = node.parent - child.conditions = [...node.conditions, ...child.conditions] - - // reduce length-based conditions into a single condition - const lengthConditions = child.conditions.filter(c => c.type === 'length') - if (lengthConditions.some(c => c.direction === 'eq')) { - for (const c of lengthConditions) { - if (c.direction === 'gte') { - child.conditions = child.conditions.filter(sc => sc.key !== c.key) - } - } - } else if (lengthConditions.length > 0) { - const minLength = Math.min(...lengthConditions.map(c => c.value)) - child.conditions = child.conditions.filter(c => c.type !== 'length') - child.conditions.push({ type: 'length', direction: 'eq', value: minLength, key: `length_eq_${minLength}` }) - } - } - for (const child of node.children) { - if (child.type === 'branch') { - stack.push(child) - } - } - } - } - - function printTree(node: RootNode | BranchNode | LeafNode) { - let str = '' - if (node.type === 'root') { - for (const child of node.children) { - str += printTree(child) - } - return str - } - if (node.conditions.length) { - str += 'if (' - str += printConditions(node.conditions) - str += ')' - } - if (node.type === 'branch') { - if (node.conditions.length && node.children.length) str += `{` - for (const child of node.children) { - str += printTree(child) - } - if (node.conditions.length && node.children.length) str += `}` - } else { - str += printRoute(node.route) - } - return str - } - - function printConditions(conditions: Array) { - const lengths = conditions.filter((c) => c.type === 'length') - const segment = conditions.filter((c) => c.type !== 'length') - const results: Array = [] - if (lengths.length > 1) { - const exact = lengths.find((c) => c.direction === 'eq') - if (exact) { - results.push(printCondition(exact)) - } else { - results.push(printCondition(lengths[0]!)) - } - } else if (lengths.length === 1) { - results.push(printCondition(lengths[0]!)) - } - for (const c of segment) { - results.push(printCondition(c)) - } - return results.join(' && ') - } - - function printCondition(condition: Condition) { - switch (condition.type) { - case 'static-sensitive': - return `s${condition.index} === '${condition.value}'` - case 'static-insensitive': - return `sc${condition.index} === '${condition.value}'` - case 'length': - if (condition.direction === 'eq') { - return `length(${condition.value})` - } else if (condition.direction === 'gte') { - return `l >= ${condition.value}` - } - break - case 'startsWith': - return `s${condition.index}.startsWith('${condition.value}')` - case 'endsWith': - return `s${condition.index}.endsWith('${condition.value}')` - case 'globalEndsWith': - return `s[l - 1].endsWith('${condition.value}')` - } - throw new Error(`Unhandled condition type: ${condition.type}`) - } - - function printRoute(route: ParsedRoute) { - const length = route.segments.length - /** - * return [ - * route.path, - * { foo: s2, bar: s4 } - * ] - */ - let result = `{` - let hasWildcard = false - for (let i = 0; i < route.segments.length; i++) { - const segment = route.segments[i]! - if (segment.type === SEGMENT_TYPE_PARAM) { - const name = segment.value.replace(/^\$/, '') - const value = `s${i}` - if (segment.prefixSegment && segment.suffixSegment) { - result += `${name}: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` - } else if (segment.prefixSegment) { - result += `${name}: ${value}.slice(${segment.prefixSegment.length}), ` - } else if (segment.suffixSegment) { - result += `${name}: ${value}.slice(0, -${segment.suffixSegment.length}), ` - } else { - result += `${name}: ${value}, ` - } - } else if (segment.type === SEGMENT_TYPE_WILDCARD) { - hasWildcard = true - const value = `s.slice(${i}).join('/')` - if (segment.prefixSegment && segment.suffixSegment) { - result += `_splat: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` - result += `'*': ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` - } else if (segment.prefixSegment) { - result += `_splat: ${value}.slice(${segment.prefixSegment.length}), ` - result += `'*': ${value}.slice(${segment.prefixSegment.length}), ` - } else if (segment.suffixSegment) { - result += `_splat: ${value}.slice(0, -${segment.suffixSegment.length}), ` - result += `'*': ${value}.slice(0, -${segment.suffixSegment.length}), ` - } else { - result += `_splat: ${value}, ` - result += `'*': ${value}, ` - } - break - } - } - result += `}` - return hasWildcard - ? `return ['${route.path}', ${result}];` - : `return ['${route.path}', params(${result}, ${length})];` - } - - function printHead(routes: Array }>) { - let head = 'const s = parsePathname(from[0] === "/" ? from : "/" + from).map((s) => s.value);' - head += 'const l = s.length;' - - // the `length()` function does exact match by default, but greater-than-or-equal match if `fuzzy` is true - head += 'const length = fuzzy ? (n) => l >= n : (n) => l === n;' - - // the `params()` function returns the params object, and if `fuzzy` is true, it also adds a `**` property with the remaining segments - head += 'const params = fuzzy ? (p, n) => { if (n && l > n) p[\'**\'] = s.slice(n).join(\'/\'); return p } : (p) => p;' - - // extract all segments from the input - // const [, s1, s2, s3] = s; - const max = routes.reduce( - (max, r) => Math.max(max, r.segments.length), - 0, - ) - if (max > 0) head += `const [,${Array.from({ length: max - 1 }, (_, i) => `s${i + 1}`).join(', ')}] = s;` - - // add toLowerCase version of each segment that is needed in a case-insensitive match - // const sc1 = s1?.toLowerCase(); - const caseInsensitiveSegments = new Set() - for (const route of routes) { - for (const condition of route.conditions) { - if (condition.type === 'static-insensitive') { - caseInsensitiveSegments.add(condition.index) - } - } - } - for (const index of caseInsensitiveSegments) { - head += `const sc${index} = s${index}?.toLowerCase();` - } - - return head - } - - function findBestCondition(node: RootNode | BranchNode, i: number, target: number) { - const child = node.children[i]! - let bestCondition: Condition | undefined - let bestMatchScore = Infinity - let bestCandidates = [child] - for (const c of child.conditions) { - const candidates = [child] - for (let j = i + 1; j < node.children.length; j++) { - const sibling = node.children[j]! - if (sibling.conditions.some((sc) => sc.key === c.key)) { - candidates.push(sibling) - } else { - break - } - } - const score = Math.abs(candidates.length - target) - if (score < bestMatchScore) { - bestMatchScore = score - bestCondition = c - bestCandidates = candidates - } - } - - return { score: bestMatchScore, condition: bestCondition, candidates: bestCandidates } - } - - function findBestLength(node: RootNode | BranchNode, i: number, target: number) { - const child = node.children[i]! - const childLengthCondition = child.conditions.find(c => c.type === 'length') - if (!childLengthCondition) { - return { score: Infinity, condition: undefined, candidates: [child] } - } - let currentMinLength = 1 - let exactLength = false - if (node.type !== 'root') { - let n = node - do { - const lengthCondition = n.conditions.find(c => c.type === 'length') - if (!lengthCondition) continue - if (lengthCondition.direction === 'eq') { - exactLength = true - break - } - if (lengthCondition.direction === 'gte') { - currentMinLength = lengthCondition.value - break - } - } while (n.parent.type === 'branch' && (n = n.parent)) - } - if (exactLength || currentMinLength >= childLengthCondition.value) { - return { score: Infinity, condition: undefined, candidates: [child] } - } - let bestMatchScore = Infinity - let bestLength: number | undefined - let bestCandidates = [child] - let bestExact = false - for (let l = currentMinLength + 1; l <= childLengthCondition.value; l++) { - const candidates = [child] - let exact = childLengthCondition.direction === 'eq' && l === childLengthCondition.value - for (let j = i + 1; j < node.children.length; j++) { - const sibling = node.children[j]! - const lengthCondition = sibling.conditions.find(c => c.type === 'length') - if (!lengthCondition) break - if (lengthCondition.value < l) break - candidates.push(sibling) - exact &&= lengthCondition.direction === 'eq' && lengthCondition.value === l - } - const score = Math.abs(candidates.length - target) - if (score < bestMatchScore) { - bestMatchScore = score - bestLength = l - bestCandidates = candidates - bestExact = exact - } - } - const condition: Condition = { type: 'length', direction: bestExact ? 'eq' : 'gte', value: bestLength!, key: `length_${bestExact ? 'eq' : 'gte'}_${bestLength}` } - return { score: bestMatchScore, condition, candidates: bestCandidates } - } - - + const fn = compileMatcher(result.flatRoutes) it('generates a matching function', async () => { expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` - "const s = parsePathname(from[0] === "/" ? from : "/" + from).map( + "const s = parsePathname(from[0] === "/" ? from : "/" + from, cache).map( (s) => s.value, ); const l = s.length; @@ -831,11 +328,13 @@ describe('work in progress', () => { `) }) - const buildMatcher = new Function('parsePathname', 'from', 'fuzzy', fn) as ( - parser: typeof parsePathname, - from: string, - fuzzy?: boolean - ) => readonly [path: string, params: Record] | undefined + const buildMatcher = new Function( + 'parsePathname', + 'from', + 'fuzzy', + 'cache', + fn, + ) as CompiledMatcher test.each([ '', @@ -868,7 +367,6 @@ describe('work in progress', () => { expect(buildMatch).toEqual(originalMatch) }) - // WARN: some of these don't work yet, they're just here to show the differences test.each([ '/users/profile/settings/hello', '/a/b/c/d/e/f/g', diff --git a/packages/router-core/tests/pathToParams.test.ts b/packages/router-core/tests/pathToParams.test.ts deleted file mode 100644 index b9644869547..00000000000 --- a/packages/router-core/tests/pathToParams.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { describe, expect, it, test } from 'vitest' -import { - joinPaths, - matchPathname, - parsePathname, - processRouteTree, -} from '../src' -import { - SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PARAM, - SEGMENT_TYPE_PATHNAME, - SEGMENT_TYPE_WILDCARD, - type Segment, -} from '../src/path' -import { format } from "prettier" - -interface TestRoute { - id: string - isRoot?: boolean - path?: string - fullPath: string - rank?: number - parentRoute?: TestRoute - children?: Array - options?: { - caseSensitive?: boolean - } -} - -type PathOrChildren = string | [string, Array] - -function createRoute( - pathOrChildren: Array, - parentPath: string, -): Array { - return pathOrChildren.map((route) => { - if (Array.isArray(route)) { - const fullPath = joinPaths([parentPath, route[0]]) - const children = createRoute(route[1], fullPath) - const r = { - id: fullPath, - path: route[0], - fullPath, - children: children, - } - children.forEach((child) => { - child.parentRoute = r - }) - - return r - } - - const fullPath = joinPaths([parentPath, route]) - - return { - id: fullPath, - path: route, - fullPath, - } - }) -} - -function createRouteTree(pathOrChildren: Array): TestRoute { - return { - id: '__root__', - fullPath: '', - isRoot: true, - path: undefined, - children: createRoute(pathOrChildren, ''), - } -} - -const routeTree = createRouteTree([ - '/', - '/users/profile/settings', // static-deep (longest static path) - '/users/profile', // static-medium (medium static path) - '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) - '/users/$id', // param-simple (plain param) - '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) - '/files/$', // wildcard (lowest priority) - '/about', // static-shallow (shorter static path) - '/a/profile/settings', - '/a/profile', - '/a/user-{$id}', - '/a/$id', - '/a/{-$slug}', - '/a/$', - '/a', - '/b/profile/settings', - '/b/profile', - '/b/user-{$id}', - '/b/$id', - '/b/{-$slug}', - '/b/$', - '/b', - '/foo/bar/$id', - '/foo/$id/bar', - '/foo/$bar', - '/foo/$bar/', - '/foo/{-$bar}/qux', - '/foo/{-$bar}/$baz/qux', - '/$id/bar/foo', - '/$id/foo/bar', - '/a/b/c/d/e/f', - '/beep/boop', - '/one/two', - '/one', - '/z/y/x/w', - '/z/y/x/v', - '/z/y/x/u', - '/z/y/x', - '/images/thumb_{$}', // wildcard with prefix - '/logs/{$}.txt', // wildcard with suffix - '/cache/temp_{$}.log', // wildcard with prefix and suffix -]) - -const result = processRouteTree({ routeTree }) - -function originalMatcher(from: string): string | undefined { - const match = result.flatRoutes.find((r) => - matchPathname('/', from, { to: r.fullPath }), - ) - return match?.fullPath -} - -describe('work in progress', () => { - it('is ordrered', () => { - expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` - [ - "/a/b/c/d/e/f", - "/z/y/x/u", - "/z/y/x/v", - "/z/y/x/w", - "/a/profile/settings", - "/b/profile/settings", - "/users/profile/settings", - "/z/y/x", - "/foo/bar/$id", - "/a/profile", - "/b/profile", - "/beep/boop", - "/one/two", - "/users/profile", - "/foo/$id/bar", - "/foo/{-$bar}/qux", - "/foo/{-$bar}/$baz/qux", - "/a/user-{$id}", - "/api/user-{$id}", - "/b/user-{$id}", - "/foo/$bar/", - "/a/$id", - "/b/$id", - "/foo/$bar", - "/users/$id", - "/a/{-$slug}", - "/b/{-$slug}", - "/posts/{-$slug}", - "/cache/temp_{$}.log", - "/images/thumb_{$}", - "/logs/{$}.txt", - "/a/$", - "/b/$", - "/files/$", - "/a", - "/about", - "/b", - "/one", - "/", - "/$id/bar/foo", - "/$id/foo/bar", - ] - `) - }) - - function segmentsToRegex(segments: ReadonlyArray): string | undefined { - if (segments.every((s) => s.type === SEGMENT_TYPE_PATHNAME)) return - let re = '' - for (let i = 0; i < segments.length; i++) { - const s = segments[i]! - if (s.type === SEGMENT_TYPE_PATHNAME) { - if (s.value === '/') { - if (i === segments.length - 1) { - re += '/?' - } - } else { - let skip = 0 - for (let j = i + 1; j < segments.length; j++) { - if (segments[j]!.type !== SEGMENT_TYPE_PATHNAME || segments[j]!.value === '/') { - break - } - skip++ - } - if (skip) { - re += `(/[^/]*){${skip + 1}}` - } else { - re += '/[^/]*' - } - } - } else if (s.type === SEGMENT_TYPE_PARAM) { - const prefix = s.prefixSegment ? RegExp.escape(s.prefixSegment) : '' - const suffix = s.suffixSegment ? RegExp.escape(s.suffixSegment) : '' - const name = s.value.replace(/^\$/, '') - const param = `(?<${name}>[^/]*)` - re += `/${prefix}${param}${suffix}` - } else if (s.type === SEGMENT_TYPE_OPTIONAL_PARAM) { - const name = s.value.replace(/^\$/, '') - const param = `(?<${name}>[^/]*)` - re += `(?:/${param})?` - } else if (s.type === SEGMENT_TYPE_WILDCARD) { - const prefix = s.prefixSegment ? RegExp.escape(s.prefixSegment) : '' - const suffix = s.suffixSegment ? RegExp.escape(s.suffixSegment) : '' - const param = `(?<_splat>.*)` - if (prefix || suffix) { - re += `/${prefix}${param}${suffix}` - } else { - re += `/?${param}` - } - break - } else { - throw new Error(`Unknown segment type: ${s.type}`) - } - } - return `^${re}$` - } - - const obj: Record = {} - - for (const route of result.flatRoutes) { - const segments = parsePathname(route.fullPath) - const re = segmentsToRegex(segments) - if (!re) continue - obj[route.fullPath] = re - } - - it('works', () => { - console.log(obj) - expect(obj).toMatchInlineSnapshot(` - { - "/$id/bar/foo": "^/(?[^/]*)(/[^/]*){2}/[^/]*$", - "/$id/foo/bar": "^/(?[^/]*)(/[^/]*){2}/[^/]*$", - "/a/$": "^/[^/]*/?(?<_splat>.*)$", - "/a/$id": "^/[^/]*/(?[^/]*)$", - "/a/user-{$id}": "^/[^/]*/\\x75ser\\x2d(?[^/]*)$", - "/a/{-$slug}": "^/[^/]*(?:/(?[^/]*))?$", - "/api/user-{$id}": "^/[^/]*/\\x75ser\\x2d(?[^/]*)$", - "/b/$": "^/[^/]*/?(?<_splat>.*)$", - "/b/$id": "^/[^/]*/(?[^/]*)$", - "/b/user-{$id}": "^/[^/]*/\\x75ser\\x2d(?[^/]*)$", - "/b/{-$slug}": "^/[^/]*(?:/(?[^/]*))?$", - "/cache/temp_{$}.log": "^/[^/]*/\\x74emp_(?<_splat>.*)\\.log$", - "/files/$": "^/[^/]*/?(?<_splat>.*)$", - "/foo/$bar": "^/[^/]*/(?[^/]*)$", - "/foo/$bar/": "^/[^/]*/(?[^/]*)/?$", - "/foo/$id/bar": "^/[^/]*/(?[^/]*)/[^/]*$", - "/foo/bar/$id": "^(/[^/]*){2}/[^/]*/(?[^/]*)$", - "/foo/{-$bar}/$baz/qux": "^/[^/]*(?:/(?[^/]*))?/(?[^/]*)/[^/]*$", - "/foo/{-$bar}/qux": "^/[^/]*(?:/(?[^/]*))?/[^/]*$", - "/images/thumb_{$}": "^/[^/]*/\\x74humb_(?<_splat>.*)$", - "/logs/{$}.txt": "^/[^/]*/(?<_splat>.*)\\.txt$", - "/posts/{-$slug}": "^/[^/]*(?:/(?[^/]*))?$", - "/users/$id": "^/[^/]*/(?[^/]*)$", - } - `) - }) - - function getParams(path: string, input: string) { - const str = obj[path] - if (!str) return {} - const match = new RegExp(str).exec(input) - return match?.groups || {} - } - - function isMatched(path: string, input: string) { - const str = obj[path] - if (!str) return false - return new RegExp(str).test(input) - } - - describe.each([ - ['/a/$id', '/a/123', { id: '123' }], - ['/a/{-$slug}', '/a/hello', { slug: 'hello' }], - ['/a/{-$slug}', '/a/', { slug: '' }], - ['/a/{-$slug}', '/a', { slug: undefined }], - ['/b/user-{$id}', '/b/user-123', { id: '123' }], - ['/logs/{$}.txt', '/logs/2022/01/01/error.txt', { _splat: '2022/01/01/error' }], - ['/foo/{-$bar}/qux', '/foo/hello/qux', { bar: 'hello' }], - ['/foo/{-$bar}/qux', '/foo/qux', { bar: undefined }], - ['/foo/{-$bar}/$baz/qux', '/foo/qux/qux', { bar: undefined, baz: 'qux' }], - ['/foo/$bar/', '/foo/qux/', { bar: 'qux' }], - ])('getParams(%s, %s) === %j', (path, input, expected) => { - it('matches', () => expect(isMatched(path, input)).toBeTruthy()) - it('returns', () => expect(getParams(path, input)).toEqual(expected)) - }) -}) \ No newline at end of file From ec6e4dcd7491968368082e10785e9b95c5e149e3 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 22:12:01 +0200 Subject: [PATCH 41/57] missing export --- packages/router-core/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 5e2737f0878..a95f302609e 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -104,9 +104,13 @@ export { removeBasepath, matchByPath, } from './path' -export { compileMatcher } from './compile-matcher' export type { Segment } from './path' + +export { compileMatcher } from './compile-matcher' +export type { CompiledMatcher } from './compile-matcher' + export { encode, decode } from './qss' + export { rootRouteId } from './root' export type { RootRouteId } from './root' From ead479587bffe3b53e1b9bb538532a35a093306a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 22:13:07 +0200 Subject: [PATCH 42/57] rename file --- .../router-core/tests/compile-matcher.test.ts | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 packages/router-core/tests/compile-matcher.test.ts diff --git a/packages/router-core/tests/compile-matcher.test.ts b/packages/router-core/tests/compile-matcher.test.ts new file mode 100644 index 00000000000..b103116a053 --- /dev/null +++ b/packages/router-core/tests/compile-matcher.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it, test } from 'vitest' +import { format } from 'prettier' +import { + joinPaths, + matchPathname, + parsePathname, + processRouteTree, +} from '../src' +import { compileMatcher } from '../src/compile-matcher' +import type { CompiledMatcher } from '../src/compile-matcher' + +interface TestRoute { + id: string + isRoot?: boolean + path?: string + fullPath: string + rank?: number + parentRoute?: TestRoute + children?: Array + options?: { + caseSensitive?: boolean + } +} + +type PathOrChildren = string | [string, Array] + +function createRoute( + pathOrChildren: Array, + parentPath: string, +): Array { + return pathOrChildren.map((route) => { + if (Array.isArray(route)) { + const fullPath = joinPaths([parentPath, route[0]]) + const children = createRoute(route[1], fullPath) + const r = { + id: fullPath, + path: route[0], + fullPath, + children: children, + } + children.forEach((child) => { + child.parentRoute = r + }) + + return r + } + + const fullPath = joinPaths([parentPath, route]) + + return { + id: fullPath, + path: route, + fullPath, + } + }) +} + +function createRouteTree(pathOrChildren: Array): TestRoute { + return { + id: '__root__', + fullPath: '', + isRoot: true, + path: undefined, + children: createRoute(pathOrChildren, ''), + } +} + +const routeTree = createRouteTree([ + '/', + '/users/profile/settings', // static-deep (longest static path) + '/users/profile', // static-medium (medium static path) + '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) + '/users/$id', // param-simple (plain param) + '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) + '/files/$', // wildcard (lowest priority) + '/about', // static-shallow (shorter static path) + '/a/profile/settings', + '/a/profile', + '/a/user-{$id}', + '/a/$id', + '/a/{-$slug}', + '/a/$', + '/a', + '/b/profile/settings', + '/b/profile', + '/b/user-{$id}', + '/b/$id', + '/b/{-$slug}', + '/b/$', + '/b', + '/foo/bar/$id', + '/foo/$id/bar', + '/foo/$bar', + '/foo/$bar/', + '/foo/{-$bar}/qux', + '/$id/bar/foo', + '/$id/foo/bar', + '/a/b/c/d/e/f', + '/beep/boop', + '/one/two', + '/one', + '/z/y/x/w', + '/z/y/x/v', + '/z/y/x/u', + '/z/y/x', + '/images/thumb_{$}', // wildcard with prefix + '/logs/{$}.txt', // wildcard with suffix + '/cache/temp_{$}.log', // wildcard with prefix and suffix +]) + +// required keys on a `route` object for `processRouteTree` to correctly generate `flatRoutes` +// - id +// - children +// - isRoot +// - path +// - fullPath + +const result = processRouteTree({ routeTree }) + +function originalMatcher( + from: string, + fuzzy?: boolean, +): readonly [string, Record] | undefined { + let match + for (const route of result.flatRoutes) { + const result = matchPathname('/', from, { to: route.fullPath, fuzzy }) + if (result) { + match = [route.fullPath, result] as const + break + } + } + return match +} + +describe('work in progress', () => { + it('is ordered', () => { + expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` + [ + "/a/b/c/d/e/f", + "/z/y/x/u", + "/z/y/x/v", + "/z/y/x/w", + "/a/profile/settings", + "/b/profile/settings", + "/users/profile/settings", + "/z/y/x", + "/foo/bar/$id", + "/a/profile", + "/b/profile", + "/beep/boop", + "/one/two", + "/users/profile", + "/foo/$id/bar", + "/foo/{-$bar}/qux", + "/a/user-{$id}", + "/api/user-{$id}", + "/b/user-{$id}", + "/foo/$bar/", + "/a/$id", + "/b/$id", + "/foo/$bar", + "/users/$id", + "/a/{-$slug}", + "/b/{-$slug}", + "/posts/{-$slug}", + "/cache/temp_{$}.log", + "/images/thumb_{$}", + "/logs/{$}.txt", + "/a/$", + "/b/$", + "/files/$", + "/a", + "/about", + "/b", + "/one", + "/", + "/$id/bar/foo", + "/$id/foo/bar", + ] + `) + }) + + const fn = compileMatcher(result.flatRoutes) + + it('generates a matching function', async () => { + expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` + "const s = parsePathname(from[0] === "/" ? from : "/" + from, cache).map( + (s) => s.value, + ); + const l = s.length; + const length = fuzzy ? (n) => l >= n : (n) => l === n; + const params = fuzzy + ? (p, n) => { + if (n && l > n) p["**"] = s.slice(n).join("/"); + return p; + } + : (p) => p; + const [, s1, s2, s3, s4, s5, s6] = s; + const sc1 = s1?.toLowerCase(); + const sc2 = s2?.toLowerCase(); + const sc3 = s3?.toLowerCase(); + const sc4 = s4?.toLowerCase(); + const sc5 = s5?.toLowerCase(); + const sc6 = s6?.toLowerCase(); + if ( + length(7) && + sc1 === "a" && + sc2 === "b" && + sc3 === "c" && + sc4 === "d" && + sc5 === "e" && + sc6 === "f" + ) + return ["/a/b/c/d/e/f", params({}, 7)]; + if (length(5) && sc1 === "z" && sc2 === "y" && sc3 === "x") { + if (sc4 === "u") return ["/z/y/x/u", params({}, 5)]; + if (sc4 === "v") return ["/z/y/x/v", params({}, 5)]; + if (sc4 === "w") return ["/z/y/x/w", params({}, 5)]; + } + if (length(4)) { + if (sc2 === "profile" && sc3 === "settings") { + if (sc1 === "a") return ["/a/profile/settings", params({}, 4)]; + if (sc1 === "b") return ["/b/profile/settings", params({}, 4)]; + if (sc1 === "users") return ["/users/profile/settings", params({}, 4)]; + } + if (sc1 === "z" && sc2 === "y" && sc3 === "x") + return ["/z/y/x", params({}, 4)]; + if (sc1 === "foo" && sc2 === "bar") + return ["/foo/bar/$id", params({ id: s3 }, 4)]; + } + if (l >= 3) { + if (length(3)) { + if (sc2 === "profile") { + if (sc1 === "a") return ["/a/profile", params({}, 3)]; + if (sc1 === "b") return ["/b/profile", params({}, 3)]; + } + if (sc1 === "beep" && sc2 === "boop") return ["/beep/boop", params({}, 3)]; + if (sc1 === "one" && sc2 === "two") return ["/one/two", params({}, 3)]; + if (sc1 === "users" && sc2 === "profile") + return ["/users/profile", params({}, 3)]; + } + if (length(4) && sc1 === "foo") { + if (sc3 === "bar") return ["/foo/$id/bar", params({ id: s2 }, 4)]; + if (sc3 === "qux") return ["/foo/{-$bar}/qux", params({ bar: s2 }, 4)]; + } + if (length(3)) { + if (sc1 === "foo" && sc2 === "qux") + return ["/foo/{-$bar}/qux", params({}, 3)]; + if (sc1 === "a" && s2.startsWith("user-")) + return ["/a/user-{$id}", params({ id: s2.slice(5) }, 3)]; + if (sc1 === "api" && s2.startsWith("user-")) + return ["/api/user-{$id}", params({ id: s2.slice(5) }, 3)]; + if (sc1 === "b" && s2.startsWith("user-")) + return ["/b/user-{$id}", params({ id: s2.slice(5) }, 3)]; + } + if (length(4) && sc1 === "foo" && sc3 === "/") + return ["/foo/$bar/", params({ bar: s2 }, 4)]; + if (length(3)) { + if (sc1 === "foo") return ["/foo/$bar/", params({ bar: s2 }, 3)]; + if (sc1 === "a") return ["/a/$id", params({ id: s2 }, 3)]; + if (sc1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; + if (sc1 === "foo") return ["/foo/$bar", params({ bar: s2 }, 3)]; + if (sc1 === "users") return ["/users/$id", params({ id: s2 }, 3)]; + if (sc1 === "a") return ["/a/{-$slug}", params({ slug: s2 }, 3)]; + } + } + if (l >= 2) { + if (length(2) && sc1 === "a") return ["/a/{-$slug}", params({}, 2)]; + if (length(3) && sc1 === "b") return ["/b/{-$slug}", params({ slug: s2 }, 3)]; + if (length(2) && sc1 === "b") return ["/b/{-$slug}", params({}, 2)]; + if (length(3) && sc1 === "posts") + return ["/posts/{-$slug}", params({ slug: s2 }, 3)]; + if (length(2) && sc1 === "posts") return ["/posts/{-$slug}", params({}, 2)]; + if (l >= 3) { + if (sc1 === "cache" && s2.startsWith("temp_") && s[l - 1].endsWith(".log")) + return [ + "/cache/temp_{$}.log", + { + _splat: s.slice(2).join("/").slice(5, -4), + "*": s.slice(2).join("/").slice(5, -4), + }, + ]; + if (sc1 === "images" && s2.startsWith("thumb_")) + return [ + "/images/thumb_{$}", + { + _splat: s.slice(2).join("/").slice(6), + "*": s.slice(2).join("/").slice(6), + }, + ]; + if (sc1 === "logs" && s[l - 1].endsWith(".txt")) + return [ + "/logs/{$}.txt", + { + _splat: s.slice(2).join("/").slice(0, -4), + "*": s.slice(2).join("/").slice(0, -4), + }, + ]; + } + if (sc1 === "a") + return [ + "/a/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + if (sc1 === "b") + return [ + "/b/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + if (sc1 === "files") + return [ + "/files/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + if (length(2)) { + if (sc1 === "a") return ["/a", params({}, 2)]; + if (sc1 === "about") return ["/about", params({}, 2)]; + if (sc1 === "b") return ["/b", params({}, 2)]; + if (sc1 === "one") return ["/one", params({}, 2)]; + } + } + if (length(1)) return ["/", params({}, 1)]; + if (length(4) && sc2 === "bar" && sc3 === "foo") + return ["/$id/bar/foo", params({ id: s1 }, 4)]; + if (length(4) && sc2 === "foo" && sc3 === "bar") + return ["/$id/foo/bar", params({ id: s1 }, 4)]; + " + `) + }) + + const buildMatcher = new Function( + 'parsePathname', + 'from', + 'fuzzy', + 'cache', + fn, + ) as CompiledMatcher + + test.each([ + '', + '/', + '/users/profile/settings', + '/foo/123', + '/FOO/123', + '/foo/123/', + '/b/123', + '/foo/qux', + '/foo/123/qux', + '/a/user-123', + '/a/123', + '/a/123/more', + '/files', + '/files/hello-world.txt', + '/something/foo/bar', + '/files/deep/nested/file.json', + '/files/', + '/images/thumb_200x300.jpg', + '/logs/2020/01/01/error.txt', + '/cache/temp_user456.log', + '/a/b/c/d/e', + ])('matching %s', (s) => { + const originalMatch = originalMatcher(s) + const buildMatch = buildMatcher(parsePathname, s) + console.log( + `matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]}`, + ) + expect(buildMatch).toEqual(originalMatch) + }) + + test.each([ + '/users/profile/settings/hello', + '/a/b/c/d/e/f/g', + '/foo/bar/baz', + '/foo/bar/baz/qux', + ])('fuzzy matching %s', (s) => { + const originalMatch = originalMatcher(s, true) + const buildMatch = buildMatcher(parsePathname, s, true) + console.log( + `fuzzy matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]} ${JSON.stringify(buildMatch?.[1])}`, + ) + expect(buildMatch).toEqual(originalMatch) + }) +}) From 4e64eb1e09f409f35a012bd2dac0d38a081fa433 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 22:13:26 +0200 Subject: [PATCH 43/57] rename --- .../tests/builtWithParams3.test.ts | 383 ------------------ 1 file changed, 383 deletions(-) delete mode 100644 packages/router-core/tests/builtWithParams3.test.ts diff --git a/packages/router-core/tests/builtWithParams3.test.ts b/packages/router-core/tests/builtWithParams3.test.ts deleted file mode 100644 index b103116a053..00000000000 --- a/packages/router-core/tests/builtWithParams3.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { describe, expect, it, test } from 'vitest' -import { format } from 'prettier' -import { - joinPaths, - matchPathname, - parsePathname, - processRouteTree, -} from '../src' -import { compileMatcher } from '../src/compile-matcher' -import type { CompiledMatcher } from '../src/compile-matcher' - -interface TestRoute { - id: string - isRoot?: boolean - path?: string - fullPath: string - rank?: number - parentRoute?: TestRoute - children?: Array - options?: { - caseSensitive?: boolean - } -} - -type PathOrChildren = string | [string, Array] - -function createRoute( - pathOrChildren: Array, - parentPath: string, -): Array { - return pathOrChildren.map((route) => { - if (Array.isArray(route)) { - const fullPath = joinPaths([parentPath, route[0]]) - const children = createRoute(route[1], fullPath) - const r = { - id: fullPath, - path: route[0], - fullPath, - children: children, - } - children.forEach((child) => { - child.parentRoute = r - }) - - return r - } - - const fullPath = joinPaths([parentPath, route]) - - return { - id: fullPath, - path: route, - fullPath, - } - }) -} - -function createRouteTree(pathOrChildren: Array): TestRoute { - return { - id: '__root__', - fullPath: '', - isRoot: true, - path: undefined, - children: createRoute(pathOrChildren, ''), - } -} - -const routeTree = createRouteTree([ - '/', - '/users/profile/settings', // static-deep (longest static path) - '/users/profile', // static-medium (medium static path) - '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) - '/users/$id', // param-simple (plain param) - '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) - '/files/$', // wildcard (lowest priority) - '/about', // static-shallow (shorter static path) - '/a/profile/settings', - '/a/profile', - '/a/user-{$id}', - '/a/$id', - '/a/{-$slug}', - '/a/$', - '/a', - '/b/profile/settings', - '/b/profile', - '/b/user-{$id}', - '/b/$id', - '/b/{-$slug}', - '/b/$', - '/b', - '/foo/bar/$id', - '/foo/$id/bar', - '/foo/$bar', - '/foo/$bar/', - '/foo/{-$bar}/qux', - '/$id/bar/foo', - '/$id/foo/bar', - '/a/b/c/d/e/f', - '/beep/boop', - '/one/two', - '/one', - '/z/y/x/w', - '/z/y/x/v', - '/z/y/x/u', - '/z/y/x', - '/images/thumb_{$}', // wildcard with prefix - '/logs/{$}.txt', // wildcard with suffix - '/cache/temp_{$}.log', // wildcard with prefix and suffix -]) - -// required keys on a `route` object for `processRouteTree` to correctly generate `flatRoutes` -// - id -// - children -// - isRoot -// - path -// - fullPath - -const result = processRouteTree({ routeTree }) - -function originalMatcher( - from: string, - fuzzy?: boolean, -): readonly [string, Record] | undefined { - let match - for (const route of result.flatRoutes) { - const result = matchPathname('/', from, { to: route.fullPath, fuzzy }) - if (result) { - match = [route.fullPath, result] as const - break - } - } - return match -} - -describe('work in progress', () => { - it('is ordered', () => { - expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` - [ - "/a/b/c/d/e/f", - "/z/y/x/u", - "/z/y/x/v", - "/z/y/x/w", - "/a/profile/settings", - "/b/profile/settings", - "/users/profile/settings", - "/z/y/x", - "/foo/bar/$id", - "/a/profile", - "/b/profile", - "/beep/boop", - "/one/two", - "/users/profile", - "/foo/$id/bar", - "/foo/{-$bar}/qux", - "/a/user-{$id}", - "/api/user-{$id}", - "/b/user-{$id}", - "/foo/$bar/", - "/a/$id", - "/b/$id", - "/foo/$bar", - "/users/$id", - "/a/{-$slug}", - "/b/{-$slug}", - "/posts/{-$slug}", - "/cache/temp_{$}.log", - "/images/thumb_{$}", - "/logs/{$}.txt", - "/a/$", - "/b/$", - "/files/$", - "/a", - "/about", - "/b", - "/one", - "/", - "/$id/bar/foo", - "/$id/foo/bar", - ] - `) - }) - - const fn = compileMatcher(result.flatRoutes) - - it('generates a matching function', async () => { - expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` - "const s = parsePathname(from[0] === "/" ? from : "/" + from, cache).map( - (s) => s.value, - ); - const l = s.length; - const length = fuzzy ? (n) => l >= n : (n) => l === n; - const params = fuzzy - ? (p, n) => { - if (n && l > n) p["**"] = s.slice(n).join("/"); - return p; - } - : (p) => p; - const [, s1, s2, s3, s4, s5, s6] = s; - const sc1 = s1?.toLowerCase(); - const sc2 = s2?.toLowerCase(); - const sc3 = s3?.toLowerCase(); - const sc4 = s4?.toLowerCase(); - const sc5 = s5?.toLowerCase(); - const sc6 = s6?.toLowerCase(); - if ( - length(7) && - sc1 === "a" && - sc2 === "b" && - sc3 === "c" && - sc4 === "d" && - sc5 === "e" && - sc6 === "f" - ) - return ["/a/b/c/d/e/f", params({}, 7)]; - if (length(5) && sc1 === "z" && sc2 === "y" && sc3 === "x") { - if (sc4 === "u") return ["/z/y/x/u", params({}, 5)]; - if (sc4 === "v") return ["/z/y/x/v", params({}, 5)]; - if (sc4 === "w") return ["/z/y/x/w", params({}, 5)]; - } - if (length(4)) { - if (sc2 === "profile" && sc3 === "settings") { - if (sc1 === "a") return ["/a/profile/settings", params({}, 4)]; - if (sc1 === "b") return ["/b/profile/settings", params({}, 4)]; - if (sc1 === "users") return ["/users/profile/settings", params({}, 4)]; - } - if (sc1 === "z" && sc2 === "y" && sc3 === "x") - return ["/z/y/x", params({}, 4)]; - if (sc1 === "foo" && sc2 === "bar") - return ["/foo/bar/$id", params({ id: s3 }, 4)]; - } - if (l >= 3) { - if (length(3)) { - if (sc2 === "profile") { - if (sc1 === "a") return ["/a/profile", params({}, 3)]; - if (sc1 === "b") return ["/b/profile", params({}, 3)]; - } - if (sc1 === "beep" && sc2 === "boop") return ["/beep/boop", params({}, 3)]; - if (sc1 === "one" && sc2 === "two") return ["/one/two", params({}, 3)]; - if (sc1 === "users" && sc2 === "profile") - return ["/users/profile", params({}, 3)]; - } - if (length(4) && sc1 === "foo") { - if (sc3 === "bar") return ["/foo/$id/bar", params({ id: s2 }, 4)]; - if (sc3 === "qux") return ["/foo/{-$bar}/qux", params({ bar: s2 }, 4)]; - } - if (length(3)) { - if (sc1 === "foo" && sc2 === "qux") - return ["/foo/{-$bar}/qux", params({}, 3)]; - if (sc1 === "a" && s2.startsWith("user-")) - return ["/a/user-{$id}", params({ id: s2.slice(5) }, 3)]; - if (sc1 === "api" && s2.startsWith("user-")) - return ["/api/user-{$id}", params({ id: s2.slice(5) }, 3)]; - if (sc1 === "b" && s2.startsWith("user-")) - return ["/b/user-{$id}", params({ id: s2.slice(5) }, 3)]; - } - if (length(4) && sc1 === "foo" && sc3 === "/") - return ["/foo/$bar/", params({ bar: s2 }, 4)]; - if (length(3)) { - if (sc1 === "foo") return ["/foo/$bar/", params({ bar: s2 }, 3)]; - if (sc1 === "a") return ["/a/$id", params({ id: s2 }, 3)]; - if (sc1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; - if (sc1 === "foo") return ["/foo/$bar", params({ bar: s2 }, 3)]; - if (sc1 === "users") return ["/users/$id", params({ id: s2 }, 3)]; - if (sc1 === "a") return ["/a/{-$slug}", params({ slug: s2 }, 3)]; - } - } - if (l >= 2) { - if (length(2) && sc1 === "a") return ["/a/{-$slug}", params({}, 2)]; - if (length(3) && sc1 === "b") return ["/b/{-$slug}", params({ slug: s2 }, 3)]; - if (length(2) && sc1 === "b") return ["/b/{-$slug}", params({}, 2)]; - if (length(3) && sc1 === "posts") - return ["/posts/{-$slug}", params({ slug: s2 }, 3)]; - if (length(2) && sc1 === "posts") return ["/posts/{-$slug}", params({}, 2)]; - if (l >= 3) { - if (sc1 === "cache" && s2.startsWith("temp_") && s[l - 1].endsWith(".log")) - return [ - "/cache/temp_{$}.log", - { - _splat: s.slice(2).join("/").slice(5, -4), - "*": s.slice(2).join("/").slice(5, -4), - }, - ]; - if (sc1 === "images" && s2.startsWith("thumb_")) - return [ - "/images/thumb_{$}", - { - _splat: s.slice(2).join("/").slice(6), - "*": s.slice(2).join("/").slice(6), - }, - ]; - if (sc1 === "logs" && s[l - 1].endsWith(".txt")) - return [ - "/logs/{$}.txt", - { - _splat: s.slice(2).join("/").slice(0, -4), - "*": s.slice(2).join("/").slice(0, -4), - }, - ]; - } - if (sc1 === "a") - return [ - "/a/$", - { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, - ]; - if (sc1 === "b") - return [ - "/b/$", - { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, - ]; - if (sc1 === "files") - return [ - "/files/$", - { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, - ]; - if (length(2)) { - if (sc1 === "a") return ["/a", params({}, 2)]; - if (sc1 === "about") return ["/about", params({}, 2)]; - if (sc1 === "b") return ["/b", params({}, 2)]; - if (sc1 === "one") return ["/one", params({}, 2)]; - } - } - if (length(1)) return ["/", params({}, 1)]; - if (length(4) && sc2 === "bar" && sc3 === "foo") - return ["/$id/bar/foo", params({ id: s1 }, 4)]; - if (length(4) && sc2 === "foo" && sc3 === "bar") - return ["/$id/foo/bar", params({ id: s1 }, 4)]; - " - `) - }) - - const buildMatcher = new Function( - 'parsePathname', - 'from', - 'fuzzy', - 'cache', - fn, - ) as CompiledMatcher - - test.each([ - '', - '/', - '/users/profile/settings', - '/foo/123', - '/FOO/123', - '/foo/123/', - '/b/123', - '/foo/qux', - '/foo/123/qux', - '/a/user-123', - '/a/123', - '/a/123/more', - '/files', - '/files/hello-world.txt', - '/something/foo/bar', - '/files/deep/nested/file.json', - '/files/', - '/images/thumb_200x300.jpg', - '/logs/2020/01/01/error.txt', - '/cache/temp_user456.log', - '/a/b/c/d/e', - ])('matching %s', (s) => { - const originalMatch = originalMatcher(s) - const buildMatch = buildMatcher(parsePathname, s) - console.log( - `matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]}`, - ) - expect(buildMatch).toEqual(originalMatch) - }) - - test.each([ - '/users/profile/settings/hello', - '/a/b/c/d/e/f/g', - '/foo/bar/baz', - '/foo/bar/baz/qux', - ])('fuzzy matching %s', (s) => { - const originalMatch = originalMatcher(s, true) - const buildMatch = buildMatcher(parsePathname, s, true) - console.log( - `fuzzy matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]} ${JSON.stringify(buildMatch?.[1])}`, - ) - expect(buildMatch).toEqual(originalMatch) - }) -}) From f9e0740d166654e536719615bcb9eb55b18634a2 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 22:27:05 +0200 Subject: [PATCH 44/57] better comments and naming --- packages/router-core/src/compile-matcher.ts | 33 ++++++++------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/router-core/src/compile-matcher.ts b/packages/router-core/src/compile-matcher.ts index a8597697f9e..3270cb45a67 100644 --- a/packages/router-core/src/compile-matcher.ts +++ b/packages/router-core/src/compile-matcher.ts @@ -44,7 +44,7 @@ export function compileMatcher( prepareOptionalParams(prepareIndexRoutes(parsedRoutes)), ) - // We start by building a flat tree with all routes as leaf nodes, all children of the root node. + // We start by building a flat tree with all routes as leaf nodes, children of the same root node. const tree: RootNode = { type: 'root', children: [] } for (const { conditions, path, segments } of all) { tree.children.push({ @@ -131,17 +131,12 @@ function prepareOptionalParams( type Condition = | { key: string; type: 'static-insensitive'; index: number; value: string } | { key: string; type: 'static-sensitive'; index: number; value: string } - | { - key: string - type: 'length' - direction: 'eq' | 'gte' | 'lte' - value: number - } + | { key: string; type: 'length'; direction: 'eq' | 'gte'; value: number } | { key: string; type: 'startsWith'; index: number; value: string } | { key: string; type: 'endsWith'; index: number; value: string } | { key: string; type: 'globalEndsWith'; value: string } -// each segment of a route can have zero or more conditions that needs to be met for the route to match +// each segment of a route can have zero or more conditions that need to be met for the route to match function toConditions(routes: Array) { return routes.map((route) => { const conditions: Array = [] @@ -288,22 +283,22 @@ function expandTree(tree: RootNode) { if (resolved.has(child)) continue // segment-based conditions should try to group as many children as possible - const bestCondition = findBestCondition( + const bestSegment = findBestSegmentCondition( node, i, node.children.length - i - 1, ) // length-based conditions should try to group as few children as possible - const bestLength = findBestLength(node, i, 0) + const bestLength = findBestLengthCondition(node, i, 0) - if (bestCondition.score === Infinity && bestLength.score === Infinity) { + if (bestSegment.score === Infinity && bestLength.score === Infinity) { // no grouping possible, just add the child as is resolved.add(child) continue } const selected = - bestCondition.score < bestLength.score ? bestCondition : bestLength + bestSegment.score < bestLength.score ? bestSegment : bestLength const condition = selected.condition! const newNode: BranchNode = { type: 'branch', @@ -345,9 +340,9 @@ function expandTree(tree: RootNode) { * and merge the conditions of the branch node into the child node. * * This turns - * `if (condition1) { if (condition2) { return route } }` + * `if (a) { if (b) { return route } }` * into - * `if (condition1 && condition2) { return route }` + * `if (a && b) { return route }` */ function contractTree(tree: RootNode) { const stack = tree.children.filter((c) => c.type === 'branch') @@ -542,7 +537,7 @@ function printHead( return head } -function findBestCondition( +function findBestSegmentCondition( node: RootNode | BranchNode, i: number, target: number, @@ -576,7 +571,7 @@ function findBestCondition( } } -function findBestLength( +function findBestLengthCondition( node: RootNode | BranchNode, i: number, target: number, @@ -589,7 +584,7 @@ function findBestLength( let currentMinLength = 1 let exactLength = false if (node.type !== 'root') { - let n: BranchNode | null = node + let n = node do { const lengthCondition = n.conditions.find((c) => c.type === 'length') if (!lengthCondition) continue @@ -601,9 +596,7 @@ function findBestLength( currentMinLength = lengthCondition.value break } - if (n.parent.type === 'branch') n = n.parent - else n = null - } while (n) + } while (n.parent.type === 'branch' && (n = n.parent)) } if (exactLength || currentMinLength >= childLengthCondition.value) { return { score: Infinity, condition: undefined, candidates: [child] } From c6617f7c5afdcdcca2282b52ea67250c33eab59c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 23:21:43 +0200 Subject: [PATCH 45/57] case sensitivity applies to all parts, not just static pathnames --- packages/router-core/src/compile-matcher.ts | 92 +++++++++++-------- .../router-core/tests/compile-matcher.test.ts | 16 ++-- 2 files changed, 65 insertions(+), 43 deletions(-) diff --git a/packages/router-core/src/compile-matcher.ts b/packages/router-core/src/compile-matcher.ts index 3270cb45a67..6a85561b011 100644 --- a/packages/router-core/src/compile-matcher.ts +++ b/packages/router-core/src/compile-matcher.ts @@ -129,12 +129,11 @@ function prepareOptionalParams( } type Condition = - | { key: string; type: 'static-insensitive'; index: number; value: string } - | { key: string; type: 'static-sensitive'; index: number; value: string } + | { key: string; type: 'static'; index: number; value: string; caseSensitive: boolean } | { key: string; type: 'length'; direction: 'eq' | 'gte'; value: number } - | { key: string; type: 'startsWith'; index: number; value: string } - | { key: string; type: 'endsWith'; index: number; value: string } - | { key: string; type: 'globalEndsWith'; value: string } + | { key: string; type: 'startsWith'; index: number; value: string; caseSensitive: boolean } + | { key: string; type: 'endsWith'; index: number; value: string; caseSensitive: boolean } + | { key: string; type: 'globalEndsWith'; value: string; caseSensitive: boolean } // each segment of a route can have zero or more conditions that need to be met for the route to match function toConditions(routes: Array) { @@ -148,60 +147,65 @@ function toConditions(routes: Array) { if (segment.type === SEGMENT_TYPE_PATHNAME) { minLength += 1 if (i === 0 && segment.value === '/') continue // skip leading slash - const value = segment.value // @ts-expect-error -- not typed yet, i don't know how I'm gonna get this value here - if (route.caseSensitive) { - conditions.push({ - type: 'static-sensitive', - index: i, - value, - key: `static_sensitive_${i}_${value}`, - }) - } else { - conditions.push({ - type: 'static-insensitive', - index: i, - value: value.toLowerCase(), - key: `static_insensitive_${i}_${value.toLowerCase()}`, - }) - } + const caseSensitive = route.caseSensitive ?? false + const value = caseSensitive ? segment.value : segment.value.toLowerCase() + conditions.push({ + type: 'static', + index: i, + value, + caseSensitive, + key: `static_${caseSensitive}_${i}_${value}`, + }) continue } if (segment.type === SEGMENT_TYPE_PARAM) { minLength += 1 + // @ts-expect-error -- not typed yet, i don't know how I'm gonna get this value here + const caseSensitive = route.caseSensitive ?? false if (segment.prefixSegment) { + const value = caseSensitive ? segment.prefixSegment : segment.prefixSegment.toLowerCase() conditions.push({ type: 'startsWith', index: i, - value: segment.prefixSegment, - key: `startsWith_${i}_${segment.prefixSegment}`, + value, + caseSensitive, + key: `startsWith_${caseSensitive}_${i}_${value}`, }) } if (segment.suffixSegment) { + const value = caseSensitive ? segment.suffixSegment : segment.suffixSegment.toLowerCase() conditions.push({ type: 'endsWith', index: i, - value: segment.suffixSegment, - key: `endsWith_${i}_${segment.suffixSegment}`, + value, + caseSensitive, + key: `endsWith_${caseSensitive}_${i}_${value}`, }) } continue } if (segment.type === SEGMENT_TYPE_WILDCARD) { hasWildcard = true + // @ts-expect-error -- not typed yet, i don't know how I'm gonna get this value here + const caseSensitive = route.caseSensitive ?? false if (segment.prefixSegment) { + const value = caseSensitive ? segment.prefixSegment : segment.prefixSegment.toLowerCase() conditions.push({ type: 'startsWith', index: i, - value: segment.prefixSegment, - key: `startsWith_${i}_${segment.prefixSegment}`, + value, + caseSensitive, + key: `startsWith_${caseSensitive}_${i}_${value}`, }) } if (segment.suffixSegment) { + const value = caseSensitive ? segment.suffixSegment : segment.suffixSegment.toLowerCase() conditions.push({ type: 'globalEndsWith', - value: segment.suffixSegment, - key: `globalEndsWith_${i}_${segment.suffixSegment}`, + value, + caseSensitive, + key: `globalEndsWith_${caseSensitive}_${i}_${value}`, }) } if (segment.suffixSegment || segment.prefixSegment) { @@ -430,10 +434,12 @@ function printConditions(conditions: Array) { function printCondition(condition: Condition) { switch (condition.type) { - case 'static-sensitive': - return `s${condition.index} === '${condition.value}'` - case 'static-insensitive': - return `sc${condition.index} === '${condition.value}'` + case 'static': + if (condition.caseSensitive) { + return `s${condition.index} === '${condition.value}'` + } else { + return `sc${condition.index} === '${condition.value}'` + } case 'length': if (condition.direction === 'eq') { return `length(${condition.value})` @@ -442,11 +448,23 @@ function printCondition(condition: Condition) { } break case 'startsWith': - return `s${condition.index}.startsWith('${condition.value}')` + if (condition.caseSensitive) { + return `s${condition.index}.startsWith('${condition.value}')` + } else { + return `sc${condition.index}?.startsWith('${condition.value}')` + } case 'endsWith': - return `s${condition.index}.endsWith('${condition.value}')` + if (condition.caseSensitive) { + return `s${condition.index}.endsWith('${condition.value}')` + } else { + return `sc${condition.index}?.endsWith('${condition.value}')` + } case 'globalEndsWith': - return `s[l - 1].endsWith('${condition.value}')` + if (condition.caseSensitive) { + return `s[l - 1].endsWith('${condition.value}')` + } else { + return `s[l - 1].toLowerCase().endsWith('${condition.value}')` + } } throw new Error(`Unhandled condition type: ${condition.type}`) } @@ -525,7 +543,7 @@ function printHead( const caseInsensitiveSegments = new Set() for (const route of routes) { for (const condition of route.conditions) { - if (condition.type === 'static-insensitive') { + if ((condition.type === 'static' || condition.type === 'endsWith' || condition.type === 'startsWith') && !condition.caseSensitive) { caseInsensitiveSegments.add(condition.index) } } diff --git a/packages/router-core/tests/compile-matcher.test.ts b/packages/router-core/tests/compile-matcher.test.ts index b103116a053..219459df5d1 100644 --- a/packages/router-core/tests/compile-matcher.test.ts +++ b/packages/router-core/tests/compile-matcher.test.ts @@ -246,11 +246,11 @@ describe('work in progress', () => { if (length(3)) { if (sc1 === "foo" && sc2 === "qux") return ["/foo/{-$bar}/qux", params({}, 3)]; - if (sc1 === "a" && s2.startsWith("user-")) + if (sc1 === "a" && sc2?.startsWith("user-")) return ["/a/user-{$id}", params({ id: s2.slice(5) }, 3)]; - if (sc1 === "api" && s2.startsWith("user-")) + if (sc1 === "api" && sc2?.startsWith("user-")) return ["/api/user-{$id}", params({ id: s2.slice(5) }, 3)]; - if (sc1 === "b" && s2.startsWith("user-")) + if (sc1 === "b" && sc2?.startsWith("user-")) return ["/b/user-{$id}", params({ id: s2.slice(5) }, 3)]; } if (length(4) && sc1 === "foo" && sc3 === "/") @@ -272,7 +272,11 @@ describe('work in progress', () => { return ["/posts/{-$slug}", params({ slug: s2 }, 3)]; if (length(2) && sc1 === "posts") return ["/posts/{-$slug}", params({}, 2)]; if (l >= 3) { - if (sc1 === "cache" && s2.startsWith("temp_") && s[l - 1].endsWith(".log")) + if ( + sc1 === "cache" && + sc2?.startsWith("temp_") && + s[l - 1].toLowerCase().endsWith(".log") + ) return [ "/cache/temp_{$}.log", { @@ -280,7 +284,7 @@ describe('work in progress', () => { "*": s.slice(2).join("/").slice(5, -4), }, ]; - if (sc1 === "images" && s2.startsWith("thumb_")) + if (sc1 === "images" && sc2?.startsWith("thumb_")) return [ "/images/thumb_{$}", { @@ -288,7 +292,7 @@ describe('work in progress', () => { "*": s.slice(2).join("/").slice(6), }, ]; - if (sc1 === "logs" && s[l - 1].endsWith(".txt")) + if (sc1 === "logs" && s[l - 1].toLowerCase().endsWith(".txt")) return [ "/logs/{$}.txt", { From 946051106a3a1432e7398031cfa70efca09a9ea5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 23:39:15 +0200 Subject: [PATCH 46/57] reduce accessor complexity --- packages/router-core/src/compile-matcher.ts | 69 ++++++++++++------- .../router-core/tests/compile-matcher.test.ts | 9 +-- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/packages/router-core/src/compile-matcher.ts b/packages/router-core/src/compile-matcher.ts index 6a85561b011..57c4ea07d1b 100644 --- a/packages/router-core/src/compile-matcher.ts +++ b/packages/router-core/src/compile-matcher.ts @@ -397,7 +397,7 @@ function printTree(node: RootNode | BranchNode | LeafNode) { } if (node.conditions.length) { str += 'if (' - str += printConditions(node.conditions) + str += printConditions(node) str += ')' } if (node.type === 'branch') { @@ -412,7 +412,8 @@ function printTree(node: RootNode | BranchNode | LeafNode) { return str } -function printConditions(conditions: Array) { +function printConditions(node: BranchNode | LeafNode) { + const conditions = node.conditions const lengths = conditions.filter((c) => c.type === 'length') const segment = conditions.filter((c) => c.type !== 'length') const results: Array = [] @@ -426,13 +427,14 @@ function printConditions(conditions: Array) { } else if (lengths.length === 1) { results.push(printCondition(lengths[0]!)) } + const [minLength] = findLengthAtNode(node) for (const c of segment) { - results.push(printCondition(c)) + results.push(printCondition(c, minLength)) } return results.join(' && ') } -function printCondition(condition: Condition) { +function printCondition(condition: Condition, minLength: number = 0) { switch (condition.type) { case 'static': if (condition.caseSensitive) { @@ -450,20 +452,24 @@ function printCondition(condition: Condition) { case 'startsWith': if (condition.caseSensitive) { return `s${condition.index}.startsWith('${condition.value}')` + } else if (minLength > condition.index) { + return `sc${condition.index}.startsWith('${condition.value}')` } else { return `sc${condition.index}?.startsWith('${condition.value}')` } case 'endsWith': if (condition.caseSensitive) { return `s${condition.index}.endsWith('${condition.value}')` + } else if (minLength > condition.index) { + return `sc${condition.index}.endsWith('${condition.value}')` } else { return `sc${condition.index}?.endsWith('${condition.value}')` } case 'globalEndsWith': if (condition.caseSensitive) { - return `s[l - 1].endsWith('${condition.value}')` + return `last.endsWith('${condition.value}')` } else { - return `s[l - 1].toLowerCase().endsWith('${condition.value}')` + return `last.toLowerCase().endsWith('${condition.value}')` } } throw new Error(`Unhandled condition type: ${condition.type}`) @@ -552,6 +558,12 @@ function printHead( head += `const sc${index} = s${index}?.toLowerCase();` } + // wildcard with a suffix requires accessing the last segment, whithout knowing its index + const hasWildcardWithSuffix = routes.some((route) => route.conditions.some((c) => c.type === 'globalEndsWith')) + if (hasWildcardWithSuffix) { + head += 'const last = s[l - 1];' + } + return head } @@ -599,24 +611,8 @@ function findBestLengthCondition( if (!childLengthCondition) { return { score: Infinity, condition: undefined, candidates: [child] } } - let currentMinLength = 1 - let exactLength = false - if (node.type !== 'root') { - let n = node - do { - const lengthCondition = n.conditions.find((c) => c.type === 'length') - if (!lengthCondition) continue - if (lengthCondition.direction === 'eq') { - exactLength = true - break - } - if (lengthCondition.direction === 'gte') { - currentMinLength = lengthCondition.value - break - } - } while (n.parent.type === 'branch' && (n = n.parent)) - } - if (exactLength || currentMinLength >= childLengthCondition.value) { + const [currentMinLength, lengthKind] = findLengthAtNode(node) + if (lengthKind === 'exact' || currentMinLength >= childLengthCondition.value) { return { score: Infinity, condition: undefined, candidates: [child] } } let bestMatchScore = Infinity @@ -655,3 +651,28 @@ function findBestLengthCondition( } return { score: bestMatchScore, condition, candidates: bestCandidates } } + +function findLengthAtNode( + node: RootNode | BranchNode | LeafNode, +) { + if (node.type === 'root') return [1, 'min'] as const + let currentMinLength = 1 + let exactLength = false + let n = node + do { + const lengthCondition = n.conditions.find((c) => c.type === 'length') + if (!lengthCondition) continue + if (lengthCondition.direction === 'eq') { + exactLength = true + break + } + if (lengthCondition.direction === 'gte') { + currentMinLength = lengthCondition.value + break + } + } while (n.parent.type === 'branch' && (n = n.parent)) + return [ + currentMinLength, + exactLength ? 'exact' : 'min', + ] as const +} \ No newline at end of file diff --git a/packages/router-core/tests/compile-matcher.test.ts b/packages/router-core/tests/compile-matcher.test.ts index 219459df5d1..268982736e5 100644 --- a/packages/router-core/tests/compile-matcher.test.ts +++ b/packages/router-core/tests/compile-matcher.test.ts @@ -202,6 +202,7 @@ describe('work in progress', () => { const sc4 = s4?.toLowerCase(); const sc5 = s5?.toLowerCase(); const sc6 = s6?.toLowerCase(); + const last = s[l - 1]; if ( length(7) && sc1 === "a" && @@ -274,8 +275,8 @@ describe('work in progress', () => { if (l >= 3) { if ( sc1 === "cache" && - sc2?.startsWith("temp_") && - s[l - 1].toLowerCase().endsWith(".log") + sc2.startsWith("temp_") && + last.toLowerCase().endsWith(".log") ) return [ "/cache/temp_{$}.log", @@ -284,7 +285,7 @@ describe('work in progress', () => { "*": s.slice(2).join("/").slice(5, -4), }, ]; - if (sc1 === "images" && sc2?.startsWith("thumb_")) + if (sc1 === "images" && sc2.startsWith("thumb_")) return [ "/images/thumb_{$}", { @@ -292,7 +293,7 @@ describe('work in progress', () => { "*": s.slice(2).join("/").slice(6), }, ]; - if (sc1 === "logs" && s[l - 1].toLowerCase().endsWith(".txt")) + if (sc1 === "logs" && last.toLowerCase().endsWith(".txt")) return [ "/logs/{$}.txt", { From c89f87d806f09f43b03b287e4bc9ecda198f403a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 23:54:18 +0200 Subject: [PATCH 47/57] add a tree pruning step --- packages/router-core/src/compile-matcher.ts | 38 +++++++++++++++++++ .../router-core/tests/compile-matcher.test.ts | 2 - 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/compile-matcher.ts b/packages/router-core/src/compile-matcher.ts index 57c4ea07d1b..66de627fb88 100644 --- a/packages/router-core/src/compile-matcher.ts +++ b/packages/router-core/src/compile-matcher.ts @@ -57,6 +57,7 @@ export function compileMatcher( expandTree(tree) contractTree(tree) + pruneTree(tree) let fn = '' fn += printHead(all) @@ -387,6 +388,43 @@ function contractTree(tree: RootNode) { } } +/** + * Remove branches and leaves that are not reachable due to the conditions of their previous siblings. + * + * This turns + * ``` + * if (a) return route1; + * if (a && b) return route2; + * ``` + * into + * ``` + * if (a) return route1; + * ``` + */ +function pruneTree(tree: RootNode) { + const stack: Array = [tree] + while (stack.length > 0) { + const node = stack.shift()! + loop: for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]! + + for (let j = 0; j < i; j++) { + const sibling = node.children[j]! + if (sibling.type !== 'leaf') continue // only a leaf node is guaranteed to return if its conditions are met + const currentIsUnreachable = sibling.conditions.every((c) => child.conditions.some((sc) => sc.key === c.key)) + if (currentIsUnreachable) { + node.children.splice(i, 1) + i -= 1 + continue loop + } + } + + if (child.type === 'leaf') continue + stack.push(child) + } + } +} + function printTree(node: RootNode | BranchNode | LeafNode) { let str = '' if (node.type === 'root') { diff --git a/packages/router-core/tests/compile-matcher.test.ts b/packages/router-core/tests/compile-matcher.test.ts index 268982736e5..f4665d97fdf 100644 --- a/packages/router-core/tests/compile-matcher.test.ts +++ b/packages/router-core/tests/compile-matcher.test.ts @@ -260,9 +260,7 @@ describe('work in progress', () => { if (sc1 === "foo") return ["/foo/$bar/", params({ bar: s2 }, 3)]; if (sc1 === "a") return ["/a/$id", params({ id: s2 }, 3)]; if (sc1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; - if (sc1 === "foo") return ["/foo/$bar", params({ bar: s2 }, 3)]; if (sc1 === "users") return ["/users/$id", params({ id: s2 }, 3)]; - if (sc1 === "a") return ["/a/{-$slug}", params({ slug: s2 }, 3)]; } } if (l >= 2) { From 0f5402ae0109f1de1df24f8e2527fa69cfe881aa Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 24 Jul 2025 23:54:28 +0200 Subject: [PATCH 48/57] typo --- packages/router-core/src/compile-matcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router-core/src/compile-matcher.ts b/packages/router-core/src/compile-matcher.ts index 66de627fb88..a37bf09cf65 100644 --- a/packages/router-core/src/compile-matcher.ts +++ b/packages/router-core/src/compile-matcher.ts @@ -389,7 +389,7 @@ function contractTree(tree: RootNode) { } /** - * Remove branches and leaves that are not reachable due to the conditions of their previous siblings. + * Remove branches and leaves that are not reachable due to the conditions a previous leaf sibling. * * This turns * ``` From 7ca2c4054a268c4785d180c4dab651beff4dc210 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 25 Jul 2025 08:37:56 +0200 Subject: [PATCH 49/57] add bench --- packages/router-core/package.json | 1 + .../tests/compile-matcher.bench.ts | 184 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 packages/router-core/tests/compile-matcher.bench.ts diff --git a/packages/router-core/package.json b/packages/router-core/package.json index d795b4de601..96d34bd455b 100644 --- a/packages/router-core/package.json +++ b/packages/router-core/package.json @@ -30,6 +30,7 @@ "test:types:ts58": "tsc", "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .", "test:unit": "vitest", + "test:perf": "vitest bench", "test:unit:dev": "pnpm run test:unit --watch", "build": "vite build" }, diff --git a/packages/router-core/tests/compile-matcher.bench.ts b/packages/router-core/tests/compile-matcher.bench.ts new file mode 100644 index 00000000000..9bb18b78422 --- /dev/null +++ b/packages/router-core/tests/compile-matcher.bench.ts @@ -0,0 +1,184 @@ +import { bench, describe } from 'vitest' +import { + joinPaths, + matchPathname, + parsePathname, + processRouteTree, +} from '../src' +import { createLRUCache } from '../src/lru-cache' +import { compileMatcher } from '../src/compile-matcher' +import type { CompiledMatcher } from '../src/compile-matcher' +import type { ParsePathnameCache } from "../src/path" + +interface TestRoute { + id: string + isRoot?: boolean + path?: string + fullPath: string + rank?: number + parentRoute?: TestRoute + children?: Array + options?: { + caseSensitive?: boolean + } +} + +type PathOrChildren = string | [string, Array] + +function createRoute( + pathOrChildren: Array, + parentPath: string, +): Array { + return pathOrChildren.map((route) => { + if (Array.isArray(route)) { + const fullPath = joinPaths([parentPath, route[0]]) + const children = createRoute(route[1], fullPath) + const r = { + id: fullPath, + path: route[0], + fullPath, + children: children, + } + children.forEach((child) => { + child.parentRoute = r + }) + + return r + } + + const fullPath = joinPaths([parentPath, route]) + + return { + id: fullPath, + path: route, + fullPath, + } + }) +} + +function createRouteTree(pathOrChildren: Array): TestRoute { + return { + id: '__root__', + fullPath: '', + isRoot: true, + path: undefined, + children: createRoute(pathOrChildren, ''), + } +} + +const routeTree = createRouteTree([ + '/', + '/users/profile/settings', // static-deep (longest static path) + '/users/profile', // static-medium (medium static path) + '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) + '/users/$id', // param-simple (plain param) + '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) + '/files/$', // wildcard (lowest priority) + '/about', // static-shallow (shorter static path) + '/a/profile/settings', + '/a/profile', + '/a/user-{$id}', + '/a/$id', + '/a/{-$slug}', + '/a/$', + '/a', + '/b/profile/settings', + '/b/profile', + '/b/user-{$id}', + '/b/$id', + '/b/{-$slug}', + '/b/$', + '/b', + '/foo/bar/$id', + '/foo/$id/bar', + '/foo/$bar', + '/foo/$bar/', + '/foo/{-$bar}/qux', + '/$id/bar/foo', + '/$id/foo/bar', + '/a/b/c/d/e/f', + '/beep/boop', + '/compiled/two', + '/compiled', + '/z/y/x/w', + '/z/y/x/v', + '/z/y/x/u', + '/z/y/x', + '/images/thumb_{$}', // wildcard with prefix + '/logs/{$}.txt', // wildcard with suffix + '/cache/temp_{$}.log', // wildcard with prefix and suffix +]) +const result = processRouteTree({ routeTree }) + +const compiled = (() => { + const cache: ParsePathnameCache = createLRUCache(1000) + const fn = compileMatcher(result.flatRoutes) + const buildMatcher = new Function( + 'parsePathname', + 'from', + 'fuzzy', + 'cache', + fn, + ) as CompiledMatcher + const wrappedMatcher = (from: string) => { + return buildMatcher(parsePathname, from, false, cache) + } + return wrappedMatcher +})() + +const original = (() => { + const cache: ParsePathnameCache = createLRUCache(1000) + + const wrappedMatcher = (from: string) => { + const match = result.flatRoutes.find((r) => + matchPathname('/', from, { to: r.fullPath }, cache), + ) + return match + } + return wrappedMatcher +})() + +const testCases = [ + '', + '/', + '/users/profile/settings', + '/foo/123', + '/foo/123/', + '/b/123', + '/foo/qux', + '/foo/123/qux', + '/foo/qux', + '/a/user-123', + '/a/123', + '/a/123/more', + '/files', + '/files/hello-world.txt', + '/something/foo/bar', + '/files/deep/nested/file.json', + '/files/', + '/images/thumb_200x300.jpg', + '/logs/error.txt', + '/cache/temp_user456.log', + '/a/b/c/d/e', +] + +describe('build.bench', () => { + bench( + 'original', + () => { + for (const from of testCases) { + original(from) + } + }, + { warmupIterations: 10 }, + ) + bench( + 'compiled', + () => { + for (const from of testCases) { + compiled(from) + } + }, + { warmupIterations: 10 }, + ) +}) From d4bc4a585ebec4de2c53247937190abad52a554b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 25 Jul 2025 12:56:46 +0200 Subject: [PATCH 50/57] add compiled-matcher E2E project --- e2e/react-router/compiled-matcher/.gitignore | 11 + e2e/react-router/compiled-matcher/index.html | 11 + .../compiled-matcher/package.json | 36 + .../compiled-matcher/playwright.config.ts | 41 + .../compiled-matcher/postcss.config.mjs | 6 + .../compiled-matcher/src/main.tsx | 27 + .../compiled-matcher/src/routeTree.gen.ts | 899 ++++++++++++++++++ .../src/routes/$id/bar/foo.tsx | 9 + .../src/routes/$id/foo/bar.tsx | 9 + .../compiled-matcher/src/routes/__root.tsx | 46 + .../compiled-matcher/src/routes/a/$.tsx | 9 + .../compiled-matcher/src/routes/a/$id.tsx | 9 + .../src/routes/a/b/c/d/e/f.tsx | 9 + .../compiled-matcher/src/routes/a/index.tsx | 9 + .../src/routes/a/profile/index.tsx | 9 + .../src/routes/a/profile/settings.tsx | 9 + .../src/routes/a/user-{$id}.tsx | 9 + .../src/routes/a/{-$slug}.tsx | 9 + .../compiled-matcher/src/routes/about.tsx | 9 + .../src/routes/api/user-{$id}.tsx | 9 + .../compiled-matcher/src/routes/b/$.tsx | 9 + .../compiled-matcher/src/routes/b/$id.tsx | 9 + .../compiled-matcher/src/routes/b/index.tsx | 9 + .../src/routes/b/profile/index.tsx | 9 + .../src/routes/b/profile/settings.tsx | 9 + .../src/routes/b/user-{$id}.tsx | 9 + .../src/routes/b/{-$slug}.tsx | 9 + .../compiled-matcher/src/routes/beep/boop.tsx | 9 + .../src/routes/cache/temp_{$}.log.tsx | 9 + .../compiled-matcher/src/routes/files/$.tsx | 9 + .../src/routes/foo/$bar.index.tsx | 9 + .../compiled-matcher/src/routes/foo/$bar.tsx | 9 + .../src/routes/foo/$id/bar.tsx | 9 + .../src/routes/foo/bar/$id.tsx | 9 + .../src/routes/foo/{-$bar}/qux.tsx | 9 + .../src/routes/images/thumb_{$}.tsx | 9 + .../compiled-matcher/src/routes/index.tsx | 9 + .../src/routes/logs/{$}.txt.tsx | 9 + .../compiled-matcher/src/routes/one.tsx | 9 + .../compiled-matcher/src/routes/one/two.tsx | 9 + .../src/routes/posts/{-$slug}.tsx | 9 + .../compiled-matcher/src/routes/users/$id.tsx | 9 + .../src/routes/users/profile/index.tsx | 9 + .../src/routes/users/profile/settings.tsx | 9 + .../src/routes/z/y/x/index.tsx | 9 + .../compiled-matcher/src/routes/z/y/x/u.tsx | 9 + .../compiled-matcher/src/routes/z/y/x/v.tsx | 9 + .../compiled-matcher/src/routes/z/y/x/w.tsx | 9 + .../compiled-matcher/src/styles.css | 13 + .../compiled-matcher/tailwind.config.mjs | 4 + .../compiled-matcher/tests/app.spec.ts | 38 + .../tests/setup/global.setup.ts | 6 + .../tests/setup/global.teardown.ts | 6 + .../compiled-matcher/tsconfig.json | 15 + .../compiled-matcher/vite.config.js | 11 + pnpm-lock.yaml | 58 ++ 56 files changed, 1588 insertions(+) create mode 100644 e2e/react-router/compiled-matcher/.gitignore create mode 100644 e2e/react-router/compiled-matcher/index.html create mode 100644 e2e/react-router/compiled-matcher/package.json create mode 100644 e2e/react-router/compiled-matcher/playwright.config.ts create mode 100644 e2e/react-router/compiled-matcher/postcss.config.mjs create mode 100644 e2e/react-router/compiled-matcher/src/main.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routeTree.gen.ts create mode 100644 e2e/react-router/compiled-matcher/src/routes/$id/bar/foo.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/$id/foo/bar.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/__root.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/a/$.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/a/$id.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/a/b/c/d/e/f.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/a/index.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/a/profile/index.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/a/profile/settings.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/about.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/api/user-{$id}.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/b/$.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/b/$id.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/b/index.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/b/profile/index.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/b/profile/settings.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/beep/boop.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/cache/temp_{$}.log.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/files/$.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/foo/$bar.index.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/foo/$bar.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/foo/$id/bar.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/foo/bar/$id.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/foo/{-$bar}/qux.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/images/thumb_{$}.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/index.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/one.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/one/two.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/posts/{-$slug}.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/users/$id.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/users/profile/index.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/users/profile/settings.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/z/y/x/index.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/z/y/x/u.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/z/y/x/v.tsx create mode 100644 e2e/react-router/compiled-matcher/src/routes/z/y/x/w.tsx create mode 100644 e2e/react-router/compiled-matcher/src/styles.css create mode 100644 e2e/react-router/compiled-matcher/tailwind.config.mjs create mode 100644 e2e/react-router/compiled-matcher/tests/app.spec.ts create mode 100644 e2e/react-router/compiled-matcher/tests/setup/global.setup.ts create mode 100644 e2e/react-router/compiled-matcher/tests/setup/global.teardown.ts create mode 100644 e2e/react-router/compiled-matcher/tsconfig.json create mode 100644 e2e/react-router/compiled-matcher/vite.config.js diff --git a/e2e/react-router/compiled-matcher/.gitignore b/e2e/react-router/compiled-matcher/.gitignore new file mode 100644 index 00000000000..4d2da67b504 --- /dev/null +++ b/e2e/react-router/compiled-matcher/.gitignore @@ -0,0 +1,11 @@ +node_modules +.DS_Store +dist +dist-hash +dist-ssr +*.local + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/react-router/compiled-matcher/index.html b/e2e/react-router/compiled-matcher/index.html new file mode 100644 index 00000000000..21e30f16951 --- /dev/null +++ b/e2e/react-router/compiled-matcher/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/e2e/react-router/compiled-matcher/package.json b/e2e/react-router/compiled-matcher/package.json new file mode 100644 index 00000000000..e2780cfc144 --- /dev/null +++ b/e2e/react-router/compiled-matcher/package.json @@ -0,0 +1,36 @@ +{ + "name": "tanstack-router-e2e-react-compiled-matcher", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "dev:e2e": "vite", + "build": "vite build && tsc --noEmit", + "build:fresh": "rm -rf .tanstack && rm ./src/routeTree.gen.ts && pnpm i && vite build", + "serve": "vite preview", + "start": "vite", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-devtools": "workspace:^", + "@tanstack/router-plugin": "workspace:^", + "@tanstack/zod-adapter": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "redaxios": "^0.5.1", + "postcss": "^8.5.1", + "autoprefixer": "^10.4.20", + "tailwindcss": "^3.4.17", + "zod": "^3.24.2" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "combinate": "^1.1.11", + "vite": "^6.3.5" + } +} \ No newline at end of file diff --git a/e2e/react-router/compiled-matcher/playwright.config.ts b/e2e/react-router/compiled-matcher/playwright.config.ts new file mode 100644 index 00000000000..4dc2271f01e --- /dev/null +++ b/e2e/react-router/compiled-matcher/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from '@playwright/test' +import { + getDummyServerPort, + getTestServerPort, +} from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const EXTERNAL_PORT = await getDummyServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + globalSetup: './tests/setup/global.setup.ts', + globalTeardown: './tests/setup/global.teardown.ts', + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && VITE_SERVER_PORT=${PORT} pnpm serve --port ${PORT}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-router/compiled-matcher/postcss.config.mjs b/e2e/react-router/compiled-matcher/postcss.config.mjs new file mode 100644 index 00000000000..2e7af2b7f1a --- /dev/null +++ b/e2e/react-router/compiled-matcher/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/e2e/react-router/compiled-matcher/src/main.tsx b/e2e/react-router/compiled-matcher/src/main.tsx new file mode 100644 index 00000000000..3dc73ddd511 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/main.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import './styles.css' + +// Set up a Router instance +const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultStaleTime: 5000, + scrollRestoration: true, +}) + +// Register things for typesafety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app')! + +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement) + root.render() +} diff --git a/e2e/react-router/compiled-matcher/src/routeTree.gen.ts b/e2e/react-router/compiled-matcher/src/routeTree.gen.ts new file mode 100644 index 00000000000..4dd627fae30 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routeTree.gen.ts @@ -0,0 +1,899 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as OneRouteImport } from './routes/one' +import { Route as AboutRouteImport } from './routes/about' +import { Route as IndexRouteImport } from './routes/index' +import { Route as BIndexRouteImport } from './routes/b/index' +import { Route as AIndexRouteImport } from './routes/a/index' +import { Route as UsersIdRouteImport } from './routes/users/$id' +import { Route as PostsChar123SlugChar125RouteImport } from './routes/posts/{-$slug}' +import { Route as OneTwoRouteImport } from './routes/one/two' +import { Route as ImagesThumb_Char123Char125RouteImport } from './routes/images/thumb_{$}' +import { Route as FooBarRouteImport } from './routes/foo/$bar' +import { Route as FilesSplatRouteImport } from './routes/files/$' +import { Route as BeepBoopRouteImport } from './routes/beep/boop' +import { Route as BChar123SlugChar125RouteImport } from './routes/b/{-$slug}' +import { Route as BUserChar123idChar125RouteImport } from './routes/b/user-{$id}' +import { Route as BIdRouteImport } from './routes/b/$id' +import { Route as BSplatRouteImport } from './routes/b/$' +import { Route as ApiUserChar123idChar125RouteImport } from './routes/api/user-{$id}' +import { Route as AChar123SlugChar125RouteImport } from './routes/a/{-$slug}' +import { Route as AUserChar123idChar125RouteImport } from './routes/a/user-{$id}' +import { Route as AIdRouteImport } from './routes/a/$id' +import { Route as ASplatRouteImport } from './routes/a/$' +import { Route as UsersProfileIndexRouteImport } from './routes/users/profile/index' +import { Route as FooBarIndexRouteImport } from './routes/foo/$bar.index' +import { Route as BProfileIndexRouteImport } from './routes/b/profile/index' +import { Route as AProfileIndexRouteImport } from './routes/a/profile/index' +import { Route as UsersProfileSettingsRouteImport } from './routes/users/profile/settings' +import { Route as LogsChar123Char125TxtRouteImport } from './routes/logs/{$}.txt' +import { Route as FooChar123BarChar125QuxRouteImport } from './routes/foo/{-$bar}/qux' +import { Route as FooBarIdRouteImport } from './routes/foo/bar/$id' +import { Route as FooIdBarRouteImport } from './routes/foo/$id/bar' +import { Route as CacheTemp_Char123Char125LogRouteImport } from './routes/cache/temp_{$}.log' +import { Route as BProfileSettingsRouteImport } from './routes/b/profile/settings' +import { Route as AProfileSettingsRouteImport } from './routes/a/profile/settings' +import { Route as IdFooBarRouteImport } from './routes/$id/foo/bar' +import { Route as IdBarFooRouteImport } from './routes/$id/bar/foo' +import { Route as ZYXIndexRouteImport } from './routes/z/y/x/index' +import { Route as ZYXWRouteImport } from './routes/z/y/x/w' +import { Route as ZYXVRouteImport } from './routes/z/y/x/v' +import { Route as ZYXURouteImport } from './routes/z/y/x/u' +import { Route as ABCDEFRouteImport } from './routes/a/b/c/d/e/f' + +const OneRoute = OneRouteImport.update({ + id: '/one', + path: '/one', + getParentRoute: () => rootRouteImport, +} as any) +const AboutRoute = AboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const BIndexRoute = BIndexRouteImport.update({ + id: '/b/', + path: '/b/', + getParentRoute: () => rootRouteImport, +} as any) +const AIndexRoute = AIndexRouteImport.update({ + id: '/a/', + path: '/a/', + getParentRoute: () => rootRouteImport, +} as any) +const UsersIdRoute = UsersIdRouteImport.update({ + id: '/users/$id', + path: '/users/$id', + getParentRoute: () => rootRouteImport, +} as any) +const PostsChar123SlugChar125Route = PostsChar123SlugChar125RouteImport.update({ + id: '/posts/{-$slug}', + path: '/posts/{-$slug}', + getParentRoute: () => rootRouteImport, +} as any) +const OneTwoRoute = OneTwoRouteImport.update({ + id: '/two', + path: '/two', + getParentRoute: () => OneRoute, +} as any) +const ImagesThumb_Char123Char125Route = + ImagesThumb_Char123Char125RouteImport.update({ + id: '/images/thumb_{$}', + path: '/images/thumb_{$}', + getParentRoute: () => rootRouteImport, + } as any) +const FooBarRoute = FooBarRouteImport.update({ + id: '/foo/$bar', + path: '/foo/$bar', + getParentRoute: () => rootRouteImport, +} as any) +const FilesSplatRoute = FilesSplatRouteImport.update({ + id: '/files/$', + path: '/files/$', + getParentRoute: () => rootRouteImport, +} as any) +const BeepBoopRoute = BeepBoopRouteImport.update({ + id: '/beep/boop', + path: '/beep/boop', + getParentRoute: () => rootRouteImport, +} as any) +const BChar123SlugChar125Route = BChar123SlugChar125RouteImport.update({ + id: '/b/{-$slug}', + path: '/b/{-$slug}', + getParentRoute: () => rootRouteImport, +} as any) +const BUserChar123idChar125Route = BUserChar123idChar125RouteImport.update({ + id: '/b/user-{$id}', + path: '/b/user-{$id}', + getParentRoute: () => rootRouteImport, +} as any) +const BIdRoute = BIdRouteImport.update({ + id: '/b/$id', + path: '/b/$id', + getParentRoute: () => rootRouteImport, +} as any) +const BSplatRoute = BSplatRouteImport.update({ + id: '/b/$', + path: '/b/$', + getParentRoute: () => rootRouteImport, +} as any) +const ApiUserChar123idChar125Route = ApiUserChar123idChar125RouteImport.update({ + id: '/api/user-{$id}', + path: '/api/user-{$id}', + getParentRoute: () => rootRouteImport, +} as any) +const AChar123SlugChar125Route = AChar123SlugChar125RouteImport.update({ + id: '/a/{-$slug}', + path: '/a/{-$slug}', + getParentRoute: () => rootRouteImport, +} as any) +const AUserChar123idChar125Route = AUserChar123idChar125RouteImport.update({ + id: '/a/user-{$id}', + path: '/a/user-{$id}', + getParentRoute: () => rootRouteImport, +} as any) +const AIdRoute = AIdRouteImport.update({ + id: '/a/$id', + path: '/a/$id', + getParentRoute: () => rootRouteImport, +} as any) +const ASplatRoute = ASplatRouteImport.update({ + id: '/a/$', + path: '/a/$', + getParentRoute: () => rootRouteImport, +} as any) +const UsersProfileIndexRoute = UsersProfileIndexRouteImport.update({ + id: '/users/profile/', + path: '/users/profile/', + getParentRoute: () => rootRouteImport, +} as any) +const FooBarIndexRoute = FooBarIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => FooBarRoute, +} as any) +const BProfileIndexRoute = BProfileIndexRouteImport.update({ + id: '/b/profile/', + path: '/b/profile/', + getParentRoute: () => rootRouteImport, +} as any) +const AProfileIndexRoute = AProfileIndexRouteImport.update({ + id: '/a/profile/', + path: '/a/profile/', + getParentRoute: () => rootRouteImport, +} as any) +const UsersProfileSettingsRoute = UsersProfileSettingsRouteImport.update({ + id: '/users/profile/settings', + path: '/users/profile/settings', + getParentRoute: () => rootRouteImport, +} as any) +const LogsChar123Char125TxtRoute = LogsChar123Char125TxtRouteImport.update({ + id: '/logs/{$}/txt', + path: '/logs/{$}/txt', + getParentRoute: () => rootRouteImport, +} as any) +const FooChar123BarChar125QuxRoute = FooChar123BarChar125QuxRouteImport.update({ + id: '/foo/{-$bar}/qux', + path: '/foo/{-$bar}/qux', + getParentRoute: () => rootRouteImport, +} as any) +const FooBarIdRoute = FooBarIdRouteImport.update({ + id: '/foo/bar/$id', + path: '/foo/bar/$id', + getParentRoute: () => rootRouteImport, +} as any) +const FooIdBarRoute = FooIdBarRouteImport.update({ + id: '/foo/$id/bar', + path: '/foo/$id/bar', + getParentRoute: () => rootRouteImport, +} as any) +const CacheTemp_Char123Char125LogRoute = + CacheTemp_Char123Char125LogRouteImport.update({ + id: '/cache/temp_{$}/log', + path: '/cache/temp_{$}/log', + getParentRoute: () => rootRouteImport, + } as any) +const BProfileSettingsRoute = BProfileSettingsRouteImport.update({ + id: '/b/profile/settings', + path: '/b/profile/settings', + getParentRoute: () => rootRouteImport, +} as any) +const AProfileSettingsRoute = AProfileSettingsRouteImport.update({ + id: '/a/profile/settings', + path: '/a/profile/settings', + getParentRoute: () => rootRouteImport, +} as any) +const IdFooBarRoute = IdFooBarRouteImport.update({ + id: '/$id/foo/bar', + path: '/$id/foo/bar', + getParentRoute: () => rootRouteImport, +} as any) +const IdBarFooRoute = IdBarFooRouteImport.update({ + id: '/$id/bar/foo', + path: '/$id/bar/foo', + getParentRoute: () => rootRouteImport, +} as any) +const ZYXIndexRoute = ZYXIndexRouteImport.update({ + id: '/z/y/x/', + path: '/z/y/x/', + getParentRoute: () => rootRouteImport, +} as any) +const ZYXWRoute = ZYXWRouteImport.update({ + id: '/z/y/x/w', + path: '/z/y/x/w', + getParentRoute: () => rootRouteImport, +} as any) +const ZYXVRoute = ZYXVRouteImport.update({ + id: '/z/y/x/v', + path: '/z/y/x/v', + getParentRoute: () => rootRouteImport, +} as any) +const ZYXURoute = ZYXURouteImport.update({ + id: '/z/y/x/u', + path: '/z/y/x/u', + getParentRoute: () => rootRouteImport, +} as any) +const ABCDEFRoute = ABCDEFRouteImport.update({ + id: '/a/b/c/d/e/f', + path: '/a/b/c/d/e/f', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/one': typeof OneRouteWithChildren + '/a/$': typeof ASplatRoute + '/a/$id': typeof AIdRoute + '/a/user-{$id}': typeof AUserChar123idChar125Route + '/a/{-$slug}': typeof AChar123SlugChar125Route + '/api/user-{$id}': typeof ApiUserChar123idChar125Route + '/b/$': typeof BSplatRoute + '/b/$id': typeof BIdRoute + '/b/user-{$id}': typeof BUserChar123idChar125Route + '/b/{-$slug}': typeof BChar123SlugChar125Route + '/beep/boop': typeof BeepBoopRoute + '/files/$': typeof FilesSplatRoute + '/foo/$bar': typeof FooBarRouteWithChildren + '/images/thumb_{$}': typeof ImagesThumb_Char123Char125Route + '/one/two': typeof OneTwoRoute + '/posts/{-$slug}': typeof PostsChar123SlugChar125Route + '/users/$id': typeof UsersIdRoute + '/a': typeof AIndexRoute + '/b': typeof BIndexRoute + '/$id/bar/foo': typeof IdBarFooRoute + '/$id/foo/bar': typeof IdFooBarRoute + '/a/profile/settings': typeof AProfileSettingsRoute + '/b/profile/settings': typeof BProfileSettingsRoute + '/cache/temp_{$}/log': typeof CacheTemp_Char123Char125LogRoute + '/foo/$id/bar': typeof FooIdBarRoute + '/foo/bar/$id': typeof FooBarIdRoute + '/foo/{-$bar}/qux': typeof FooChar123BarChar125QuxRoute + '/logs/{$}/txt': typeof LogsChar123Char125TxtRoute + '/users/profile/settings': typeof UsersProfileSettingsRoute + '/a/profile': typeof AProfileIndexRoute + '/b/profile': typeof BProfileIndexRoute + '/foo/$bar/': typeof FooBarIndexRoute + '/users/profile': typeof UsersProfileIndexRoute + '/z/y/x/u': typeof ZYXURoute + '/z/y/x/v': typeof ZYXVRoute + '/z/y/x/w': typeof ZYXWRoute + '/z/y/x': typeof ZYXIndexRoute + '/a/b/c/d/e/f': typeof ABCDEFRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/one': typeof OneRouteWithChildren + '/a/$': typeof ASplatRoute + '/a/$id': typeof AIdRoute + '/a/user-{$id}': typeof AUserChar123idChar125Route + '/a/{-$slug}': typeof AChar123SlugChar125Route + '/api/user-{$id}': typeof ApiUserChar123idChar125Route + '/b/$': typeof BSplatRoute + '/b/$id': typeof BIdRoute + '/b/user-{$id}': typeof BUserChar123idChar125Route + '/b/{-$slug}': typeof BChar123SlugChar125Route + '/beep/boop': typeof BeepBoopRoute + '/files/$': typeof FilesSplatRoute + '/images/thumb_{$}': typeof ImagesThumb_Char123Char125Route + '/one/two': typeof OneTwoRoute + '/posts/{-$slug}': typeof PostsChar123SlugChar125Route + '/users/$id': typeof UsersIdRoute + '/a': typeof AIndexRoute + '/b': typeof BIndexRoute + '/$id/bar/foo': typeof IdBarFooRoute + '/$id/foo/bar': typeof IdFooBarRoute + '/a/profile/settings': typeof AProfileSettingsRoute + '/b/profile/settings': typeof BProfileSettingsRoute + '/cache/temp_{$}/log': typeof CacheTemp_Char123Char125LogRoute + '/foo/$id/bar': typeof FooIdBarRoute + '/foo/bar/$id': typeof FooBarIdRoute + '/foo/{-$bar}/qux': typeof FooChar123BarChar125QuxRoute + '/logs/{$}/txt': typeof LogsChar123Char125TxtRoute + '/users/profile/settings': typeof UsersProfileSettingsRoute + '/a/profile': typeof AProfileIndexRoute + '/b/profile': typeof BProfileIndexRoute + '/foo/$bar': typeof FooBarIndexRoute + '/users/profile': typeof UsersProfileIndexRoute + '/z/y/x/u': typeof ZYXURoute + '/z/y/x/v': typeof ZYXVRoute + '/z/y/x/w': typeof ZYXWRoute + '/z/y/x': typeof ZYXIndexRoute + '/a/b/c/d/e/f': typeof ABCDEFRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/one': typeof OneRouteWithChildren + '/a/$': typeof ASplatRoute + '/a/$id': typeof AIdRoute + '/a/user-{$id}': typeof AUserChar123idChar125Route + '/a/{-$slug}': typeof AChar123SlugChar125Route + '/api/user-{$id}': typeof ApiUserChar123idChar125Route + '/b/$': typeof BSplatRoute + '/b/$id': typeof BIdRoute + '/b/user-{$id}': typeof BUserChar123idChar125Route + '/b/{-$slug}': typeof BChar123SlugChar125Route + '/beep/boop': typeof BeepBoopRoute + '/files/$': typeof FilesSplatRoute + '/foo/$bar': typeof FooBarRouteWithChildren + '/images/thumb_{$}': typeof ImagesThumb_Char123Char125Route + '/one/two': typeof OneTwoRoute + '/posts/{-$slug}': typeof PostsChar123SlugChar125Route + '/users/$id': typeof UsersIdRoute + '/a/': typeof AIndexRoute + '/b/': typeof BIndexRoute + '/$id/bar/foo': typeof IdBarFooRoute + '/$id/foo/bar': typeof IdFooBarRoute + '/a/profile/settings': typeof AProfileSettingsRoute + '/b/profile/settings': typeof BProfileSettingsRoute + '/cache/temp_{$}/log': typeof CacheTemp_Char123Char125LogRoute + '/foo/$id/bar': typeof FooIdBarRoute + '/foo/bar/$id': typeof FooBarIdRoute + '/foo/{-$bar}/qux': typeof FooChar123BarChar125QuxRoute + '/logs/{$}/txt': typeof LogsChar123Char125TxtRoute + '/users/profile/settings': typeof UsersProfileSettingsRoute + '/a/profile/': typeof AProfileIndexRoute + '/b/profile/': typeof BProfileIndexRoute + '/foo/$bar/': typeof FooBarIndexRoute + '/users/profile/': typeof UsersProfileIndexRoute + '/z/y/x/u': typeof ZYXURoute + '/z/y/x/v': typeof ZYXVRoute + '/z/y/x/w': typeof ZYXWRoute + '/z/y/x/': typeof ZYXIndexRoute + '/a/b/c/d/e/f': typeof ABCDEFRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/about' + | '/one' + | '/a/$' + | '/a/$id' + | '/a/user-{$id}' + | '/a/{-$slug}' + | '/api/user-{$id}' + | '/b/$' + | '/b/$id' + | '/b/user-{$id}' + | '/b/{-$slug}' + | '/beep/boop' + | '/files/$' + | '/foo/$bar' + | '/images/thumb_{$}' + | '/one/two' + | '/posts/{-$slug}' + | '/users/$id' + | '/a' + | '/b' + | '/$id/bar/foo' + | '/$id/foo/bar' + | '/a/profile/settings' + | '/b/profile/settings' + | '/cache/temp_{$}/log' + | '/foo/$id/bar' + | '/foo/bar/$id' + | '/foo/{-$bar}/qux' + | '/logs/{$}/txt' + | '/users/profile/settings' + | '/a/profile' + | '/b/profile' + | '/foo/$bar/' + | '/users/profile' + | '/z/y/x/u' + | '/z/y/x/v' + | '/z/y/x/w' + | '/z/y/x' + | '/a/b/c/d/e/f' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/about' + | '/one' + | '/a/$' + | '/a/$id' + | '/a/user-{$id}' + | '/a/{-$slug}' + | '/api/user-{$id}' + | '/b/$' + | '/b/$id' + | '/b/user-{$id}' + | '/b/{-$slug}' + | '/beep/boop' + | '/files/$' + | '/images/thumb_{$}' + | '/one/two' + | '/posts/{-$slug}' + | '/users/$id' + | '/a' + | '/b' + | '/$id/bar/foo' + | '/$id/foo/bar' + | '/a/profile/settings' + | '/b/profile/settings' + | '/cache/temp_{$}/log' + | '/foo/$id/bar' + | '/foo/bar/$id' + | '/foo/{-$bar}/qux' + | '/logs/{$}/txt' + | '/users/profile/settings' + | '/a/profile' + | '/b/profile' + | '/foo/$bar' + | '/users/profile' + | '/z/y/x/u' + | '/z/y/x/v' + | '/z/y/x/w' + | '/z/y/x' + | '/a/b/c/d/e/f' + id: + | '__root__' + | '/' + | '/about' + | '/one' + | '/a/$' + | '/a/$id' + | '/a/user-{$id}' + | '/a/{-$slug}' + | '/api/user-{$id}' + | '/b/$' + | '/b/$id' + | '/b/user-{$id}' + | '/b/{-$slug}' + | '/beep/boop' + | '/files/$' + | '/foo/$bar' + | '/images/thumb_{$}' + | '/one/two' + | '/posts/{-$slug}' + | '/users/$id' + | '/a/' + | '/b/' + | '/$id/bar/foo' + | '/$id/foo/bar' + | '/a/profile/settings' + | '/b/profile/settings' + | '/cache/temp_{$}/log' + | '/foo/$id/bar' + | '/foo/bar/$id' + | '/foo/{-$bar}/qux' + | '/logs/{$}/txt' + | '/users/profile/settings' + | '/a/profile/' + | '/b/profile/' + | '/foo/$bar/' + | '/users/profile/' + | '/z/y/x/u' + | '/z/y/x/v' + | '/z/y/x/w' + | '/z/y/x/' + | '/a/b/c/d/e/f' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AboutRoute: typeof AboutRoute + OneRoute: typeof OneRouteWithChildren + ASplatRoute: typeof ASplatRoute + AIdRoute: typeof AIdRoute + AUserChar123idChar125Route: typeof AUserChar123idChar125Route + AChar123SlugChar125Route: typeof AChar123SlugChar125Route + ApiUserChar123idChar125Route: typeof ApiUserChar123idChar125Route + BSplatRoute: typeof BSplatRoute + BIdRoute: typeof BIdRoute + BUserChar123idChar125Route: typeof BUserChar123idChar125Route + BChar123SlugChar125Route: typeof BChar123SlugChar125Route + BeepBoopRoute: typeof BeepBoopRoute + FilesSplatRoute: typeof FilesSplatRoute + FooBarRoute: typeof FooBarRouteWithChildren + ImagesThumb_Char123Char125Route: typeof ImagesThumb_Char123Char125Route + PostsChar123SlugChar125Route: typeof PostsChar123SlugChar125Route + UsersIdRoute: typeof UsersIdRoute + AIndexRoute: typeof AIndexRoute + BIndexRoute: typeof BIndexRoute + IdBarFooRoute: typeof IdBarFooRoute + IdFooBarRoute: typeof IdFooBarRoute + AProfileSettingsRoute: typeof AProfileSettingsRoute + BProfileSettingsRoute: typeof BProfileSettingsRoute + CacheTemp_Char123Char125LogRoute: typeof CacheTemp_Char123Char125LogRoute + FooIdBarRoute: typeof FooIdBarRoute + FooBarIdRoute: typeof FooBarIdRoute + FooChar123BarChar125QuxRoute: typeof FooChar123BarChar125QuxRoute + LogsChar123Char125TxtRoute: typeof LogsChar123Char125TxtRoute + UsersProfileSettingsRoute: typeof UsersProfileSettingsRoute + AProfileIndexRoute: typeof AProfileIndexRoute + BProfileIndexRoute: typeof BProfileIndexRoute + UsersProfileIndexRoute: typeof UsersProfileIndexRoute + ZYXURoute: typeof ZYXURoute + ZYXVRoute: typeof ZYXVRoute + ZYXWRoute: typeof ZYXWRoute + ZYXIndexRoute: typeof ZYXIndexRoute + ABCDEFRoute: typeof ABCDEFRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/one': { + id: '/one' + path: '/one' + fullPath: '/one' + preLoaderRoute: typeof OneRouteImport + parentRoute: typeof rootRouteImport + } + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/b/': { + id: '/b/' + path: '/b' + fullPath: '/b' + preLoaderRoute: typeof BIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/a/': { + id: '/a/' + path: '/a' + fullPath: '/a' + preLoaderRoute: typeof AIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/users/$id': { + id: '/users/$id' + path: '/users/$id' + fullPath: '/users/$id' + preLoaderRoute: typeof UsersIdRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/{-$slug}': { + id: '/posts/{-$slug}' + path: '/posts/{-$slug}' + fullPath: '/posts/{-$slug}' + preLoaderRoute: typeof PostsChar123SlugChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/one/two': { + id: '/one/two' + path: '/two' + fullPath: '/one/two' + preLoaderRoute: typeof OneTwoRouteImport + parentRoute: typeof OneRoute + } + '/images/thumb_{$}': { + id: '/images/thumb_{$}' + path: '/images/thumb_{$}' + fullPath: '/images/thumb_{$}' + preLoaderRoute: typeof ImagesThumb_Char123Char125RouteImport + parentRoute: typeof rootRouteImport + } + '/foo/$bar': { + id: '/foo/$bar' + path: '/foo/$bar' + fullPath: '/foo/$bar' + preLoaderRoute: typeof FooBarRouteImport + parentRoute: typeof rootRouteImport + } + '/files/$': { + id: '/files/$' + path: '/files/$' + fullPath: '/files/$' + preLoaderRoute: typeof FilesSplatRouteImport + parentRoute: typeof rootRouteImport + } + '/beep/boop': { + id: '/beep/boop' + path: '/beep/boop' + fullPath: '/beep/boop' + preLoaderRoute: typeof BeepBoopRouteImport + parentRoute: typeof rootRouteImport + } + '/b/{-$slug}': { + id: '/b/{-$slug}' + path: '/b/{-$slug}' + fullPath: '/b/{-$slug}' + preLoaderRoute: typeof BChar123SlugChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/b/user-{$id}': { + id: '/b/user-{$id}' + path: '/b/user-{$id}' + fullPath: '/b/user-{$id}' + preLoaderRoute: typeof BUserChar123idChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/b/$id': { + id: '/b/$id' + path: '/b/$id' + fullPath: '/b/$id' + preLoaderRoute: typeof BIdRouteImport + parentRoute: typeof rootRouteImport + } + '/b/$': { + id: '/b/$' + path: '/b/$' + fullPath: '/b/$' + preLoaderRoute: typeof BSplatRouteImport + parentRoute: typeof rootRouteImport + } + '/api/user-{$id}': { + id: '/api/user-{$id}' + path: '/api/user-{$id}' + fullPath: '/api/user-{$id}' + preLoaderRoute: typeof ApiUserChar123idChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/a/{-$slug}': { + id: '/a/{-$slug}' + path: '/a/{-$slug}' + fullPath: '/a/{-$slug}' + preLoaderRoute: typeof AChar123SlugChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/a/user-{$id}': { + id: '/a/user-{$id}' + path: '/a/user-{$id}' + fullPath: '/a/user-{$id}' + preLoaderRoute: typeof AUserChar123idChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/a/$id': { + id: '/a/$id' + path: '/a/$id' + fullPath: '/a/$id' + preLoaderRoute: typeof AIdRouteImport + parentRoute: typeof rootRouteImport + } + '/a/$': { + id: '/a/$' + path: '/a/$' + fullPath: '/a/$' + preLoaderRoute: typeof ASplatRouteImport + parentRoute: typeof rootRouteImport + } + '/users/profile/': { + id: '/users/profile/' + path: '/users/profile' + fullPath: '/users/profile' + preLoaderRoute: typeof UsersProfileIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/foo/$bar/': { + id: '/foo/$bar/' + path: '/' + fullPath: '/foo/$bar/' + preLoaderRoute: typeof FooBarIndexRouteImport + parentRoute: typeof FooBarRoute + } + '/b/profile/': { + id: '/b/profile/' + path: '/b/profile' + fullPath: '/b/profile' + preLoaderRoute: typeof BProfileIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/a/profile/': { + id: '/a/profile/' + path: '/a/profile' + fullPath: '/a/profile' + preLoaderRoute: typeof AProfileIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/users/profile/settings': { + id: '/users/profile/settings' + path: '/users/profile/settings' + fullPath: '/users/profile/settings' + preLoaderRoute: typeof UsersProfileSettingsRouteImport + parentRoute: typeof rootRouteImport + } + '/logs/{$}/txt': { + id: '/logs/{$}/txt' + path: '/logs/{$}/txt' + fullPath: '/logs/{$}/txt' + preLoaderRoute: typeof LogsChar123Char125TxtRouteImport + parentRoute: typeof rootRouteImport + } + '/foo/{-$bar}/qux': { + id: '/foo/{-$bar}/qux' + path: '/foo/{-$bar}/qux' + fullPath: '/foo/{-$bar}/qux' + preLoaderRoute: typeof FooChar123BarChar125QuxRouteImport + parentRoute: typeof rootRouteImport + } + '/foo/bar/$id': { + id: '/foo/bar/$id' + path: '/foo/bar/$id' + fullPath: '/foo/bar/$id' + preLoaderRoute: typeof FooBarIdRouteImport + parentRoute: typeof rootRouteImport + } + '/foo/$id/bar': { + id: '/foo/$id/bar' + path: '/foo/$id/bar' + fullPath: '/foo/$id/bar' + preLoaderRoute: typeof FooIdBarRouteImport + parentRoute: typeof rootRouteImport + } + '/cache/temp_{$}/log': { + id: '/cache/temp_{$}/log' + path: '/cache/temp_{$}/log' + fullPath: '/cache/temp_{$}/log' + preLoaderRoute: typeof CacheTemp_Char123Char125LogRouteImport + parentRoute: typeof rootRouteImport + } + '/b/profile/settings': { + id: '/b/profile/settings' + path: '/b/profile/settings' + fullPath: '/b/profile/settings' + preLoaderRoute: typeof BProfileSettingsRouteImport + parentRoute: typeof rootRouteImport + } + '/a/profile/settings': { + id: '/a/profile/settings' + path: '/a/profile/settings' + fullPath: '/a/profile/settings' + preLoaderRoute: typeof AProfileSettingsRouteImport + parentRoute: typeof rootRouteImport + } + '/$id/foo/bar': { + id: '/$id/foo/bar' + path: '/$id/foo/bar' + fullPath: '/$id/foo/bar' + preLoaderRoute: typeof IdFooBarRouteImport + parentRoute: typeof rootRouteImport + } + '/$id/bar/foo': { + id: '/$id/bar/foo' + path: '/$id/bar/foo' + fullPath: '/$id/bar/foo' + preLoaderRoute: typeof IdBarFooRouteImport + parentRoute: typeof rootRouteImport + } + '/z/y/x/': { + id: '/z/y/x/' + path: '/z/y/x' + fullPath: '/z/y/x' + preLoaderRoute: typeof ZYXIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/z/y/x/w': { + id: '/z/y/x/w' + path: '/z/y/x/w' + fullPath: '/z/y/x/w' + preLoaderRoute: typeof ZYXWRouteImport + parentRoute: typeof rootRouteImport + } + '/z/y/x/v': { + id: '/z/y/x/v' + path: '/z/y/x/v' + fullPath: '/z/y/x/v' + preLoaderRoute: typeof ZYXVRouteImport + parentRoute: typeof rootRouteImport + } + '/z/y/x/u': { + id: '/z/y/x/u' + path: '/z/y/x/u' + fullPath: '/z/y/x/u' + preLoaderRoute: typeof ZYXURouteImport + parentRoute: typeof rootRouteImport + } + '/a/b/c/d/e/f': { + id: '/a/b/c/d/e/f' + path: '/a/b/c/d/e/f' + fullPath: '/a/b/c/d/e/f' + preLoaderRoute: typeof ABCDEFRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +interface OneRouteChildren { + OneTwoRoute: typeof OneTwoRoute +} + +const OneRouteChildren: OneRouteChildren = { + OneTwoRoute: OneTwoRoute, +} + +const OneRouteWithChildren = OneRoute._addFileChildren(OneRouteChildren) + +interface FooBarRouteChildren { + FooBarIndexRoute: typeof FooBarIndexRoute +} + +const FooBarRouteChildren: FooBarRouteChildren = { + FooBarIndexRoute: FooBarIndexRoute, +} + +const FooBarRouteWithChildren = + FooBarRoute._addFileChildren(FooBarRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AboutRoute: AboutRoute, + OneRoute: OneRouteWithChildren, + ASplatRoute: ASplatRoute, + AIdRoute: AIdRoute, + AUserChar123idChar125Route: AUserChar123idChar125Route, + AChar123SlugChar125Route: AChar123SlugChar125Route, + ApiUserChar123idChar125Route: ApiUserChar123idChar125Route, + BSplatRoute: BSplatRoute, + BIdRoute: BIdRoute, + BUserChar123idChar125Route: BUserChar123idChar125Route, + BChar123SlugChar125Route: BChar123SlugChar125Route, + BeepBoopRoute: BeepBoopRoute, + FilesSplatRoute: FilesSplatRoute, + FooBarRoute: FooBarRouteWithChildren, + ImagesThumb_Char123Char125Route: ImagesThumb_Char123Char125Route, + PostsChar123SlugChar125Route: PostsChar123SlugChar125Route, + UsersIdRoute: UsersIdRoute, + AIndexRoute: AIndexRoute, + BIndexRoute: BIndexRoute, + IdBarFooRoute: IdBarFooRoute, + IdFooBarRoute: IdFooBarRoute, + AProfileSettingsRoute: AProfileSettingsRoute, + BProfileSettingsRoute: BProfileSettingsRoute, + CacheTemp_Char123Char125LogRoute: CacheTemp_Char123Char125LogRoute, + FooIdBarRoute: FooIdBarRoute, + FooBarIdRoute: FooBarIdRoute, + FooChar123BarChar125QuxRoute: FooChar123BarChar125QuxRoute, + LogsChar123Char125TxtRoute: LogsChar123Char125TxtRoute, + UsersProfileSettingsRoute: UsersProfileSettingsRoute, + AProfileIndexRoute: AProfileIndexRoute, + BProfileIndexRoute: BProfileIndexRoute, + UsersProfileIndexRoute: UsersProfileIndexRoute, + ZYXURoute: ZYXURoute, + ZYXVRoute: ZYXVRoute, + ZYXWRoute: ZYXWRoute, + ZYXIndexRoute: ZYXIndexRoute, + ABCDEFRoute: ABCDEFRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/e2e/react-router/compiled-matcher/src/routes/$id/bar/foo.tsx b/e2e/react-router/compiled-matcher/src/routes/$id/bar/foo.tsx new file mode 100644 index 00000000000..d297530468a --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/$id/bar/foo.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$id/bar/foo')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/$id/bar/foo"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/$id/foo/bar.tsx b/e2e/react-router/compiled-matcher/src/routes/$id/foo/bar.tsx new file mode 100644 index 00000000000..47af8bdfe9b --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/$id/foo/bar.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$id/foo/bar')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/$id/foo/bar"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/__root.tsx b/e2e/react-router/compiled-matcher/src/routes/__root.tsx new file mode 100644 index 00000000000..3b0a92c6043 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/__root.tsx @@ -0,0 +1,46 @@ +import { + HeadContent, + Link, + Outlet, + createRootRoute, + useRouter, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' + +export const Route = createRootRoute({ + component: RootComponent, + notFoundComponent: () => { + return ( +
+

This is the notFoundComponent configured on root route

+ Start Over +
+ ) + }, +}) + +function RootComponent() { + const router = useRouter() + return ( + <> + +
+ {Object.keys(router.routesByPath).map((to) => ( + + {to} + + ))} +
+
+ + {/* Start rendering router matches */} + + + ) +} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/$.tsx b/e2e/react-router/compiled-matcher/src/routes/a/$.tsx new file mode 100644 index 00000000000..c1efe777e3e --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/$.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/$')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/$"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/$id.tsx b/e2e/react-router/compiled-matcher/src/routes/a/$id.tsx new file mode 100644 index 00000000000..563909e296b --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/$id.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/$id')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/$id"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/b/c/d/e/f.tsx b/e2e/react-router/compiled-matcher/src/routes/a/b/c/d/e/f.tsx new file mode 100644 index 00000000000..e6cb4db25ee --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/b/c/d/e/f.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/b/c/d/e/f')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/b/c/d/e/f"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/index.tsx b/e2e/react-router/compiled-matcher/src/routes/a/index.tsx new file mode 100644 index 00000000000..9f3aaa404e5 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/profile/index.tsx b/e2e/react-router/compiled-matcher/src/routes/a/profile/index.tsx new file mode 100644 index 00000000000..5d67db2e70d --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/profile/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/profile/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/profile/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/profile/settings.tsx b/e2e/react-router/compiled-matcher/src/routes/a/profile/settings.tsx new file mode 100644 index 00000000000..517b4219fe5 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/profile/settings.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/profile/settings')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/profile/settings"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx b/e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx new file mode 100644 index 00000000000..cfc9844f3b6 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/user-{$id}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/a/user-{$id}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx b/e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx new file mode 100644 index 00000000000..be72225f032 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/{-$slug}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/a/{-$slug}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/about.tsx b/e2e/react-router/compiled-matcher/src/routes/about.tsx new file mode 100644 index 00000000000..1e6c7068e00 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/about.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/about')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/about"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/api/user-{$id}.tsx b/e2e/react-router/compiled-matcher/src/routes/api/user-{$id}.tsx new file mode 100644 index 00000000000..9ef50fca904 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/api/user-{$id}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/api/user-{$id}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/api/user-{$id}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/$.tsx b/e2e/react-router/compiled-matcher/src/routes/b/$.tsx new file mode 100644 index 00000000000..1d360267045 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/$.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/$')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/b/$"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/$id.tsx b/e2e/react-router/compiled-matcher/src/routes/b/$id.tsx new file mode 100644 index 00000000000..004b382eaed --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/$id.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/$id')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/b/$id"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/index.tsx b/e2e/react-router/compiled-matcher/src/routes/b/index.tsx new file mode 100644 index 00000000000..dc875444191 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/b/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/profile/index.tsx b/e2e/react-router/compiled-matcher/src/routes/b/profile/index.tsx new file mode 100644 index 00000000000..5f32f2d4eef --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/profile/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/profile/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/b/profile/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/profile/settings.tsx b/e2e/react-router/compiled-matcher/src/routes/b/profile/settings.tsx new file mode 100644 index 00000000000..eeec76d78ab --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/profile/settings.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/profile/settings')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/b/profile/settings"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx b/e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx new file mode 100644 index 00000000000..a4d4a44e181 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/user-{$id}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/b/user-{$id}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx b/e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx new file mode 100644 index 00000000000..cd36c15a60a --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/{-$slug}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/b/{-$slug}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/beep/boop.tsx b/e2e/react-router/compiled-matcher/src/routes/beep/boop.tsx new file mode 100644 index 00000000000..2d0115db443 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/beep/boop.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/beep/boop')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/beep/boop"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/cache/temp_{$}.log.tsx b/e2e/react-router/compiled-matcher/src/routes/cache/temp_{$}.log.tsx new file mode 100644 index 00000000000..f15516beab4 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/cache/temp_{$}.log.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/cache/temp_{$}/log')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/cache/temp_{$}.log"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/files/$.tsx b/e2e/react-router/compiled-matcher/src/routes/files/$.tsx new file mode 100644 index 00000000000..0c4ab5c1a07 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/files/$.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/files/$')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/files/$"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/foo/$bar.index.tsx b/e2e/react-router/compiled-matcher/src/routes/foo/$bar.index.tsx new file mode 100644 index 00000000000..a55255222b4 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/foo/$bar.index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/$bar/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/foo/$bar/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/foo/$bar.tsx b/e2e/react-router/compiled-matcher/src/routes/foo/$bar.tsx new file mode 100644 index 00000000000..b83a197ac7c --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/foo/$bar.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/$bar')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/foo/$bar"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/foo/$id/bar.tsx b/e2e/react-router/compiled-matcher/src/routes/foo/$id/bar.tsx new file mode 100644 index 00000000000..025eb7a2bf6 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/foo/$id/bar.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/$id/bar')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/foo/$id/bar"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/foo/bar/$id.tsx b/e2e/react-router/compiled-matcher/src/routes/foo/bar/$id.tsx new file mode 100644 index 00000000000..e06fb3ff6b2 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/foo/bar/$id.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/bar/$id')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/foo/bar/$id"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/foo/{-$bar}/qux.tsx b/e2e/react-router/compiled-matcher/src/routes/foo/{-$bar}/qux.tsx new file mode 100644 index 00000000000..6eccf8435d5 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/foo/{-$bar}/qux.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/{-$bar}/qux')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/foo/{-$bar}/qux"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/images/thumb_{$}.tsx b/e2e/react-router/compiled-matcher/src/routes/images/thumb_{$}.tsx new file mode 100644 index 00000000000..9574267e2a7 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/images/thumb_{$}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/images/thumb_{$}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/images/thumb_{$}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/index.tsx b/e2e/react-router/compiled-matcher/src/routes/index.tsx new file mode 100644 index 00000000000..d58928d9ed0 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx b/e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx new file mode 100644 index 00000000000..3f9ca21f15f --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/logs/{$}/txt')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/logs/{$}.txt"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/one.tsx b/e2e/react-router/compiled-matcher/src/routes/one.tsx new file mode 100644 index 00000000000..8334d30aa20 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/one.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/one')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/one"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/one/two.tsx b/e2e/react-router/compiled-matcher/src/routes/one/two.tsx new file mode 100644 index 00000000000..8fe6c5615bb --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/one/two.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/one/two')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/one/two"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/posts/{-$slug}.tsx b/e2e/react-router/compiled-matcher/src/routes/posts/{-$slug}.tsx new file mode 100644 index 00000000000..d065bba0fdc --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/posts/{-$slug}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/{-$slug}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/posts/{-$slug}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/users/$id.tsx b/e2e/react-router/compiled-matcher/src/routes/users/$id.tsx new file mode 100644 index 00000000000..99635ef89a8 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/users/$id.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/users/$id')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/users/$id"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/users/profile/index.tsx b/e2e/react-router/compiled-matcher/src/routes/users/profile/index.tsx new file mode 100644 index 00000000000..b042f0e5c94 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/users/profile/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/users/profile/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/users/profile/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/users/profile/settings.tsx b/e2e/react-router/compiled-matcher/src/routes/users/profile/settings.tsx new file mode 100644 index 00000000000..d0eece68ae4 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/users/profile/settings.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/users/profile/settings')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/users/profile/settings"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/z/y/x/index.tsx b/e2e/react-router/compiled-matcher/src/routes/z/y/x/index.tsx new file mode 100644 index 00000000000..fb49b8aeaeb --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/z/y/x/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/z/y/x/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/z/y/x/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/z/y/x/u.tsx b/e2e/react-router/compiled-matcher/src/routes/z/y/x/u.tsx new file mode 100644 index 00000000000..20afb916ca7 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/z/y/x/u.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/z/y/x/u')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/z/y/x/u"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/z/y/x/v.tsx b/e2e/react-router/compiled-matcher/src/routes/z/y/x/v.tsx new file mode 100644 index 00000000000..a19ed76a280 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/z/y/x/v.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/z/y/x/v')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/z/y/x/v"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/z/y/x/w.tsx b/e2e/react-router/compiled-matcher/src/routes/z/y/x/w.tsx new file mode 100644 index 00000000000..e6f02739452 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/z/y/x/w.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/z/y/x/w')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/z/y/x/w"!
+} diff --git a/e2e/react-router/compiled-matcher/src/styles.css b/e2e/react-router/compiled-matcher/src/styles.css new file mode 100644 index 00000000000..0b8e317099c --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/styles.css @@ -0,0 +1,13 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html { + color-scheme: light dark; +} +* { + @apply border-gray-200 dark:border-gray-800; +} +body { + @apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200; +} diff --git a/e2e/react-router/compiled-matcher/tailwind.config.mjs b/e2e/react-router/compiled-matcher/tailwind.config.mjs new file mode 100644 index 00000000000..4986094b9d5 --- /dev/null +++ b/e2e/react-router/compiled-matcher/tailwind.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'], +} diff --git a/e2e/react-router/compiled-matcher/tests/app.spec.ts b/e2e/react-router/compiled-matcher/tests/app.spec.ts new file mode 100644 index 00000000000..285474fb480 --- /dev/null +++ b/e2e/react-router/compiled-matcher/tests/app.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test' + +test.beforeEach(async ({ page }) => { + await page.goto('/') +}) + +test('exact path matching', async ({ page }) => { + const links = [ + { url: '', route: '/' }, + { url: '/', route: '/' }, + { url: '/users/profile/settings', route: '/users/profile/settings' }, + // { url: '/foo/123', route: '/foo/$bar/' }, + // { url: '/FOO/123', route: '/foo/$bar/' }, + // { url: '/foo/123/', route: '/foo/$bar/' }, + { url: '/b/123', route: '/b/$id' }, + { url: '/foo/qux', route: '/foo/{-$bar}/qux' }, + { url: '/foo/123/qux', route: '/foo/{-$bar}/qux' }, + { url: '/a/user-123', route: '/a/user-{$id}' }, + { url: '/a/123', route: '/a/$id' }, + { url: '/a/123/more', route: '/a/$' }, + { url: '/files', route: '/files/$' }, + { url: '/files/hello-world.txt', route: '/files/$' }, + { url: '/something/foo/bar', route: '/$id/foo/bar' }, + { url: '/files/deep/nested/file.json', route: '/files/$' }, + { url: '/files/', route: '/files/$' }, + { url: '/images/thumb_200x300.jpg', route: '/images/thumb_{$}' }, + { url: '/logs/2020/01/01/error.txt', route: '/logs/{$}.txt' }, + { url: '/cache/temp_user456.log', route: '/cache/temp_{$}.log' }, + { url: '/a/b/c/d/e', route: '/a/$' }, + ] + for (const link of links) { + await test.step(`nav to '${link.url}'`, async () => { + console.log(`nav to '${link.url}'`) + await page.goto(link.url) + await expect(page.getByText(`Hello "${link.route}"!`)).toBeVisible() + }) + } +}) diff --git a/e2e/react-router/compiled-matcher/tests/setup/global.setup.ts b/e2e/react-router/compiled-matcher/tests/setup/global.setup.ts new file mode 100644 index 00000000000..3593d10ab90 --- /dev/null +++ b/e2e/react-router/compiled-matcher/tests/setup/global.setup.ts @@ -0,0 +1,6 @@ +import { e2eStartDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function setup() { + await e2eStartDummyServer(packageJson.name) +} diff --git a/e2e/react-router/compiled-matcher/tests/setup/global.teardown.ts b/e2e/react-router/compiled-matcher/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..62fd79911cc --- /dev/null +++ b/e2e/react-router/compiled-matcher/tests/setup/global.teardown.ts @@ -0,0 +1,6 @@ +import { e2eStopDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function teardown() { + await e2eStopDummyServer(packageJson.name) +} diff --git a/e2e/react-router/compiled-matcher/tsconfig.json b/e2e/react-router/compiled-matcher/tsconfig.json new file mode 100644 index 00000000000..82cf0bcd2c9 --- /dev/null +++ b/e2e/react-router/compiled-matcher/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "skipLibCheck": true, + "resolveJsonModule": true, + "allowJs": true, + "types": ["vite/client"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/e2e/react-router/compiled-matcher/vite.config.js b/e2e/react-router/compiled-matcher/vite.config.js new file mode 100644 index 00000000000..ab615485ae6 --- /dev/null +++ b/e2e/react-router/compiled-matcher/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + tanstackRouter({ target: 'react', autoCodeSplitting: true }), + react(), + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7959a7e582..b7b1a7880e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,6 +621,64 @@ importers: specifier: 6.3.5 version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) + e2e/react-router/compiled-matcher: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@tanstack/router-plugin': + specifier: workspace:* + version: link:../../../packages/router-plugin + '@tanstack/zod-adapter': + specifier: workspace:* + version: link:../../../packages/zod-adapter + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.5.3) + postcss: + specifier: ^8.5.1 + version: 8.5.3 + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + redaxios: + specifier: ^0.5.1 + version: 0.5.1 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.17 + zod: + specifier: ^3.24.2 + version: 3.25.57 + devDependencies: + '@playwright/test': + specifier: ^1.52.0 + version: 1.52.0 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/react': + specifier: ^19.0.8 + version: 19.0.8 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.0.3(@types/react@19.0.8) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.6.0(vite@6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)) + combinate: + specifier: ^1.1.11 + version: 1.1.11 + vite: + specifier: 6.3.5 + version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) + e2e/react-router/generator-cli-only: dependencies: '@tanstack/react-router': From 0356bc74f872430963becfd3efee61d45a49efb4 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 27 Jul 2025 22:21:14 +0200 Subject: [PATCH 51/57] prettier --- e2e/react-router/compiled-matcher/package.json | 2 +- e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx | 4 ++-- e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx | 4 ++-- .../compiled-matcher/src/routes/api/user-{$id}.tsx | 4 ++-- e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx | 4 ++-- e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx | 4 ++-- .../compiled-matcher/src/routes/cache/temp_{$}.log.tsx | 4 ++-- .../compiled-matcher/src/routes/foo/{-$bar}/qux.tsx | 4 ++-- .../compiled-matcher/src/routes/images/thumb_{$}.tsx | 4 ++-- e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx | 4 ++-- .../compiled-matcher/src/routes/posts/{-$slug}.tsx | 4 ++-- packages/router-core/tests/compile-matcher.bench.ts | 2 +- 12 files changed, 22 insertions(+), 22 deletions(-) diff --git a/e2e/react-router/compiled-matcher/package.json b/e2e/react-router/compiled-matcher/package.json index e2780cfc144..766f04cb608 100644 --- a/e2e/react-router/compiled-matcher/package.json +++ b/e2e/react-router/compiled-matcher/package.json @@ -33,4 +33,4 @@ "combinate": "^1.1.11", "vite": "^6.3.5" } -} \ No newline at end of file +} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx b/e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx index cfc9844f3b6..d8b6f32fc17 100644 --- a/e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx +++ b/e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/a/user-{$id}')({ - component: RouteComponent, + component: RouteComponent, }) function RouteComponent() { - return
{'Hello "/a/user-{$id}"!'}
+ return
{'Hello "/a/user-{$id}"!'}
} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx b/e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx index be72225f032..8ce00b261f7 100644 --- a/e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx +++ b/e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/a/{-$slug}')({ - component: RouteComponent, + component: RouteComponent, }) function RouteComponent() { - return
{'Hello "/a/{-$slug}"!'}
+ return
{'Hello "/a/{-$slug}"!'}
} diff --git a/e2e/react-router/compiled-matcher/src/routes/api/user-{$id}.tsx b/e2e/react-router/compiled-matcher/src/routes/api/user-{$id}.tsx index 9ef50fca904..85578d57917 100644 --- a/e2e/react-router/compiled-matcher/src/routes/api/user-{$id}.tsx +++ b/e2e/react-router/compiled-matcher/src/routes/api/user-{$id}.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/api/user-{$id}')({ - component: RouteComponent, + component: RouteComponent, }) function RouteComponent() { - return
{'Hello "/api/user-{$id}"!'}
+ return
{'Hello "/api/user-{$id}"!'}
} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx b/e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx index a4d4a44e181..afd075f3501 100644 --- a/e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx +++ b/e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/b/user-{$id}')({ - component: RouteComponent, + component: RouteComponent, }) function RouteComponent() { - return
{'Hello "/b/user-{$id}"!'}
+ return
{'Hello "/b/user-{$id}"!'}
} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx b/e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx index cd36c15a60a..d03dcbe5371 100644 --- a/e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx +++ b/e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/b/{-$slug}')({ - component: RouteComponent, + component: RouteComponent, }) function RouteComponent() { - return
{'Hello "/b/{-$slug}"!'}
+ return
{'Hello "/b/{-$slug}"!'}
} diff --git a/e2e/react-router/compiled-matcher/src/routes/cache/temp_{$}.log.tsx b/e2e/react-router/compiled-matcher/src/routes/cache/temp_{$}.log.tsx index f15516beab4..2c0abaaf804 100644 --- a/e2e/react-router/compiled-matcher/src/routes/cache/temp_{$}.log.tsx +++ b/e2e/react-router/compiled-matcher/src/routes/cache/temp_{$}.log.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/cache/temp_{$}/log')({ - component: RouteComponent, + component: RouteComponent, }) function RouteComponent() { - return
{'Hello "/cache/temp_{$}.log"!'}
+ return
{'Hello "/cache/temp_{$}.log"!'}
} diff --git a/e2e/react-router/compiled-matcher/src/routes/foo/{-$bar}/qux.tsx b/e2e/react-router/compiled-matcher/src/routes/foo/{-$bar}/qux.tsx index 6eccf8435d5..37af5c50756 100644 --- a/e2e/react-router/compiled-matcher/src/routes/foo/{-$bar}/qux.tsx +++ b/e2e/react-router/compiled-matcher/src/routes/foo/{-$bar}/qux.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/foo/{-$bar}/qux')({ - component: RouteComponent, + component: RouteComponent, }) function RouteComponent() { - return
{'Hello "/foo/{-$bar}/qux"!'}
+ return
{'Hello "/foo/{-$bar}/qux"!'}
} diff --git a/e2e/react-router/compiled-matcher/src/routes/images/thumb_{$}.tsx b/e2e/react-router/compiled-matcher/src/routes/images/thumb_{$}.tsx index 9574267e2a7..e81725cb16f 100644 --- a/e2e/react-router/compiled-matcher/src/routes/images/thumb_{$}.tsx +++ b/e2e/react-router/compiled-matcher/src/routes/images/thumb_{$}.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/images/thumb_{$}')({ - component: RouteComponent, + component: RouteComponent, }) function RouteComponent() { - return
{'Hello "/images/thumb_{$}"!'}
+ return
{'Hello "/images/thumb_{$}"!'}
} diff --git a/e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx b/e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx index 3f9ca21f15f..456bceb9483 100644 --- a/e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx +++ b/e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/logs/{$}/txt')({ - component: RouteComponent, + component: RouteComponent, }) function RouteComponent() { - return
{'Hello "/logs/{$}.txt"!'}
+ return
{'Hello "/logs/{$}.txt"!'}
} diff --git a/e2e/react-router/compiled-matcher/src/routes/posts/{-$slug}.tsx b/e2e/react-router/compiled-matcher/src/routes/posts/{-$slug}.tsx index d065bba0fdc..c494586b528 100644 --- a/e2e/react-router/compiled-matcher/src/routes/posts/{-$slug}.tsx +++ b/e2e/react-router/compiled-matcher/src/routes/posts/{-$slug}.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/posts/{-$slug}')({ - component: RouteComponent, + component: RouteComponent, }) function RouteComponent() { - return
{'Hello "/posts/{-$slug}"!'}
+ return
{'Hello "/posts/{-$slug}"!'}
} diff --git a/packages/router-core/tests/compile-matcher.bench.ts b/packages/router-core/tests/compile-matcher.bench.ts index 9bb18b78422..3dde2294657 100644 --- a/packages/router-core/tests/compile-matcher.bench.ts +++ b/packages/router-core/tests/compile-matcher.bench.ts @@ -8,7 +8,7 @@ import { import { createLRUCache } from '../src/lru-cache' import { compileMatcher } from '../src/compile-matcher' import type { CompiledMatcher } from '../src/compile-matcher' -import type { ParsePathnameCache } from "../src/path" +import type { ParsePathnameCache } from '../src/path' interface TestRoute { id: string From 7ae9b8a21b8da8b4ef627ae938636f18f5534eb6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 31 Jul 2025 00:01:34 +0200 Subject: [PATCH 52/57] add unit test for optional param followed by regular param --- packages/router-core/tests/compile-matcher.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/router-core/tests/compile-matcher.test.ts b/packages/router-core/tests/compile-matcher.test.ts index f4665d97fdf..5f2a3319f4b 100644 --- a/packages/router-core/tests/compile-matcher.test.ts +++ b/packages/router-core/tests/compile-matcher.test.ts @@ -105,7 +105,8 @@ const routeTree = createRouteTree([ '/z/y/x', '/images/thumb_{$}', // wildcard with prefix '/logs/{$}.txt', // wildcard with suffix - '/cache/temp_{$}.log', // wildcard with prefix and suffix + '/cache/temp_{$}.log', // wildcard with prefix and suffix, + '/momomo/{-$one}/$two' ]) // required keys on a `route` object for `processRouteTree` to correctly generate `flatRoutes` @@ -160,6 +161,7 @@ describe('work in progress', () => { "/b/$id", "/foo/$bar", "/users/$id", + "/momomo/{-$one}/$two", "/a/{-$slug}", "/b/{-$slug}", "/posts/{-$slug}", @@ -262,6 +264,12 @@ describe('work in progress', () => { if (sc1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; if (sc1 === "users") return ["/users/$id", params({ id: s2 }, 3)]; } + if (sc1 === "momomo") { + if (length(4)) + return ["/momomo/{-$one}/$two", params({ one: s2, two: s3 }, 4)]; + if (length(3)) return ["/momomo/{-$one}/$two", params({ two: s2 }, 3)]; + } + if (length(3) && sc1 === "a") return ["/a/{-$slug}", params({ slug: s2 }, 3)]; } if (l >= 2) { if (length(2) && sc1 === "a") return ["/a/{-$slug}", params({}, 2)]; @@ -361,6 +369,8 @@ describe('work in progress', () => { '/logs/2020/01/01/error.txt', '/cache/temp_user456.log', '/a/b/c/d/e', + '/momomo/1111/2222', + '/momomo/2222', ])('matching %s', (s) => { const originalMatch = originalMatcher(s) const buildMatch = buildMatcher(parsePathname, s) From b8f9dd1a60ce51ed2b0c12cac388ef0ecb9a08b2 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 31 Jul 2025 00:38:54 +0200 Subject: [PATCH 53/57] improve tree pruning --- packages/router-core/src/compile-matcher.ts | 35 +++++++++++++++++-- .../router-core/tests/compile-matcher.test.ts | 1 - 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/compile-matcher.ts b/packages/router-core/src/compile-matcher.ts index a37bf09cf65..26f291bc47d 100644 --- a/packages/router-core/src/compile-matcher.ts +++ b/packages/router-core/src/compile-matcher.ts @@ -400,6 +400,23 @@ function contractTree(tree: RootNode) { * ``` * if (a) return route1; * ``` + * + * TODO: this could be improved by + * - looking at length covering checks + * ``` + * if (l >= 2 && a) return route1; + * if (l === 2 && a) return route2; // this is unreachable + * ``` + * - looking recursively through previous siblings of the parent, not just previous siblings in the same parent + * ``` + * if (a) { + * if (b) return route1; + * } + * if (c) return route2; + * if (a) { + * if (b && c) return route3; // this is unreachable + * } + * ``` */ function pruneTree(tree: RootNode) { const stack: Array = [tree] @@ -410,8 +427,7 @@ function pruneTree(tree: RootNode) { for (let j = 0; j < i; j++) { const sibling = node.children[j]! - if (sibling.type !== 'leaf') continue // only a leaf node is guaranteed to return if its conditions are met - const currentIsUnreachable = sibling.conditions.every((c) => child.conditions.some((sc) => sc.key === c.key)) + const currentIsUnreachable = flattenConditions(sibling).some(variant => variant.every((c) => child.conditions.some((sc) => sc.key === c.key))) if (currentIsUnreachable) { node.children.splice(i, 1) i -= 1 @@ -425,6 +441,21 @@ function pruneTree(tree: RootNode) { } } +/** + * Returns a list of all sets of conditions that end up with a match from the given node. + */ +function flattenConditions(node: LeafNode | BranchNode): Array> { + if (node.type === 'leaf') return [node.conditions] + const conditions: Array> = [] + for (const child of node.children) { + const variants = flattenConditions(child) + for (const variant of variants) { + conditions.push([...node.conditions, ...variant]) + } + } + return conditions +} + function printTree(node: RootNode | BranchNode | LeafNode) { let str = '' if (node.type === 'root') { diff --git a/packages/router-core/tests/compile-matcher.test.ts b/packages/router-core/tests/compile-matcher.test.ts index 5f2a3319f4b..0deb195069f 100644 --- a/packages/router-core/tests/compile-matcher.test.ts +++ b/packages/router-core/tests/compile-matcher.test.ts @@ -269,7 +269,6 @@ describe('work in progress', () => { return ["/momomo/{-$one}/$two", params({ one: s2, two: s3 }, 4)]; if (length(3)) return ["/momomo/{-$one}/$two", params({ two: s2 }, 3)]; } - if (length(3) && sc1 === "a") return ["/a/{-$slug}", params({ slug: s2 }, 3)]; } if (l >= 2) { if (length(2) && sc1 === "a") return ["/a/{-$slug}", params({}, 2)]; From c86a84fee30e3596319c37604c860d159cd39d0d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 31 Jul 2025 08:12:07 +0200 Subject: [PATCH 54/57] simplify tree pruning --- packages/router-core/src/compile-matcher.ts | 76 ++++++------------- .../router-core/tests/compile-matcher.test.ts | 24 +++--- 2 files changed, 34 insertions(+), 66 deletions(-) diff --git a/packages/router-core/src/compile-matcher.ts b/packages/router-core/src/compile-matcher.ts index 26f291bc47d..20c48688c49 100644 --- a/packages/router-core/src/compile-matcher.ts +++ b/packages/router-core/src/compile-matcher.ts @@ -46,18 +46,20 @@ export function compileMatcher( // We start by building a flat tree with all routes as leaf nodes, children of the same root node. const tree: RootNode = { type: 'root', children: [] } + + const children: Array = [] for (const { conditions, path, segments } of all) { - tree.children.push({ + children.push({ type: 'leaf', route: { path, segments }, parent: tree, conditions, }) } + tree.children = removeUnreachable(children) expandTree(tree) contractTree(tree) - pruneTree(tree) let fn = '' fn += printHead(all) @@ -389,7 +391,7 @@ function contractTree(tree: RootNode) { } /** - * Remove branches and leaves that are not reachable due to the conditions a previous leaf sibling. + * Remove leaves that are not reachable due to the conditions a previous leaf sibling. * * This turns * ``` @@ -400,60 +402,30 @@ function contractTree(tree: RootNode) { * ``` * if (a) return route1; * ``` - * - * TODO: this could be improved by - * - looking at length covering checks - * ``` - * if (l >= 2 && a) return route1; - * if (l === 2 && a) return route2; // this is unreachable - * ``` - * - looking recursively through previous siblings of the parent, not just previous siblings in the same parent - * ``` - * if (a) { - * if (b) return route1; - * } - * if (c) return route2; - * if (a) { - * if (b && c) return route3; // this is unreachable - * } - * ``` */ -function pruneTree(tree: RootNode) { - const stack: Array = [tree] - while (stack.length > 0) { - const node = stack.shift()! - loop: for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]! - - for (let j = 0; j < i; j++) { - const sibling = node.children[j]! - const currentIsUnreachable = flattenConditions(sibling).some(variant => variant.every((c) => child.conditions.some((sc) => sc.key === c.key))) - if (currentIsUnreachable) { - node.children.splice(i, 1) - i -= 1 - continue loop +function removeUnreachable(nodes: Array) { + loop: for (let i = 0; i < nodes.length; i++) { + const candidate = nodes[i]! + + // look through all previous siblings + for (let j = 0; j < i; j++) { + const sibling = nodes[j]! + // if every condition the sibling requires is also present in the candidate, + // then that means the candidate is unreachable + const candidateIsUnreachable = sibling.conditions.every((c) => { + if (c.type === 'length' && c.direction === 'gte') { + return candidate.conditions.some((sc) => sc.key === c.key || (sc.type === 'length' && sc.direction === 'eq' && sc.value >= c.value)) } + return candidate.conditions.some((sc) => sc.key === c.key) + }) + if (candidateIsUnreachable) { + nodes.splice(i, 1) + i -= 1 + continue loop } - - if (child.type === 'leaf') continue - stack.push(child) - } - } -} - -/** - * Returns a list of all sets of conditions that end up with a match from the given node. - */ -function flattenConditions(node: LeafNode | BranchNode): Array> { - if (node.type === 'leaf') return [node.conditions] - const conditions: Array> = [] - for (const child of node.children) { - const variants = flattenConditions(child) - for (const variant of variants) { - conditions.push([...node.conditions, ...variant]) } } - return conditions + return nodes } function printTree(node: RootNode | BranchNode | LeafNode) { diff --git a/packages/router-core/tests/compile-matcher.test.ts b/packages/router-core/tests/compile-matcher.test.ts index 0deb195069f..ebb8de388da 100644 --- a/packages/router-core/tests/compile-matcher.test.ts +++ b/packages/router-core/tests/compile-matcher.test.ts @@ -264,16 +264,16 @@ describe('work in progress', () => { if (sc1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; if (sc1 === "users") return ["/users/$id", params({ id: s2 }, 3)]; } - if (sc1 === "momomo") { - if (length(4)) - return ["/momomo/{-$one}/$two", params({ one: s2, two: s3 }, 4)]; - if (length(3)) return ["/momomo/{-$one}/$two", params({ two: s2 }, 3)]; - } + if (length(4) && sc1 === "momomo") + return ["/momomo/{-$one}/$two", params({ one: s2, two: s3 }, 4)]; + if (length(3) && sc1 === "momomo") + return ["/momomo/{-$one}/$two", params({ two: s2 }, 3)]; } if (l >= 2) { - if (length(2) && sc1 === "a") return ["/a/{-$slug}", params({}, 2)]; - if (length(3) && sc1 === "b") return ["/b/{-$slug}", params({ slug: s2 }, 3)]; - if (length(2) && sc1 === "b") return ["/b/{-$slug}", params({}, 2)]; + if (length(2)) { + if (sc1 === "a") return ["/a/{-$slug}", params({}, 2)]; + if (sc1 === "b") return ["/b/{-$slug}", params({}, 2)]; + } if (length(3) && sc1 === "posts") return ["/posts/{-$slug}", params({ slug: s2 }, 3)]; if (length(2) && sc1 === "posts") return ["/posts/{-$slug}", params({}, 2)]; @@ -322,12 +322,8 @@ describe('work in progress', () => { "/files/$", { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, ]; - if (length(2)) { - if (sc1 === "a") return ["/a", params({}, 2)]; - if (sc1 === "about") return ["/about", params({}, 2)]; - if (sc1 === "b") return ["/b", params({}, 2)]; - if (sc1 === "one") return ["/one", params({}, 2)]; - } + if (length(2) && sc1 === "about") return ["/about", params({}, 2)]; + if (length(2) && sc1 === "one") return ["/one", params({}, 2)]; } if (length(1)) return ["/", params({}, 1)]; if (length(4) && sc2 === "bar" && sc3 === "foo") From 2858cfac4ca7939ff0cdd3b8885c56ea863b0800 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 31 Jul 2025 08:16:37 +0200 Subject: [PATCH 55/57] add optional+regular param test case to benchmark --- packages/router-core/tests/compile-matcher.bench.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/router-core/tests/compile-matcher.bench.ts b/packages/router-core/tests/compile-matcher.bench.ts index 3dde2294657..b802c0377ff 100644 --- a/packages/router-core/tests/compile-matcher.bench.ts +++ b/packages/router-core/tests/compile-matcher.bench.ts @@ -107,6 +107,7 @@ const routeTree = createRouteTree([ '/images/thumb_{$}', // wildcard with prefix '/logs/{$}.txt', // wildcard with suffix '/cache/temp_{$}.log', // wildcard with prefix and suffix + '/momomo/{-$one}/$two' ]) const result = processRouteTree({ routeTree }) @@ -160,6 +161,8 @@ const testCases = [ '/logs/error.txt', '/cache/temp_user456.log', '/a/b/c/d/e', + '/momomo/1111/2222', + '/momomo/2222', ] describe('build.bench', () => { From 99a98f936a2bca24942c58146259a4618cbdac8a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 31 Jul 2025 08:23:05 +0200 Subject: [PATCH 56/57] add code comment for possible pruning improvement --- packages/router-core/src/compile-matcher.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/router-core/src/compile-matcher.ts b/packages/router-core/src/compile-matcher.ts index 20c48688c49..9d2d4d58e62 100644 --- a/packages/router-core/src/compile-matcher.ts +++ b/packages/router-core/src/compile-matcher.ts @@ -416,6 +416,8 @@ function removeUnreachable(nodes: Array) { if (c.type === 'length' && c.direction === 'gte') { return candidate.conditions.some((sc) => sc.key === c.key || (sc.type === 'length' && sc.direction === 'eq' && sc.value >= c.value)) } + // TODO: we could add other "covering" cases like the one above here, + // such as the sibling having a `startsWith` condition and the candidate having a static condition that starts with the same value (taking case sensitivity into account) return candidate.conditions.some((sc) => sc.key === c.key) }) if (candidateIsUnreachable) { From a2f0f1706e66e51069c3a6818de4ba05cc483e1d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 31 Jul 2025 08:31:45 +0200 Subject: [PATCH 57/57] add another benchmark for single lookup --- .../tests/compile-matcher.bench.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/router-core/tests/compile-matcher.bench.ts b/packages/router-core/tests/compile-matcher.bench.ts index b802c0377ff..b80a194cc43 100644 --- a/packages/router-core/tests/compile-matcher.bench.ts +++ b/packages/router-core/tests/compile-matcher.bench.ts @@ -165,7 +165,7 @@ const testCases = [ '/momomo/2222', ] -describe('build.bench', () => { +describe('build.bench needle in a haystack', () => { bench( 'original', () => { @@ -185,3 +185,37 @@ describe('build.bench', () => { { warmupIterations: 10 }, ) }) + +/** + * Sometimes in the app, we already know the path we want to match against. + * The compiled matcher does not support this. + * This benchmark tests the performance of the compiled matcher looking through ALL routes + * vs. the original matcher comparing against a single path. + */ +describe('build.bench single match', () => { + const solutions = testCases.map((from) => original(from)?.fullPath) + + const cache: ParsePathnameCache = createLRUCache(1000) + const originalSingle = (from: string, to: string) => matchPathname('/', from, { to }, cache) + + bench( + 'original (single)', + () => { + for (let i = 0; i < testCases.length; i++) { + const from = testCases[i]! + const match = solutions[i]! + originalSingle(from, match) + } + }, + { warmupIterations: 10 }, + ) + bench( + 'compiled', + () => { + for (const from of testCases) { + compiled(from) + } + }, + { warmupIterations: 10 }, + ) +})