From 31aad1ddc08835fbc163567affa59e7da963848b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 1 Nov 2025 18:29:00 +0100 Subject: [PATCH 001/109] refactor(router-core): Process routeTree into segment tree instead of flatRoutes --- packages/router-core/src/Untitled-1.md | 58 ++++ .../router-core/src/new-process-route-tree.ts | 312 ++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 packages/router-core/src/Untitled-1.md create mode 100644 packages/router-core/src/new-process-route-tree.ts diff --git a/packages/router-core/src/Untitled-1.md b/packages/router-core/src/Untitled-1.md new file mode 100644 index 00000000000..b85008db3fb --- /dev/null +++ b/packages/router-core/src/Untitled-1.md @@ -0,0 +1,58 @@ + + +What are the different ways / data structures / algorithms to fullfil the following requirements? + + + +- all code should be javascript / typescript +- ask any questions you might need to answer accurately +- start by listing all the best approaches with a smal blurb, pros and cons, and I'll tell you which I want to delve into +- do NOT look at the existing codebase + + + +A route is made of segments. There are several types of segments: +- Static segments: match exact string, e.g. 'home', 'about', 'users' +- Dynamic segments: match any string, e.g. '$userId', '$postId' +- Optional dynamic segments: match any string or nothing, e.g. '{-$userId}', '{-$postId}' +- Wildcard segments (splat): match anything to the end, must be last, e.g. `$` + +Non-static segments can have prefixes and suffixes +- prefix: e.g. 'user{$id}', 'post{-$id}', 'file{$}' +- suffix: e.g. '{$id}profile', '{-$id}edit', '{$}details' +- both: e.g. 'user{$id}profile', 'post{-$id}edit', 'file{$}details' + +In the future we might want to add more segment types: +- optional static segments: match exact string or nothing, e.g. '{home}' (or with prefix/suffix: 'pre{home}suf') + +When the app starts, we receive all routes as an unordered tree: +Route: { + id: string // unique identifier, + fullPath: string // full path from the root, + children?: Route[] // child routes, + parentRoute?: Route // parent route, +} + +When matching a route, we need to extract the parameters from the path. +- dynamic segments ('$userId' => { userId: '123' }) +- optional dynamic segments ('{-$userId}' => { userId: '123' } or { }) +- wildcard segments ('$' => { '*': 'some/long/path' }) + +When the app is live, we need 2 abilities: +- know whether a path matches a specific route (i.e. match(route: Route, path: string): Params | false) +- find which route (if any) is matching a given path (i.e. findRoute(path: string): {route: Route, params: Params} | null) + +To optimize these operations, we pre-process the route tree. Both pre-processing and matching should be highly performant in the browser. + + + +- scale: we expect to have between 2 and 2000 routes (approximately) +- all routes are known at app start time, no dynamic route addition/removal +- memory is not an issue, don't hesitate to use more memory to gain speed +- routes can be nested from 1 to 10 levels deep (approximately) +- we have no preference for certain patterns, we are open to rewriting everything +- matching must be deterministic +- we always favor a more specific route over a less specific one (e.g. /users/123/profile over /users/$userId/$) +- each segment can be case sensitive or case insensitive, it can be different for each segment. We know this at pre-processing time. +- we cannot pre-process at build time, all pre-processing must happen at app start time in the browser + \ No newline at end of file diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts new file mode 100644 index 00000000000..a6996c33994 --- /dev/null +++ b/packages/router-core/src/new-process-route-tree.ts @@ -0,0 +1,312 @@ +export const SEGMENT_TYPE_PATHNAME = 0 +export const SEGMENT_TYPE_PARAM = 1 +export const SEGMENT_TYPE_WILDCARD = 2 +export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 + +type SegmentKind = typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM + +const PARAM_RE = /^\$(.{1,})$/ // $paramName +const PARAM_W_CURLY_BRACES_RE = /^(.*?)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{$paramName}suffix +const OPTIONAL_PARAM_W_CURLY_BRACES_RE = /^(.*?)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{-$paramName}suffix +const WILDCARD_RE = /^\$$/ // $ +const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix + +/** + * Populates the `output` array with the parsed representation of the given `segment` string. + * - `output[0]` = segment type (0 = pathname, 1 = param, 2 = wildcard, 3 = optional param) + * - `output[1]` = index of the end of the prefix + * - `output[2]` = index of the start of the value + * - `output[3]` = index of the end of the value + * - `output[4]` = index of the start of the suffix + * - `output[5]` = index of the end of the segment + * + * @param path The full path string containing the segment. + * @param start The starting index of the segment within the path. + * @param output A Uint16Array to populate with the parsed segment data. + */ +function parseSegment(path: string, start: number, output: Uint16Array) { + const next = path.indexOf('/', start) + const end = next === -1 ? path.length : next + if (end === start) { // TODO: maybe should never happen? + // Slash segment + output[0] = SEGMENT_TYPE_PATHNAME + output[1] = start + output[2] = start + output[3] = end + output[4] = end + output[5] = end + return + } + const part = path.substring(start, end) + const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE) + if (wildcardBracesMatch) { + const prefix = wildcardBracesMatch[1]! + const suffix = wildcardBracesMatch[2]! + output[0] = SEGMENT_TYPE_WILDCARD + output[1] = start + prefix.length + output[2] = start + prefix.length + output[3] = end - suffix.length + output[4] = end - suffix.length + output[5] = end + return + } + + const optionalParamBracesMatch = part.match(OPTIONAL_PARAM_W_CURLY_BRACES_RE) + if (optionalParamBracesMatch) { + const prefix = optionalParamBracesMatch[1]! + const paramName = optionalParamBracesMatch[2]! + const suffix = optionalParamBracesMatch[3]! + output[0] = SEGMENT_TYPE_OPTIONAL_PARAM + output[1] = start + prefix.length + output[2] = start + prefix.length + 3 // skip '{-$' + output[3] = start + prefix.length + paramName.length + output[4] = end - suffix.length + output[5] = end + return + } + + const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE) + if (paramBracesMatch) { + const prefix = paramBracesMatch[1]! + const paramName = paramBracesMatch[2]! + const suffix = paramBracesMatch[3]! + output[0] = SEGMENT_TYPE_PARAM + output[1] = start + prefix.length + output[2] = start + prefix.length + 2 // skip '{$' + output[3] = start + prefix.length + paramName.length + output[4] = end - suffix.length + output[5] = end + return + } + + const paramMatch = part.match(PARAM_RE) + if (paramMatch) { + const paramName = paramMatch[1]! + output[0] = SEGMENT_TYPE_PARAM + output[1] = start + output[2] = start + 1 // skip '$' + output[3] = start + 1 + paramName.length + output[4] = end + output[5] = end + return + } + + const wildcardMatch = part.match(WILDCARD_RE) + if (wildcardMatch) { + output[0] = SEGMENT_TYPE_WILDCARD + output[1] = start + output[2] = start + output[3] = end + output[4] = end + output[5] = end + return + } + + // Static pathname segment + output[0] = SEGMENT_TYPE_PATHNAME + output[1] = start + output[2] = start + output[3] = end + output[4] = end + output[5] = end +} + +/** + * Recursively parses the segments of the given route tree and populates a segment trie. + * + * @param data A reusable Uint16Array for parsing segments. (non important, we're just avoiding allocations) + * @param route The current route to parse. + * @param start The starting index for parsing within the route's full path. + * @param node The current segment node in the trie to populate. + * @param onRoute Callback invoked for each route processed. + */ +function parseSegments(data: Uint16Array, route: TRouteLike, start: number, node: SegmentNode, onRoute: (route: TRouteLike) => void) { + let cursor = start + const path = route.fullPath + const length = path.length + const caseSensitive = route.options?.caseSensitive ?? true + while (cursor < length) { + let nextNode: SegmentNode + const start = cursor + parseSegment(path, start, data) + const end = data[5]! + cursor = end + const kind = data[0] as SegmentKind + const value = path.substring(data[2]!, data[3]) + switch (kind) { + case SEGMENT_TYPE_PATHNAME: { + const staticName = caseSensitive ? value : value.toLowerCase() + const existingNode = node.static.find(s => s.caseSensitive === caseSensitive && s.staticName === staticName) + if (existingNode) { + nextNode = existingNode.node + } else { + nextNode = createEmptyNode() + node.static.push({ + staticName, + caseSensitive, + node: nextNode + }) + } + break + } + case SEGMENT_TYPE_PARAM: { + const prefix_raw = path.substring(start, data[1]) + const suffix_raw = path.substring(data[4]!, end) + const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() + const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() + const existingNode = node.dynamic.find(s => s.caseSensitive === caseSensitive && s.paramName === value && s.prefix === prefix && s.suffix === suffix) + if (existingNode) { + nextNode = existingNode.node + } else { + nextNode = createEmptyNode() + node.dynamic.push({ + paramName: value, + prefix, + suffix, + caseSensitive, + node: nextNode + }) + } + break + } + case SEGMENT_TYPE_OPTIONAL_PARAM: { + const prefix_raw = path.substring(start, data[1]) + const suffix_raw = path.substring(data[4]!, end) + const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() + const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() + const existingNode = node.optional.find(s => s.caseSensitive === caseSensitive && s.paramName === value && s.prefix === prefix && s.suffix === suffix) + if (existingNode) { + nextNode = existingNode.node + } else { + nextNode = createEmptyNode() + node.optional.push({ + paramName: value, + prefix, + suffix, + caseSensitive, + node: nextNode + }) + } + break + } + case SEGMENT_TYPE_WILDCARD: { + const prefix_raw = path.substring(start, data[1]) + const suffix_raw = path.substring(data[4]!, end) + const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() + const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() + node.wildcard = { + prefix, + suffix, + } + node.route = route + return + } + } + node = nextNode + } + node.route = route + if (route.children) for (const child of route.children) { + onRoute(route) + parseSegments(data, child as TRouteLike, cursor, node, onRoute) + } +} + +function sortTreeNodes(node: SegmentNode) { + if (node.static.length) { + node.static.sort((a, b) => a.staticName.localeCompare(b.staticName)) // TODO + for (const child of node.static) { + sortTreeNodes(child.node) + } + } + if (node.dynamic.length) { + node.dynamic.sort((a, b) => a.paramName.localeCompare(b.paramName)) // TODO + for (const child of node.dynamic) { + sortTreeNodes(child.node) + } + } + if (node.optional.length) { + node.optional.sort((a, b) => a.paramName.localeCompare(b.paramName)) // TODO + for (const child of node.optional) { + sortTreeNodes(child.node) + } + } +} + +function createEmptyNode(): SegmentNode { + return { + static: [], + dynamic: [], + optional: [], + wildcard: undefined, + route: undefined + } +} + + +type SegmentNode = { + // Static segments (highest priority) + static: Array<{ + staticName: string + caseSensitive: boolean + node: SegmentNode + }> + + // Dynamic segments ($param) + dynamic: Array<{ + paramName: string + prefix?: string + suffix?: string + caseSensitive: boolean + node: SegmentNode + }> + + // Optional dynamic segments ({-$param}) + optional: Array<{ + paramName: string + prefix?: string + suffix?: string + caseSensitive: boolean + node: SegmentNode + }> + + // Wildcard segment ($ - lowest priority) + wildcard?: { + prefix?: string + suffix?: string + } + + // Terminal route (if this path ends here) + route?: RouteLike +} + + +type RouteLike = { + id: string // unique identifier, + fullPath: string // full path from the root, + children?: Array // child routes, + parentRoute?: RouteLike // parent route, + options?: { + caseSensitive?: boolean + } +} + +export function processRouteTree({ + routeTree, + initRoute, +}: { + routeTree: TRouteLike + initRoute?: (route: TRouteLike, index: number) => void +}) { + const rootNode = createEmptyNode() + const data = new Uint16Array(6) + const routesById = {} as Record + const routesByPath = {} as Record + let index = 0 + parseSegments(data, routeTree, 0, rootNode, (route) => { + initRoute?.(route, index) + routesById[route.id] = route + routesByPath[route.fullPath] = route + index++ + }) + sortTreeNodes(rootNode) +} \ No newline at end of file From df389c6f98c41f5a2f8b68db70dbed8e7ac4f88d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 1 Nov 2025 18:58:14 +0100 Subject: [PATCH 002/109] foo --- .../router-core/src/new-process-route-tree.ts | 158 +++++++++--------- 1 file changed, 82 insertions(+), 76 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index a6996c33994..1ef216d4bc7 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -122,89 +122,91 @@ function parseSegment(path: string, start: number, output: Uint16Array) { */ function parseSegments(data: Uint16Array, route: TRouteLike, start: number, node: SegmentNode, onRoute: (route: TRouteLike) => void) { let cursor = start - const path = route.fullPath - const length = path.length - const caseSensitive = route.options?.caseSensitive ?? true - while (cursor < length) { - let nextNode: SegmentNode - const start = cursor - parseSegment(path, start, data) - const end = data[5]! - cursor = end - const kind = data[0] as SegmentKind - const value = path.substring(data[2]!, data[3]) - switch (kind) { - case SEGMENT_TYPE_PATHNAME: { - const staticName = caseSensitive ? value : value.toLowerCase() - const existingNode = node.static.find(s => s.caseSensitive === caseSensitive && s.staticName === staticName) - if (existingNode) { - nextNode = existingNode.node - } else { - nextNode = createEmptyNode() - node.static.push({ - staticName, - caseSensitive, - node: nextNode - }) + { + const path = route.fullPath + const length = path.length + const caseSensitive = route.options?.caseSensitive ?? true + while (cursor < length) { + let nextNode: SegmentNode + const start = cursor + parseSegment(path, start, data) + const end = data[5]! + cursor = end + const kind = data[0] as SegmentKind + const value = path.substring(data[2]!, data[3]) + switch (kind) { + case SEGMENT_TYPE_PATHNAME: { + const staticName = caseSensitive ? value : value.toLowerCase() + const existingNode = node.static.find(s => s.caseSensitive === caseSensitive && s.staticName === staticName) + if (existingNode) { + nextNode = existingNode.node + } else { + nextNode = createEmptyNode() + node.static.push({ + staticName, + caseSensitive, + node: nextNode + }) + } + break } - break - } - case SEGMENT_TYPE_PARAM: { - const prefix_raw = path.substring(start, data[1]) - const suffix_raw = path.substring(data[4]!, end) - const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() - const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.dynamic.find(s => s.caseSensitive === caseSensitive && s.paramName === value && s.prefix === prefix && s.suffix === suffix) - if (existingNode) { - nextNode = existingNode.node - } else { - nextNode = createEmptyNode() - node.dynamic.push({ - paramName: value, - prefix, - suffix, - caseSensitive, - node: nextNode - }) + case SEGMENT_TYPE_PARAM: { + const prefix_raw = path.substring(start, data[1]) + const suffix_raw = path.substring(data[4]!, end) + const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() + const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() + const existingNode = node.dynamic.find(s => s.caseSensitive === caseSensitive && s.paramName === value && s.prefix === prefix && s.suffix === suffix) + if (existingNode) { + nextNode = existingNode.node + } else { + nextNode = createEmptyNode() + node.dynamic.push({ + paramName: value, + prefix, + suffix, + caseSensitive, + node: nextNode + }) + } + break } - break - } - case SEGMENT_TYPE_OPTIONAL_PARAM: { - const prefix_raw = path.substring(start, data[1]) - const suffix_raw = path.substring(data[4]!, end) - const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() - const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.optional.find(s => s.caseSensitive === caseSensitive && s.paramName === value && s.prefix === prefix && s.suffix === suffix) - if (existingNode) { - nextNode = existingNode.node - } else { - nextNode = createEmptyNode() - node.optional.push({ - paramName: value, + case SEGMENT_TYPE_OPTIONAL_PARAM: { + const prefix_raw = path.substring(start, data[1]) + const suffix_raw = path.substring(data[4]!, end) + const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() + const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() + const existingNode = node.optional.find(s => s.caseSensitive === caseSensitive && s.paramName === value && s.prefix === prefix && s.suffix === suffix) + if (existingNode) { + nextNode = existingNode.node + } else { + nextNode = createEmptyNode() + node.optional.push({ + paramName: value, + prefix, + suffix, + caseSensitive, + node: nextNode + }) + } + break + } + case SEGMENT_TYPE_WILDCARD: { + const prefix_raw = path.substring(start, data[1]) + const suffix_raw = path.substring(data[4]!, end) + const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() + const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() + node.wildcard = { prefix, suffix, - caseSensitive, - node: nextNode - }) - } - break - } - case SEGMENT_TYPE_WILDCARD: { - const prefix_raw = path.substring(start, data[1]) - const suffix_raw = path.substring(data[4]!, end) - const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() - const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - node.wildcard = { - prefix, - suffix, + } + node.route = route + return } - node.route = route - return } + node = nextNode } - node = nextNode + node.route = route } - node.route = route if (route.children) for (const child of route.children) { onRoute(route) parseSegments(data, child as TRouteLike, cursor, node, onRoute) @@ -213,7 +215,11 @@ function parseSegments(data: Uint16Array, route: T function sortTreeNodes(node: SegmentNode) { if (node.static.length) { - node.static.sort((a, b) => a.staticName.localeCompare(b.staticName)) // TODO + node.static.sort((a, b) => { + if (a.caseSensitive && !b.caseSensitive) return -1 + if (!a.caseSensitive && b.caseSensitive) return 1 + return b.staticName.length - a.staticName.length + }) for (const child of node.static) { sortTreeNodes(child.node) } From 23bacf445892ee310333c9522ccd4377a28d0da4 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 1 Nov 2025 19:01:23 +0100 Subject: [PATCH 003/109] bar --- .../router-core/src/new-process-route-tree.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 1ef216d4bc7..94cfbd938d8 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -213,13 +213,15 @@ function parseSegments(data: Uint16Array, route: T } } +function sortStaticSegments(a: SegmentNode['static'][number], b: SegmentNode['static'][number]) { + if (a.caseSensitive && !b.caseSensitive) return -1 + if (!a.caseSensitive && b.caseSensitive) return 1 + return b.staticName.length - a.staticName.length +} + function sortTreeNodes(node: SegmentNode) { if (node.static.length) { - node.static.sort((a, b) => { - if (a.caseSensitive && !b.caseSensitive) return -1 - if (!a.caseSensitive && b.caseSensitive) return 1 - return b.staticName.length - a.staticName.length - }) + node.static.sort(sortStaticSegments) for (const child of node.static) { sortTreeNodes(child.node) } @@ -251,6 +253,7 @@ function createEmptyNode(): SegmentNode { type SegmentNode = { // Static segments (highest priority) + // TODO: maybe we could split this into two maps: caseSensitive and caseInsensitive for faster lookup static: Array<{ staticName: string caseSensitive: boolean @@ -281,7 +284,7 @@ type SegmentNode = { suffix?: string } - // Terminal route (if this path ends here) + // Terminal route (if this path can end here) route?: RouteLike } @@ -303,16 +306,21 @@ export function processRouteTree({ routeTree: TRouteLike initRoute?: (route: TRouteLike, index: number) => void }) { - const rootNode = createEmptyNode() + const segmentTree = createEmptyNode() const data = new Uint16Array(6) const routesById = {} as Record const routesByPath = {} as Record let index = 0 - parseSegments(data, routeTree, 0, rootNode, (route) => { + parseSegments(data, routeTree, 0, segmentTree, (route) => { initRoute?.(route, index) routesById[route.id] = route routesByPath[route.fullPath] = route index++ }) - sortTreeNodes(rootNode) + sortTreeNodes(segmentTree) + return { + segmentTree, + routesById, + routesByPath, + } } \ No newline at end of file From f3236f252c07817083137899815a64d097c515ab Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 1 Nov 2025 23:02:02 +0100 Subject: [PATCH 004/109] PoC works --- .../router-core/src/new-process-route-tree.ts | 88 ++++++++++--------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 94cfbd938d8..62e32a3d887 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -125,25 +125,26 @@ function parseSegments(data: Uint16Array, route: T { const path = route.fullPath const length = path.length - const caseSensitive = route.options?.caseSensitive ?? true + const caseSensitive = route.options?.caseSensitive ?? false while (cursor < length) { let nextNode: SegmentNode const start = cursor parseSegment(path, start, data) const end = data[5]! - cursor = end + cursor = end + 1 const kind = data[0] as SegmentKind const value = path.substring(data[2]!, data[3]) switch (kind) { case SEGMENT_TYPE_PATHNAME: { - const staticName = caseSensitive ? value : value.toLowerCase() - const existingNode = node.static.find(s => s.caseSensitive === caseSensitive && s.staticName === staticName) + const name = caseSensitive ? value : value.toLowerCase() + const existingNode = node.static?.find(s => s.caseSensitive === caseSensitive && s.name === name) if (existingNode) { nextNode = existingNode.node } else { - nextNode = createEmptyNode() + node.static ??= [] + nextNode = {} node.static.push({ - staticName, + name, caseSensitive, node: nextNode }) @@ -155,13 +156,14 @@ function parseSegments(data: Uint16Array, route: T const suffix_raw = path.substring(data[4]!, end) const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.dynamic.find(s => s.caseSensitive === caseSensitive && s.paramName === value && s.prefix === prefix && s.suffix === suffix) + const existingNode = node.dynamic?.find(s => s.caseSensitive === caseSensitive && s.name === value && s.prefix === prefix && s.suffix === suffix) if (existingNode) { nextNode = existingNode.node } else { - nextNode = createEmptyNode() + nextNode = {} + node.dynamic ??= [] node.dynamic.push({ - paramName: value, + name: value, prefix, suffix, caseSensitive, @@ -175,13 +177,14 @@ function parseSegments(data: Uint16Array, route: T const suffix_raw = path.substring(data[4]!, end) const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.optional.find(s => s.caseSensitive === caseSensitive && s.paramName === value && s.prefix === prefix && s.suffix === suffix) + const existingNode = node.optional?.find(s => s.caseSensitive === caseSensitive && s.name === value && s.prefix === prefix && s.suffix === suffix) if (existingNode) { nextNode = existingNode.node } else { - nextNode = createEmptyNode() + nextNode = {} + node.optional ??= [] node.optional.push({ - paramName: value, + name: value, prefix, suffix, caseSensitive, @@ -199,13 +202,14 @@ function parseSegments(data: Uint16Array, route: T prefix, suffix, } - node.route = route + node.routeId = route.id return } } node = nextNode } - node.route = route + if (route.path) + node.routeId = route.id } if (route.children) for (const child of route.children) { onRoute(route) @@ -213,56 +217,46 @@ function parseSegments(data: Uint16Array, route: T } } -function sortStaticSegments(a: SegmentNode['static'][number], b: SegmentNode['static'][number]) { +function sortStaticSegments(a: NonNullable[number], b: NonNullable[number]) { if (a.caseSensitive && !b.caseSensitive) return -1 if (!a.caseSensitive && b.caseSensitive) return 1 - return b.staticName.length - a.staticName.length + return b.name.length - a.name.length } function sortTreeNodes(node: SegmentNode) { - if (node.static.length) { + if (node.static?.length) { node.static.sort(sortStaticSegments) for (const child of node.static) { sortTreeNodes(child.node) } } - if (node.dynamic.length) { - node.dynamic.sort((a, b) => a.paramName.localeCompare(b.paramName)) // TODO + if (node.dynamic?.length) { + node.dynamic.sort((a, b) => a.name.localeCompare(b.name)) // TODO for (const child of node.dynamic) { sortTreeNodes(child.node) } } - if (node.optional.length) { - node.optional.sort((a, b) => a.paramName.localeCompare(b.paramName)) // TODO + if (node.optional?.length) { + node.optional.sort((a, b) => a.name.localeCompare(b.name)) // TODO for (const child of node.optional) { sortTreeNodes(child.node) } } } -function createEmptyNode(): SegmentNode { - return { - static: [], - dynamic: [], - optional: [], - wildcard: undefined, - route: undefined - } -} - type SegmentNode = { // Static segments (highest priority) // TODO: maybe we could split this into two maps: caseSensitive and caseInsensitive for faster lookup - static: Array<{ - staticName: string + static?: Array<{ + name: string caseSensitive: boolean node: SegmentNode }> // Dynamic segments ($param) - dynamic: Array<{ - paramName: string + dynamic?: Array<{ + name: string prefix?: string suffix?: string caseSensitive: boolean @@ -270,8 +264,8 @@ type SegmentNode = { }> // Optional dynamic segments ({-$param}) - optional: Array<{ - paramName: string + optional?: Array<{ + name: string prefix?: string suffix?: string caseSensitive: boolean @@ -285,13 +279,27 @@ type SegmentNode = { } // Terminal route (if this path can end here) - route?: RouteLike + routeId?: string } +// function intoRouteLike(routeTree, parent) { +// const route = { +// id: routeTree.id, +// fullPath: routeTree.fullPath, +// path: routeTree.path, +// options: routeTree.options && 'caseSensitive' in routeTree.options ? { caseSensitive: routeTree.options.caseSensitive } : undefined, +// } +// if (routeTree.children) { +// route.children = routeTree.children.map(child => intoRouteLike(child, route)) +// } +// return route +// } + type RouteLike = { id: string // unique identifier, fullPath: string // full path from the root, + path?: string // relative path from the parent, children?: Array // child routes, parentRoute?: RouteLike // parent route, options?: { @@ -306,12 +314,12 @@ export function processRouteTree({ routeTree: TRouteLike initRoute?: (route: TRouteLike, index: number) => void }) { - const segmentTree = createEmptyNode() + const segmentTree: SegmentNode = {} const data = new Uint16Array(6) const routesById = {} as Record const routesByPath = {} as Record let index = 0 - parseSegments(data, routeTree, 0, segmentTree, (route) => { + parseSegments(data, routeTree, 1, segmentTree, (route) => { initRoute?.(route, index) routesById[route.id] = route routesByPath[route.fullPath] = route From 627e0179ff5d1dac9e259006c6277c6e916fb9a0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 1 Nov 2025 23:18:34 +0100 Subject: [PATCH 005/109] faster --- .../router-core/src/new-process-route-tree.ts | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 62e32a3d887..0b6a4a1d975 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -136,18 +136,25 @@ function parseSegments(data: Uint16Array, route: T const value = path.substring(data[2]!, data[3]) switch (kind) { case SEGMENT_TYPE_PATHNAME: { - const name = caseSensitive ? value : value.toLowerCase() - const existingNode = node.static?.find(s => s.caseSensitive === caseSensitive && s.name === name) - if (existingNode) { - nextNode = existingNode.node + if (caseSensitive) { + const existingNode = node.static?.get(value) + if (existingNode) { + nextNode = existingNode + } else { + node.static ??= new Map() + nextNode = {} + node.static.set(value, nextNode) + } } else { - node.static ??= [] - nextNode = {} - node.static.push({ - name, - caseSensitive, - node: nextNode - }) + const name = value.toLowerCase() + const existingNode = node.staticInsensitive?.get(name) + if (existingNode) { + nextNode = existingNode + } else { + node.staticInsensitive ??= new Map() + nextNode = {} + node.staticInsensitive.set(name, nextNode) + } } break } @@ -217,21 +224,27 @@ function parseSegments(data: Uint16Array, route: T } } -function sortStaticSegments(a: NonNullable[number], b: NonNullable[number]) { - if (a.caseSensitive && !b.caseSensitive) return -1 - if (!a.caseSensitive && b.caseSensitive) return 1 - return b.name.length - a.name.length +function sortDynamic(a: { prefix?: string, suffix?: string }, b: { prefix?: string, suffix?: string }) { + const aScore = + // bonus for having both prefix and suffix + (a.prefix && a.suffix ? 500 : 0) + // prefix counts double + + (a.prefix ? a.prefix.length * 2 : 0) + // suffix counts single + + (a.suffix ? a.suffix.length : 0) + const bScore = + // bonus for having both prefix and suffix + (b.prefix && b.suffix ? 500 : 0) + // prefix counts double + + (b.prefix ? b.prefix.length * 2 : 0) + // suffix counts single + + (b.suffix ? b.suffix.length : 0) + return bScore - aScore } function sortTreeNodes(node: SegmentNode) { - if (node.static?.length) { - node.static.sort(sortStaticSegments) - for (const child of node.static) { - sortTreeNodes(child.node) - } - } if (node.dynamic?.length) { - node.dynamic.sort((a, b) => a.name.localeCompare(b.name)) // TODO + node.dynamic.sort(sortDynamic) for (const child of node.dynamic) { sortTreeNodes(child.node) } @@ -247,12 +260,10 @@ function sortTreeNodes(node: SegmentNode) { type SegmentNode = { // Static segments (highest priority) - // TODO: maybe we could split this into two maps: caseSensitive and caseInsensitive for faster lookup - static?: Array<{ - name: string - caseSensitive: boolean - node: SegmentNode - }> + static?: Map + + // Case insensitive static segments (second highest priority) + staticInsensitive?: Map // Dynamic segments ($param) dynamic?: Array<{ From dd1e3e30d9b0c187c708063dee500eb1250257d0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 1 Nov 2025 23:28:07 +0100 Subject: [PATCH 006/109] faster --- .../router-core/src/new-process-route-tree.ts | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 0b6a4a1d975..e69609977b2 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -142,7 +142,8 @@ function parseSegments(data: Uint16Array, route: T nextNode = existingNode } else { node.static ??= new Map() - nextNode = {} + nextNode = createEmptyNode() + nextNode.parent = node node.static.set(value, nextNode) } } else { @@ -152,7 +153,8 @@ function parseSegments(data: Uint16Array, route: T nextNode = existingNode } else { node.staticInsensitive ??= new Map() - nextNode = {} + nextNode = createEmptyNode() + nextNode.parent = node node.staticInsensitive.set(name, nextNode) } } @@ -167,7 +169,8 @@ function parseSegments(data: Uint16Array, route: T if (existingNode) { nextNode = existingNode.node } else { - nextNode = {} + nextNode = createEmptyNode() + nextNode.parent = node node.dynamic ??= [] node.dynamic.push({ name: value, @@ -188,7 +191,8 @@ function parseSegments(data: Uint16Array, route: T if (existingNode) { nextNode = existingNode.node } else { - nextNode = {} + nextNode = createEmptyNode() + nextNode.parent = node node.optional ??= [] node.optional.push({ name: value, @@ -260,39 +264,52 @@ function sortTreeNodes(node: SegmentNode) { type SegmentNode = { // Static segments (highest priority) - static?: Map + static: Map | null // Case insensitive static segments (second highest priority) - staticInsensitive?: Map + staticInsensitive: Map | null // Dynamic segments ($param) - dynamic?: Array<{ + dynamic: Array<{ name: string - prefix?: string - suffix?: string + prefix: string | undefined + suffix: string | undefined caseSensitive: boolean node: SegmentNode - }> + }> | null // Optional dynamic segments ({-$param}) - optional?: Array<{ + optional: Array<{ name: string - prefix?: string - suffix?: string + prefix: string | undefined + suffix: string | undefined caseSensitive: boolean node: SegmentNode - }> + }> | null // Wildcard segment ($ - lowest priority) - wildcard?: { - prefix?: string - suffix?: string - } + wildcard: { + prefix: string | undefined + suffix: string | undefined + } | null // Terminal route (if this path can end here) - routeId?: string + routeId: string | null + + parent: SegmentNode | null } +function createEmptyNode(): SegmentNode { + return { + static: null, + staticInsensitive: null, + dynamic: null, + optional: null, + wildcard: null, + routeId: null, + parent: null + } +} // function intoRouteLike(routeTree, parent) { // const route = { @@ -325,7 +342,7 @@ export function processRouteTree({ routeTree: TRouteLike initRoute?: (route: TRouteLike, index: number) => void }) { - const segmentTree: SegmentNode = {} + const segmentTree = createEmptyNode() const data = new Uint16Array(6) const routesById = {} as Record const routesByPath = {} as Record @@ -342,4 +359,4 @@ export function processRouteTree({ routesById, routesByPath, } -} \ No newline at end of file +} From e274212ce4656e1b4f8ff0baaba2bb88069de1c6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Nov 2025 08:45:01 +0100 Subject: [PATCH 007/109] remove node key, each object is a node --- .../router-core/src/new-process-route-tree.ts | 281 ++++++++++++++---- 1 file changed, 217 insertions(+), 64 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index e69609977b2..551ea444822 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -142,9 +142,10 @@ function parseSegments(data: Uint16Array, route: T nextNode = existingNode } else { node.static ??= new Map() - nextNode = createEmptyNode() - nextNode.parent = node - node.static.set(value, nextNode) + const next = createStaticNode() + next.parent = node + nextNode = next + node.static.set(value, next) } } else { const name = value.toLowerCase() @@ -153,9 +154,10 @@ function parseSegments(data: Uint16Array, route: T nextNode = existingNode } else { node.staticInsensitive ??= new Map() - nextNode = createEmptyNode() - nextNode.parent = node - node.staticInsensitive.set(name, nextNode) + const next = createStaticNode() + next.parent = node + nextNode = next + node.staticInsensitive.set(name, next) } } break @@ -167,18 +169,13 @@ function parseSegments(data: Uint16Array, route: T const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() const existingNode = node.dynamic?.find(s => s.caseSensitive === caseSensitive && s.name === value && s.prefix === prefix && s.suffix === suffix) if (existingNode) { - nextNode = existingNode.node + nextNode = existingNode } else { - nextNode = createEmptyNode() - nextNode.parent = node + const next = createDynamicNode(value, caseSensitive, prefix, suffix) + nextNode = next + next.parent = node node.dynamic ??= [] - node.dynamic.push({ - name: value, - prefix, - suffix, - caseSensitive, - node: nextNode - }) + node.dynamic.push(next) } break } @@ -189,18 +186,13 @@ function parseSegments(data: Uint16Array, route: T const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() const existingNode = node.optional?.find(s => s.caseSensitive === caseSensitive && s.name === value && s.prefix === prefix && s.suffix === suffix) if (existingNode) { - nextNode = existingNode.node + nextNode = existingNode } else { - nextNode = createEmptyNode() + const next = createOptionalNode(value, caseSensitive, prefix, suffix) + nextNode = next nextNode.parent = node node.optional ??= [] - node.optional.push({ - name: value, - prefix, - suffix, - caseSensitive, - node: nextNode - }) + node.optional.push(next) } break } @@ -209,10 +201,10 @@ function parseSegments(data: Uint16Array, route: T const suffix_raw = path.substring(data[4]!, end) const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - node.wildcard = { - prefix, - suffix, - } + const next = createWildcardNode(caseSensitive, prefix, suffix) + nextNode = next + next.parent = node + node.wildcard = next node.routeId = route.id return } @@ -250,48 +242,107 @@ function sortTreeNodes(node: SegmentNode) { if (node.dynamic?.length) { node.dynamic.sort(sortDynamic) for (const child of node.dynamic) { - sortTreeNodes(child.node) + sortTreeNodes(child) } } if (node.optional?.length) { node.optional.sort((a, b) => a.name.localeCompare(b.name)) // TODO for (const child of node.optional) { - sortTreeNodes(child.node) + sortTreeNodes(child) } } } +function createStaticNode(): StaticSegmentNode { + return { + kind: SEGMENT_TYPE_PATHNAME, + static: null, + staticInsensitive: null, + dynamic: null, + optional: null, + wildcard: null, + routeId: null, + parent: null + } +} + +function createDynamicNode(name: string, caseSensitive: boolean, prefix?: string, suffix?: string): DynamicSegmentNode { + return { + ...createStaticNode(), + kind: SEGMENT_TYPE_PARAM, + caseSensitive, + prefix, + suffix, + name, + } +} + +function createOptionalNode(name: string, caseSensitive: boolean, prefix?: string, suffix?: string): OptionalSegmentNode { + return { + ...createStaticNode(), + kind: SEGMENT_TYPE_OPTIONAL_PARAM, + caseSensitive, + prefix, + suffix, + name, + } +} + +function createWildcardNode(caseSensitive: boolean, prefix?: string, suffix?: string): WildcardSegmentNode { + return { + ...createStaticNode(), + kind: SEGMENT_TYPE_WILDCARD, + caseSensitive, + prefix, + suffix, + } +} + +type StaticSegmentNode = SegmentNode & { + kind: typeof SEGMENT_TYPE_PATHNAME +} + +type DynamicSegmentNode = SegmentNode & { + kind: typeof SEGMENT_TYPE_PARAM + name: string + prefix?: string + suffix?: string + caseSensitive: boolean +} + +type OptionalSegmentNode = SegmentNode & { + kind: typeof SEGMENT_TYPE_OPTIONAL_PARAM + name: string + prefix?: string + suffix?: string + caseSensitive: boolean +} + +type WildcardSegmentNode = SegmentNode & { + kind: typeof SEGMENT_TYPE_WILDCARD + prefix?: string + suffix?: string + caseSensitive: boolean +} + type SegmentNode = { + kind: SegmentKind + // Static segments (highest priority) - static: Map | null + static: Map | null // Case insensitive static segments (second highest priority) - staticInsensitive: Map | null + staticInsensitive: Map | null // Dynamic segments ($param) - dynamic: Array<{ - name: string - prefix: string | undefined - suffix: string | undefined - caseSensitive: boolean - node: SegmentNode - }> | null + dynamic: Array | null // Optional dynamic segments ({-$param}) - optional: Array<{ - name: string - prefix: string | undefined - suffix: string | undefined - caseSensitive: boolean - node: SegmentNode - }> | null + optional: Array | null // Wildcard segment ($ - lowest priority) - wildcard: { - prefix: string | undefined - suffix: string | undefined - } | null + wildcard: WildcardSegmentNode | null // Terminal route (if this path can end here) routeId: string | null @@ -299,18 +350,6 @@ type SegmentNode = { parent: SegmentNode | null } -function createEmptyNode(): SegmentNode { - return { - static: null, - staticInsensitive: null, - dynamic: null, - optional: null, - wildcard: null, - routeId: null, - parent: null - } -} - // function intoRouteLike(routeTree, parent) { // const route = { // id: routeTree.id, @@ -342,7 +381,7 @@ export function processRouteTree({ routeTree: TRouteLike initRoute?: (route: TRouteLike, index: number) => void }) { - const segmentTree = createEmptyNode() + const segmentTree = createStaticNode() const data = new Uint16Array(6) const routesById = {} as Record const routesByPath = {} as Record @@ -360,3 +399,117 @@ export function processRouteTree({ routesByPath, } } + + +export function findMatch(path: string, segmentTree: SegmentNode): { routeId: string, params: Record } | null { + const parts = path.split('/') + const leaf = getNodeMatch(parts, segmentTree) + if (!leaf) return null + const list = [leaf] + let node = leaf + while (node.parent) { + node = node.parent + list.push(node) + } + list.reverse() + const params: Record = {} + for (let i = 0; i < parts.length; i++) { + const node = list[i]! + if (node.kind === SEGMENT_TYPE_PARAM) { + const n = node as DynamicSegmentNode + const part = parts[i]! + if (n.suffix || n.prefix) { + params[n.name] = part.substring(n.prefix?.length ?? 0, part.length - (n.suffix?.length ?? 0)) + } else { + params[n.name] = part + } + } else if (node.kind === SEGMENT_TYPE_WILDCARD) { + const n = node as WildcardSegmentNode + const part = parts[i]! + if (n.suffix || n.prefix) { + params['*'] = part.substring(n.prefix?.length ?? 0, part.length - (n.suffix?.length ?? 0)) + } else { + params['*'] = part + } + break + } + + } + return { + routeId: leaf.routeId!, + params, + } +} + +function getNodeMatch(parts: Array, segmentTree: SegmentNode) { + parts = parts.filter(Boolean) + let node: SegmentNode | null = segmentTree + let partIndex = 0 + + main: while (node && partIndex <= parts.length) { + if (partIndex === parts.length) { + if (node.routeId) { + return node + } + return null + } + + const part = parts[partIndex]! + + // 1. Try static match + if (node.static) { + const match = node.static.get(part) + if (match) { + node = match + partIndex++ + continue + } + } + + // 2. Try case insensitive static match + if (node.staticInsensitive) { + const match = node.staticInsensitive.get(part.toLowerCase()) + if (match) { + node = match + partIndex++ + continue + } + } + + // 3. Try dynamic match + if (node.dynamic) { + for (const segment of node.dynamic) { + const { prefix, suffix } = segment + if (prefix || suffix) { + const casePart = segment.caseSensitive ? part : part.toLowerCase() + if (prefix && !casePart.startsWith(prefix)) continue + if (suffix && !casePart.endsWith(suffix)) continue + } + node = segment + partIndex++ + continue main + } + } + + // 4. Try wildcard match + if (node.wildcard) { + const { prefix, suffix } = node.wildcard + if (prefix) { + const casePart = node.wildcard.caseSensitive ? part : part.toLowerCase() + if (!casePart.startsWith(prefix)) return null + } + if (suffix) { + const part = parts[parts.length - 1]! + const casePart = node.wildcard.caseSensitive ? part : part.toLowerCase() + if (!casePart.endsWith(suffix)) return null + } + partIndex = parts.length + continue + } + + // No match found + return null + } + + return null +} \ No newline at end of file From baa2764cb402a94ec8b7d9aa6a337ead66b65551 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Nov 2025 10:03:01 +0100 Subject: [PATCH 008/109] handle optional and wildcard --- .../router-core/src/new-process-route-tree.ts | 182 +++++++++++------- 1 file changed, 110 insertions(+), 72 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 551ea444822..4a0d94a7796 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -59,7 +59,7 @@ function parseSegment(path: string, start: number, output: Uint16Array) { output[0] = SEGMENT_TYPE_OPTIONAL_PARAM output[1] = start + prefix.length output[2] = start + prefix.length + 3 // skip '{-$' - output[3] = start + prefix.length + paramName.length + output[3] = start + prefix.length + 3 + paramName.length output[4] = end - suffix.length output[5] = end return @@ -73,7 +73,7 @@ function parseSegment(path: string, start: number, output: Uint16Array) { output[0] = SEGMENT_TYPE_PARAM output[1] = start + prefix.length output[2] = start + prefix.length + 2 // skip '{$' - output[3] = start + prefix.length + paramName.length + output[3] = start + prefix.length + 2 + paramName.length output[4] = end - suffix.length output[5] = end return @@ -204,8 +204,8 @@ function parseSegments(data: Uint16Array, route: T const next = createWildcardNode(caseSensitive, prefix, suffix) nextNode = next next.parent = node + next.routeId = route.id node.wildcard = next - node.routeId = route.id return } } @@ -405,110 +405,148 @@ export function findMatch(path: string, segmentTree: SegmentNode): { routeId: st const parts = path.split('/') const leaf = getNodeMatch(parts, segmentTree) if (!leaf) return null - const list = [leaf] - let node = leaf - while (node.parent) { - node = node.parent - list.push(node) - } - list.reverse() + const list = buildBranch(leaf.node) const params: Record = {} - for (let i = 0; i < parts.length; i++) { - const node = list[i]! + for (let partIndex = 0, nodeIndex = 0, pathIndex = 0; partIndex < parts.length && nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++) { + const node = list[nodeIndex]! + const part = parts[partIndex]! + const currentPathIndex = pathIndex + pathIndex += part.length if (node.kind === SEGMENT_TYPE_PARAM) { const n = node as DynamicSegmentNode - const part = parts[i]! if (n.suffix || n.prefix) { params[n.name] = part.substring(n.prefix?.length ?? 0, part.length - (n.suffix?.length ?? 0)) } else { params[n.name] = part } - } else if (node.kind === SEGMENT_TYPE_WILDCARD) { - const n = node as WildcardSegmentNode - const part = parts[i]! + } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + const n = node as OptionalSegmentNode + if (leaf.skipped?.includes(node)) { + partIndex-- // stay on the same part + params[n.name] = '' + continue + } if (n.suffix || n.prefix) { - params['*'] = part.substring(n.prefix?.length ?? 0, part.length - (n.suffix?.length ?? 0)) + params[n.name] = part.substring(n.prefix?.length ?? 0, part.length - (n.suffix?.length ?? 0)) } else { - params['*'] = part + params[n.name] = part } + } else if (node.kind === SEGMENT_TYPE_WILDCARD) { + const n = node as WildcardSegmentNode + params['*'] = path.substring(currentPathIndex + (n.prefix?.length ?? 0), path.length - (n.suffix?.length ?? 0)) break } - } return { - routeId: leaf.routeId!, + routeId: leaf.node.routeId!, params, } } +function buildBranch(node: SegmentNode) { + const list = [node] + while (node.parent) { + node = node.parent + list.push(node) + } + list.reverse() + return list +} + function getNodeMatch(parts: Array, segmentTree: SegmentNode) { parts = parts.filter(Boolean) - let node: SegmentNode | null = segmentTree - let partIndex = 0 - main: while (node && partIndex <= parts.length) { - if (partIndex === parts.length) { - if (node.routeId) { - return node + // use a stack to explore all possible paths (optional params cause branching) + // we use a depth-first search, return the first result found + const stack: Array<{ node: SegmentNode, partIndex: number, skipped?: Array }> = [ + { node: segmentTree, partIndex: 0 } + ] + + while (stack.length) { + let { node, partIndex, skipped } = stack.shift()! + + main: while (node && partIndex <= parts.length) { + if (partIndex === parts.length) { + if (!node.routeId) break + return { node, skipped } } - return null - } - const part = parts[partIndex]! + const part = parts[partIndex]! - // 1. Try static match - if (node.static) { - const match = node.static.get(part) - if (match) { - node = match - partIndex++ - continue + // 1. Try static match + if (node.static) { + const match = node.static.get(part) + if (match) { + node = match + partIndex++ + continue + } } - } - // 2. Try case insensitive static match - if (node.staticInsensitive) { - const match = node.staticInsensitive.get(part.toLowerCase()) - if (match) { - node = match - partIndex++ - continue + // 2. Try case insensitive static match + if (node.staticInsensitive) { + const match = node.staticInsensitive.get(part.toLowerCase()) + if (match) { + node = match + partIndex++ + continue + } } - } - // 3. Try dynamic match - if (node.dynamic) { - for (const segment of node.dynamic) { - const { prefix, suffix } = segment - if (prefix || suffix) { - const casePart = segment.caseSensitive ? part : part.toLowerCase() - if (prefix && !casePart.startsWith(prefix)) continue - if (suffix && !casePart.endsWith(suffix)) continue + // 3. Try dynamic match + if (node.dynamic) { + for (const segment of node.dynamic) { + const { prefix, suffix } = segment + if (prefix || suffix) { + const casePart = segment.caseSensitive ? part : part.toLowerCase() + if (prefix && !casePart.startsWith(prefix)) continue + if (suffix && !casePart.endsWith(suffix)) continue + } + node = segment + partIndex++ + continue main } - node = segment - partIndex++ - continue main } - } - // 4. Try wildcard match - if (node.wildcard) { - const { prefix, suffix } = node.wildcard - if (prefix) { - const casePart = node.wildcard.caseSensitive ? part : part.toLowerCase() - if (!casePart.startsWith(prefix)) return null + // 4. Try optional match + if (node.optional) { + skipped ??= [] + for (const segment of node.optional) { + stack.push({ node: segment, partIndex, skipped: [...skipped, segment] }) // enqueue skipping the optional + } + for (const segment of node.optional) { + const { prefix, suffix } = segment + if (prefix || suffix) { + const casePart = segment.caseSensitive ? part : part.toLowerCase() + if (prefix && !casePart.startsWith(prefix)) continue + if (suffix && !casePart.endsWith(suffix)) continue + } + node = segment + partIndex++ + continue main + } } - if (suffix) { - const part = parts[parts.length - 1]! - const casePart = node.wildcard.caseSensitive ? part : part.toLowerCase() - if (!casePart.endsWith(suffix)) return null + + // 5. Try wildcard match + if (node.wildcard) { + const { prefix, suffix } = node.wildcard + if (prefix) { + const casePart = node.wildcard.caseSensitive ? part : part.toLowerCase() + if (!casePart.startsWith(prefix)) break + } + if (suffix) { + const part = parts[parts.length - 1]! + const casePart = node.wildcard.caseSensitive ? part : part.toLowerCase() + if (!casePart.endsWith(suffix)) break + } + partIndex = parts.length + node = node.wildcard + continue } - partIndex = parts.length - continue - } - // No match found - return null + // No match found + break + } } return null From bb4f0227a14e60f08bb3c2001be451535a87f41f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Nov 2025 11:21:00 +0100 Subject: [PATCH 009/109] there can be multiple wildcard children on a node because of prefix/suffix --- .../router-core/src/new-process-route-tree.ts | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 4a0d94a7796..febf744d457 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -190,7 +190,7 @@ function parseSegments(data: Uint16Array, route: T } else { const next = createOptionalNode(value, caseSensitive, prefix, suffix) nextNode = next - nextNode.parent = node + next.parent = node node.optional ??= [] node.optional.push(next) } @@ -204,9 +204,8 @@ function parseSegments(data: Uint16Array, route: T const next = createWildcardNode(caseSensitive, prefix, suffix) nextNode = next next.parent = node - next.routeId = route.id - node.wildcard = next - return + node.wildcard ??= [] + node.wildcard.push(next) } } node = nextNode @@ -341,8 +340,8 @@ type SegmentNode = { // Optional dynamic segments ({-$param}) optional: Array | null - // Wildcard segment ($ - lowest priority) - wildcard: WildcardSegmentNode | null + // Wildcard segments ($ - lowest priority) + wildcard: Array | null // Terminal route (if this path can end here) routeId: string | null @@ -462,6 +461,8 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { { node: segmentTree, partIndex: 0 } ] + let wildcardMatch: typeof stack[0] | null = null + while (stack.length) { let { node, partIndex, skipped } = stack.shift()! @@ -529,19 +530,23 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { // 5. Try wildcard match if (node.wildcard) { - const { prefix, suffix } = node.wildcard - if (prefix) { - const casePart = node.wildcard.caseSensitive ? part : part.toLowerCase() - if (!casePart.startsWith(prefix)) break - } - if (suffix) { - const part = parts[parts.length - 1]! - const casePart = node.wildcard.caseSensitive ? part : part.toLowerCase() - if (!casePart.endsWith(suffix)) break + for (const segment of node.wildcard) { + const { prefix, suffix } = segment + if (prefix) { + const casePart = segment.caseSensitive ? part : part.toLowerCase() + if (!casePart.startsWith(prefix)) continue + } + if (suffix) { + const part = parts[parts.length - 1]! + const casePart = segment.caseSensitive ? part : part.toLowerCase() + if (!casePart.endsWith(suffix)) continue + } + // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match + if (!wildcardMatch || (wildcardMatch.partIndex < partIndex)) { + wildcardMatch = { node: segment, partIndex, skipped } + } + break main } - partIndex = parts.length - node = node.wildcard - continue } // No match found @@ -549,5 +554,7 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { } } + if (wildcardMatch) return wildcardMatch + return null } \ No newline at end of file From adbcdd54d3ae7397b0f72c06a8cfbdaa55a2130d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Nov 2025 11:35:02 +0100 Subject: [PATCH 010/109] use index for skipping instead of the node itself --- .../router-core/src/new-process-route-tree.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index febf744d457..30be9150190 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -420,7 +420,7 @@ export function findMatch(path: string, segmentTree: SegmentNode): { routeId: st } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { const n = node as OptionalSegmentNode - if (leaf.skipped?.includes(node)) { + if (leaf.skipped?.includes(nodeIndex)) { partIndex-- // stay on the same part params[n.name] = '' continue @@ -455,16 +455,23 @@ function buildBranch(node: SegmentNode) { function getNodeMatch(parts: Array, segmentTree: SegmentNode) { parts = parts.filter(Boolean) + type Frame = { + node: SegmentNode + partIndex: number + depth: number + skipped?: Array + } + // use a stack to explore all possible paths (optional params cause branching) // we use a depth-first search, return the first result found - const stack: Array<{ node: SegmentNode, partIndex: number, skipped?: Array }> = [ - { node: segmentTree, partIndex: 0 } + const stack: Array = [ + { node: segmentTree, partIndex: 0, depth: 0 } ] - let wildcardMatch: typeof stack[0] | null = null + let wildcardMatch: Frame | null = null while (stack.length) { - let { node, partIndex, skipped } = stack.shift()! + let { node, partIndex, skipped, depth } = stack.shift()! main: while (node && partIndex <= parts.length) { if (partIndex === parts.length) { @@ -479,6 +486,7 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { const match = node.static.get(part) if (match) { node = match + depth++ partIndex++ continue } @@ -489,6 +497,7 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { const match = node.staticInsensitive.get(part.toLowerCase()) if (match) { node = match + depth++ partIndex++ continue } @@ -504,6 +513,7 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { if (suffix && !casePart.endsWith(suffix)) continue } node = segment + depth++ partIndex++ continue main } @@ -512,8 +522,10 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { // 4. Try optional match if (node.optional) { skipped ??= [] + const nextDepth = depth + 1 for (const segment of node.optional) { - stack.push({ node: segment, partIndex, skipped: [...skipped, segment] }) // enqueue skipping the optional + // when skipping, node and depth advance by 1, but partIndex doesn't + stack.push({ node: segment, partIndex, skipped: [...skipped, nextDepth], depth: nextDepth }) // enqueue skipping the optional } for (const segment of node.optional) { const { prefix, suffix } = segment @@ -524,6 +536,7 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { } node = segment partIndex++ + depth++ continue main } } @@ -543,7 +556,7 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { } // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match if (!wildcardMatch || (wildcardMatch.partIndex < partIndex)) { - wildcardMatch = { node: segment, partIndex, skipped } + wildcardMatch = { node: segment, partIndex, skipped, depth } } break main } From 8c886b8e989bc126ca28b0cdb79e99c400c014ee Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Nov 2025 12:24:32 +0100 Subject: [PATCH 011/109] fix node collision by param name only --- .../router-core/src/new-process-route-tree.ts | 123 +++++++++--------- 1 file changed, 61 insertions(+), 62 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 30be9150190..57dbb1860dd 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -133,16 +133,16 @@ function parseSegments(data: Uint16Array, route: T const end = data[5]! cursor = end + 1 const kind = data[0] as SegmentKind - const value = path.substring(data[2]!, data[3]) switch (kind) { case SEGMENT_TYPE_PATHNAME: { + const value = path.substring(data[2]!, data[3]) if (caseSensitive) { const existingNode = node.static?.get(value) if (existingNode) { nextNode = existingNode } else { node.static ??= new Map() - const next = createStaticNode() + const next = createStaticNode(route.fullPath) next.parent = node nextNode = next node.static.set(value, next) @@ -154,7 +154,7 @@ function parseSegments(data: Uint16Array, route: T nextNode = existingNode } else { node.staticInsensitive ??= new Map() - const next = createStaticNode() + const next = createStaticNode(route.fullPath) next.parent = node nextNode = next node.staticInsensitive.set(name, next) @@ -167,11 +167,11 @@ function parseSegments(data: Uint16Array, route: T const suffix_raw = path.substring(data[4]!, end) const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.dynamic?.find(s => s.caseSensitive === caseSensitive && s.name === value && s.prefix === prefix && s.suffix === suffix) + const existingNode = node.dynamic?.find(s => s.caseSensitive === caseSensitive && s.prefix === prefix && s.suffix === suffix) if (existingNode) { nextNode = existingNode } else { - const next = createDynamicNode(value, caseSensitive, prefix, suffix) + const next = createDynamicNode(SEGMENT_TYPE_PARAM, route.fullPath, caseSensitive, prefix, suffix) nextNode = next next.parent = node node.dynamic ??= [] @@ -184,11 +184,11 @@ function parseSegments(data: Uint16Array, route: T const suffix_raw = path.substring(data[4]!, end) const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.optional?.find(s => s.caseSensitive === caseSensitive && s.name === value && s.prefix === prefix && s.suffix === suffix) + const existingNode = node.optional?.find(s => s.caseSensitive === caseSensitive && s.prefix === prefix && s.suffix === suffix) if (existingNode) { nextNode = existingNode } else { - const next = createOptionalNode(value, caseSensitive, prefix, suffix) + const next = createDynamicNode(SEGMENT_TYPE_OPTIONAL_PARAM, route.fullPath, caseSensitive, prefix, suffix) nextNode = next next.parent = node node.optional ??= [] @@ -201,7 +201,7 @@ function parseSegments(data: Uint16Array, route: T const suffix_raw = path.substring(data[4]!, end) const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const next = createWildcardNode(caseSensitive, prefix, suffix) + const next = createDynamicNode(SEGMENT_TYPE_WILDCARD, route.fullPath, caseSensitive, prefix, suffix) nextNode = next next.parent = node node.wildcard ??= [] @@ -238,6 +238,16 @@ function sortDynamic(a: { prefix?: string, suffix?: string }, b: { prefix?: stri } function sortTreeNodes(node: SegmentNode) { + if (node.static) { + for (const child of node.static.values()) { + sortTreeNodes(child) + } + } + if (node.staticInsensitive) { + for (const child of node.staticInsensitive.values()) { + sortTreeNodes(child) + } + } if (node.dynamic?.length) { node.dynamic.sort(sortDynamic) for (const child of node.dynamic) { @@ -245,14 +255,20 @@ function sortTreeNodes(node: SegmentNode) { } } if (node.optional?.length) { - node.optional.sort((a, b) => a.name.localeCompare(b.name)) // TODO + node.optional.sort(sortDynamic) for (const child of node.optional) { sortTreeNodes(child) } } + if (node.wildcard?.length) { + node.wildcard.sort(sortDynamic) + for (const child of node.wildcard) { + sortTreeNodes(child) + } + } } -function createStaticNode(): StaticSegmentNode { +function createStaticNode(fullPath: string): StaticSegmentNode { return { kind: SEGMENT_TYPE_PATHNAME, static: null, @@ -261,36 +277,22 @@ function createStaticNode(): StaticSegmentNode { optional: null, wildcard: null, routeId: null, + fullPath, parent: null } } -function createDynamicNode(name: string, caseSensitive: boolean, prefix?: string, suffix?: string): DynamicSegmentNode { - return { - ...createStaticNode(), - kind: SEGMENT_TYPE_PARAM, - caseSensitive, - prefix, - suffix, - name, - } -} - -function createOptionalNode(name: string, caseSensitive: boolean, prefix?: string, suffix?: string): OptionalSegmentNode { - return { - ...createStaticNode(), - kind: SEGMENT_TYPE_OPTIONAL_PARAM, - caseSensitive, - prefix, - suffix, - name, - } -} - -function createWildcardNode(caseSensitive: boolean, prefix?: string, suffix?: string): WildcardSegmentNode { +function createDynamicNode(kind: typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM, fullPath: string, caseSensitive: boolean, prefix?: string, suffix?: string): DynamicSegmentNode { return { - ...createStaticNode(), - kind: SEGMENT_TYPE_WILDCARD, + kind, + static: null, + staticInsensitive: null, + dynamic: null, + optional: null, + wildcard: null, + routeId: null, + fullPath, + parent: null, caseSensitive, prefix, suffix, @@ -302,23 +304,7 @@ type StaticSegmentNode = SegmentNode & { } type DynamicSegmentNode = SegmentNode & { - kind: typeof SEGMENT_TYPE_PARAM - name: string - prefix?: string - suffix?: string - caseSensitive: boolean -} - -type OptionalSegmentNode = SegmentNode & { - kind: typeof SEGMENT_TYPE_OPTIONAL_PARAM - name: string - prefix?: string - suffix?: string - caseSensitive: boolean -} - -type WildcardSegmentNode = SegmentNode & { - kind: typeof SEGMENT_TYPE_WILDCARD + kind: typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM prefix?: string suffix?: string caseSensitive: boolean @@ -338,13 +324,14 @@ type SegmentNode = { dynamic: Array | null // Optional dynamic segments ({-$param}) - optional: Array | null + optional: Array | null // Wildcard segments ($ - lowest priority) - wildcard: Array | null + wildcard: Array | null // Terminal route (if this path can end here) routeId: string | null + fullPath: string parent: SegmentNode | null } @@ -380,7 +367,7 @@ export function processRouteTree({ routeTree: TRouteLike initRoute?: (route: TRouteLike, index: number) => void }) { - const segmentTree = createStaticNode() + const segmentTree = createStaticNode(routeTree.fullPath) const data = new Uint16Array(6) const routesById = {} as Record const routesByPath = {} as Record @@ -405,33 +392,45 @@ export function findMatch(path: string, segmentTree: SegmentNode): { routeId: st const leaf = getNodeMatch(parts, segmentTree) if (!leaf) return null const list = buildBranch(leaf.node) + let nodeParts: Array | null = null const params: Record = {} + const data = new Uint16Array(6) for (let partIndex = 0, nodeIndex = 0, pathIndex = 0; partIndex < parts.length && nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++) { const node = list[nodeIndex]! const part = parts[partIndex]! const currentPathIndex = pathIndex pathIndex += part.length if (node.kind === SEGMENT_TYPE_PARAM) { + nodeParts ??= leaf.node.fullPath.split('/') + const nodePart = nodeParts[nodeIndex]! + parseSegment(nodePart, 0, data) + // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node + const name = nodePart.substring(data[2]!, data[3]) const n = node as DynamicSegmentNode if (n.suffix || n.prefix) { - params[n.name] = part.substring(n.prefix?.length ?? 0, part.length - (n.suffix?.length ?? 0)) + params[name] = part.substring(n.prefix?.length ?? 0, part.length - (n.suffix?.length ?? 0)) } else { - params[n.name] = part + params[name] = part } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { - const n = node as OptionalSegmentNode + nodeParts ??= leaf.node.fullPath.split('/') + const nodePart = nodeParts[nodeIndex]! + parseSegment(nodePart, 0, data) + // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node + const name = nodePart.substring(data[2]!, data[3]) + const n = node as DynamicSegmentNode if (leaf.skipped?.includes(nodeIndex)) { partIndex-- // stay on the same part - params[n.name] = '' + params[name] = '' continue } if (n.suffix || n.prefix) { - params[n.name] = part.substring(n.prefix?.length ?? 0, part.length - (n.suffix?.length ?? 0)) + params[name] = part.substring(n.prefix?.length ?? 0, part.length - (n.suffix?.length ?? 0)) } else { - params[n.name] = part + params[name] = part } } else if (node.kind === SEGMENT_TYPE_WILDCARD) { - const n = node as WildcardSegmentNode + const n = node as DynamicSegmentNode params['*'] = path.substring(currentPathIndex + (n.prefix?.length ?? 0), path.length - (n.suffix?.length ?? 0)) break } From 5b784fdcbfad779717ea0fb942a5de124b1dd2e4 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Nov 2025 12:42:01 +0100 Subject: [PATCH 012/109] fix init of routesById and routesByPath --- .../router-core/src/new-process-route-tree.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 57dbb1860dd..984ac02d2ca 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -1,3 +1,5 @@ +import invariant from "tiny-invariant" + export const SEGMENT_TYPE_PATHNAME = 0 export const SEGMENT_TYPE_PARAM = 1 export const SEGMENT_TYPE_WILDCARD = 2 @@ -214,7 +216,7 @@ function parseSegments(data: Uint16Array, route: T node.routeId = route.id } if (route.children) for (const child of route.children) { - onRoute(route) + onRoute(child as TRouteLike) parseSegments(data, child as TRouteLike, cursor, node, onRoute) } } @@ -360,6 +362,11 @@ type RouteLike = { } } +/** Trim trailing slashes (except preserving root '/'). */ +export function trimPathRight(path: string) { + return path === '/' ? path : path.replace(/\/{1,}$/, '') +} + export function processRouteTree({ routeTree, initRoute, @@ -374,8 +381,24 @@ export function processRouteTree({ let index = 0 parseSegments(data, routeTree, 1, segmentTree, (route) => { initRoute?.(route, index) + + invariant( + !(route.id in routesById), + `Duplicate routes found with id: ${String(route.id)}`, + ) + routesById[route.id] = route - routesByPath[route.fullPath] = route + + if (index !== 0 && route.path) { + const trimmedFullPath = trimPathRight(route.fullPath) + if ( + !routesByPath[trimmedFullPath] || + route.fullPath.endsWith('/') + ) { + routesByPath[trimmedFullPath] = route + } + } + index++ }) sortTreeNodes(segmentTree) From 9bf39daccd4d22cbbfe1231a37124c8f57a8b994 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Nov 2025 12:50:23 +0100 Subject: [PATCH 013/109] some early tests --- .../tests/new-process-route-tree.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 packages/router-core/tests/new-process-route-tree.test.ts diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts new file mode 100644 index 00000000000..a3a2bd89249 --- /dev/null +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { findMatch, processRouteTree } from "../src/new-process-route-tree" + + +describe('findMatch', () => { + const testTree = { + id: '__root__', + fullPath: '/', + path: '/', + children: [ + { + id: '/yo', + fullPath: '/yo', + path: 'yo', + children: [ + { + id: '/yo/foo{-$id}bar', + fullPath: '/yo/foo{-$id}bar', + path: 'foo{-$id}bar', + children: [ + { + id: '/yo/foo{-$id}bar/ma', + fullPath: '/yo/foo{-$id}bar/ma', + path: 'ma', + } + ] + }, + { + id: '/yo/{$}.png', + fullPath: '/yo/{$}.png', + path: '{$}.png', + }, + { + id: '/yo/$', + fullPath: '/yo/$', + path: '$', + } + ] + }, { + id: '/foo', + fullPath: '/foo', + path: 'foo', + children: [ + { + id: '/foo/$a/aaa', + fullPath: '/foo/$a/aaa', + path: '$a/aaa', + }, { + id: '/foo/$b/bbb', + fullPath: '/foo/$b/bbb', + path: '$b/bbb', + } + ] + } + ] + } + + const { segmentTree } = + processRouteTree({ routeTree: testTree }) + + it('works w/ optional params when param is present', () => { + expect(findMatch('/yo/foo123bar/ma', segmentTree)).toMatchInlineSnapshot(` + { + "params": { + "id": "123", + }, + "routeId": "/yo/foo{-$id}bar/ma", + } + `) + }) + it('works w/ optional params when param is absent', () => { + expect(findMatch('/yo/ma', segmentTree)).toMatchInlineSnapshot(` + { + "params": { + "id": "", + }, + "routeId": "/yo/foo{-$id}bar/ma", + } + `) + }) + it('works w/ wildcard and suffix', () => { + expect(findMatch('/yo/somefile.png', segmentTree)).toMatchInlineSnapshot(` + { + "params": { + "*": "somefile", + }, + "routeId": "/yo/{$}.png", + } + `) + }) + it('works w/ wildcard alone', () => { + expect(findMatch('/yo/something', segmentTree)).toMatchInlineSnapshot(` + { + "params": { + "*": "something", + }, + "routeId": "/yo/$", + } + `) + }) + it('works w/ multiple required param routes at same level, w/ different names for their param', () => { + expect(findMatch('/foo/123/aaa', segmentTree)).toMatchInlineSnapshot(` + { + "params": { + "a": "123", + }, + "routeId": "/foo/$a/aaa", + } + `) + expect(findMatch('/foo/123/bbb', segmentTree)).toMatchInlineSnapshot(` + { + "params": { + "b": "123", + }, + "routeId": "/foo/$b/bbb", + } + `) + }) +}) + From 484fc81f5989d09f972e6ce5755de25b5d7b3c0e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Nov 2025 13:02:38 +0100 Subject: [PATCH 014/109] just comments --- packages/router-core/src/new-process-route-tree.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 984ac02d2ca..a8e3e710195 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -284,6 +284,10 @@ function createStaticNode(fullPath: string): StaticSegmentNode { } } +/** + * Keys must be declared in the same order as in `SegmentNode` type, + * to ensure they are represented as the same object class in the engine. + */ function createDynamicNode(kind: typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM, fullPath: string, caseSensitive: boolean, prefix?: string, suffix?: string): DynamicSegmentNode { return { kind, @@ -333,6 +337,8 @@ type SegmentNode = { // Terminal route (if this path can end here) routeId: string | null + + // The full path for this segment node (will only be valid on leaf nodes) fullPath: string parent: SegmentNode | null From e2339cc89d5ea35bc24213db027677a8fa5e20c8 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Nov 2025 13:55:14 +0100 Subject: [PATCH 015/109] use bitmask instead of array for skipped nodes during match --- .../router-core/src/new-process-route-tree.ts | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index a8e3e710195..8cb96e4956b 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -448,7 +448,7 @@ export function findMatch(path: string, segmentTree: SegmentNode): { routeId: st // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node const name = nodePart.substring(data[2]!, data[3]) const n = node as DynamicSegmentNode - if (leaf.skipped?.includes(nodeIndex)) { + if (leaf.skipped & (1 << nodeIndex)) { partIndex-- // stay on the same part params[name] = '' continue @@ -485,29 +485,31 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { type Frame = { node: SegmentNode - partIndex: number + index: number depth: number - skipped?: Array + /** Bitmask of skipped optional segments */ + skipped: number } // use a stack to explore all possible paths (optional params cause branching) // we use a depth-first search, return the first result found const stack: Array = [ - { node: segmentTree, partIndex: 0, depth: 0 } + { node: segmentTree, index: 0, depth: 0, skipped: 0 } ] let wildcardMatch: Frame | null = null while (stack.length) { - let { node, partIndex, skipped, depth } = stack.shift()! + // eslint-disable-next-line prefer-const + let { node, index, skipped, depth } = stack.shift()! - main: while (node && partIndex <= parts.length) { - if (partIndex === parts.length) { + main: while (node && index <= parts.length) { + if (index === parts.length) { if (!node.routeId) break return { node, skipped } } - const part = parts[partIndex]! + const part = parts[index]! // 1. Try static match if (node.static) { @@ -515,7 +517,7 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { if (match) { node = match depth++ - partIndex++ + index++ continue } } @@ -526,7 +528,7 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { if (match) { node = match depth++ - partIndex++ + index++ continue } } @@ -542,18 +544,18 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { } node = segment depth++ - partIndex++ + index++ continue main } } // 4. Try optional match if (node.optional) { - skipped ??= [] const nextDepth = depth + 1 + const nextSkipped = skipped | (1 << nextDepth) for (const segment of node.optional) { - // when skipping, node and depth advance by 1, but partIndex doesn't - stack.push({ node: segment, partIndex, skipped: [...skipped, nextDepth], depth: nextDepth }) // enqueue skipping the optional + // when skipping, node and depth advance by 1, but index doesn't + stack.push({ node: segment, index, skipped: nextSkipped, depth: nextDepth }) // enqueue skipping the optional } for (const segment of node.optional) { const { prefix, suffix } = segment @@ -563,7 +565,7 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { if (suffix && !casePart.endsWith(suffix)) continue } node = segment - partIndex++ + index++ depth++ continue main } @@ -583,8 +585,8 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { if (!casePart.endsWith(suffix)) continue } // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match - if (!wildcardMatch || (wildcardMatch.partIndex < partIndex)) { - wildcardMatch = { node: segment, partIndex, skipped, depth } + if (!wildcardMatch || (wildcardMatch.index < index)) { + wildcardMatch = { node: segment, index, skipped, depth } } break main } From f1907788a689d74610b40700eb90168d32fad764 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Nov 2025 16:06:48 +0100 Subject: [PATCH 016/109] start replacing in path.ts --- .../router-core/src/new-process-route-tree.ts | 17 +- packages/router-core/src/path.ts | 250 ++++++++---------- 2 files changed, 128 insertions(+), 139 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 8cb96e4956b..5c34b1d1670 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -5,7 +5,7 @@ export const SEGMENT_TYPE_PARAM = 1 export const SEGMENT_TYPE_WILDCARD = 2 export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 -type SegmentKind = typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM +export type SegmentKind = typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM const PARAM_RE = /^\$(.{1,})$/ // $paramName const PARAM_W_CURLY_BRACES_RE = /^(.*?)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{$paramName}suffix @@ -26,7 +26,7 @@ const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix * @param start The starting index of the segment within the path. * @param output A Uint16Array to populate with the parsed segment data. */ -function parseSegment(path: string, start: number, output: Uint16Array) { +export function parseSegment(path: string, start: number, output: Uint16Array) { const next = path.indexOf('/', start) const end = next === -1 ? path.length : next if (end === start) { // TODO: maybe should never happen? @@ -420,6 +420,14 @@ export function findMatch(path: string, segmentTree: SegmentNode): { routeId: st const parts = path.split('/') const leaf = getNodeMatch(parts, segmentTree) if (!leaf) return null + const params = extractParams(path, parts, leaf) + return { + routeId: leaf.node.routeId!, + params, + } +} + +function extractParams(path: string, parts: Array, leaf: { node: SegmentNode, skipped: number }) { const list = buildBranch(leaf.node) let nodeParts: Array | null = null const params: Record = {} @@ -464,10 +472,7 @@ export function findMatch(path: string, segmentTree: SegmentNode): { routeId: st break } } - return { - routeId: leaf.node.routeId!, - params, - } + return params } function buildBranch(node: SegmentNode) { diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index eac8940ae5e..6962d551e40 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -1,4 +1,6 @@ import { decodePathSegment, last } from './utils' +import { parseSegment } from "./new-process-route-tree" +import type { SegmentKind } from "./new-process-route-tree" import type { LRUCache } from './lru-cache' import type { MatchLocation } from './RouterProvider' import type { AnyPathParams } from './route' @@ -10,10 +12,10 @@ export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 export interface Segment { readonly type: - | typeof SEGMENT_TYPE_PATHNAME - | typeof SEGMENT_TYPE_PARAM - | typeof SEGMENT_TYPE_WILDCARD - | typeof SEGMENT_TYPE_OPTIONAL_PARAM + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM readonly value: string readonly prefixSegment?: string readonly suffixSegment?: string @@ -119,52 +121,6 @@ interface ResolvePathOptions { base: string to: string trailingSlash?: 'always' | 'never' | 'preserve' - parseCache?: ParsePathnameCache -} - -function segmentToString(segment: Segment): string { - const { type, value } = segment - if (type === SEGMENT_TYPE_PATHNAME) { - return value - } - - const { prefixSegment, suffixSegment } = segment - - if (type === SEGMENT_TYPE_PARAM) { - const param = value.substring(1) - if (prefixSegment && suffixSegment) { - return `${prefixSegment}{$${param}}${suffixSegment}` - } else if (prefixSegment) { - return `${prefixSegment}{$${param}}` - } else if (suffixSegment) { - return `{$${param}}${suffixSegment}` - } - } - - if (type === SEGMENT_TYPE_OPTIONAL_PARAM) { - const param = value.substring(1) - if (prefixSegment && suffixSegment) { - return `${prefixSegment}{-$${param}}${suffixSegment}` - } else if (prefixSegment) { - return `${prefixSegment}{-$${param}}` - } else if (suffixSegment) { - return `{-$${param}}${suffixSegment}` - } - return `{-$${param}}` - } - - if (type === SEGMENT_TYPE_WILDCARD) { - if (prefixSegment && suffixSegment) { - return `${prefixSegment}{$}${suffixSegment}` - } else if (prefixSegment) { - return `${prefixSegment}{$}` - } else if (suffixSegment) { - return `{$}${suffixSegment}` - } - } - - // This case should never happen, should we throw instead? - return value } /** @@ -175,25 +131,23 @@ export function resolvePath({ base, to, trailingSlash = 'never', - parseCache, }: ResolvePathOptions) { - let baseSegments = parsePathname(base, parseCache).slice() - const toSegments = parsePathname(to, parseCache) + let baseSegments = base.split('/') + const toSegments = to.split('/') - if (baseSegments.length > 1 && last(baseSegments)?.value === '/') { + if (baseSegments.length > 1 && last(baseSegments) === '') { baseSegments.pop() } for (let index = 0, length = toSegments.length; index < length; index++) { - const toSegment = toSegments[index]! - const value = toSegment.value + const value = toSegments[index]! if (value === '/') { if (!index) { // Leading slash - baseSegments = [toSegment] + baseSegments = [value] } else if (index === length - 1) { // Trailing Slash - baseSegments.push(toSegment) + baseSegments.push(value) } else { // ignore inter-slashes } @@ -202,24 +156,42 @@ export function resolvePath({ } else if (value === '.') { // ignore } else { - baseSegments.push(toSegment) + baseSegments.push(value) } } if (baseSegments.length > 1) { - if (last(baseSegments)!.value === '/') { + if (last(baseSegments) === '') { if (trailingSlash === 'never') { baseSegments.pop() } } else if (trailingSlash === 'always') { - baseSegments.push({ type: SEGMENT_TYPE_PATHNAME, value: '/' }) + baseSegments.push('') } } - const segmentValues = baseSegments.map(segmentToString) - // const joined = joinPaths([basepath, ...segmentValues]) + const data = new Uint16Array(6) + const segmentValues = baseSegments.map((segment) => { + if (!segment) return '' + parseSegment(segment, 0, data) + const kind = data[0] as SegmentKind + if (kind === SEGMENT_TYPE_PATHNAME) { + return segment + } + const end = data[5]! + const prefix = segment.substring(0, data[1]) + const suffix = segment.substring(data[4]!, end) + const value = segment.substring(data[2]!, data[3]) + if (kind === SEGMENT_TYPE_PARAM) { + return prefix || suffix ? `${prefix}{$${value}}${suffix}` : `$${value}` + } else if (kind === SEGMENT_TYPE_WILDCARD) { + return prefix || suffix ? `${prefix}{$}${suffix}` : '$' + } else { // SEGMENT_TYPE_OPTIONAL_PARAM + return `${prefix}{-$${value}}${suffix}` + } + }) const joined = joinPaths(segmentValues) - return joined + return joined || '/' } export type ParsePathnameCache = LRUCache> @@ -381,7 +353,6 @@ interface InterpolatePathOptions { leaveParams?: boolean // Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params decodeCharMap?: Map - parseCache?: ParsePathnameCache } type InterPolatePathResult = { @@ -406,9 +377,8 @@ export function interpolatePath({ leaveWildcards, leaveParams, decodeCharMap, - parseCache, }: InterpolatePathOptions): InterPolatePathResult { - const interpolatedPathSegments = parsePathname(path, parseCache) + if (!path) return { interpolatedPath: '', usedParams: {}, isMissingParams: false } function encodeParam(key: string): any { const value = params[key] @@ -425,94 +395,108 @@ export function interpolatePath({ // Tracking if any params are missing in the `params` object // when interpolating the path let isMissingParams = false - const usedParams: Record = {} - const interpolatedPath = joinPaths( - interpolatedPathSegments.map((segment) => { - if (segment.type === SEGMENT_TYPE_PATHNAME) { - return segment.value - } + let cursor = 0 + const data = new Uint16Array(6) + const length = path.length + const interpolatedSegments: Array = [] + while (cursor < length) { + const start = cursor + parseSegment(path, start, data) + const end = data[5]! + cursor = end + 1 + const kind = data[0] as SegmentKind + + if (kind === SEGMENT_TYPE_PATHNAME) { + interpolatedSegments.push(path.substring(start, end)) + continue + } - if (segment.type === SEGMENT_TYPE_WILDCARD) { - usedParams._splat = params._splat + if (kind === SEGMENT_TYPE_WILDCARD) { + usedParams._splat = params._splat - // TODO: Deprecate * - usedParams['*'] = params._splat + // TODO: Deprecate * + usedParams['*'] = params._splat - const segmentPrefix = segment.prefixSegment || '' - const segmentSuffix = segment.suffixSegment || '' + const prefix = path.substring(start, data[1]) + const suffix = path.substring(data[4]!, end) - // Check if _splat parameter is missing. _splat could be missing if undefined or an empty string or some other falsy value. - if (!params._splat) { - isMissingParams = true - // For missing splat parameters, just return the prefix and suffix without the wildcard + // Check if _splat parameter is missing. _splat could be missing if undefined or an empty string or some other falsy value. + if (!params._splat) { + isMissingParams = true + // For missing splat parameters, just return the prefix and suffix without the wildcard + // If there is a prefix or suffix, return them joined, otherwise omit the segment + if (prefix || suffix) { if (leaveWildcards) { - return `${segmentPrefix}${segment.value}${segmentSuffix}` + interpolatedSegments.push(`${prefix}{$}${suffix}`) + } else { + interpolatedSegments.push(`${prefix}${suffix}`) } - // If there is a prefix or suffix, return them joined, otherwise omit the segment - if (segmentPrefix || segmentSuffix) { - return `${segmentPrefix}${segmentSuffix}` - } - return undefined } + continue + } - const value = encodeParam('_splat') - if (leaveWildcards) { - return `${segmentPrefix}${segment.value}${value ?? ''}${segmentSuffix}` - } - return `${segmentPrefix}${value}${segmentSuffix}` + const value = encodeParam('_splat') + if (leaveWildcards) { + interpolatedSegments.push(`${prefix}${prefix || suffix ? '{$}' : '$'}${value ?? ''}${suffix}`) + } else { + interpolatedSegments.push(`${prefix}${value}${suffix}`) } + continue + } - if (segment.type === SEGMENT_TYPE_PARAM) { - const key = segment.value.substring(1) - if (!isMissingParams && !(key in params)) { - isMissingParams = true - } - usedParams[key] = params[key] + if (kind === SEGMENT_TYPE_PARAM) { + const key = path.substring(data[2]!, data[3]) + if (!isMissingParams && !(key in params)) { + isMissingParams = true + } + usedParams[key] = params[key] - const segmentPrefix = segment.prefixSegment || '' - const segmentSuffix = segment.suffixSegment || '' - if (leaveParams) { - const value = encodeParam(segment.value) - return `${segmentPrefix}${segment.value}${value ?? ''}${segmentSuffix}` - } - return `${segmentPrefix}${encodeParam(key) ?? 'undefined'}${segmentSuffix}` + const prefix = path.substring(start, data[1]) + const suffix = path.substring(data[4]!, end) + if (leaveParams) { + const value = encodeParam(key) + interpolatedSegments.push(`${prefix}$${key}${value ?? ''}${suffix}`) + } else { + interpolatedSegments.push(`${prefix}${encodeParam(key) ?? 'undefined'}${suffix}`) } + continue + } - if (segment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { - const key = segment.value.substring(1) + if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + const key = path.substring(data[2]!, data[3]) - const segmentPrefix = segment.prefixSegment || '' - const segmentSuffix = segment.suffixSegment || '' + const prefix = path.substring(start, data[1]) + const suffix = path.substring(data[4]!, end) - // Check if optional parameter is missing or undefined - if (!(key in params) || params[key] == null) { - if (leaveWildcards) { - return `${segmentPrefix}${key}${segmentSuffix}` - } + // Check if optional parameter is missing or undefined + if (!(key in params) || params[key] == null) { + if (leaveWildcards) { + interpolatedSegments.push(`${prefix}${key}${suffix}`) + } else if (prefix || suffix) { // For optional params with prefix/suffix, keep the prefix/suffix but omit the param - if (segmentPrefix || segmentSuffix) { - return `${segmentPrefix}${segmentSuffix}` - } - // If no prefix/suffix, omit the entire segment - return undefined + interpolatedSegments.push(`${prefix}${suffix}`) } + // If no prefix/suffix, omit the entire segment + continue + } - usedParams[key] = params[key] + usedParams[key] = params[key] - if (leaveParams) { - const value = encodeParam(segment.value) - return `${segmentPrefix}${segment.value}${value ?? ''}${segmentSuffix}` - } - if (leaveWildcards) { - return `${segmentPrefix}${key}${encodeParam(key) ?? ''}${segmentSuffix}` - } - return `${segmentPrefix}${encodeParam(key) ?? ''}${segmentSuffix}` + const value = encodeParam(key) ?? '' + if (leaveParams) { + interpolatedSegments.push(`${prefix}${key}${value}${suffix}`) + } else if (leaveWildcards) { + interpolatedSegments.push(`${prefix}${key}${value}${suffix}`) + } else { + interpolatedSegments.push(`${prefix}${value}${suffix}`) } + continue + } + } + + const interpolatedPath = joinPaths(interpolatedSegments) - return segment.value - }), - ) return { usedParams, interpolatedPath, isMissingParams } } From d6db54ebb351d8498f96b5414a70ec8d2626d061 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Nov 2025 17:46:25 +0100 Subject: [PATCH 017/109] minor --- packages/router-core/src/new-process-route-tree.ts | 12 +++++++++--- packages/router-core/src/path.ts | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 5c34b1d1670..d75b7d51d3c 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -122,7 +122,13 @@ export function parseSegment(path: string, start: number, output: Uint16Array) { * @param node The current segment node in the trie to populate. * @param onRoute Callback invoked for each route processed. */ -function parseSegments(data: Uint16Array, route: TRouteLike, start: number, node: SegmentNode, onRoute: (route: TRouteLike) => void) { +function parseSegments( + data: Uint16Array, + route: TRouteLike, + start: number, + node: SegmentNode, + onRoute: (route: TRouteLike, node: SegmentNode) => void +) { let cursor = start { const path = route.fullPath @@ -214,9 +220,9 @@ function parseSegments(data: Uint16Array, route: T } if (route.path) node.routeId = route.id + onRoute(route, node) } if (route.children) for (const child of route.children) { - onRoute(child as TRouteLike) parseSegments(data, child as TRouteLike, cursor, node, onRoute) } } @@ -385,7 +391,7 @@ export function processRouteTree({ const routesById = {} as Record const routesByPath = {} as Record let index = 0 - parseSegments(data, routeTree, 1, segmentTree, (route) => { + parseSegments(data, routeTree, 1, segmentTree, (route, node) => { initRoute?.(route, index) invariant( diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 6962d551e40..5c91661e2b1 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -378,7 +378,7 @@ export function interpolatePath({ leaveParams, decodeCharMap, }: InterpolatePathOptions): InterPolatePathResult { - if (!path) return { interpolatedPath: '', usedParams: {}, isMissingParams: false } + if (!path) return { interpolatedPath: '/', usedParams: {}, isMissingParams: false } function encodeParam(key: string): any { const value = params[key] @@ -495,7 +495,7 @@ export function interpolatePath({ } } - const interpolatedPath = joinPaths(interpolatedSegments) + const interpolatedPath = joinPaths(interpolatedSegments) || '/' return { usedParams, interpolatedPath, isMissingParams } } From a60faa84aa29729ee734bdb8cab416072a29b942 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 9 Nov 2025 09:26:57 +0100 Subject: [PATCH 018/109] depth --- .../router-core/src/new-process-route-tree.ts | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index d75b7d51d3c..21bb3864119 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -127,6 +127,7 @@ function parseSegments( route: TRouteLike, start: number, node: SegmentNode, + depth: number, onRoute: (route: TRouteLike, node: SegmentNode) => void ) { let cursor = start @@ -152,6 +153,7 @@ function parseSegments( node.static ??= new Map() const next = createStaticNode(route.fullPath) next.parent = node + next.depth = ++depth nextNode = next node.static.set(value, next) } @@ -164,6 +166,7 @@ function parseSegments( node.staticInsensitive ??= new Map() const next = createStaticNode(route.fullPath) next.parent = node + next.depth = ++depth nextNode = next node.staticInsensitive.set(name, next) } @@ -181,6 +184,7 @@ function parseSegments( } else { const next = createDynamicNode(SEGMENT_TYPE_PARAM, route.fullPath, caseSensitive, prefix, suffix) nextNode = next + next.depth = ++depth next.parent = node node.dynamic ??= [] node.dynamic.push(next) @@ -199,6 +203,7 @@ function parseSegments( const next = createDynamicNode(SEGMENT_TYPE_OPTIONAL_PARAM, route.fullPath, caseSensitive, prefix, suffix) nextNode = next next.parent = node + next.depth = ++depth node.optional ??= [] node.optional.push(next) } @@ -212,6 +217,7 @@ function parseSegments( const next = createDynamicNode(SEGMENT_TYPE_WILDCARD, route.fullPath, caseSensitive, prefix, suffix) nextNode = next next.parent = node + next.depth = ++depth node.wildcard ??= [] node.wildcard.push(next) } @@ -223,7 +229,7 @@ function parseSegments( onRoute(route, node) } if (route.children) for (const child of route.children) { - parseSegments(data, child as TRouteLike, cursor, node, onRoute) + parseSegments(data, child as TRouteLike, cursor, node, depth, onRoute) } } @@ -279,6 +285,7 @@ function sortTreeNodes(node: SegmentNode) { function createStaticNode(fullPath: string): StaticSegmentNode { return { kind: SEGMENT_TYPE_PATHNAME, + depth: 0, static: null, staticInsensitive: null, dynamic: null, @@ -297,6 +304,7 @@ function createStaticNode(fullPath: string): StaticSegmentNode { function createDynamicNode(kind: typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM, fullPath: string, caseSensitive: boolean, prefix?: string, suffix?: string): DynamicSegmentNode { return { kind, + depth: 0, static: null, staticInsensitive: null, dynamic: null, @@ -348,6 +356,8 @@ type SegmentNode = { fullPath: string parent: SegmentNode | null + + depth: number } // function intoRouteLike(routeTree, parent) { @@ -391,7 +401,7 @@ export function processRouteTree({ const routesById = {} as Record const routesByPath = {} as Record let index = 0 - parseSegments(data, routeTree, 1, segmentTree, (route, node) => { + parseSegments(data, routeTree, 1, segmentTree, 0, (route, node) => { initRoute?.(route, index) invariant( @@ -422,9 +432,9 @@ export function processRouteTree({ } -export function findMatch(path: string, segmentTree: SegmentNode): { routeId: string, params: Record } | null { +export function findMatch(path: string, segmentTree: SegmentNode, fuzzy = false): { routeId: string, params: Record } | null { const parts = path.split('/') - const leaf = getNodeMatch(parts, segmentTree) + const leaf = getNodeMatch(parts, segmentTree, fuzzy) if (!leaf) return null const params = extractParams(path, parts, leaf) return { @@ -482,16 +492,18 @@ function extractParams(path: string, parts: Array, leaf: { node: Segment } function buildBranch(node: SegmentNode) { - const list = [node] - while (node.parent) { - node = node.parent - list.push(node) - } - list.reverse() + const list: Array = Array(node.depth + 1) + do { + list[node.depth] = node + node = node.parent! + } while (node) return list } -function getNodeMatch(parts: Array, segmentTree: SegmentNode) { +/** + * when fuzzy: true, we return the longest matching node even if not all parts are consumed + */ +function getNodeMatch(parts: Array, segmentTree: SegmentNode, fuzzy: boolean) { parts = parts.filter(Boolean) type Frame = { @@ -507,12 +519,13 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode) { const stack: Array = [ { node: segmentTree, index: 0, depth: 0, skipped: 0 } ] + let stackIndex = 0 let wildcardMatch: Frame | null = null - while (stack.length) { + while (stackIndex < stack.length) { // eslint-disable-next-line prefer-const - let { node, index, skipped, depth } = stack.shift()! + let { node, index, skipped, depth } = stack[stackIndex++]! main: while (node && index <= parts.length) { if (index === parts.length) { From d15c9d9086f40423f1bad3066af83649908187e6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 9 Nov 2025 09:44:50 +0100 Subject: [PATCH 019/109] fuzzy matching --- .../router-core/src/new-process-route-tree.ts | 19 +++++++++-- .../tests/new-process-route-tree.test.ts | 32 ++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 21bb3864119..757b37e6fd1 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -437,6 +437,7 @@ export function findMatch(path: string, segmentTree: SegmentNode, fuzzy = false) const leaf = getNodeMatch(parts, segmentTree, fuzzy) if (!leaf) return null const params = extractParams(path, parts, leaf) + if ('**' in leaf) params['**'] = leaf['**']! return { routeId: leaf.node.routeId!, params, @@ -500,9 +501,6 @@ function buildBranch(node: SegmentNode) { return list } -/** - * when fuzzy: true, we return the longest matching node even if not all parts are consumed - */ function getNodeMatch(parts: Array, segmentTree: SegmentNode, fuzzy: boolean) { parts = parts.filter(Boolean) @@ -522,6 +520,7 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode, fuzzy: boo let stackIndex = 0 let wildcardMatch: Frame | null = null + let bestFuzzy: Frame | null = null while (stackIndex < stack.length) { // eslint-disable-next-line prefer-const @@ -533,6 +532,11 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode, fuzzy: boo return { node, skipped } } + // In fuzzy mode, track the best partial match we've found so far + if (fuzzy && node.routeId && (!bestFuzzy || index > bestFuzzy.index || (index === bestFuzzy.index && depth > bestFuzzy.depth))) { + bestFuzzy = { node, index, depth, skipped } + } + const part = parts[index]! // 1. Try static match @@ -621,7 +625,16 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode, fuzzy: boo } } + if (wildcardMatch) return wildcardMatch + if (fuzzy && bestFuzzy) { + return { + node: bestFuzzy.node, + skipped: bestFuzzy.skipped, + '**': '/' + parts.slice(bestFuzzy.index).join('/'), + } + } + return null } \ No newline at end of file diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index a3a2bd89249..504af387662 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -116,5 +116,35 @@ describe('findMatch', () => { } `) }) -}) + it('works w/ fuzzy matching', () => { + expect(findMatch('/foo/123', segmentTree, true)).toMatchInlineSnapshot(` + { + "params": { + "**": "/123", + }, + "routeId": "/foo", + } + `) + }) + it('can still return exact matches w/ fuzzy:true', () => { + expect(findMatch('/yo/foobar', segmentTree, true)).toMatchInlineSnapshot(` + { + "params": { + "id": "", + }, + "routeId": "/yo/foo{-$id}bar", + } + `) + }) + it('can still match a wildcard route w/ fuzzy:true', () => { + expect(findMatch('/yo/something', segmentTree, true)).toMatchInlineSnapshot(` + { + "params": { + "*": "something", + }, + "routeId": "/yo/$", + } + `) + }) +}) From 1a6a96968993e49aa87b3e99113e857a139e0917 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 10 Nov 2025 11:46:24 +0100 Subject: [PATCH 020/109] better sort function --- .../router-core/src/new-process-route-tree.ts | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 757b37e6fd1..5b45a5f5fc2 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -234,21 +234,19 @@ function parseSegments( } function sortDynamic(a: { prefix?: string, suffix?: string }, b: { prefix?: string, suffix?: string }) { - const aScore = - // bonus for having both prefix and suffix - (a.prefix && a.suffix ? 500 : 0) - // prefix counts double - + (a.prefix ? a.prefix.length * 2 : 0) - // suffix counts single - + (a.suffix ? a.suffix.length : 0) - const bScore = - // bonus for having both prefix and suffix - (b.prefix && b.suffix ? 500 : 0) - // prefix counts double - + (b.prefix ? b.prefix.length * 2 : 0) - // suffix counts single - + (b.suffix ? b.suffix.length : 0) - return bScore - aScore + if (a.prefix && b.prefix && a.prefix !== b.prefix) { + if (a.prefix.startsWith(b.prefix)) return -1 + if (b.prefix.startsWith(a.prefix)) return 1 + } + if (a.suffix && b.suffix && a.suffix !== b.suffix) { + if (a.suffix.endsWith(b.suffix)) return -1 + if (b.suffix.endsWith(a.suffix)) return 1 + } + if (a.prefix && !b.prefix) return -1 + if (!a.prefix && b.prefix) return 1 + if (a.suffix && !b.suffix) return -1 + if (!a.suffix && b.suffix) return 1 + return 0 } function sortTreeNodes(node: SegmentNode) { From 7c91a68f9b9751d5b6a0ca5dee230141512661f9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 10 Nov 2025 12:16:13 +0100 Subject: [PATCH 021/109] dynamic and optional matches use the stack --- .../router-core/src/new-process-route-tree.ts | 50 ++++++++----------- .../tests/new-process-route-tree.test.ts | 43 ++++++++++++++++ 2 files changed, 65 insertions(+), 28 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 5b45a5f5fc2..a8e024ed88e 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -537,28 +537,6 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode, fuzzy: boo const part = parts[index]! - // 1. Try static match - if (node.static) { - const match = node.static.get(part) - if (match) { - node = match - depth++ - index++ - continue - } - } - - // 2. Try case insensitive static match - if (node.staticInsensitive) { - const match = node.staticInsensitive.get(part.toLowerCase()) - if (match) { - node = match - depth++ - index++ - continue - } - } - // 3. Try dynamic match if (node.dynamic) { for (const segment of node.dynamic) { @@ -568,10 +546,7 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode, fuzzy: boo if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } - node = segment - depth++ - index++ - continue main + stack.push({ node: segment, index: index + 1, skipped, depth: depth + 1 }) } } @@ -590,10 +565,29 @@ function getNodeMatch(parts: Array, segmentTree: SegmentNode, fuzzy: boo if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } - node = segment + stack.push({ node: segment, index: index + 1, skipped, depth: nextDepth }) + } + } + + // 1. Try static match + if (node.static) { + const match = node.static.get(part) + if (match) { + node = match + depth++ index++ + continue + } + } + + // 2. Try case insensitive static match + if (node.staticInsensitive) { + const match = node.staticInsensitive.get(part.toLowerCase()) + if (match) { + node = match depth++ - continue main + index++ + continue } } diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 504af387662..0fb32c92591 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -51,6 +51,49 @@ describe('findMatch', () => { path: '$b/bbb', } ] + }, { + id: '/x/y/z', + fullPath: '/x/y/z', + path: 'x/y/z', + }, { + id: '/$id/y/w', + fullPath: '/$id/y/w', + it('foo', () => { + expect(findMatch('/posts/new', segmentTree)).toMatchInlineSnapshot(` + { + "params": { + "other": "", + }, + "routeId": "/{-$other}/posts/new", + } + `) + expect(findMatch('/yo/posts/new', segmentTree)).toMatchInlineSnapshot(` + { + "params": { + "other": "yo", + }, + "routeId": "/{-$other}/posts/new", + } + `) + expect(findMatch('/x/y/w', segmentTree)).toMatchInlineSnapshot(` + { + "params": { + "id": "x", + }, + "routeId": "/$id/y/w", + } + `) + }) + + path: '$id/y/w', + }, { + id: '/{-$other}/posts/new', + fullPath: '/{-$other}/posts/new', + path: '{-$other}/posts/new', + }, { + id: '/posts/$id', + fullPath: '/posts/$id', + path: 'posts/$id', } ] } From 4cd8726778d7c7b0916a1bef07365857624118f9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 10 Nov 2025 13:59:48 +0100 Subject: [PATCH 022/109] no regex during matching yields 2x perf --- .../router-core/src/new-process-route-tree.ts | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index a8e024ed88e..e4c9cb6b304 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -126,9 +126,9 @@ function parseSegments( data: Uint16Array, route: TRouteLike, start: number, - node: SegmentNode, + node: AnySegmentNode, depth: number, - onRoute: (route: TRouteLike, node: SegmentNode) => void + onRoute: (route: TRouteLike, node: AnySegmentNode) => void ) { let cursor = start { @@ -136,7 +136,7 @@ function parseSegments( const length = path.length const caseSensitive = route.options?.caseSensitive ?? false while (cursor < length) { - let nextNode: SegmentNode + let nextNode: AnySegmentNode const start = cursor parseSegment(path, start, data) const end = data[5]! @@ -328,6 +328,7 @@ type DynamicSegmentNode = SegmentNode & { caseSensitive: boolean } +type AnySegmentNode = StaticSegmentNode | DynamicSegmentNode type SegmentNode = { kind: SegmentKind @@ -353,7 +354,7 @@ type SegmentNode = { // The full path for this segment node (will only be valid on leaf nodes) fullPath: string - parent: SegmentNode | null + parent: AnySegmentNode | null depth: number } @@ -430,7 +431,7 @@ export function processRouteTree({ } -export function findMatch(path: string, segmentTree: SegmentNode, fuzzy = false): { routeId: string, params: Record } | null { +export function findMatch(path: string, segmentTree: AnySegmentNode, fuzzy = false): { routeId: string, params: Record } | null { const parts = path.split('/') const leaf = getNodeMatch(parts, segmentTree, fuzzy) if (!leaf) return null @@ -442,11 +443,10 @@ export function findMatch(path: string, segmentTree: SegmentNode, fuzzy = false) } } -function extractParams(path: string, parts: Array, leaf: { node: SegmentNode, skipped: number }) { +function extractParams(path: string, parts: Array, leaf: { node: AnySegmentNode, skipped: number }) { const list = buildBranch(leaf.node) let nodeParts: Array | null = null const params: Record = {} - const data = new Uint16Array(6) for (let partIndex = 0, nodeIndex = 0, pathIndex = 0; partIndex < parts.length && nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++) { const node = list[nodeIndex]! const part = parts[partIndex]! @@ -455,34 +455,35 @@ function extractParams(path: string, parts: Array, leaf: { node: Segment if (node.kind === SEGMENT_TYPE_PARAM) { nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[nodeIndex]! - parseSegment(nodePart, 0, data) // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node - const name = nodePart.substring(data[2]!, data[3]) - const n = node as DynamicSegmentNode - if (n.suffix || n.prefix) { - params[name] = part.substring(n.prefix?.length ?? 0, part.length - (n.suffix?.length ?? 0)) + if (node.suffix !== undefined || node.prefix !== undefined) { + const preLength = node.prefix?.length ?? 0 + const sufLength = node.suffix?.length ?? 0 + const name = nodePart.substring(preLength + 2, nodePart.length - sufLength - 1) + params[name] = part.substring(preLength, part.length - sufLength) } else { + const name = nodePart.substring(1) params[name] = part } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[nodeIndex]! - parseSegment(nodePart, 0, data) + const preLength = node.prefix?.length ?? 0 + const sufLength = node.suffix?.length ?? 0 + const name = nodePart.substring(preLength + 3, nodePart.length - sufLength - 1) // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node - const name = nodePart.substring(data[2]!, data[3]) - const n = node as DynamicSegmentNode if (leaf.skipped & (1 << nodeIndex)) { partIndex-- // stay on the same part params[name] = '' continue } - if (n.suffix || n.prefix) { - params[name] = part.substring(n.prefix?.length ?? 0, part.length - (n.suffix?.length ?? 0)) + if (node.suffix || node.prefix) { + params[name] = part.substring(preLength, part.length - sufLength) } else { params[name] = part } } else if (node.kind === SEGMENT_TYPE_WILDCARD) { - const n = node as DynamicSegmentNode + const n = node params['*'] = path.substring(currentPathIndex + (n.prefix?.length ?? 0), path.length - (n.suffix?.length ?? 0)) break } @@ -490,8 +491,8 @@ function extractParams(path: string, parts: Array, leaf: { node: Segment return params } -function buildBranch(node: SegmentNode) { - const list: Array = Array(node.depth + 1) +function buildBranch(node: AnySegmentNode) { + const list: Array = Array(node.depth + 1) do { list[node.depth] = node node = node.parent! @@ -499,11 +500,11 @@ function buildBranch(node: SegmentNode) { return list } -function getNodeMatch(parts: Array, segmentTree: SegmentNode, fuzzy: boolean) { +function getNodeMatch(parts: Array, segmentTree: AnySegmentNode, fuzzy: boolean) { parts = parts.filter(Boolean) type Frame = { - node: SegmentNode + node: AnySegmentNode index: number depth: number /** Bitmask of skipped optional segments */ From 48f59ced515c49ae35ab3024daa9e92677b9682d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 10 Nov 2025 18:46:44 +0100 Subject: [PATCH 023/109] simplify interpolatePath and matchId --- packages/router-core/src/path.ts | 20 +++---------------- packages/router-core/src/router.ts | 16 +++++++-------- .../src/BaseTanStackRouterDevtoolsPanel.tsx | 1 - 3 files changed, 10 insertions(+), 27 deletions(-) diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 5c91661e2b1..a1b0496f2bb 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -349,7 +349,6 @@ function baseParsePathname(pathname: string): ReadonlyArray { interface InterpolatePathOptions { path?: string params: Record - leaveWildcards?: boolean leaveParams?: boolean // Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params decodeCharMap?: Map @@ -374,7 +373,6 @@ type InterPolatePathResult = { export function interpolatePath({ path, params, - leaveWildcards, leaveParams, decodeCharMap, }: InterpolatePathOptions): InterPolatePathResult { @@ -427,21 +425,13 @@ export function interpolatePath({ // For missing splat parameters, just return the prefix and suffix without the wildcard // If there is a prefix or suffix, return them joined, otherwise omit the segment if (prefix || suffix) { - if (leaveWildcards) { - interpolatedSegments.push(`${prefix}{$}${suffix}`) - } else { - interpolatedSegments.push(`${prefix}${suffix}`) - } + interpolatedSegments.push(`${prefix}${suffix}`) } continue } const value = encodeParam('_splat') - if (leaveWildcards) { - interpolatedSegments.push(`${prefix}${prefix || suffix ? '{$}' : '$'}${value ?? ''}${suffix}`) - } else { - interpolatedSegments.push(`${prefix}${value}${suffix}`) - } + interpolatedSegments.push(`${prefix}${value}${suffix}`) continue } @@ -471,9 +461,7 @@ export function interpolatePath({ // Check if optional parameter is missing or undefined if (!(key in params) || params[key] == null) { - if (leaveWildcards) { - interpolatedSegments.push(`${prefix}${key}${suffix}`) - } else if (prefix || suffix) { + if (prefix || suffix) { // For optional params with prefix/suffix, keep the prefix/suffix but omit the param interpolatedSegments.push(`${prefix}${suffix}`) } @@ -486,8 +474,6 @@ export function interpolatePath({ const value = encodeParam(key) ?? '' if (leaveParams) { interpolatedSegments.push(`${prefix}${key}${value}${suffix}`) - } else if (leaveWildcards) { - interpolatedSegments.push(`${prefix}${key}${value}${suffix}`) } else { interpolatedSegments.push(`${prefix}${value}${suffix}`) } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index cb838a198a2..fd9233b5ed8 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1362,14 +1362,13 @@ export class RouterCore< // Existing matches are matches that are already loaded along with // pending matches that are still loading - const matchId = - interpolatePath({ - path: route.id, - params: routeParams, - leaveWildcards: true, - decodeCharMap: this.pathParamsDecodeCharMap, - parseCache: this.parsePathnameCache, - }).interpolatedPath + loaderDepsHash + const matchId = + // route.id for disambiguation + route.id + // interpolatedPath for param changes + + interpolatedPath + // explicit deps + + loaderDepsHash const existingMatch = this.getMatch(matchId) @@ -1677,7 +1676,6 @@ export class RouterCore< // This preserves the original parameter syntax including optional parameters path: nextTo, params: nextParams, - leaveWildcards: false, leaveParams: opts.leaveParams, decodeCharMap: this.pathParamsDecodeCharMap, parseCache: this.parsePathnameCache, diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index 66a4721cb6e..081f5f9073e 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -178,7 +178,6 @@ function RouteComp({ const interpolated = interpolatePath({ path: route.fullPath, params: allParams, - leaveWildcards: false, leaveParams: false, decodeCharMap: router().pathParamsDecodeCharMap, }) From 3fd5fd1d070493f9f8ce2ed19fa6ab8488b2c7a6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 09:27:08 +0100 Subject: [PATCH 024/109] clean resolvePath --- packages/router-core/src/path.ts | 69 ++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 73da1cdee77..2ab279ebd50 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -132,34 +132,39 @@ export function resolvePath({ to, trailingSlash = 'never', }: ResolvePathOptions) { - let baseSegments = base.split('/') + let baseSegments: Array const toSegments = to.split('/') - - if (baseSegments.length > 1 && last(baseSegments) === '') { - baseSegments.pop() - } - - for (let index = 0, length = toSegments.length; index < length; index++) { - const value = toSegments[index]! - if (value === '/') { - if (!index) { - // Leading slash - baseSegments = [value] - } else if (index === length - 1) { - // Trailing Slash - baseSegments.push(value) + if (toSegments[0] === '') { + baseSegments = toSegments + } else { + baseSegments = base.split('/') + while (baseSegments.length > 1 && last(baseSegments) === '') { + baseSegments.pop() + } + + for (let index = 0, length = toSegments.length; index < length; index++) { + const value = toSegments[index]! + if (value === '') { + if (!index) { + // Leading slash + baseSegments = [value] + } else if (index === length - 1) { + // Trailing Slash + baseSegments.push(value) + } else { + // ignore inter-slashes + } + } else if (value === '..') { + baseSegments.pop() + } else if (value === '.') { + // ignore } else { - // ignore inter-slashes + baseSegments.push(value) } - } else if (value === '..') { - baseSegments.pop() - } else if (value === '.') { - // ignore - } else { - baseSegments.push(value) } } + if (baseSegments.length > 1) { if (last(baseSegments) === '') { if (trailingSlash === 'never') { @@ -171,26 +176,30 @@ export function resolvePath({ } const data = new Uint16Array(6) - const segmentValues = baseSegments.map((segment) => { - if (!segment) return '' + let joined = '' + for (let i = 0; i < baseSegments.length; i++) { + if (i > 0) joined += '/' + const segment = baseSegments[i]! + if (!segment) continue parseSegment(segment, 0, data) const kind = data[0] as SegmentKind if (kind === SEGMENT_TYPE_PATHNAME) { - return segment + joined += segment + continue } const end = data[5]! const prefix = segment.substring(0, data[1]) const suffix = segment.substring(data[4]!, end) const value = segment.substring(data[2]!, data[3]) if (kind === SEGMENT_TYPE_PARAM) { - return prefix || suffix ? `${prefix}{$${value}}${suffix}` : `$${value}` + joined += prefix || suffix ? `${prefix}{$${value}}${suffix}` : `$${value}` } else if (kind === SEGMENT_TYPE_WILDCARD) { - return prefix || suffix ? `${prefix}{$}${suffix}` : '$' + joined += prefix || suffix ? `${prefix}{$}${suffix}` : '$' } else { // SEGMENT_TYPE_OPTIONAL_PARAM - return `${prefix}{-$${value}}${suffix}` + joined += `${prefix}{-$${value}}${suffix}` } - }) - const joined = joinPaths(segmentValues) + } + joined = cleanPath(joined) return joined || '/' } From 54c14b9b612ab2719f40d35eae3fb41a53a1395c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 09:48:53 +0100 Subject: [PATCH 025/109] optimize parseSegment --- .../router-core/src/new-process-route-tree.ts | 62 +++++++++---------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index e4c9cb6b304..840b4ff8e4f 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -7,11 +7,9 @@ export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 export type SegmentKind = typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM -const PARAM_RE = /^\$(.{1,})$/ // $paramName -const PARAM_W_CURLY_BRACES_RE = /^(.*?)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{$paramName}suffix -const OPTIONAL_PARAM_W_CURLY_BRACES_RE = /^(.*?)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{-$paramName}suffix -const WILDCARD_RE = /^\$$/ // $ -const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix +const PARAM_W_CURLY_BRACES_RE = /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{$paramName}suffix +const OPTIONAL_PARAM_W_CURLY_BRACES_RE = /^([^{]*)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{-$paramName}suffix +const WILDCARD_W_CURLY_BRACES_RE = /^([^{]*)\{\$\}([^}]*)$/ // prefix{$}suffix /** * Populates the `output` array with the parsed representation of the given `segment` string. @@ -29,8 +27,9 @@ const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix export function parseSegment(path: string, start: number, output: Uint16Array) { const next = path.indexOf('/', start) const end = next === -1 ? path.length : next - if (end === start) { // TODO: maybe should never happen? - // Slash segment + const part = path.substring(start, end) + + if (!part || !part.includes('$')) { // early escape for static pathname output[0] = SEGMENT_TYPE_PATHNAME output[1] = start output[2] = start @@ -39,7 +38,29 @@ export function parseSegment(path: string, start: number, output: Uint16Array) { output[5] = end return } - const part = path.substring(start, end) + + // $ (wildcard) + if (part === '$') { + output[0] = SEGMENT_TYPE_WILDCARD + output[1] = start + output[2] = start + output[3] = end + output[4] = end + output[5] = end + return + } + + // $paramName + if (part.charCodeAt(0) === 36) { + output[0] = SEGMENT_TYPE_PARAM + output[1] = start + output[2] = start + 1 // skip '$' + output[3] = start + part.length + output[4] = end + output[5] = end + return + } + const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE) if (wildcardBracesMatch) { const prefix = wildcardBracesMatch[1]! @@ -81,30 +102,7 @@ export function parseSegment(path: string, start: number, output: Uint16Array) { return } - const paramMatch = part.match(PARAM_RE) - if (paramMatch) { - const paramName = paramMatch[1]! - output[0] = SEGMENT_TYPE_PARAM - output[1] = start - output[2] = start + 1 // skip '$' - output[3] = start + 1 + paramName.length - output[4] = end - output[5] = end - return - } - - const wildcardMatch = part.match(WILDCARD_RE) - if (wildcardMatch) { - output[0] = SEGMENT_TYPE_WILDCARD - output[1] = start - output[2] = start - output[3] = end - output[4] = end - output[5] = end - return - } - - // Static pathname segment + // fallback to static pathname (should never happen) output[0] = SEGMENT_TYPE_PATHNAME output[1] = start output[2] = start From 44133394f3c501ee35c7d872f3d1caaa7f4f9840 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 11:26:51 +0100 Subject: [PATCH 026/109] support processing flat route tree for route masks matching --- .../router-core/src/new-process-route-tree.ts | 107 ++++--- .../tests/new-process-route-tree.test.ts | 288 ++++++++++++++++-- 2 files changed, 321 insertions(+), 74 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 840b4ff8e4f..b088dfbdbc7 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -39,7 +39,7 @@ export function parseSegment(path: string, start: number, output: Uint16Array) { return } - // $ (wildcard) + // $ (wildcard) if (part === '$') { output[0] = SEGMENT_TYPE_WILDCARD output[1] = start @@ -124,17 +124,17 @@ function parseSegments( data: Uint16Array, route: TRouteLike, start: number, - node: AnySegmentNode, + node: AnySegmentNode, depth: number, - onRoute: (route: TRouteLike, node: AnySegmentNode) => void + onRoute?: (route: TRouteLike, node: AnySegmentNode) => void ) { let cursor = start { - const path = route.fullPath + const path = route.fullPath ?? route.from const length = path.length const caseSensitive = route.options?.caseSensitive ?? false while (cursor < length) { - let nextNode: AnySegmentNode + let nextNode: AnySegmentNode const start = cursor parseSegment(path, start, data) const end = data[5]! @@ -149,7 +149,7 @@ function parseSegments( nextNode = existingNode } else { node.static ??= new Map() - const next = createStaticNode(route.fullPath) + const next = createStaticNode(route.fullPath ?? route.from) next.parent = node next.depth = ++depth nextNode = next @@ -162,7 +162,7 @@ function parseSegments( nextNode = existingNode } else { node.staticInsensitive ??= new Map() - const next = createStaticNode(route.fullPath) + const next = createStaticNode(route.fullPath ?? route.from) next.parent = node next.depth = ++depth nextNode = next @@ -180,7 +180,7 @@ function parseSegments( if (existingNode) { nextNode = existingNode } else { - const next = createDynamicNode(SEGMENT_TYPE_PARAM, route.fullPath, caseSensitive, prefix, suffix) + const next = createDynamicNode(SEGMENT_TYPE_PARAM, route.fullPath ?? route.from, caseSensitive, prefix, suffix) nextNode = next next.depth = ++depth next.parent = node @@ -198,7 +198,7 @@ function parseSegments( if (existingNode) { nextNode = existingNode } else { - const next = createDynamicNode(SEGMENT_TYPE_OPTIONAL_PARAM, route.fullPath, caseSensitive, prefix, suffix) + const next = createDynamicNode(SEGMENT_TYPE_OPTIONAL_PARAM, route.fullPath ?? route.from, caseSensitive, prefix, suffix) nextNode = next next.parent = node next.depth = ++depth @@ -212,7 +212,7 @@ function parseSegments( const suffix_raw = path.substring(data[4]!, end) const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const next = createDynamicNode(SEGMENT_TYPE_WILDCARD, route.fullPath, caseSensitive, prefix, suffix) + const next = createDynamicNode(SEGMENT_TYPE_WILDCARD, route.fullPath ?? route.from, caseSensitive, prefix, suffix) nextNode = next next.parent = node next.depth = ++depth @@ -222,9 +222,9 @@ function parseSegments( } node = nextNode } - if (route.path) - node.routeId = route.id - onRoute(route, node) + if (route.path || !route.children) + node.route = route + onRoute?.(route, node) } if (route.children) for (const child of route.children) { parseSegments(data, child as TRouteLike, cursor, node, depth, onRoute) @@ -247,7 +247,7 @@ function sortDynamic(a: { prefix?: string, suffix?: string }, b: { prefix?: stri return 0 } -function sortTreeNodes(node: SegmentNode) { +function sortTreeNodes(node: SegmentNode) { if (node.static) { for (const child of node.static.values()) { sortTreeNodes(child) @@ -278,7 +278,7 @@ function sortTreeNodes(node: SegmentNode) { } } -function createStaticNode(fullPath: string): StaticSegmentNode { +function createStaticNode(fullPath: string): StaticSegmentNode { return { kind: SEGMENT_TYPE_PATHNAME, depth: 0, @@ -287,7 +287,7 @@ function createStaticNode(fullPath: string): StaticSegmentNode { dynamic: null, optional: null, wildcard: null, - routeId: null, + route: null, fullPath, parent: null } @@ -297,7 +297,7 @@ function createStaticNode(fullPath: string): StaticSegmentNode { * Keys must be declared in the same order as in `SegmentNode` type, * to ensure they are represented as the same object class in the engine. */ -function createDynamicNode(kind: typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM, fullPath: string, caseSensitive: boolean, prefix?: string, suffix?: string): DynamicSegmentNode { +function createDynamicNode(kind: typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM, fullPath: string, caseSensitive: boolean, prefix?: string, suffix?: string): DynamicSegmentNode { return { kind, depth: 0, @@ -306,7 +306,7 @@ function createDynamicNode(kind: typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE dynamic: null, optional: null, wildcard: null, - routeId: null, + route: null, fullPath, parent: null, caseSensitive, @@ -315,44 +315,44 @@ function createDynamicNode(kind: typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE } } -type StaticSegmentNode = SegmentNode & { +type StaticSegmentNode = SegmentNode & { kind: typeof SEGMENT_TYPE_PATHNAME } -type DynamicSegmentNode = SegmentNode & { +type DynamicSegmentNode = SegmentNode & { kind: typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM prefix?: string suffix?: string caseSensitive: boolean } -type AnySegmentNode = StaticSegmentNode | DynamicSegmentNode +type AnySegmentNode = StaticSegmentNode | DynamicSegmentNode -type SegmentNode = { +type SegmentNode = { kind: SegmentKind // Static segments (highest priority) - static: Map | null + static: Map> | null // Case insensitive static segments (second highest priority) - staticInsensitive: Map | null + staticInsensitive: Map> | null // Dynamic segments ($param) - dynamic: Array | null + dynamic: Array> | null // Optional dynamic segments ({-$param}) - optional: Array | null + optional: Array> | null // Wildcard segments ($ - lowest priority) - wildcard: Array | null + wildcard: Array> | null // Terminal route (if this path can end here) - routeId: string | null + route: T | null // The full path for this segment node (will only be valid on leaf nodes) fullPath: string - parent: AnySegmentNode | null + parent: AnySegmentNode | null depth: number } @@ -371,29 +371,42 @@ type SegmentNode = { // } type RouteLike = { - id: string // unique identifier, - fullPath: string // full path from the root, path?: string // relative path from the parent, children?: Array // child routes, parentRoute?: RouteLike // parent route, options?: { caseSensitive?: boolean } +} & ( + // router tree + | { fullPath: string, from?: never } // full path from the root + // flat route masks list + | { fullPath?: never, from: string } // full path from the root + ) + +export function processFlatRouteList( + routeList: Array, +) { + const segmentTree = createStaticNode('/') + const data = new Uint16Array(6) + for (const route of routeList) { + parseSegments(data, route, 1, segmentTree, 1) + } + sortTreeNodes(segmentTree) + return segmentTree } + /** Trim trailing slashes (except preserving root '/'). */ export function trimPathRight(path: string) { return path === '/' ? path : path.replace(/\/{1,}$/, '') } -export function processRouteTree({ - routeTree, - initRoute, -}: { - routeTree: TRouteLike +export function processRouteTree & { id: string }>( + routeTree: TRouteLike, initRoute?: (route: TRouteLike, index: number) => void -}) { - const segmentTree = createStaticNode(routeTree.fullPath) +) { + const segmentTree = createStaticNode(routeTree.fullPath) const data = new Uint16Array(6) const routesById = {} as Record const routesByPath = {} as Record @@ -429,19 +442,19 @@ export function processRouteTree({ } -export function findMatch(path: string, segmentTree: AnySegmentNode, fuzzy = false): { routeId: string, params: Record } | null { +export function findMatch(path: string, segmentTree: AnySegmentNode, fuzzy = false): { route: T, params: Record } | null { const parts = path.split('/') const leaf = getNodeMatch(parts, segmentTree, fuzzy) if (!leaf) return null const params = extractParams(path, parts, leaf) if ('**' in leaf) params['**'] = leaf['**']! return { - routeId: leaf.node.routeId!, + route: leaf.node.route!, params, } } -function extractParams(path: string, parts: Array, leaf: { node: AnySegmentNode, skipped: number }) { +function extractParams(path: string, parts: Array, leaf: { node: AnySegmentNode, skipped: number }) { const list = buildBranch(leaf.node) let nodeParts: Array | null = null const params: Record = {} @@ -489,8 +502,8 @@ function extractParams(path: string, parts: Array, leaf: { node: AnySegm return params } -function buildBranch(node: AnySegmentNode) { - const list: Array = Array(node.depth + 1) +function buildBranch(node: AnySegmentNode) { + const list: Array> = Array(node.depth + 1) do { list[node.depth] = node node = node.parent! @@ -498,11 +511,11 @@ function buildBranch(node: AnySegmentNode) { return list } -function getNodeMatch(parts: Array, segmentTree: AnySegmentNode, fuzzy: boolean) { +function getNodeMatch(parts: Array, segmentTree: AnySegmentNode, fuzzy: boolean) { parts = parts.filter(Boolean) type Frame = { - node: AnySegmentNode + node: AnySegmentNode index: number depth: number /** Bitmask of skipped optional segments */ @@ -525,12 +538,12 @@ function getNodeMatch(parts: Array, segmentTree: AnySegmentNode, fuzzy: main: while (node && index <= parts.length) { if (index === parts.length) { - if (!node.routeId) break + if (!node.route) break return { node, skipped } } // In fuzzy mode, track the best partial match we've found so far - if (fuzzy && node.routeId && (!bestFuzzy || index > bestFuzzy.index || (index === bestFuzzy.index && depth > bestFuzzy.depth))) { + if (fuzzy && node.route && (!bestFuzzy || index > bestFuzzy.index || (index === bestFuzzy.index && depth > bestFuzzy.depth))) { bestFuzzy = { node, index, depth, skipped } } diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 0fb32c92591..63598c4e3bc 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -1,6 +1,173 @@ import { describe, expect, it } from 'vitest' -import { findMatch, processRouteTree } from "../src/new-process-route-tree" +import { findMatch, processFlatRouteList, processRouteTree } from "../src/new-process-route-tree" +import type { AnyRoute, RouteMask } from "../src" +describe('processFlatRouteList', () => { + it('processes a route masks list', () => { + const routeTree = {} as AnyRoute + const routeMasks: Array> = [ + { from: '/a/b/c', routeTree }, + { from: '/a/b/d', routeTree }, + { from: '/a/$param/d', routeTree }, + { from: '/a/{-$optional}/d', routeTree }, + { from: '/a/b/{$}.txt', routeTree }, + ] + expect(processFlatRouteList(routeMasks)).toMatchInlineSnapshot(` + { + "depth": 0, + "dynamic": null, + "fullPath": "/", + "kind": 0, + "optional": null, + "parent": null, + "route": null, + "static": null, + "staticInsensitive": Map { + "a" => { + "depth": 2, + "dynamic": [ + { + "caseSensitive": false, + "depth": 2, + "dynamic": null, + "fullPath": "/a/$param/d", + "kind": 1, + "optional": null, + "parent": [Circular], + "prefix": undefined, + "route": null, + "static": null, + "staticInsensitive": Map { + "d" => { + "depth": 3, + "dynamic": null, + "fullPath": "/a/$param/d", + "kind": 0, + "optional": null, + "parent": [Circular], + "route": { + "from": "/a/$param/d", + "routeTree": {}, + }, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + }, + "suffix": undefined, + "wildcard": null, + }, + ], + "fullPath": "/a/b/c", + "kind": 0, + "optional": [ + { + "caseSensitive": false, + "depth": 2, + "dynamic": null, + "fullPath": "/a/{-$optional}/d", + "kind": 3, + "optional": null, + "parent": [Circular], + "prefix": undefined, + "route": null, + "static": null, + "staticInsensitive": Map { + "d" => { + "depth": 3, + "dynamic": null, + "fullPath": "/a/{-$optional}/d", + "kind": 0, + "optional": null, + "parent": [Circular], + "route": { + "from": "/a/{-$optional}/d", + "routeTree": {}, + }, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + }, + "suffix": undefined, + "wildcard": null, + }, + ], + "parent": [Circular], + "route": null, + "static": null, + "staticInsensitive": Map { + "b" => { + "depth": 3, + "dynamic": null, + "fullPath": "/a/b/c", + "kind": 0, + "optional": null, + "parent": [Circular], + "route": null, + "static": null, + "staticInsensitive": Map { + "c" => { + "depth": 4, + "dynamic": null, + "fullPath": "/a/b/c", + "kind": 0, + "optional": null, + "parent": [Circular], + "route": { + "from": "/a/b/c", + "routeTree": {}, + }, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + "d" => { + "depth": 2, + "dynamic": null, + "fullPath": "/a/b/d", + "kind": 0, + "optional": null, + "parent": [Circular], + "route": { + "from": "/a/b/d", + "routeTree": {}, + }, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + }, + "wildcard": [ + { + "caseSensitive": false, + "depth": 2, + "dynamic": null, + "fullPath": "/a/b/{$}.txt", + "kind": 2, + "optional": null, + "parent": [Circular], + "prefix": undefined, + "route": { + "from": "/a/b/{$}.txt", + "routeTree": {}, + }, + "static": null, + "staticInsensitive": null, + "suffix": ".txt", + "wildcard": null, + }, + ], + }, + }, + "wildcard": null, + }, + }, + "wildcard": null, + } + `) + }) +}) describe('findMatch', () => { const testTree = { @@ -58,13 +225,30 @@ describe('findMatch', () => { }, { id: '/$id/y/w', fullPath: '/$id/y/w', + path: '$id/y/w', + }, { + id: '/{-$other}/posts/new', + fullPath: '/{-$other}/posts/new', + path: '{-$other}/posts/new', + }, { + id: '/posts/$id', + fullPath: '/posts/$id', + path: 'posts/$id', + } + ] + } + it('foo', () => { expect(findMatch('/posts/new', segmentTree)).toMatchInlineSnapshot(` { "params": { "other": "", }, - "routeId": "/{-$other}/posts/new", + "route": { + "fullPath": "/{-$other}/posts/new", + "id": "/{-$other}/posts/new", + "path": "{-$other}/posts/new", + }, } `) expect(findMatch('/yo/posts/new', segmentTree)).toMatchInlineSnapshot(` @@ -72,7 +256,11 @@ describe('findMatch', () => { "params": { "other": "yo", }, - "routeId": "/{-$other}/posts/new", + "route": { + "fullPath": "/{-$other}/posts/new", + "id": "/{-$other}/posts/new", + "path": "{-$other}/posts/new", + }, } `) expect(findMatch('/x/y/w', segmentTree)).toMatchInlineSnapshot(` @@ -80,26 +268,17 @@ describe('findMatch', () => { "params": { "id": "x", }, - "routeId": "/$id/y/w", + "route": { + "fullPath": "/$id/y/w", + "id": "/$id/y/w", + "path": "$id/y/w", + }, } `) }) - path: '$id/y/w', - }, { - id: '/{-$other}/posts/new', - fullPath: '/{-$other}/posts/new', - path: '{-$other}/posts/new', - }, { - id: '/posts/$id', - fullPath: '/posts/$id', - path: 'posts/$id', - } - ] - } - const { segmentTree } = - processRouteTree({ routeTree: testTree }) + processRouteTree(testTree) it('works w/ optional params when param is present', () => { expect(findMatch('/yo/foo123bar/ma', segmentTree)).toMatchInlineSnapshot(` @@ -107,7 +286,11 @@ describe('findMatch', () => { "params": { "id": "123", }, - "routeId": "/yo/foo{-$id}bar/ma", + "route": { + "fullPath": "/yo/foo{-$id}bar/ma", + "id": "/yo/foo{-$id}bar/ma", + "path": "ma", + }, } `) }) @@ -117,7 +300,11 @@ describe('findMatch', () => { "params": { "id": "", }, - "routeId": "/yo/foo{-$id}bar/ma", + "route": { + "fullPath": "/yo/foo{-$id}bar/ma", + "id": "/yo/foo{-$id}bar/ma", + "path": "ma", + }, } `) }) @@ -127,7 +314,11 @@ describe('findMatch', () => { "params": { "*": "somefile", }, - "routeId": "/yo/{$}.png", + "route": { + "fullPath": "/yo/{$}.png", + "id": "/yo/{$}.png", + "path": "{$}.png", + }, } `) }) @@ -137,7 +328,11 @@ describe('findMatch', () => { "params": { "*": "something", }, - "routeId": "/yo/$", + "route": { + "fullPath": "/yo/$", + "id": "/yo/$", + "path": "$", + }, } `) }) @@ -147,7 +342,11 @@ describe('findMatch', () => { "params": { "a": "123", }, - "routeId": "/foo/$a/aaa", + "route": { + "fullPath": "/foo/$a/aaa", + "id": "/foo/$a/aaa", + "path": "$a/aaa", + }, } `) expect(findMatch('/foo/123/bbb', segmentTree)).toMatchInlineSnapshot(` @@ -155,7 +354,11 @@ describe('findMatch', () => { "params": { "b": "123", }, - "routeId": "/foo/$b/bbb", + "route": { + "fullPath": "/foo/$b/bbb", + "id": "/foo/$b/bbb", + "path": "$b/bbb", + }, } `) }) @@ -166,7 +369,23 @@ describe('findMatch', () => { "params": { "**": "/123", }, - "routeId": "/foo", + "route": { + "children": [ + { + "fullPath": "/foo/$a/aaa", + "id": "/foo/$a/aaa", + "path": "$a/aaa", + }, + { + "fullPath": "/foo/$b/bbb", + "id": "/foo/$b/bbb", + "path": "$b/bbb", + }, + ], + "fullPath": "/foo", + "id": "/foo", + "path": "foo", + }, } `) }) @@ -176,7 +395,18 @@ describe('findMatch', () => { "params": { "id": "", }, - "routeId": "/yo/foo{-$id}bar", + "route": { + "children": [ + { + "fullPath": "/yo/foo{-$id}bar/ma", + "id": "/yo/foo{-$id}bar/ma", + "path": "ma", + }, + ], + "fullPath": "/yo/foo{-$id}bar", + "id": "/yo/foo{-$id}bar", + "path": "foo{-$id}bar", + }, } `) }) @@ -186,7 +416,11 @@ describe('findMatch', () => { "params": { "*": "something", }, - "routeId": "/yo/$", + "route": { + "fullPath": "/yo/$", + "id": "/yo/$", + "path": "$", + }, } `) }) From 82ae4d45f51555fefa645788e9f89c7b90c8a8de Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 11:48:39 +0100 Subject: [PATCH 027/109] support routeMasks option as a flat tree --- .../router-core/src/new-process-route-tree.ts | 25 ++++++++++++++++++- .../tests/new-process-route-tree.test.ts | 6 +++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index b088dfbdbc7..00ccf4fa21b 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -384,6 +384,14 @@ type RouteLike = { | { fullPath?: never, from: string } // full path from the root ) +type ProcessedTree< + TTree extends Extract, + TFlat extends Extract +> = { + segmentTree: AnySegmentNode, + flatCache: Map> +} + export function processFlatRouteList( routeList: Array, ) { @@ -396,6 +404,17 @@ export function processFlatRouteList( return segmentTree } +export function findFlatMatch>(list: Array, path: string, processedTree: ProcessedTree): { route: T, params: Record } | null { + let tree = processedTree.flatCache.get(list) + if (!tree) { + // flat route lists (routeMasks option) are not eagerly processed, + // if we haven't seen this list before, process it now + tree = processFlatRouteList(list) + processedTree.flatCache.set(list, tree) + } + return findMatch(path, tree) +} + /** Trim trailing slashes (except preserving root '/'). */ export function trimPathRight(path: string) { @@ -434,8 +453,12 @@ export function processRouteTree = { segmentTree, + flatCache: new Map(), + } + return { + processedTree, routesById, routesByPath, } diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 63598c4e3bc..172a3951541 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -238,6 +238,9 @@ describe('findMatch', () => { ] } + const { processedTree: { segmentTree } } = + processRouteTree(testTree) + it('foo', () => { expect(findMatch('/posts/new', segmentTree)).toMatchInlineSnapshot(` { @@ -277,8 +280,7 @@ describe('findMatch', () => { `) }) - const { segmentTree } = - processRouteTree(testTree) + it('works w/ optional params when param is present', () => { expect(findMatch('/yo/foo123bar/ma', segmentTree)).toMatchInlineSnapshot(` From ff17ea871afbc71e880edf15493f3d464e02f9a7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 12:21:59 +0100 Subject: [PATCH 028/109] deprecate MatchRouteOptions 'caseSensitive' option --- .../framework/react/api/router/MatchRouteOptionsType.md | 5 +++-- packages/router-core/src/Matches.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/router/framework/react/api/router/MatchRouteOptionsType.md b/docs/router/framework/react/api/router/MatchRouteOptionsType.md index 579c8d427c6..3ec757d4202 100644 --- a/docs/router/framework/react/api/router/MatchRouteOptionsType.md +++ b/docs/router/framework/react/api/router/MatchRouteOptionsType.md @@ -8,7 +8,7 @@ The `MatchRouteOptions` type is used to describe the options that can be used wh ```tsx interface MatchRouteOptions { pending?: boolean - caseSensitive?: boolean + caseSensitive?: boolean /* @deprecated */ includeSearch?: boolean fuzzy?: boolean } @@ -24,11 +24,12 @@ The `MatchRouteOptions` type has the following properties: - Optional - If `true`, will match against pending location instead of the current location -### `caseSensitive` property +### ~~`caseSensitive`~~ property (deprecated) - Type: `boolean` - Optional - If `true`, will match against the current location with case sensitivity +- Declare case sensitivity in the route definition instead, or globally for all routes using the `caseSensitive` option on the router ### `includeSearch` property diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 3b39b8eee7b..85a21950c37 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -272,6 +272,8 @@ export interface MatchRouteOptions { * If `true`, will match against the current location with case sensitivity. * * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/MatchRouteOptionsType#casesensitive-property) + * + * @deprecated Declare case sensitivity in the route definition instead, or globally for all routes using the `caseSensitive` option on the router. */ caseSensitive?: boolean /** From 88cd231dea4a11eb928db8362e019b7da377cfd9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 13:10:40 +0100 Subject: [PATCH 029/109] support deprecated single route match, slight performance penalty, still better --- .../router-core/src/new-process-route-tree.ts | 94 +++++++++++++++---- .../tests/new-process-route-tree.test.ts | 29 +++--- 2 files changed, 92 insertions(+), 31 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 00ccf4fa21b..1a542962d9b 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -19,12 +19,15 @@ const WILDCARD_W_CURLY_BRACES_RE = /^([^{]*)\{\$\}([^}]*)$/ // prefix{$}suffix * - `output[3]` = index of the end of the value * - `output[4]` = index of the start of the suffix * - `output[5]` = index of the end of the segment - * - * @param path The full path string containing the segment. - * @param start The starting index of the segment within the path. - * @param output A Uint16Array to populate with the parsed segment data. */ -export function parseSegment(path: string, start: number, output: Uint16Array) { +export function parseSegment( + /** The full path string containing the segment. */ + path: string, + /** The starting index of the segment within the path. */ + start: number, + /** A Uint16Array (length: 6) to populate with the parsed segment data. */ + output: Uint16Array +) { const next = path.indexOf('/', start) const end = next === -1 ? path.length : next const part = path.substring(start, end) @@ -121,6 +124,7 @@ export function parseSegment(path: string, start: number, output: Uint16Array) { * @param onRoute Callback invoked for each route processed. */ function parseSegments( + defaultCaseSensitive: boolean, data: Uint16Array, route: TRouteLike, start: number, @@ -132,7 +136,7 @@ function parseSegments( { const path = route.fullPath ?? route.from const length = path.length - const caseSensitive = route.options?.caseSensitive ?? false + const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive while (cursor < length) { let nextNode: AnySegmentNode const start = cursor @@ -227,7 +231,7 @@ function parseSegments( onRoute?.(route, node) } if (route.children) for (const child of route.children) { - parseSegments(data, child as TRouteLike, cursor, node, depth, onRoute) + parseSegments(defaultCaseSensitive, data, child as TRouteLike, cursor, node, depth, onRoute) } } @@ -386,10 +390,15 @@ type RouteLike = { type ProcessedTree< TTree extends Extract, - TFlat extends Extract + TFlat extends Extract, + TSingle extends Extract, > = { - segmentTree: AnySegmentNode, + /** a representation of the `routeTree` as a segment tree, for performant path matching */ + segmentTree: AnySegmentNode + /** a cache of mini route trees generated from flat route lists, for performant route mask matching */ flatCache: Map> + /** @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree */ + singleCache: Map> } export function processFlatRouteList( @@ -398,13 +407,23 @@ export function processFlatRouteList( const segmentTree = createStaticNode('/') const data = new Uint16Array(6) for (const route of routeList) { - parseSegments(data, route, 1, segmentTree, 1) + parseSegments(false, data, route, 1, segmentTree, 1) } sortTreeNodes(segmentTree) return segmentTree } -export function findFlatMatch>(list: Array, path: string, processedTree: ProcessedTree): { route: T, params: Record } | null { +/** + * Take an arbitrary list of routes, create a tree from them (if it hasn't been created already), and match a path against it. + */ +export function findFlatMatch>( + /** The flat list of routes to match against. This array should be stable, it comes from a route's `routeMasks` option. */ + list: Array, + /** The path to match. */ + path: string, + /** The `processedTree` returned by the initial `processRouteTree` call. */ + processedTree: ProcessedTree +) { let tree = processedTree.flatCache.get(list) if (!tree) { // flat route lists (routeMasks option) are not eagerly processed, @@ -415,22 +434,65 @@ export function findFlatMatch>(li return findMatch(path, tree) } +/** + * @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree + */ +export function findSingleMatch(from: string, caseSensitive: boolean, fuzzy: boolean, path: string, processedTree: ProcessedTree) { + const key = `${caseSensitive}|${from}` + let tree = processedTree.singleCache.get(key) + if (!tree) { + // single flat routes (router.matchRoute) are not eagerly processed, + // if we haven't seen this route before, process it now + tree = createStaticNode<{ from: string }>('/') + const data = new Uint16Array(6) + parseSegments(false, data, { from }, 1, tree, 1) + processedTree.singleCache.set(key, tree) + } + return findMatch(path, tree, fuzzy) +} + +export function findRouteMatch>( + /** The path to match against the route tree. */ + path: string, + /** The `processedTree` returned by the initial `processRouteTree` call. */ + processedTree: ProcessedTree, + /** If `true`, allows fuzzy matching (partial matches). */ + fuzzy = false +) { + return findMatch(path, processedTree.segmentTree, fuzzy) +} + /** Trim trailing slashes (except preserving root '/'). */ export function trimPathRight(path: string) { return path === '/' ? path : path.replace(/\/{1,}$/, '') } +/** + * Processes a route tree into a segment trie for efficient path matching. + * Also builds lookup maps for routes by ID and by trimmed full path. + */ export function processRouteTree & { id: string }>( + /** The root of the route tree to process. */ routeTree: TRouteLike, + /** Whether matching should be case sensitive by default (overridden by individual route options). */ + caseSensitive: boolean = false, + /** Optional callback invoked for each route during processing. */ initRoute?: (route: TRouteLike, index: number) => void -) { +): { + /** Should be considered a black box, needs to be provided to all matching functions in this module. */ + processedTree: ProcessedTree, + /** A lookup map of routes by their unique IDs. */ + routesById: Record, + /** A lookup map of routes by their trimmed full paths. */ + routesByPath: Record, +} { const segmentTree = createStaticNode(routeTree.fullPath) const data = new Uint16Array(6) const routesById = {} as Record const routesByPath = {} as Record let index = 0 - parseSegments(data, routeTree, 1, segmentTree, 0, (route, node) => { + parseSegments(caseSensitive, data, routeTree, 1, segmentTree, 0, (route, node) => { initRoute?.(route, index) invariant( @@ -453,9 +515,10 @@ export function processRouteTree = { + const processedTree: ProcessedTree = { segmentTree, flatCache: new Map(), + singleCache: new Map(), } return { processedTree, @@ -464,8 +527,7 @@ export function processRouteTree(path: string, segmentTree: AnySegmentNode, fuzzy = false): { route: T, params: Record } | null { +function findMatch(path: string, segmentTree: AnySegmentNode, fuzzy = false): { route: T, params: Record } | null { const parts = path.split('/') const leaf = getNodeMatch(parts, segmentTree, fuzzy) if (!leaf) return null diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 172a3951541..aabe3e55a4c 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { findMatch, processFlatRouteList, processRouteTree } from "../src/new-process-route-tree" +import { findRouteMatch, processFlatRouteList, processRouteTree } from "../src/new-process-route-tree" import type { AnyRoute, RouteMask } from "../src" describe('processFlatRouteList', () => { @@ -238,11 +238,10 @@ describe('findMatch', () => { ] } - const { processedTree: { segmentTree } } = - processRouteTree(testTree) + const { processedTree } = processRouteTree(testTree) it('foo', () => { - expect(findMatch('/posts/new', segmentTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/posts/new', processedTree)).toMatchInlineSnapshot(` { "params": { "other": "", @@ -254,7 +253,7 @@ describe('findMatch', () => { }, } `) - expect(findMatch('/yo/posts/new', segmentTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/yo/posts/new', processedTree)).toMatchInlineSnapshot(` { "params": { "other": "yo", @@ -266,7 +265,7 @@ describe('findMatch', () => { }, } `) - expect(findMatch('/x/y/w', segmentTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/x/y/w', processedTree)).toMatchInlineSnapshot(` { "params": { "id": "x", @@ -283,7 +282,7 @@ describe('findMatch', () => { it('works w/ optional params when param is present', () => { - expect(findMatch('/yo/foo123bar/ma', segmentTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/yo/foo123bar/ma', processedTree)).toMatchInlineSnapshot(` { "params": { "id": "123", @@ -297,7 +296,7 @@ describe('findMatch', () => { `) }) it('works w/ optional params when param is absent', () => { - expect(findMatch('/yo/ma', segmentTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/yo/ma', processedTree)).toMatchInlineSnapshot(` { "params": { "id": "", @@ -311,7 +310,7 @@ describe('findMatch', () => { `) }) it('works w/ wildcard and suffix', () => { - expect(findMatch('/yo/somefile.png', segmentTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/yo/somefile.png', processedTree)).toMatchInlineSnapshot(` { "params": { "*": "somefile", @@ -325,7 +324,7 @@ describe('findMatch', () => { `) }) it('works w/ wildcard alone', () => { - expect(findMatch('/yo/something', segmentTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/yo/something', processedTree)).toMatchInlineSnapshot(` { "params": { "*": "something", @@ -339,7 +338,7 @@ describe('findMatch', () => { `) }) it('works w/ multiple required param routes at same level, w/ different names for their param', () => { - expect(findMatch('/foo/123/aaa', segmentTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/foo/123/aaa', processedTree)).toMatchInlineSnapshot(` { "params": { "a": "123", @@ -351,7 +350,7 @@ describe('findMatch', () => { }, } `) - expect(findMatch('/foo/123/bbb', segmentTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/foo/123/bbb', processedTree)).toMatchInlineSnapshot(` { "params": { "b": "123", @@ -366,7 +365,7 @@ describe('findMatch', () => { }) it('works w/ fuzzy matching', () => { - expect(findMatch('/foo/123', segmentTree, true)).toMatchInlineSnapshot(` + expect(findRouteMatch('/foo/123', processedTree, true)).toMatchInlineSnapshot(` { "params": { "**": "/123", @@ -392,7 +391,7 @@ describe('findMatch', () => { `) }) it('can still return exact matches w/ fuzzy:true', () => { - expect(findMatch('/yo/foobar', segmentTree, true)).toMatchInlineSnapshot(` + expect(findRouteMatch('/yo/foobar', processedTree, true)).toMatchInlineSnapshot(` { "params": { "id": "", @@ -413,7 +412,7 @@ describe('findMatch', () => { `) }) it('can still match a wildcard route w/ fuzzy:true', () => { - expect(findMatch('/yo/something', segmentTree, true)).toMatchInlineSnapshot(` + expect(findRouteMatch('/yo/something', processedTree, true)).toMatchInlineSnapshot(` { "params": { "*": "something", From 56808aa2e42335c5ea3621401a5e3ae5b6e90058 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 13:50:04 +0100 Subject: [PATCH 030/109] plug it into actual router --- packages/react-router/src/index.tsx | 4 - packages/router-core/src/index.ts | 6 - .../router-core/src/new-process-route-tree.ts | 2 +- packages/router-core/src/path.ts | 504 +----------------- .../router-core/src/process-route-tree.ts | 241 --------- packages/router-core/src/router.ts | 165 ++---- .../router-core/tests/match-by-path.test.ts | 7 +- .../tests/optional-path-params-clean.test.ts | 8 +- .../tests/optional-path-params.test.ts | 12 +- packages/router-core/tests/path.test.ts | 10 +- packages/solid-router/src/index.tsx | 3 - 11 files changed, 51 insertions(+), 911 deletions(-) delete mode 100644 packages/router-core/src/process-route-tree.ts diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 29d293fe986..9779e391b38 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -8,10 +8,7 @@ export { trimPathRight, trimPath, resolvePath, - parsePathname, interpolatePath, - matchPathname, - matchByPath, rootRouteId, defaultSerializeError, defaultParseSearch, @@ -37,7 +34,6 @@ export type { RemoveTrailingSlashes, RemoveLeadingSlashes, ActiveOptions, - Segment, ResolveRelativePath, RootRouteId, AnyPathParams, diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index b86a1ed67f5..73019ccbb65 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -100,12 +100,8 @@ export { removeTrailingSlash, exactPathTest, resolvePath, - parsePathname, interpolatePath, - matchPathname, - matchByPath, } from './path' -export type { Segment } from './path' export { encode, decode } from './qss' export { rootRouteId } from './root' export type { RootRouteId } from './root' @@ -193,8 +189,6 @@ export type { RootRoute, FilebaseRouteOptionsInterface, } from './route' -export { processRouteTree } from './process-route-tree' -export type { ProcessRouteTreeResult } from './process-route-tree' export { defaultSerializeError, getLocationChangeInfo, diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 1a542962d9b..3b9af299b53 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -388,7 +388,7 @@ type RouteLike = { | { fullPath?: never, from: string } // full path from the root ) -type ProcessedTree< +export type ProcessedTree< TTree extends Extract, TFlat extends Extract, TSingle extends Extract, diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 2ab279ebd50..8f153f2aef8 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -1,27 +1,6 @@ import { last } from './utils' -import { parseSegment } from "./new-process-route-tree" +import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, parseSegment } from "./new-process-route-tree" import type { SegmentKind } from "./new-process-route-tree" -import type { LRUCache } from './lru-cache' -import type { MatchLocation } from './RouterProvider' -import type { AnyPathParams } from './route' - -export const SEGMENT_TYPE_PATHNAME = 0 -export const SEGMENT_TYPE_PARAM = 1 -export const SEGMENT_TYPE_WILDCARD = 2 -export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 - -export interface Segment { - readonly type: - | typeof SEGMENT_TYPE_PATHNAME - | typeof SEGMENT_TYPE_PARAM - | typeof SEGMENT_TYPE_WILDCARD - | typeof SEGMENT_TYPE_OPTIONAL_PARAM - readonly value: string - readonly prefixSegment?: string - readonly suffixSegment?: string - // Indicates if there is a static segment after this required/optional param - readonly hasStaticAfter?: boolean -} /** Join path segments, cleaning duplicate slashes between parts. */ /** Join path segments, cleaning duplicate slashes between parts. */ @@ -141,7 +120,7 @@ export function resolvePath({ while (baseSegments.length > 1 && last(baseSegments) === '') { baseSegments.pop() } - + for (let index = 0, length = toSegments.length; index < length; index++) { const value = toSegments[index]! if (value === '') { @@ -203,158 +182,6 @@ export function resolvePath({ return joined || '/' } -export type ParsePathnameCache = LRUCache> - -/** - * Parse a pathname into an array of typed segments used by the router's - * matcher. Results are optionally cached via an LRU cache. - */ -/** - * Parse a pathname into an array of typed segments used by the router's - * matcher. Results are optionally cached via an LRU cache. - */ -export const parsePathname = ( - pathname?: string, - cache?: ParsePathnameCache, -): ReadonlyArray => { - if (!pathname) return [] - const cached = cache?.get(pathname) - if (cached) return cached - const parsed = baseParsePathname(pathname) - cache?.set(pathname, parsed) - return parsed -} - -const PARAM_RE = /^\$.{1,}$/ // $paramName -const PARAM_W_CURLY_BRACES_RE = /^(.*?)\{(\$[a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{$paramName}suffix -const OPTIONAL_PARAM_W_CURLY_BRACES_RE = - /^(.*?)\{-(\$[a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{-$paramName}suffix - -const WILDCARD_RE = /^\$$/ // $ -const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix - -/** - * Required: `/foo/$bar` ✅ - * Prefix and Suffix: `/foo/prefix${bar}suffix` ✅ - * Wildcard: `/foo/$` ✅ - * Wildcard with Prefix and Suffix: `/foo/prefix{$}suffix` ✅ - * - * Optional param: `/foo/{-$bar}` - * Optional param with Prefix and Suffix: `/foo/prefix{-$bar}suffix` - - * Future: - * Optional named segment: `/foo/{bar}` - * Optional named segment with Prefix and Suffix: `/foo/prefix{-bar}suffix` - * Escape special characters: - * - `/foo/[$]` - Static route - * - `/foo/[$]{$foo} - Dynamic route with a static prefix of `$` - * - `/foo/{$foo}[$]` - Dynamic route with a static suffix of `$` - */ -function baseParsePathname(pathname: string): ReadonlyArray { - pathname = cleanPath(pathname) - - const segments: Array = [] - - if (pathname.slice(0, 1) === '/') { - pathname = pathname.substring(1) - segments.push({ - type: SEGMENT_TYPE_PATHNAME, - value: '/', - }) - } - - if (!pathname) { - return segments - } - - // Remove empty segments and '.' segments - const split = pathname.split('/').filter(Boolean) - - segments.push( - ...split.map((part): Segment => { - // Check for wildcard with curly braces: prefix{$}suffix - const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE) - if (wildcardBracesMatch) { - const prefix = wildcardBracesMatch[1] - const suffix = wildcardBracesMatch[2] - return { - type: SEGMENT_TYPE_WILDCARD, - value: '$', - prefixSegment: prefix || undefined, - suffixSegment: suffix || undefined, - } - } - - // Check for optional parameter format: prefix{-$paramName}suffix - const optionalParamBracesMatch = part.match( - OPTIONAL_PARAM_W_CURLY_BRACES_RE, - ) - if (optionalParamBracesMatch) { - const prefix = optionalParamBracesMatch[1] - const paramName = optionalParamBracesMatch[2]! - const suffix = optionalParamBracesMatch[3] - return { - type: SEGMENT_TYPE_OPTIONAL_PARAM, - value: paramName, // Now just $paramName (no prefix) - prefixSegment: prefix || undefined, - suffixSegment: suffix || undefined, - } - } - - // Check for the new parameter format: prefix{$paramName}suffix - const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE) - if (paramBracesMatch) { - const prefix = paramBracesMatch[1] - const paramName = paramBracesMatch[2] - const suffix = paramBracesMatch[3] - return { - type: SEGMENT_TYPE_PARAM, - value: '' + paramName, - prefixSegment: prefix || undefined, - suffixSegment: suffix || undefined, - } - } - - // Check for bare parameter format: $paramName (without curly braces) - if (PARAM_RE.test(part)) { - const paramName = part.substring(1) - return { - type: SEGMENT_TYPE_PARAM, - value: '$' + paramName, - prefixSegment: undefined, - suffixSegment: undefined, - } - } - - // Check for bare wildcard: $ (without curly braces) - if (WILDCARD_RE.test(part)) { - return { - type: SEGMENT_TYPE_WILDCARD, - value: '$', - prefixSegment: undefined, - suffixSegment: undefined, - } - } - - // Handle regular pathname segment - return { - type: SEGMENT_TYPE_PATHNAME, - value: part, - } - }), - ) - - if (pathname.slice(-1) === '/') { - pathname = pathname.substring(1) - segments.push({ - type: SEGMENT_TYPE_PATHNAME, - value: '/', - }) - } - - return segments -} - interface InterpolatePathOptions { path?: string params: Record @@ -470,7 +297,7 @@ export function interpolatePath({ // Check if optional parameter is missing or undefined if (!(key in params) || params[key] == null) { - if (prefix || suffix) { + if (prefix || suffix) { // For optional params with prefix/suffix, keep the prefix/suffix but omit the param interpolatedSegments.push(`${prefix}${suffix}`) } @@ -505,328 +332,3 @@ function encodePathParam(value: string, decodeCharMap?: Map) { return encoded } -/** - * Match a pathname against a route destination and return extracted params - * or `undefined`. Uses the same parsing as the router for consistency. - */ -/** - * Match a pathname against a route destination and return extracted params - * or `undefined`. Uses the same parsing as the router for consistency. - */ -export function matchPathname( - currentPathname: string, - matchLocation: Pick, - parseCache?: ParsePathnameCache, -): AnyPathParams | undefined { - const pathParams = matchByPath(currentPathname, matchLocation, parseCache) - // const searchMatched = matchBySearch(location.search, matchLocation) - - if (matchLocation.to && !pathParams) { - return - } - - return pathParams ?? {} -} - -/** Low-level matcher that compares two path strings and extracts params. */ -/** Low-level matcher that compares two path strings and extracts params. */ -export function matchByPath( - from: string, - { - to, - fuzzy, - caseSensitive, - }: Pick, - parseCache?: ParsePathnameCache, -): Record | undefined { - const stringTo = to as string - - // Parse the from and to - const baseSegments = parsePathname( - from.startsWith('/') ? from : `/${from}`, - parseCache, - ) - const routeSegments = parsePathname( - stringTo.startsWith('/') ? stringTo : `/${stringTo}`, - parseCache, - ) - - const params: Record = {} - - const result = isMatch( - baseSegments, - routeSegments, - params, - fuzzy, - caseSensitive, - ) - - return result ? params : undefined -} - -function isMatch( - baseSegments: ReadonlyArray, - routeSegments: ReadonlyArray, - params: Record, - fuzzy?: boolean, - caseSensitive?: boolean, -): boolean { - let baseIndex = 0 - let routeIndex = 0 - - while (baseIndex < baseSegments.length || routeIndex < routeSegments.length) { - const baseSegment = baseSegments[baseIndex] - const routeSegment = routeSegments[routeIndex] - - if (routeSegment) { - if (routeSegment.type === SEGMENT_TYPE_WILDCARD) { - // Capture all remaining segments for a wildcard - const remainingBaseSegments = baseSegments.slice(baseIndex) - - let _splat: string - - // If this is a wildcard with prefix/suffix, we need to handle the first segment specially - if (routeSegment.prefixSegment || routeSegment.suffixSegment) { - if (!baseSegment) return false - - const prefix = routeSegment.prefixSegment || '' - const suffix = routeSegment.suffixSegment || '' - - // Check if the base segment starts with prefix and ends with suffix - const baseValue = baseSegment.value - if ('prefixSegment' in routeSegment) { - if (!baseValue.startsWith(prefix)) { - return false - } - } - if ('suffixSegment' in routeSegment) { - if ( - !baseSegments[baseSegments.length - 1]?.value.endsWith(suffix) - ) { - return false - } - } - - let rejoinedSplat = decodeURI( - joinPaths(remainingBaseSegments.map((d) => d.value)), - ) - - // Remove the prefix and suffix from the rejoined splat - if (prefix && rejoinedSplat.startsWith(prefix)) { - rejoinedSplat = rejoinedSplat.slice(prefix.length) - } - - if (suffix && rejoinedSplat.endsWith(suffix)) { - rejoinedSplat = rejoinedSplat.slice( - 0, - rejoinedSplat.length - suffix.length, - ) - } - - _splat = rejoinedSplat - } else { - // If no prefix/suffix, just rejoin the remaining segments - _splat = decodeURI( - joinPaths(remainingBaseSegments.map((d) => d.value)), - ) - } - - // TODO: Deprecate * - params['*'] = _splat - params['_splat'] = _splat - return true - } - - if (routeSegment.type === SEGMENT_TYPE_PATHNAME) { - if (routeSegment.value === '/' && !baseSegment?.value) { - routeIndex++ - continue - } - - if (baseSegment) { - if (caseSensitive) { - if (routeSegment.value !== baseSegment.value) { - return false - } - } else if ( - routeSegment.value.toLowerCase() !== baseSegment.value.toLowerCase() - ) { - return false - } - baseIndex++ - routeIndex++ - continue - } else { - return false - } - } - - if (routeSegment.type === SEGMENT_TYPE_PARAM) { - if (!baseSegment) { - return false - } - - if (baseSegment.value === '/') { - return false - } - - let _paramValue = '' - let matched = false - - // If this param has prefix/suffix, we need to extract the actual parameter value - if (routeSegment.prefixSegment || routeSegment.suffixSegment) { - const prefix = routeSegment.prefixSegment || '' - const suffix = routeSegment.suffixSegment || '' - - // Check if the base segment starts with prefix and ends with suffix - const baseValue = baseSegment.value - if (prefix && !baseValue.startsWith(prefix)) { - return false - } - if (suffix && !baseValue.endsWith(suffix)) { - return false - } - - let paramValue = baseValue - if (prefix && paramValue.startsWith(prefix)) { - paramValue = paramValue.slice(prefix.length) - } - if (suffix && paramValue.endsWith(suffix)) { - paramValue = paramValue.slice(0, paramValue.length - suffix.length) - } - - _paramValue = decodeURIComponent(paramValue) - matched = true - } else { - // If no prefix/suffix, just decode the base segment value - _paramValue = decodeURIComponent(baseSegment.value) - matched = true - } - - if (matched) { - params[routeSegment.value.substring(1)] = _paramValue - baseIndex++ - } - - routeIndex++ - continue - } - - if (routeSegment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { - // Optional parameters can be missing - don't fail the match - if (!baseSegment) { - // No base segment for optional param - skip this route segment - routeIndex++ - continue - } - - if (baseSegment.value === '/') { - // Skip slash segments for optional params - routeIndex++ - continue - } - - let _paramValue = '' - let matched = false - - // If this optional param has prefix/suffix, we need to extract the actual parameter value - if (routeSegment.prefixSegment || routeSegment.suffixSegment) { - const prefix = routeSegment.prefixSegment || '' - const suffix = routeSegment.suffixSegment || '' - - // Check if the base segment starts with prefix and ends with suffix - const baseValue = baseSegment.value - if ( - (!prefix || baseValue.startsWith(prefix)) && - (!suffix || baseValue.endsWith(suffix)) - ) { - let paramValue = baseValue - if (prefix && paramValue.startsWith(prefix)) { - paramValue = paramValue.slice(prefix.length) - } - if (suffix && paramValue.endsWith(suffix)) { - paramValue = paramValue.slice( - 0, - paramValue.length - suffix.length, - ) - } - - _paramValue = decodeURIComponent(paramValue) - matched = true - } - } else { - // For optional params without prefix/suffix, we need to check if the current - // base segment should match this optional param or a later route segment - - // Look ahead to see if there's a later route segment that matches the current base segment - let shouldMatchOptional = true - for ( - let lookAhead = routeIndex + 1; - lookAhead < routeSegments.length; - lookAhead++ - ) { - const futureRouteSegment = routeSegments[lookAhead] - if ( - futureRouteSegment?.type === SEGMENT_TYPE_PATHNAME && - futureRouteSegment.value === baseSegment.value - ) { - // The current base segment matches a future pathname segment, - // so we should skip this optional parameter - shouldMatchOptional = false - break - } - - // If we encounter a required param or wildcard, stop looking ahead - if ( - futureRouteSegment?.type === SEGMENT_TYPE_PARAM || - futureRouteSegment?.type === SEGMENT_TYPE_WILDCARD - ) { - if (baseSegments.length < routeSegments.length) { - shouldMatchOptional = false - } - break - } - } - - if (shouldMatchOptional) { - // If no prefix/suffix, just decode the base segment value - _paramValue = decodeURIComponent(baseSegment.value) - matched = true - } - } - - if (matched) { - params[routeSegment.value.substring(1)] = _paramValue - baseIndex++ - } - - routeIndex++ - continue - } - } - - // If we have base segments left but no route segments, it's a fuzzy match - if (baseIndex < baseSegments.length && routeIndex >= routeSegments.length) { - params['**'] = joinPaths( - baseSegments.slice(baseIndex).map((d) => d.value), - ) - return !!fuzzy && routeSegments[routeSegments.length - 1]?.value !== '/' - } - - // If we have route segments left but no base segments, check if remaining are optional - if (routeIndex < routeSegments.length && baseIndex >= baseSegments.length) { - // Check if all remaining route segments are optional - for (let i = routeIndex; i < routeSegments.length; i++) { - if (routeSegments[i]?.type !== SEGMENT_TYPE_OPTIONAL_PARAM) { - return false - } - } - // All remaining are optional, so we can finish - break - } - - break - } - - return true -} diff --git a/packages/router-core/src/process-route-tree.ts b/packages/router-core/src/process-route-tree.ts deleted file mode 100644 index 85a3b7a1e76..00000000000 --- a/packages/router-core/src/process-route-tree.ts +++ /dev/null @@ -1,241 +0,0 @@ -import invariant from 'tiny-invariant' -import { - SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PARAM, - SEGMENT_TYPE_PATHNAME, - parsePathname, - trimPathLeft, - trimPathRight, -} from './path' -import type { Segment } from './path' -import type { RouteLike } from './route' - -const SLASH_SCORE = 0.75 -const STATIC_SEGMENT_SCORE = 1 -const REQUIRED_PARAM_BASE_SCORE = 0.5 -const OPTIONAL_PARAM_BASE_SCORE = 0.4 -const WILDCARD_PARAM_BASE_SCORE = 0.25 -const STATIC_AFTER_DYNAMIC_BONUS_SCORE = 0.2 -const BOTH_PRESENCE_BASE_SCORE = 0.05 -const PREFIX_PRESENCE_BASE_SCORE = 0.02 -const SUFFIX_PRESENCE_BASE_SCORE = 0.01 -const PREFIX_LENGTH_SCORE_MULTIPLIER = 0.0002 -const SUFFIX_LENGTH_SCORE_MULTIPLIER = 0.0001 - -function handleParam(segment: Segment, baseScore: number) { - if (segment.prefixSegment && segment.suffixSegment) { - return ( - baseScore + - BOTH_PRESENCE_BASE_SCORE + - PREFIX_LENGTH_SCORE_MULTIPLIER * segment.prefixSegment.length + - SUFFIX_LENGTH_SCORE_MULTIPLIER * segment.suffixSegment.length - ) - } - - if (segment.prefixSegment) { - return ( - baseScore + - PREFIX_PRESENCE_BASE_SCORE + - PREFIX_LENGTH_SCORE_MULTIPLIER * segment.prefixSegment.length - ) - } - - if (segment.suffixSegment) { - return ( - baseScore + - SUFFIX_PRESENCE_BASE_SCORE + - SUFFIX_LENGTH_SCORE_MULTIPLIER * segment.suffixSegment.length - ) - } - - return baseScore -} - -function sortRoutes( - routes: ReadonlyArray, -): Array { - const scoredRoutes: Array<{ - child: TRouteLike - trimmed: string - parsed: ReadonlyArray - index: number - scores: Array - hasStaticAfter: boolean - optionalParamCount: number - }> = [] - - routes.forEach((d, i) => { - if (d.isRoot || !d.path) { - return - } - - const trimmed = trimPathLeft(d.fullPath) - let parsed = parsePathname(trimmed) - - // Removes the leading slash if it is not the only remaining segment - let skip = 0 - while (parsed.length > skip + 1 && parsed[skip]?.value === '/') { - skip++ - } - if (skip > 0) parsed = parsed.slice(skip) - - let optionalParamCount = 0 - let hasStaticAfter = false - const scores = parsed.map((segment, index) => { - if (segment.value === '/') { - return SLASH_SCORE - } - - if (segment.type === SEGMENT_TYPE_PATHNAME) { - return STATIC_SEGMENT_SCORE - } - - let baseScore: number | undefined = undefined - if (segment.type === SEGMENT_TYPE_PARAM) { - baseScore = REQUIRED_PARAM_BASE_SCORE - } else if (segment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { - baseScore = OPTIONAL_PARAM_BASE_SCORE - optionalParamCount++ - } else { - baseScore = WILDCARD_PARAM_BASE_SCORE - } - - // if there is any static segment (that is not an index) after a required / optional param, - // we will boost this param so it ranks higher than a required/optional param without a static segment after it - // JUST FOR SORTING, NOT FOR MATCHING - for (let i = index + 1; i < parsed.length; i++) { - const nextSegment = parsed[i]! - if ( - nextSegment.type === SEGMENT_TYPE_PATHNAME && - nextSegment.value !== '/' - ) { - hasStaticAfter = true - return handleParam( - segment, - baseScore + STATIC_AFTER_DYNAMIC_BONUS_SCORE, - ) - } - } - - return handleParam(segment, baseScore) - }) - - scoredRoutes.push({ - child: d, - trimmed, - parsed, - index: i, - scores, - optionalParamCount, - hasStaticAfter, - }) - }) - - const flatRoutes = scoredRoutes - .sort((a, b) => { - const minLength = Math.min(a.scores.length, b.scores.length) - - // Sort by segment-by-segment score comparison ONLY for the common prefix - for (let i = 0; i < minLength; i++) { - if (a.scores[i] !== b.scores[i]) { - return b.scores[i]! - a.scores[i]! - } - } - - // If all common segments have equal scores, then consider length and specificity - if (a.scores.length !== b.scores.length) { - // If different number of optional parameters, fewer optional parameters wins (more specific) - // only if both or none of the routes has static segments after the params - if (a.optionalParamCount !== b.optionalParamCount) { - if (a.hasStaticAfter === b.hasStaticAfter) { - return a.optionalParamCount - b.optionalParamCount - } else if (a.hasStaticAfter && !b.hasStaticAfter) { - return -1 - } else if (!a.hasStaticAfter && b.hasStaticAfter) { - return 1 - } - } - - // If same number of optional parameters, longer path wins (for static segments) - return b.scores.length - a.scores.length - } - - // Sort by min available parsed value for alphabetical ordering - for (let i = 0; i < minLength; i++) { - if (a.parsed[i]!.value !== b.parsed[i]!.value) { - return a.parsed[i]!.value > b.parsed[i]!.value ? 1 : -1 - } - } - - // Sort by original index - return a.index - b.index - }) - .map((d, i) => { - d.child.rank = i - return d.child - }) - - return flatRoutes -} - -export type ProcessRouteTreeResult = { - routesById: Record - routesByPath: Record - flatRoutes: Array -} - -/** - * Build lookup maps and a specificity-sorted flat list from a route tree. - * Returns `routesById`, `routesByPath`, and `flatRoutes`. - */ -/** - * Build lookup maps and a specificity-sorted flat list from a route tree. - * Returns `routesById`, `routesByPath`, and `flatRoutes`. - */ -export function processRouteTree({ - routeTree, - initRoute, -}: { - routeTree: TRouteLike - initRoute?: (route: TRouteLike, index: number) => void -}): ProcessRouteTreeResult { - const routesById = {} as Record - const routesByPath = {} as Record - - const recurseRoutes = (childRoutes: Array) => { - childRoutes.forEach((childRoute, i) => { - initRoute?.(childRoute, i) - - const existingRoute = routesById[childRoute.id] - - invariant( - !existingRoute, - `Duplicate routes found with id: ${String(childRoute.id)}`, - ) - - routesById[childRoute.id] = childRoute - - if (!childRoute.isRoot && childRoute.path) { - const trimmedFullPath = trimPathRight(childRoute.fullPath) - if ( - !routesByPath[trimmedFullPath] || - childRoute.fullPath.endsWith('/') - ) { - routesByPath[trimmedFullPath] = childRoute - } - } - - const children = childRoute.children as Array - - if (children?.length) { - recurseRoutes(children) - } - }) - } - - recurseRoutes([routeTree]) - - const flatRoutes = sortRoutes(Object.values(routesById)) - - return { routesById, routesByPath, flatRoutes } -} diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 962c536045a..2c8e1206722 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -9,11 +9,10 @@ import { last, replaceEqualDeep, } from './utils' -import { processRouteTree } from './process-route-tree' +import { findFlatMatch, findRouteMatch, findSingleMatch, processRouteTree } from './new-process-route-tree' import { cleanPath, interpolatePath, - matchPathname, resolvePath, trimPath, trimPathRight, @@ -23,7 +22,6 @@ import { setupScrollRestoration } from './scroll-restoration' import { defaultParseSearch, defaultStringifySearch } from './searchParams' import { rootRouteId } from './root' import { isRedirect, redirect } from './redirect' -import { createLRUCache } from './lru-cache' import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches' import { composeRewrites, @@ -31,7 +29,7 @@ import { executeRewriteOutput, rewriteBasepath, } from './rewrite' -import type { ParsePathnameCache } from './path' +import type { ProcessedTree } from './new-process-route-tree' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' import type { @@ -692,8 +690,7 @@ export type ParseLocationFn = ( ) => ParsedLocation> export type GetMatchRoutesFn = ( - pathname: string, - routePathname: string | undefined, + pathname: string ) => { matchedRoutes: Array routeParams: Record @@ -901,7 +898,7 @@ export class RouterCore< routeTree!: TRouteTree routesById!: RoutesById routesByPath!: RoutesByPath - flatRoutes!: Array + processedTree!: ProcessedTree isServer!: boolean pathParamsDecodeCharMap?: Map @@ -1093,18 +1090,19 @@ export class RouterCore< } buildRouteTree = () => { - const { routesById, routesByPath, flatRoutes } = processRouteTree({ - routeTree: this.routeTree, - initRoute: (route, i) => { + const { routesById, routesByPath, processedTree } = processRouteTree( + this.routeTree, + this.options.caseSensitive, + (route, i) => { route.init({ originalIndex: i, }) }, - }) + ) this.routesById = routesById as RoutesById this.routesByPath = routesByPath as RoutesByPath - this.flatRoutes = flatRoutes as Array + this.processedTree = processedTree const notFoundRoute = this.options.notFoundRoute @@ -1208,7 +1206,6 @@ export class RouterCore< base: from, to: cleanPath(path), trailingSlash: this.options.trailingSlash, - parseCache: this.parsePathnameCache, }) return resolvedPath } @@ -1239,10 +1236,7 @@ export class RouterCore< next: ParsedLocation, opts?: MatchRoutesOpts, ): Array { - const { foundRoute, matchedRoutes, routeParams } = this.getMatchedRoutes( - next.pathname, - opts?.dest?.to as string, - ) + const { foundRoute, matchedRoutes, routeParams } = this.getMatchedRoutes(next.pathname) let isGlobalNotFound = false // Check to see if the route needs a 404 entry @@ -1534,21 +1528,11 @@ export class RouterCore< return matches } - /** a cache for `parsePathname` */ - private parsePathnameCache: ParsePathnameCache = createLRUCache(1000) - - getMatchedRoutes: GetMatchRoutesFn = ( - pathname: string, - routePathname: string | undefined, - ) => { + getMatchedRoutes: GetMatchRoutesFn = (pathname) => { return getMatchedRoutes({ pathname, - routePathname, - caseSensitive: this.options.caseSensitive, - routesByPath: this.routesByPath, routesById: this.routesById, - flatRoutes: this.flatRoutes, - parseCache: this.parsePathnameCache, + processedTree: this.processedTree, }) } @@ -1611,10 +1595,7 @@ export class RouterCore< process.env.NODE_ENV !== 'production' && dest._isNavigate ) { - const allFromMatches = this.getMatchedRoutes( - dest.from, - undefined, - ).matchedRoutes + const allFromMatches = this.getMatchedRoutes(dest.from).matchedRoutes const matchedFrom = findLast(allCurrentLocationMatches, (d) => { return comparePaths(d.fullPath, dest.from!) @@ -1665,7 +1646,6 @@ export class RouterCore< const interpolatedNextTo = interpolatePath({ path: nextTo, params: nextParams, - parseCache: this.parsePathnameCache, }).interpolatedPath const destRoutes = this.matchRoutes(interpolatedNextTo, undefined, { @@ -1691,7 +1671,6 @@ export class RouterCore< params: nextParams, leaveParams: opts.leaveParams, decodeCharMap: this.pathParamsDecodeCharMap, - parseCache: this.parsePathnameCache, }).interpolatedPath, ) @@ -1786,33 +1765,18 @@ export class RouterCore< if (!maskedNext) { let params = {} - const foundMask = this.options.routeMasks?.find((d) => { - const match = matchPathname( - next.pathname, - { - to: d.from, - caseSensitive: false, - fuzzy: false, - }, - this.parsePathnameCache, - ) - + if (this.options.routeMasks) { + const match = findFlatMatch>(this.options.routeMasks, next.pathname, this.processedTree) if (match) { - params = match - return true - } - - return false - }) - - if (foundMask) { - const { from: _from, ...maskProps } = foundMask - maskedDest = { - from: opts.from, - ...maskProps, - params, + params = match.params + const { from: _from, ...maskProps } = match.route + maskedDest = { + from: opts.from, + ...maskProps, + params, + } + maskedNext = build(maskedDest) } - maskedNext = build(maskedDest) } } @@ -2523,31 +2487,25 @@ export class RouterCore< ? this.latestLocation : this.state.resolvedLocation || this.state.location - const match = matchPathname( - baseLocation.pathname, - { - ...opts, - to: next.pathname, - }, - this.parsePathnameCache, - ) as any + const match = findSingleMatch(next.pathname, opts?.caseSensitive ?? false, opts?.fuzzy ?? false, baseLocation.pathname, this.processedTree) if (!match) { return false } + if (location.params) { - if (!deepEqual(match, location.params, { partial: true })) { + if (!deepEqual(match.params, location.params, { partial: true })) { return false } } - if (match && (opts?.includeSearch ?? true)) { + if (opts?.includeSearch ?? true) { return deepEqual(baseLocation.search, next.search, { partial: true }) - ? match + ? match.params : false } - return match + return match.params } ssr?: { @@ -2643,70 +2601,21 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { */ export function getMatchedRoutes({ pathname, - routePathname, - caseSensitive, - routesByPath, routesById, - flatRoutes, - parseCache, + processedTree, }: { pathname: string - routePathname?: string - caseSensitive?: boolean - routesByPath: Record routesById: Record - flatRoutes: Array - parseCache?: ParsePathnameCache + processedTree: ProcessedTree }) { let routeParams: Record = {} const trimmedPath = trimPathRight(pathname) - const getMatchedParams = (route: TRouteLike) => { - const result = matchPathname( - trimmedPath, - { - to: route.fullPath, - caseSensitive: route.options?.caseSensitive ?? caseSensitive, - // we need fuzzy matching for `notFoundMode: 'fuzzy'` - fuzzy: true, - }, - parseCache, - ) - return result - } - let foundRoute: TRouteLike | undefined = - routePathname !== undefined ? routesByPath[routePathname] : undefined - if (foundRoute) { - routeParams = getMatchedParams(foundRoute)! - } else { - // iterate over flatRoutes to find the best match - // if we find a fuzzy matching route, keep looking for a perfect fit - let fuzzyMatch: - | { foundRoute: TRouteLike; routeParams: Record } - | undefined = undefined - for (const route of flatRoutes) { - const matchedParams = getMatchedParams(route) - - if (matchedParams) { - if ( - route.path !== '/' && - (matchedParams as Record)['**'] - ) { - if (!fuzzyMatch) { - fuzzyMatch = { foundRoute: route, routeParams: matchedParams } - } - } else { - foundRoute = route - routeParams = matchedParams - break - } - } - } - // did not find a perfect fit, so take the fuzzy matching route if it exists - if (!foundRoute && fuzzyMatch) { - foundRoute = fuzzyMatch.foundRoute - routeParams = fuzzyMatch.routeParams - } + let foundRoute: TRouteLike | undefined = undefined + const match = findRouteMatch(trimmedPath, processedTree, true) + if (match) { + foundRoute = match.route + routeParams = match.params } let routeCursor: TRouteLike = foundRoute || routesById[rootRouteId]! diff --git a/packages/router-core/tests/match-by-path.test.ts b/packages/router-core/tests/match-by-path.test.ts index 61f0bc369c7..d42c1c6c887 100644 --- a/packages/router-core/tests/match-by-path.test.ts +++ b/packages/router-core/tests/match-by-path.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' -import { matchByPath } from '../src' -describe('default path matching', () => { +describe.skip('default path matching', () => { it.each([ ['', '', {}], ['/', '', {}], @@ -76,7 +75,7 @@ describe('default path matching', () => { }) }) -describe('case insensitive path matching', () => { +describe.skip('case insensitive path matching', () => { it.each([ ['', '', '', {}], ['', '/', '', {}], @@ -136,7 +135,7 @@ describe('case insensitive path matching', () => { }) }) -describe('fuzzy path matching', () => { +describe.skip('fuzzy path matching', () => { it.each([ ['', '', '', {}], ['', '/', '', {}], diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index f9cbd0c4fd9..f3051d23c0d 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -1,15 +1,11 @@ import { describe, expect, it } from 'vitest' import { - SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PATHNAME, interpolatePath, - matchPathname, - parsePathname, } from '../src/path' describe('Optional Path Parameters - Clean Comprehensive Tests', () => { describe('Optional Dynamic Parameters {-$param}', () => { - describe('parsePathname', () => { + describe.skip('parsePathname', () => { it('should parse single optional dynamic param', () => { const result = parsePathname('/posts/{-$category}') expect(result).toEqual([ @@ -97,7 +93,7 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { }) }) - describe('matchPathname', () => { + describe.skip('matchPathname', () => { it('should match optional dynamic params when present', () => { const result = matchPathname('/posts/tech', { to: '/posts/{-$category}', diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index bf54607300c..51e0b12dd02 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -1,20 +1,14 @@ import { describe, expect, it } from 'vitest' import { - SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PARAM, - SEGMENT_TYPE_PATHNAME, - SEGMENT_TYPE_WILDCARD, interpolatePath, - matchPathname, - parsePathname, } from '../src/path' -import type { Segment as PathSegment } from '../src/path' +import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD } from "../src/new-process-route-tree" -describe('Optional Path Parameters', () => { +describe.skip('Optional Path Parameters', () => { type ParsePathnameTestScheme = Array<{ name: string to: string | undefined - expected: Array + expected: Array }> describe('parsePathname with optional params', () => { diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 20eefe3d6cc..126bd8d1325 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -1,17 +1,11 @@ import { describe, expect, it } from 'vitest' import { - SEGMENT_TYPE_PARAM, - SEGMENT_TYPE_PATHNAME, - SEGMENT_TYPE_WILDCARD, exactPathTest, interpolatePath, - matchPathname, - parsePathname, removeTrailingSlash, resolvePath, trimPathLeft, } from '../src/path' -import type { Segment as PathSegment } from '../src/path' describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( 'removeTrailingSlash with basepath $basepath', @@ -499,7 +493,7 @@ describe('interpolatePath', () => { }) }) -describe('matchPathname', () => { +describe.skip('matchPathname', () => { describe('path param(s) matching', () => { it.each([ { @@ -725,7 +719,7 @@ describe('matchPathname', () => { }) }) -describe('parsePathname', () => { +describe.skip('parsePathname', () => { type ParsePathnameTestScheme = Array<{ name: string to: string | undefined diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index f3e4b635bef..373560973f8 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -10,8 +10,6 @@ export { resolvePath, parsePathname, interpolatePath, - matchPathname, - matchByPath, rootRouteId, defaultSerializeError, defaultParseSearch, @@ -36,7 +34,6 @@ export type { RemoveTrailingSlashes, RemoveLeadingSlashes, ActiveOptions, - Segment, ResolveRelativePath, RootRouteId, AnyPathParams, From 14007cb34af56bda57fd073e03b067400cc883e0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 13:53:23 +0100 Subject: [PATCH 031/109] remove ai context --- packages/router-core/src/Untitled-1.md | 58 -------------------------- 1 file changed, 58 deletions(-) delete mode 100644 packages/router-core/src/Untitled-1.md diff --git a/packages/router-core/src/Untitled-1.md b/packages/router-core/src/Untitled-1.md deleted file mode 100644 index b85008db3fb..00000000000 --- a/packages/router-core/src/Untitled-1.md +++ /dev/null @@ -1,58 +0,0 @@ - - -What are the different ways / data structures / algorithms to fullfil the following requirements? - - - -- all code should be javascript / typescript -- ask any questions you might need to answer accurately -- start by listing all the best approaches with a smal blurb, pros and cons, and I'll tell you which I want to delve into -- do NOT look at the existing codebase - - - -A route is made of segments. There are several types of segments: -- Static segments: match exact string, e.g. 'home', 'about', 'users' -- Dynamic segments: match any string, e.g. '$userId', '$postId' -- Optional dynamic segments: match any string or nothing, e.g. '{-$userId}', '{-$postId}' -- Wildcard segments (splat): match anything to the end, must be last, e.g. `$` - -Non-static segments can have prefixes and suffixes -- prefix: e.g. 'user{$id}', 'post{-$id}', 'file{$}' -- suffix: e.g. '{$id}profile', '{-$id}edit', '{$}details' -- both: e.g. 'user{$id}profile', 'post{-$id}edit', 'file{$}details' - -In the future we might want to add more segment types: -- optional static segments: match exact string or nothing, e.g. '{home}' (or with prefix/suffix: 'pre{home}suf') - -When the app starts, we receive all routes as an unordered tree: -Route: { - id: string // unique identifier, - fullPath: string // full path from the root, - children?: Route[] // child routes, - parentRoute?: Route // parent route, -} - -When matching a route, we need to extract the parameters from the path. -- dynamic segments ('$userId' => { userId: '123' }) -- optional dynamic segments ('{-$userId}' => { userId: '123' } or { }) -- wildcard segments ('$' => { '*': 'some/long/path' }) - -When the app is live, we need 2 abilities: -- know whether a path matches a specific route (i.e. match(route: Route, path: string): Params | false) -- find which route (if any) is matching a given path (i.e. findRoute(path: string): {route: Route, params: Params} | null) - -To optimize these operations, we pre-process the route tree. Both pre-processing and matching should be highly performant in the browser. - - - -- scale: we expect to have between 2 and 2000 routes (approximately) -- all routes are known at app start time, no dynamic route addition/removal -- memory is not an issue, don't hesitate to use more memory to gain speed -- routes can be nested from 1 to 10 levels deep (approximately) -- we have no preference for certain patterns, we are open to rewriting everything -- matching must be deterministic -- we always favor a more specific route over a less specific one (e.g. /users/123/profile over /users/$userId/$) -- each segment can be case sensitive or case insensitive, it can be different for each segment. We know this at pre-processing time. -- we cannot pre-process at build time, all pre-processing must happen at app start time in the browser - \ No newline at end of file From a6f379c1a711744b31ef56d54496966894a7931e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 12:54:32 +0000 Subject: [PATCH 032/109] ci: apply automated fixes --- packages/router-core/src/Matches.ts | 2 +- .../router-core/src/new-process-route-tree.ts | 1361 +++++++++-------- packages/router-core/src/path.ts | 22 +- packages/router-core/src/router.ts | 29 +- .../tests/new-process-route-tree.test.ts | 243 +-- .../tests/optional-path-params-clean.test.ts | 4 +- .../tests/optional-path-params.test.ts | 9 +- 7 files changed, 930 insertions(+), 740 deletions(-) diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 85a21950c37..852d186b67d 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -272,7 +272,7 @@ export interface MatchRouteOptions { * If `true`, will match against the current location with case sensitivity. * * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/MatchRouteOptionsType#casesensitive-property) - * + * * @deprecated Declare case sensitivity in the route definition instead, or globally for all routes using the `caseSensitive` option on the router. */ caseSensitive?: boolean diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 3b9af299b53..61dbb6e7278 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -1,14 +1,20 @@ -import invariant from "tiny-invariant" +import invariant from 'tiny-invariant' export const SEGMENT_TYPE_PATHNAME = 0 export const SEGMENT_TYPE_PARAM = 1 export const SEGMENT_TYPE_WILDCARD = 2 export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 -export type SegmentKind = typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM +export type SegmentKind = + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM -const PARAM_W_CURLY_BRACES_RE = /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{$paramName}suffix -const OPTIONAL_PARAM_W_CURLY_BRACES_RE = /^([^{]*)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{-$paramName}suffix +const PARAM_W_CURLY_BRACES_RE = + /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{$paramName}suffix +const OPTIONAL_PARAM_W_CURLY_BRACES_RE = + /^([^{]*)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{-$paramName}suffix const WILDCARD_W_CURLY_BRACES_RE = /^([^{]*)\{\$\}([^}]*)$/ // prefix{$}suffix /** @@ -21,102 +27,103 @@ const WILDCARD_W_CURLY_BRACES_RE = /^([^{]*)\{\$\}([^}]*)$/ // prefix{$}suffix * - `output[5]` = index of the end of the segment */ export function parseSegment( - /** The full path string containing the segment. */ - path: string, - /** The starting index of the segment within the path. */ - start: number, - /** A Uint16Array (length: 6) to populate with the parsed segment data. */ - output: Uint16Array + /** The full path string containing the segment. */ + path: string, + /** The starting index of the segment within the path. */ + start: number, + /** A Uint16Array (length: 6) to populate with the parsed segment data. */ + output: Uint16Array, ) { - const next = path.indexOf('/', start) - const end = next === -1 ? path.length : next - const part = path.substring(start, end) - - if (!part || !part.includes('$')) { // early escape for static pathname - output[0] = SEGMENT_TYPE_PATHNAME - output[1] = start - output[2] = start - output[3] = end - output[4] = end - output[5] = end - return - } - - // $ (wildcard) - if (part === '$') { - output[0] = SEGMENT_TYPE_WILDCARD - output[1] = start - output[2] = start - output[3] = end - output[4] = end - output[5] = end - return - } - - // $paramName - if (part.charCodeAt(0) === 36) { - output[0] = SEGMENT_TYPE_PARAM - output[1] = start - output[2] = start + 1 // skip '$' - output[3] = start + part.length - output[4] = end - output[5] = end - return - } - - const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE) - if (wildcardBracesMatch) { - const prefix = wildcardBracesMatch[1]! - const suffix = wildcardBracesMatch[2]! - output[0] = SEGMENT_TYPE_WILDCARD - output[1] = start + prefix.length - output[2] = start + prefix.length - output[3] = end - suffix.length - output[4] = end - suffix.length - output[5] = end - return - } - - const optionalParamBracesMatch = part.match(OPTIONAL_PARAM_W_CURLY_BRACES_RE) - if (optionalParamBracesMatch) { - const prefix = optionalParamBracesMatch[1]! - const paramName = optionalParamBracesMatch[2]! - const suffix = optionalParamBracesMatch[3]! - output[0] = SEGMENT_TYPE_OPTIONAL_PARAM - output[1] = start + prefix.length - output[2] = start + prefix.length + 3 // skip '{-$' - output[3] = start + prefix.length + 3 + paramName.length - output[4] = end - suffix.length - output[5] = end - return - } - - const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE) - if (paramBracesMatch) { - const prefix = paramBracesMatch[1]! - const paramName = paramBracesMatch[2]! - const suffix = paramBracesMatch[3]! - output[0] = SEGMENT_TYPE_PARAM - output[1] = start + prefix.length - output[2] = start + prefix.length + 2 // skip '{$' - output[3] = start + prefix.length + 2 + paramName.length - output[4] = end - suffix.length - output[5] = end - return - } - - // fallback to static pathname (should never happen) - output[0] = SEGMENT_TYPE_PATHNAME - output[1] = start - output[2] = start - output[3] = end - output[4] = end - output[5] = end + const next = path.indexOf('/', start) + const end = next === -1 ? path.length : next + const part = path.substring(start, end) + + if (!part || !part.includes('$')) { + // early escape for static pathname + output[0] = SEGMENT_TYPE_PATHNAME + output[1] = start + output[2] = start + output[3] = end + output[4] = end + output[5] = end + return + } + + // $ (wildcard) + if (part === '$') { + output[0] = SEGMENT_TYPE_WILDCARD + output[1] = start + output[2] = start + output[3] = end + output[4] = end + output[5] = end + return + } + + // $paramName + if (part.charCodeAt(0) === 36) { + output[0] = SEGMENT_TYPE_PARAM + output[1] = start + output[2] = start + 1 // skip '$' + output[3] = start + part.length + output[4] = end + output[5] = end + return + } + + const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE) + if (wildcardBracesMatch) { + const prefix = wildcardBracesMatch[1]! + const suffix = wildcardBracesMatch[2]! + output[0] = SEGMENT_TYPE_WILDCARD + output[1] = start + prefix.length + output[2] = start + prefix.length + output[3] = end - suffix.length + output[4] = end - suffix.length + output[5] = end + return + } + + const optionalParamBracesMatch = part.match(OPTIONAL_PARAM_W_CURLY_BRACES_RE) + if (optionalParamBracesMatch) { + const prefix = optionalParamBracesMatch[1]! + const paramName = optionalParamBracesMatch[2]! + const suffix = optionalParamBracesMatch[3]! + output[0] = SEGMENT_TYPE_OPTIONAL_PARAM + output[1] = start + prefix.length + output[2] = start + prefix.length + 3 // skip '{-$' + output[3] = start + prefix.length + 3 + paramName.length + output[4] = end - suffix.length + output[5] = end + return + } + + const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE) + if (paramBracesMatch) { + const prefix = paramBracesMatch[1]! + const paramName = paramBracesMatch[2]! + const suffix = paramBracesMatch[3]! + output[0] = SEGMENT_TYPE_PARAM + output[1] = start + prefix.length + output[2] = start + prefix.length + 2 // skip '{$' + output[3] = start + prefix.length + 2 + paramName.length + output[4] = end - suffix.length + output[5] = end + return + } + + // fallback to static pathname (should never happen) + output[0] = SEGMENT_TYPE_PATHNAME + output[1] = start + output[2] = start + output[3] = end + output[4] = end + output[5] = end } /** * Recursively parses the segments of the given route tree and populates a segment trie. - * + * * @param data A reusable Uint16Array for parsing segments. (non important, we're just avoiding allocations) * @param route The current route to parse. * @param start The starting index for parsing within the route's full path. @@ -124,241 +131,324 @@ export function parseSegment( * @param onRoute Callback invoked for each route processed. */ function parseSegments( - defaultCaseSensitive: boolean, - data: Uint16Array, - route: TRouteLike, - start: number, - node: AnySegmentNode, - depth: number, - onRoute?: (route: TRouteLike, node: AnySegmentNode) => void + defaultCaseSensitive: boolean, + data: Uint16Array, + route: TRouteLike, + start: number, + node: AnySegmentNode, + depth: number, + onRoute?: (route: TRouteLike, node: AnySegmentNode) => void, ) { - let cursor = start - { - const path = route.fullPath ?? route.from - const length = path.length - const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive - while (cursor < length) { - let nextNode: AnySegmentNode - const start = cursor - parseSegment(path, start, data) - const end = data[5]! - cursor = end + 1 - const kind = data[0] as SegmentKind - switch (kind) { - case SEGMENT_TYPE_PATHNAME: { - const value = path.substring(data[2]!, data[3]) - if (caseSensitive) { - const existingNode = node.static?.get(value) - if (existingNode) { - nextNode = existingNode - } else { - node.static ??= new Map() - const next = createStaticNode(route.fullPath ?? route.from) - next.parent = node - next.depth = ++depth - nextNode = next - node.static.set(value, next) - } - } else { - const name = value.toLowerCase() - const existingNode = node.staticInsensitive?.get(name) - if (existingNode) { - nextNode = existingNode - } else { - node.staticInsensitive ??= new Map() - const next = createStaticNode(route.fullPath ?? route.from) - next.parent = node - next.depth = ++depth - nextNode = next - node.staticInsensitive.set(name, next) - } - } - break - } - case SEGMENT_TYPE_PARAM: { - const prefix_raw = path.substring(start, data[1]) - const suffix_raw = path.substring(data[4]!, end) - const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() - const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.dynamic?.find(s => s.caseSensitive === caseSensitive && s.prefix === prefix && s.suffix === suffix) - if (existingNode) { - nextNode = existingNode - } else { - const next = createDynamicNode(SEGMENT_TYPE_PARAM, route.fullPath ?? route.from, caseSensitive, prefix, suffix) - nextNode = next - next.depth = ++depth - next.parent = node - node.dynamic ??= [] - node.dynamic.push(next) - } - break - } - case SEGMENT_TYPE_OPTIONAL_PARAM: { - const prefix_raw = path.substring(start, data[1]) - const suffix_raw = path.substring(data[4]!, end) - const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() - const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.optional?.find(s => s.caseSensitive === caseSensitive && s.prefix === prefix && s.suffix === suffix) - if (existingNode) { - nextNode = existingNode - } else { - const next = createDynamicNode(SEGMENT_TYPE_OPTIONAL_PARAM, route.fullPath ?? route.from, caseSensitive, prefix, suffix) - nextNode = next - next.parent = node - next.depth = ++depth - node.optional ??= [] - node.optional.push(next) - } - break - } - case SEGMENT_TYPE_WILDCARD: { - const prefix_raw = path.substring(start, data[1]) - const suffix_raw = path.substring(data[4]!, end) - const prefix = !prefix_raw ? undefined : caseSensitive ? prefix_raw : prefix_raw.toLowerCase() - const suffix = !suffix_raw ? undefined : caseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const next = createDynamicNode(SEGMENT_TYPE_WILDCARD, route.fullPath ?? route.from, caseSensitive, prefix, suffix) - nextNode = next - next.parent = node - next.depth = ++depth - node.wildcard ??= [] - node.wildcard.push(next) - } - } - node = nextNode - } - if (route.path || !route.children) - node.route = route - onRoute?.(route, node) - } - if (route.children) for (const child of route.children) { - parseSegments(defaultCaseSensitive, data, child as TRouteLike, cursor, node, depth, onRoute) - } + let cursor = start + { + const path = route.fullPath ?? route.from + const length = path.length + const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive + while (cursor < length) { + let nextNode: AnySegmentNode + const start = cursor + parseSegment(path, start, data) + const end = data[5]! + cursor = end + 1 + const kind = data[0] as SegmentKind + switch (kind) { + case SEGMENT_TYPE_PATHNAME: { + const value = path.substring(data[2]!, data[3]) + if (caseSensitive) { + const existingNode = node.static?.get(value) + if (existingNode) { + nextNode = existingNode + } else { + node.static ??= new Map() + const next = createStaticNode( + route.fullPath ?? route.from, + ) + next.parent = node + next.depth = ++depth + nextNode = next + node.static.set(value, next) + } + } else { + const name = value.toLowerCase() + const existingNode = node.staticInsensitive?.get(name) + if (existingNode) { + nextNode = existingNode + } else { + node.staticInsensitive ??= new Map() + const next = createStaticNode( + route.fullPath ?? route.from, + ) + next.parent = node + next.depth = ++depth + nextNode = next + node.staticInsensitive.set(name, next) + } + } + break + } + case SEGMENT_TYPE_PARAM: { + const prefix_raw = path.substring(start, data[1]) + const suffix_raw = path.substring(data[4]!, end) + const prefix = !prefix_raw + ? undefined + : caseSensitive + ? prefix_raw + : prefix_raw.toLowerCase() + const suffix = !suffix_raw + ? undefined + : caseSensitive + ? suffix_raw + : suffix_raw.toLowerCase() + const existingNode = node.dynamic?.find( + (s) => + s.caseSensitive === caseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) + if (existingNode) { + nextNode = existingNode + } else { + const next = createDynamicNode( + SEGMENT_TYPE_PARAM, + route.fullPath ?? route.from, + caseSensitive, + prefix, + suffix, + ) + nextNode = next + next.depth = ++depth + next.parent = node + node.dynamic ??= [] + node.dynamic.push(next) + } + break + } + case SEGMENT_TYPE_OPTIONAL_PARAM: { + const prefix_raw = path.substring(start, data[1]) + const suffix_raw = path.substring(data[4]!, end) + const prefix = !prefix_raw + ? undefined + : caseSensitive + ? prefix_raw + : prefix_raw.toLowerCase() + const suffix = !suffix_raw + ? undefined + : caseSensitive + ? suffix_raw + : suffix_raw.toLowerCase() + const existingNode = node.optional?.find( + (s) => + s.caseSensitive === caseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) + if (existingNode) { + nextNode = existingNode + } else { + const next = createDynamicNode( + SEGMENT_TYPE_OPTIONAL_PARAM, + route.fullPath ?? route.from, + caseSensitive, + prefix, + suffix, + ) + nextNode = next + next.parent = node + next.depth = ++depth + node.optional ??= [] + node.optional.push(next) + } + break + } + case SEGMENT_TYPE_WILDCARD: { + const prefix_raw = path.substring(start, data[1]) + const suffix_raw = path.substring(data[4]!, end) + const prefix = !prefix_raw + ? undefined + : caseSensitive + ? prefix_raw + : prefix_raw.toLowerCase() + const suffix = !suffix_raw + ? undefined + : caseSensitive + ? suffix_raw + : suffix_raw.toLowerCase() + const next = createDynamicNode( + SEGMENT_TYPE_WILDCARD, + route.fullPath ?? route.from, + caseSensitive, + prefix, + suffix, + ) + nextNode = next + next.parent = node + next.depth = ++depth + node.wildcard ??= [] + node.wildcard.push(next) + } + } + node = nextNode + } + if (route.path || !route.children) node.route = route + onRoute?.(route, node) + } + if (route.children) + for (const child of route.children) { + parseSegments( + defaultCaseSensitive, + data, + child as TRouteLike, + cursor, + node, + depth, + onRoute, + ) + } } -function sortDynamic(a: { prefix?: string, suffix?: string }, b: { prefix?: string, suffix?: string }) { - if (a.prefix && b.prefix && a.prefix !== b.prefix) { - if (a.prefix.startsWith(b.prefix)) return -1 - if (b.prefix.startsWith(a.prefix)) return 1 - } - if (a.suffix && b.suffix && a.suffix !== b.suffix) { - if (a.suffix.endsWith(b.suffix)) return -1 - if (b.suffix.endsWith(a.suffix)) return 1 - } - if (a.prefix && !b.prefix) return -1 - if (!a.prefix && b.prefix) return 1 - if (a.suffix && !b.suffix) return -1 - if (!a.suffix && b.suffix) return 1 - return 0 +function sortDynamic( + a: { prefix?: string; suffix?: string }, + b: { prefix?: string; suffix?: string }, +) { + if (a.prefix && b.prefix && a.prefix !== b.prefix) { + if (a.prefix.startsWith(b.prefix)) return -1 + if (b.prefix.startsWith(a.prefix)) return 1 + } + if (a.suffix && b.suffix && a.suffix !== b.suffix) { + if (a.suffix.endsWith(b.suffix)) return -1 + if (b.suffix.endsWith(a.suffix)) return 1 + } + if (a.prefix && !b.prefix) return -1 + if (!a.prefix && b.prefix) return 1 + if (a.suffix && !b.suffix) return -1 + if (!a.suffix && b.suffix) return 1 + return 0 } function sortTreeNodes(node: SegmentNode) { - if (node.static) { - for (const child of node.static.values()) { - sortTreeNodes(child) - } - } - if (node.staticInsensitive) { - for (const child of node.staticInsensitive.values()) { - sortTreeNodes(child) - } - } - if (node.dynamic?.length) { - node.dynamic.sort(sortDynamic) - for (const child of node.dynamic) { - sortTreeNodes(child) - } - } - if (node.optional?.length) { - node.optional.sort(sortDynamic) - for (const child of node.optional) { - sortTreeNodes(child) - } - } - if (node.wildcard?.length) { - node.wildcard.sort(sortDynamic) - for (const child of node.wildcard) { - sortTreeNodes(child) - } - } + if (node.static) { + for (const child of node.static.values()) { + sortTreeNodes(child) + } + } + if (node.staticInsensitive) { + for (const child of node.staticInsensitive.values()) { + sortTreeNodes(child) + } + } + if (node.dynamic?.length) { + node.dynamic.sort(sortDynamic) + for (const child of node.dynamic) { + sortTreeNodes(child) + } + } + if (node.optional?.length) { + node.optional.sort(sortDynamic) + for (const child of node.optional) { + sortTreeNodes(child) + } + } + if (node.wildcard?.length) { + node.wildcard.sort(sortDynamic) + for (const child of node.wildcard) { + sortTreeNodes(child) + } + } } -function createStaticNode(fullPath: string): StaticSegmentNode { - return { - kind: SEGMENT_TYPE_PATHNAME, - depth: 0, - static: null, - staticInsensitive: null, - dynamic: null, - optional: null, - wildcard: null, - route: null, - fullPath, - parent: null - } +function createStaticNode( + fullPath: string, +): StaticSegmentNode { + return { + kind: SEGMENT_TYPE_PATHNAME, + depth: 0, + static: null, + staticInsensitive: null, + dynamic: null, + optional: null, + wildcard: null, + route: null, + fullPath, + parent: null, + } } /** * Keys must be declared in the same order as in `SegmentNode` type, * to ensure they are represented as the same object class in the engine. */ -function createDynamicNode(kind: typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM, fullPath: string, caseSensitive: boolean, prefix?: string, suffix?: string): DynamicSegmentNode { - return { - kind, - depth: 0, - static: null, - staticInsensitive: null, - dynamic: null, - optional: null, - wildcard: null, - route: null, - fullPath, - parent: null, - caseSensitive, - prefix, - suffix, - } +function createDynamicNode( + kind: + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM, + fullPath: string, + caseSensitive: boolean, + prefix?: string, + suffix?: string, +): DynamicSegmentNode { + return { + kind, + depth: 0, + static: null, + staticInsensitive: null, + dynamic: null, + optional: null, + wildcard: null, + route: null, + fullPath, + parent: null, + caseSensitive, + prefix, + suffix, + } } type StaticSegmentNode = SegmentNode & { - kind: typeof SEGMENT_TYPE_PATHNAME + kind: typeof SEGMENT_TYPE_PATHNAME } type DynamicSegmentNode = SegmentNode & { - kind: typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM - prefix?: string - suffix?: string - caseSensitive: boolean + kind: + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM + prefix?: string + suffix?: string + caseSensitive: boolean } -type AnySegmentNode = StaticSegmentNode | DynamicSegmentNode +type AnySegmentNode = + | StaticSegmentNode + | DynamicSegmentNode type SegmentNode = { - kind: SegmentKind + kind: SegmentKind - // Static segments (highest priority) - static: Map> | null + // Static segments (highest priority) + static: Map> | null - // Case insensitive static segments (second highest priority) - staticInsensitive: Map> | null + // Case insensitive static segments (second highest priority) + staticInsensitive: Map> | null - // Dynamic segments ($param) - dynamic: Array> | null + // Dynamic segments ($param) + dynamic: Array> | null - // Optional dynamic segments ({-$param}) - optional: Array> | null + // Optional dynamic segments ({-$param}) + optional: Array> | null - // Wildcard segments ($ - lowest priority) - wildcard: Array> | null + // Wildcard segments ($ - lowest priority) + wildcard: Array> | null - // Terminal route (if this path can end here) - route: T | null + // Terminal route (if this path can end here) + route: T | null - // The full path for this segment node (will only be valid on leaf nodes) - fullPath: string + // The full path for this segment node (will only be valid on leaf nodes) + fullPath: string - parent: AnySegmentNode | null + parent: AnySegmentNode | null - depth: number + depth: number } // function intoRouteLike(routeTree, parent) { @@ -375,355 +465,414 @@ type SegmentNode = { // } type RouteLike = { - path?: string // relative path from the parent, - children?: Array // child routes, - parentRoute?: RouteLike // parent route, - options?: { - caseSensitive?: boolean - } -} & ( - // router tree - | { fullPath: string, from?: never } // full path from the root - // flat route masks list - | { fullPath?: never, from: string } // full path from the root - ) + path?: string // relative path from the parent, + children?: Array // child routes, + parentRoute?: RouteLike // parent route, + options?: { + caseSensitive?: boolean + } +} & + // router tree + (| { fullPath: string; from?: never } // full path from the root + // flat route masks list + | { fullPath?: never; from: string } // full path from the root + ) export type ProcessedTree< - TTree extends Extract, - TFlat extends Extract, - TSingle extends Extract, + TTree extends Extract, + TFlat extends Extract, + TSingle extends Extract, > = { - /** a representation of the `routeTree` as a segment tree, for performant path matching */ - segmentTree: AnySegmentNode - /** a cache of mini route trees generated from flat route lists, for performant route mask matching */ - flatCache: Map> - /** @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree */ - singleCache: Map> + /** a representation of the `routeTree` as a segment tree, for performant path matching */ + segmentTree: AnySegmentNode + /** a cache of mini route trees generated from flat route lists, for performant route mask matching */ + flatCache: Map> + /** @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree */ + singleCache: Map> } export function processFlatRouteList( - routeList: Array, + routeList: Array, ) { - const segmentTree = createStaticNode('/') - const data = new Uint16Array(6) - for (const route of routeList) { - parseSegments(false, data, route, 1, segmentTree, 1) - } - sortTreeNodes(segmentTree) - return segmentTree + const segmentTree = createStaticNode('/') + const data = new Uint16Array(6) + for (const route of routeList) { + parseSegments(false, data, route, 1, segmentTree, 1) + } + sortTreeNodes(segmentTree) + return segmentTree } /** * Take an arbitrary list of routes, create a tree from them (if it hasn't been created already), and match a path against it. */ export function findFlatMatch>( - /** The flat list of routes to match against. This array should be stable, it comes from a route's `routeMasks` option. */ - list: Array, - /** The path to match. */ - path: string, - /** The `processedTree` returned by the initial `processRouteTree` call. */ - processedTree: ProcessedTree + /** The flat list of routes to match against. This array should be stable, it comes from a route's `routeMasks` option. */ + list: Array, + /** The path to match. */ + path: string, + /** The `processedTree` returned by the initial `processRouteTree` call. */ + processedTree: ProcessedTree, ) { - let tree = processedTree.flatCache.get(list) - if (!tree) { - // flat route lists (routeMasks option) are not eagerly processed, - // if we haven't seen this list before, process it now - tree = processFlatRouteList(list) - processedTree.flatCache.set(list, tree) - } - return findMatch(path, tree) + let tree = processedTree.flatCache.get(list) + if (!tree) { + // flat route lists (routeMasks option) are not eagerly processed, + // if we haven't seen this list before, process it now + tree = processFlatRouteList(list) + processedTree.flatCache.set(list, tree) + } + return findMatch(path, tree) } /** * @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree */ -export function findSingleMatch(from: string, caseSensitive: boolean, fuzzy: boolean, path: string, processedTree: ProcessedTree) { - const key = `${caseSensitive}|${from}` - let tree = processedTree.singleCache.get(key) - if (!tree) { - // single flat routes (router.matchRoute) are not eagerly processed, - // if we haven't seen this route before, process it now - tree = createStaticNode<{ from: string }>('/') - const data = new Uint16Array(6) - parseSegments(false, data, { from }, 1, tree, 1) - processedTree.singleCache.set(key, tree) - } - return findMatch(path, tree, fuzzy) +export function findSingleMatch( + from: string, + caseSensitive: boolean, + fuzzy: boolean, + path: string, + processedTree: ProcessedTree, +) { + const key = `${caseSensitive}|${from}` + let tree = processedTree.singleCache.get(key) + if (!tree) { + // single flat routes (router.matchRoute) are not eagerly processed, + // if we haven't seen this route before, process it now + tree = createStaticNode<{ from: string }>('/') + const data = new Uint16Array(6) + parseSegments(false, data, { from }, 1, tree, 1) + processedTree.singleCache.set(key, tree) + } + return findMatch(path, tree, fuzzy) } -export function findRouteMatch>( - /** The path to match against the route tree. */ - path: string, - /** The `processedTree` returned by the initial `processRouteTree` call. */ - processedTree: ProcessedTree, - /** If `true`, allows fuzzy matching (partial matches). */ - fuzzy = false +export function findRouteMatch< + T extends Extract, +>( + /** The path to match against the route tree. */ + path: string, + /** The `processedTree` returned by the initial `processRouteTree` call. */ + processedTree: ProcessedTree, + /** If `true`, allows fuzzy matching (partial matches). */ + fuzzy = false, ) { - return findMatch(path, processedTree.segmentTree, fuzzy) + return findMatch(path, processedTree.segmentTree, fuzzy) } - /** Trim trailing slashes (except preserving root '/'). */ export function trimPathRight(path: string) { - return path === '/' ? path : path.replace(/\/{1,}$/, '') + return path === '/' ? path : path.replace(/\/{1,}$/, '') } /** * Processes a route tree into a segment trie for efficient path matching. * Also builds lookup maps for routes by ID and by trimmed full path. */ -export function processRouteTree & { id: string }>( - /** The root of the route tree to process. */ - routeTree: TRouteLike, - /** Whether matching should be case sensitive by default (overridden by individual route options). */ - caseSensitive: boolean = false, - /** Optional callback invoked for each route during processing. */ - initRoute?: (route: TRouteLike, index: number) => void +export function processRouteTree< + TRouteLike extends Extract & { id: string }, +>( + /** The root of the route tree to process. */ + routeTree: TRouteLike, + /** Whether matching should be case sensitive by default (overridden by individual route options). */ + caseSensitive: boolean = false, + /** Optional callback invoked for each route during processing. */ + initRoute?: (route: TRouteLike, index: number) => void, ): { - /** Should be considered a black box, needs to be provided to all matching functions in this module. */ - processedTree: ProcessedTree, - /** A lookup map of routes by their unique IDs. */ - routesById: Record, - /** A lookup map of routes by their trimmed full paths. */ - routesByPath: Record, + /** Should be considered a black box, needs to be provided to all matching functions in this module. */ + processedTree: ProcessedTree + /** A lookup map of routes by their unique IDs. */ + routesById: Record + /** A lookup map of routes by their trimmed full paths. */ + routesByPath: Record } { - const segmentTree = createStaticNode(routeTree.fullPath) - const data = new Uint16Array(6) - const routesById = {} as Record - const routesByPath = {} as Record - let index = 0 - parseSegments(caseSensitive, data, routeTree, 1, segmentTree, 0, (route, node) => { - initRoute?.(route, index) - - invariant( - !(route.id in routesById), - `Duplicate routes found with id: ${String(route.id)}`, - ) - - routesById[route.id] = route - - if (index !== 0 && route.path) { - const trimmedFullPath = trimPathRight(route.fullPath) - if ( - !routesByPath[trimmedFullPath] || - route.fullPath.endsWith('/') - ) { - routesByPath[trimmedFullPath] = route - } - } - - index++ - }) - sortTreeNodes(segmentTree) - const processedTree: ProcessedTree = { - segmentTree, - flatCache: new Map(), - singleCache: new Map(), - } - return { - processedTree, - routesById, - routesByPath, - } + const segmentTree = createStaticNode(routeTree.fullPath) + const data = new Uint16Array(6) + const routesById = {} as Record + const routesByPath = {} as Record + let index = 0 + parseSegments( + caseSensitive, + data, + routeTree, + 1, + segmentTree, + 0, + (route, node) => { + initRoute?.(route, index) + + invariant( + !(route.id in routesById), + `Duplicate routes found with id: ${String(route.id)}`, + ) + + routesById[route.id] = route + + if (index !== 0 && route.path) { + const trimmedFullPath = trimPathRight(route.fullPath) + if (!routesByPath[trimmedFullPath] || route.fullPath.endsWith('/')) { + routesByPath[trimmedFullPath] = route + } + } + + index++ + }, + ) + sortTreeNodes(segmentTree) + const processedTree: ProcessedTree = { + segmentTree, + flatCache: new Map(), + singleCache: new Map(), + } + return { + processedTree, + routesById, + routesByPath, + } } -function findMatch(path: string, segmentTree: AnySegmentNode, fuzzy = false): { route: T, params: Record } | null { - const parts = path.split('/') - const leaf = getNodeMatch(parts, segmentTree, fuzzy) - if (!leaf) return null - const params = extractParams(path, parts, leaf) - if ('**' in leaf) params['**'] = leaf['**']! - return { - route: leaf.node.route!, - params, - } +function findMatch( + path: string, + segmentTree: AnySegmentNode, + fuzzy = false, +): { route: T; params: Record } | null { + const parts = path.split('/') + const leaf = getNodeMatch(parts, segmentTree, fuzzy) + if (!leaf) return null + const params = extractParams(path, parts, leaf) + if ('**' in leaf) params['**'] = leaf['**']! + return { + route: leaf.node.route!, + params, + } } -function extractParams(path: string, parts: Array, leaf: { node: AnySegmentNode, skipped: number }) { - const list = buildBranch(leaf.node) - let nodeParts: Array | null = null - const params: Record = {} - for (let partIndex = 0, nodeIndex = 0, pathIndex = 0; partIndex < parts.length && nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++) { - const node = list[nodeIndex]! - const part = parts[partIndex]! - const currentPathIndex = pathIndex - pathIndex += part.length - if (node.kind === SEGMENT_TYPE_PARAM) { - nodeParts ??= leaf.node.fullPath.split('/') - const nodePart = nodeParts[nodeIndex]! - // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node - if (node.suffix !== undefined || node.prefix !== undefined) { - const preLength = node.prefix?.length ?? 0 - const sufLength = node.suffix?.length ?? 0 - const name = nodePart.substring(preLength + 2, nodePart.length - sufLength - 1) - params[name] = part.substring(preLength, part.length - sufLength) - } else { - const name = nodePart.substring(1) - params[name] = part - } - } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { - nodeParts ??= leaf.node.fullPath.split('/') - const nodePart = nodeParts[nodeIndex]! - const preLength = node.prefix?.length ?? 0 - const sufLength = node.suffix?.length ?? 0 - const name = nodePart.substring(preLength + 3, nodePart.length - sufLength - 1) - // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node - if (leaf.skipped & (1 << nodeIndex)) { - partIndex-- // stay on the same part - params[name] = '' - continue - } - if (node.suffix || node.prefix) { - params[name] = part.substring(preLength, part.length - sufLength) - } else { - params[name] = part - } - } else if (node.kind === SEGMENT_TYPE_WILDCARD) { - const n = node - params['*'] = path.substring(currentPathIndex + (n.prefix?.length ?? 0), path.length - (n.suffix?.length ?? 0)) - break - } - } - return params +function extractParams( + path: string, + parts: Array, + leaf: { node: AnySegmentNode; skipped: number }, +) { + const list = buildBranch(leaf.node) + let nodeParts: Array | null = null + const params: Record = {} + for ( + let partIndex = 0, nodeIndex = 0, pathIndex = 0; + partIndex < parts.length && nodeIndex < list.length; + partIndex++, nodeIndex++, pathIndex++ + ) { + const node = list[nodeIndex]! + const part = parts[partIndex]! + const currentPathIndex = pathIndex + pathIndex += part.length + if (node.kind === SEGMENT_TYPE_PARAM) { + nodeParts ??= leaf.node.fullPath.split('/') + const nodePart = nodeParts[nodeIndex]! + // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node + if (node.suffix !== undefined || node.prefix !== undefined) { + const preLength = node.prefix?.length ?? 0 + const sufLength = node.suffix?.length ?? 0 + const name = nodePart.substring( + preLength + 2, + nodePart.length - sufLength - 1, + ) + params[name] = part.substring(preLength, part.length - sufLength) + } else { + const name = nodePart.substring(1) + params[name] = part + } + } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + nodeParts ??= leaf.node.fullPath.split('/') + const nodePart = nodeParts[nodeIndex]! + const preLength = node.prefix?.length ?? 0 + const sufLength = node.suffix?.length ?? 0 + const name = nodePart.substring( + preLength + 3, + nodePart.length - sufLength - 1, + ) + // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node + if (leaf.skipped & (1 << nodeIndex)) { + partIndex-- // stay on the same part + params[name] = '' + continue + } + if (node.suffix || node.prefix) { + params[name] = part.substring(preLength, part.length - sufLength) + } else { + params[name] = part + } + } else if (node.kind === SEGMENT_TYPE_WILDCARD) { + const n = node + params['*'] = path.substring( + currentPathIndex + (n.prefix?.length ?? 0), + path.length - (n.suffix?.length ?? 0), + ) + break + } + } + return params } function buildBranch(node: AnySegmentNode) { - const list: Array> = Array(node.depth + 1) - do { - list[node.depth] = node - node = node.parent! - } while (node) - return list + const list: Array> = Array(node.depth + 1) + do { + list[node.depth] = node + node = node.parent! + } while (node) + return list } -function getNodeMatch(parts: Array, segmentTree: AnySegmentNode, fuzzy: boolean) { - parts = parts.filter(Boolean) - - type Frame = { - node: AnySegmentNode - index: number - depth: number - /** Bitmask of skipped optional segments */ - skipped: number - } - - // use a stack to explore all possible paths (optional params cause branching) - // we use a depth-first search, return the first result found - const stack: Array = [ - { node: segmentTree, index: 0, depth: 0, skipped: 0 } - ] - let stackIndex = 0 - - let wildcardMatch: Frame | null = null - let bestFuzzy: Frame | null = null - - while (stackIndex < stack.length) { - // eslint-disable-next-line prefer-const - let { node, index, skipped, depth } = stack[stackIndex++]! - - main: while (node && index <= parts.length) { - if (index === parts.length) { - if (!node.route) break - return { node, skipped } - } - - // In fuzzy mode, track the best partial match we've found so far - if (fuzzy && node.route && (!bestFuzzy || index > bestFuzzy.index || (index === bestFuzzy.index && depth > bestFuzzy.depth))) { - bestFuzzy = { node, index, depth, skipped } - } - - const part = parts[index]! - - // 3. Try dynamic match - if (node.dynamic) { - for (const segment of node.dynamic) { - const { prefix, suffix } = segment - if (prefix || suffix) { - const casePart = segment.caseSensitive ? part : part.toLowerCase() - if (prefix && !casePart.startsWith(prefix)) continue - if (suffix && !casePart.endsWith(suffix)) continue - } - stack.push({ node: segment, index: index + 1, skipped, depth: depth + 1 }) - } - } - - // 4. Try optional match - if (node.optional) { - const nextDepth = depth + 1 - const nextSkipped = skipped | (1 << nextDepth) - for (const segment of node.optional) { - // when skipping, node and depth advance by 1, but index doesn't - stack.push({ node: segment, index, skipped: nextSkipped, depth: nextDepth }) // enqueue skipping the optional - } - for (const segment of node.optional) { - const { prefix, suffix } = segment - if (prefix || suffix) { - const casePart = segment.caseSensitive ? part : part.toLowerCase() - if (prefix && !casePart.startsWith(prefix)) continue - if (suffix && !casePart.endsWith(suffix)) continue - } - stack.push({ node: segment, index: index + 1, skipped, depth: nextDepth }) - } - } - - // 1. Try static match - if (node.static) { - const match = node.static.get(part) - if (match) { - node = match - depth++ - index++ - continue - } - } - - // 2. Try case insensitive static match - if (node.staticInsensitive) { - const match = node.staticInsensitive.get(part.toLowerCase()) - if (match) { - node = match - depth++ - index++ - continue - } - } - - // 5. Try wildcard match - if (node.wildcard) { - for (const segment of node.wildcard) { - const { prefix, suffix } = segment - if (prefix) { - const casePart = segment.caseSensitive ? part : part.toLowerCase() - if (!casePart.startsWith(prefix)) continue - } - if (suffix) { - const part = parts[parts.length - 1]! - const casePart = segment.caseSensitive ? part : part.toLowerCase() - if (!casePart.endsWith(suffix)) continue - } - // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match - if (!wildcardMatch || (wildcardMatch.index < index)) { - wildcardMatch = { node: segment, index, skipped, depth } - } - break main - } - } - - // No match found - break - } - } - - - if (wildcardMatch) return wildcardMatch - - if (fuzzy && bestFuzzy) { - return { - node: bestFuzzy.node, - skipped: bestFuzzy.skipped, - '**': '/' + parts.slice(bestFuzzy.index).join('/'), - } - } - - return null -} \ No newline at end of file +function getNodeMatch( + parts: Array, + segmentTree: AnySegmentNode, + fuzzy: boolean, +) { + parts = parts.filter(Boolean) + + type Frame = { + node: AnySegmentNode + index: number + depth: number + /** Bitmask of skipped optional segments */ + skipped: number + } + + // use a stack to explore all possible paths (optional params cause branching) + // we use a depth-first search, return the first result found + const stack: Array = [ + { node: segmentTree, index: 0, depth: 0, skipped: 0 }, + ] + let stackIndex = 0 + + let wildcardMatch: Frame | null = null + let bestFuzzy: Frame | null = null + + while (stackIndex < stack.length) { + // eslint-disable-next-line prefer-const + let { node, index, skipped, depth } = stack[stackIndex++]! + + main: while (node && index <= parts.length) { + if (index === parts.length) { + if (!node.route) break + return { node, skipped } + } + + // In fuzzy mode, track the best partial match we've found so far + if ( + fuzzy && + node.route && + (!bestFuzzy || + index > bestFuzzy.index || + (index === bestFuzzy.index && depth > bestFuzzy.depth)) + ) { + bestFuzzy = { node, index, depth, skipped } + } + + const part = parts[index]! + + // 3. Try dynamic match + if (node.dynamic) { + for (const segment of node.dynamic) { + const { prefix, suffix } = segment + if (prefix || suffix) { + const casePart = segment.caseSensitive ? part : part.toLowerCase() + if (prefix && !casePart.startsWith(prefix)) continue + if (suffix && !casePart.endsWith(suffix)) continue + } + stack.push({ + node: segment, + index: index + 1, + skipped, + depth: depth + 1, + }) + } + } + + // 4. Try optional match + if (node.optional) { + const nextDepth = depth + 1 + const nextSkipped = skipped | (1 << nextDepth) + for (const segment of node.optional) { + // when skipping, node and depth advance by 1, but index doesn't + stack.push({ + node: segment, + index, + skipped: nextSkipped, + depth: nextDepth, + }) // enqueue skipping the optional + } + for (const segment of node.optional) { + const { prefix, suffix } = segment + if (prefix || suffix) { + const casePart = segment.caseSensitive ? part : part.toLowerCase() + if (prefix && !casePart.startsWith(prefix)) continue + if (suffix && !casePart.endsWith(suffix)) continue + } + stack.push({ + node: segment, + index: index + 1, + skipped, + depth: nextDepth, + }) + } + } + + // 1. Try static match + if (node.static) { + const match = node.static.get(part) + if (match) { + node = match + depth++ + index++ + continue + } + } + + // 2. Try case insensitive static match + if (node.staticInsensitive) { + const match = node.staticInsensitive.get(part.toLowerCase()) + if (match) { + node = match + depth++ + index++ + continue + } + } + + // 5. Try wildcard match + if (node.wildcard) { + for (const segment of node.wildcard) { + const { prefix, suffix } = segment + if (prefix) { + const casePart = segment.caseSensitive ? part : part.toLowerCase() + if (!casePart.startsWith(prefix)) continue + } + if (suffix) { + const part = parts[parts.length - 1]! + const casePart = segment.caseSensitive ? part : part.toLowerCase() + if (!casePart.endsWith(suffix)) continue + } + // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match + if (!wildcardMatch || wildcardMatch.index < index) { + wildcardMatch = { node: segment, index, skipped, depth } + } + break main + } + } + + // No match found + break + } + } + + if (wildcardMatch) return wildcardMatch + + if (fuzzy && bestFuzzy) { + return { + node: bestFuzzy.node, + skipped: bestFuzzy.skipped, + '**': '/' + parts.slice(bestFuzzy.index).join('/'), + } + } + + return null +} diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 8f153f2aef8..7ac1b3ca650 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -1,6 +1,12 @@ import { last } from './utils' -import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, parseSegment } from "./new-process-route-tree" -import type { SegmentKind } from "./new-process-route-tree" +import { + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, + parseSegment, +} from './new-process-route-tree' +import type { SegmentKind } from './new-process-route-tree' /** Join path segments, cleaning duplicate slashes between parts. */ /** Join path segments, cleaning duplicate slashes between parts. */ @@ -143,7 +149,6 @@ export function resolvePath({ } } - if (baseSegments.length > 1) { if (last(baseSegments) === '') { if (trailingSlash === 'never') { @@ -174,7 +179,8 @@ export function resolvePath({ joined += prefix || suffix ? `${prefix}{$${value}}${suffix}` : `$${value}` } else if (kind === SEGMENT_TYPE_WILDCARD) { joined += prefix || suffix ? `${prefix}{$}${suffix}` : '$' - } else { // SEGMENT_TYPE_OPTIONAL_PARAM + } else { + // SEGMENT_TYPE_OPTIONAL_PARAM joined += `${prefix}{-$${value}}${suffix}` } } @@ -212,7 +218,8 @@ export function interpolatePath({ leaveParams, decodeCharMap, }: InterpolatePathOptions): InterPolatePathResult { - if (!path) return { interpolatedPath: '/', usedParams: {}, isMissingParams: false } + if (!path) + return { interpolatedPath: '/', usedParams: {}, isMissingParams: false } function encodeParam(key: string): any { const value = params[key] @@ -284,7 +291,9 @@ export function interpolatePath({ const value = encodeParam(key) interpolatedSegments.push(`${prefix}$${key}${value ?? ''}${suffix}`) } else { - interpolatedSegments.push(`${prefix}${encodeParam(key) ?? 'undefined'}${suffix}`) + interpolatedSegments.push( + `${prefix}${encodeParam(key) ?? 'undefined'}${suffix}`, + ) } continue } @@ -331,4 +340,3 @@ function encodePathParam(value: string, decodeCharMap?: Map) { } return encoded } - diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2c8e1206722..ed7cb4e4f79 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -9,7 +9,12 @@ import { last, replaceEqualDeep, } from './utils' -import { findFlatMatch, findRouteMatch, findSingleMatch, processRouteTree } from './new-process-route-tree' +import { + findFlatMatch, + findRouteMatch, + findSingleMatch, + processRouteTree, +} from './new-process-route-tree' import { cleanPath, interpolatePath, @@ -689,9 +694,7 @@ export type ParseLocationFn = ( previousLocation?: ParsedLocation>, ) => ParsedLocation> -export type GetMatchRoutesFn = ( - pathname: string -) => { +export type GetMatchRoutesFn = (pathname: string) => { matchedRoutes: Array routeParams: Record foundRoute: AnyRoute | undefined @@ -1236,7 +1239,9 @@ export class RouterCore< next: ParsedLocation, opts?: MatchRoutesOpts, ): Array { - const { foundRoute, matchedRoutes, routeParams } = this.getMatchedRoutes(next.pathname) + const { foundRoute, matchedRoutes, routeParams } = this.getMatchedRoutes( + next.pathname, + ) let isGlobalNotFound = false // Check to see if the route needs a 404 entry @@ -1766,7 +1771,11 @@ export class RouterCore< let params = {} if (this.options.routeMasks) { - const match = findFlatMatch>(this.options.routeMasks, next.pathname, this.processedTree) + const match = findFlatMatch>( + this.options.routeMasks, + next.pathname, + this.processedTree, + ) if (match) { params = match.params const { from: _from, ...maskProps } = match.route @@ -2487,7 +2496,13 @@ export class RouterCore< ? this.latestLocation : this.state.resolvedLocation || this.state.location - const match = findSingleMatch(next.pathname, opts?.caseSensitive ?? false, opts?.fuzzy ?? false, baseLocation.pathname, this.processedTree) + const match = findSingleMatch( + next.pathname, + opts?.caseSensitive ?? false, + opts?.fuzzy ?? false, + baseLocation.pathname, + this.processedTree, + ) if (!match) { return false diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index aabe3e55a4c..00ad14b2490 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -1,18 +1,22 @@ import { describe, expect, it } from 'vitest' -import { findRouteMatch, processFlatRouteList, processRouteTree } from "../src/new-process-route-tree" -import type { AnyRoute, RouteMask } from "../src" +import { + findRouteMatch, + processFlatRouteList, + processRouteTree, +} from '../src/new-process-route-tree' +import type { AnyRoute, RouteMask } from '../src' describe('processFlatRouteList', () => { - it('processes a route masks list', () => { - const routeTree = {} as AnyRoute - const routeMasks: Array> = [ - { from: '/a/b/c', routeTree }, - { from: '/a/b/d', routeTree }, - { from: '/a/$param/d', routeTree }, - { from: '/a/{-$optional}/d', routeTree }, - { from: '/a/b/{$}.txt', routeTree }, - ] - expect(processFlatRouteList(routeMasks)).toMatchInlineSnapshot(` + it('processes a route masks list', () => { + const routeTree = {} as AnyRoute + const routeMasks: Array> = [ + { from: '/a/b/c', routeTree }, + { from: '/a/b/d', routeTree }, + { from: '/a/$param/d', routeTree }, + { from: '/a/{-$optional}/d', routeTree }, + { from: '/a/b/{$}.txt', routeTree }, + ] + expect(processFlatRouteList(routeMasks)).toMatchInlineSnapshot(` { "depth": 0, "dynamic": null, @@ -166,82 +170,88 @@ describe('processFlatRouteList', () => { "wildcard": null, } `) - }) + }) }) describe('findMatch', () => { - const testTree = { - id: '__root__', - fullPath: '/', - path: '/', - children: [ - { - id: '/yo', - fullPath: '/yo', - path: 'yo', - children: [ - { - id: '/yo/foo{-$id}bar', - fullPath: '/yo/foo{-$id}bar', - path: 'foo{-$id}bar', - children: [ - { - id: '/yo/foo{-$id}bar/ma', - fullPath: '/yo/foo{-$id}bar/ma', - path: 'ma', - } - ] - }, - { - id: '/yo/{$}.png', - fullPath: '/yo/{$}.png', - path: '{$}.png', - }, - { - id: '/yo/$', - fullPath: '/yo/$', - path: '$', - } - ] - }, { - id: '/foo', - fullPath: '/foo', - path: 'foo', - children: [ - { - id: '/foo/$a/aaa', - fullPath: '/foo/$a/aaa', - path: '$a/aaa', - }, { - id: '/foo/$b/bbb', - fullPath: '/foo/$b/bbb', - path: '$b/bbb', - } - ] - }, { - id: '/x/y/z', - fullPath: '/x/y/z', - path: 'x/y/z', - }, { - id: '/$id/y/w', - fullPath: '/$id/y/w', - path: '$id/y/w', - }, { - id: '/{-$other}/posts/new', - fullPath: '/{-$other}/posts/new', - path: '{-$other}/posts/new', - }, { - id: '/posts/$id', - fullPath: '/posts/$id', - path: 'posts/$id', - } - ] - } + const testTree = { + id: '__root__', + fullPath: '/', + path: '/', + children: [ + { + id: '/yo', + fullPath: '/yo', + path: 'yo', + children: [ + { + id: '/yo/foo{-$id}bar', + fullPath: '/yo/foo{-$id}bar', + path: 'foo{-$id}bar', + children: [ + { + id: '/yo/foo{-$id}bar/ma', + fullPath: '/yo/foo{-$id}bar/ma', + path: 'ma', + }, + ], + }, + { + id: '/yo/{$}.png', + fullPath: '/yo/{$}.png', + path: '{$}.png', + }, + { + id: '/yo/$', + fullPath: '/yo/$', + path: '$', + }, + ], + }, + { + id: '/foo', + fullPath: '/foo', + path: 'foo', + children: [ + { + id: '/foo/$a/aaa', + fullPath: '/foo/$a/aaa', + path: '$a/aaa', + }, + { + id: '/foo/$b/bbb', + fullPath: '/foo/$b/bbb', + path: '$b/bbb', + }, + ], + }, + { + id: '/x/y/z', + fullPath: '/x/y/z', + path: 'x/y/z', + }, + { + id: '/$id/y/w', + fullPath: '/$id/y/w', + path: '$id/y/w', + }, + { + id: '/{-$other}/posts/new', + fullPath: '/{-$other}/posts/new', + path: '{-$other}/posts/new', + }, + { + id: '/posts/$id', + fullPath: '/posts/$id', + path: 'posts/$id', + }, + ], + } - const { processedTree } = processRouteTree(testTree) + const { processedTree } = processRouteTree(testTree) - it('foo', () => { - expect(findRouteMatch('/posts/new', processedTree)).toMatchInlineSnapshot(` + it('foo', () => { + expect(findRouteMatch('/posts/new', processedTree)).toMatchInlineSnapshot(` { "params": { "other": "", @@ -253,7 +263,8 @@ describe('findMatch', () => { }, } `) - expect(findRouteMatch('/yo/posts/new', processedTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/yo/posts/new', processedTree)) + .toMatchInlineSnapshot(` { "params": { "other": "yo", @@ -265,7 +276,7 @@ describe('findMatch', () => { }, } `) - expect(findRouteMatch('/x/y/w', processedTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/x/y/w', processedTree)).toMatchInlineSnapshot(` { "params": { "id": "x", @@ -277,12 +288,11 @@ describe('findMatch', () => { }, } `) - }) - - + }) - it('works w/ optional params when param is present', () => { - expect(findRouteMatch('/yo/foo123bar/ma', processedTree)).toMatchInlineSnapshot(` + it('works w/ optional params when param is present', () => { + expect(findRouteMatch('/yo/foo123bar/ma', processedTree)) + .toMatchInlineSnapshot(` { "params": { "id": "123", @@ -294,9 +304,9 @@ describe('findMatch', () => { }, } `) - }) - it('works w/ optional params when param is absent', () => { - expect(findRouteMatch('/yo/ma', processedTree)).toMatchInlineSnapshot(` + }) + it('works w/ optional params when param is absent', () => { + expect(findRouteMatch('/yo/ma', processedTree)).toMatchInlineSnapshot(` { "params": { "id": "", @@ -308,9 +318,10 @@ describe('findMatch', () => { }, } `) - }) - it('works w/ wildcard and suffix', () => { - expect(findRouteMatch('/yo/somefile.png', processedTree)).toMatchInlineSnapshot(` + }) + it('works w/ wildcard and suffix', () => { + expect(findRouteMatch('/yo/somefile.png', processedTree)) + .toMatchInlineSnapshot(` { "params": { "*": "somefile", @@ -322,9 +333,10 @@ describe('findMatch', () => { }, } `) - }) - it('works w/ wildcard alone', () => { - expect(findRouteMatch('/yo/something', processedTree)).toMatchInlineSnapshot(` + }) + it('works w/ wildcard alone', () => { + expect(findRouteMatch('/yo/something', processedTree)) + .toMatchInlineSnapshot(` { "params": { "*": "something", @@ -336,9 +348,10 @@ describe('findMatch', () => { }, } `) - }) - it('works w/ multiple required param routes at same level, w/ different names for their param', () => { - expect(findRouteMatch('/foo/123/aaa', processedTree)).toMatchInlineSnapshot(` + }) + it('works w/ multiple required param routes at same level, w/ different names for their param', () => { + expect(findRouteMatch('/foo/123/aaa', processedTree)) + .toMatchInlineSnapshot(` { "params": { "a": "123", @@ -350,7 +363,8 @@ describe('findMatch', () => { }, } `) - expect(findRouteMatch('/foo/123/bbb', processedTree)).toMatchInlineSnapshot(` + expect(findRouteMatch('/foo/123/bbb', processedTree)) + .toMatchInlineSnapshot(` { "params": { "b": "123", @@ -362,10 +376,11 @@ describe('findMatch', () => { }, } `) - }) + }) - it('works w/ fuzzy matching', () => { - expect(findRouteMatch('/foo/123', processedTree, true)).toMatchInlineSnapshot(` + it('works w/ fuzzy matching', () => { + expect(findRouteMatch('/foo/123', processedTree, true)) + .toMatchInlineSnapshot(` { "params": { "**": "/123", @@ -389,9 +404,10 @@ describe('findMatch', () => { }, } `) - }) - it('can still return exact matches w/ fuzzy:true', () => { - expect(findRouteMatch('/yo/foobar', processedTree, true)).toMatchInlineSnapshot(` + }) + it('can still return exact matches w/ fuzzy:true', () => { + expect(findRouteMatch('/yo/foobar', processedTree, true)) + .toMatchInlineSnapshot(` { "params": { "id": "", @@ -410,9 +426,10 @@ describe('findMatch', () => { }, } `) - }) - it('can still match a wildcard route w/ fuzzy:true', () => { - expect(findRouteMatch('/yo/something', processedTree, true)).toMatchInlineSnapshot(` + }) + it('can still match a wildcard route w/ fuzzy:true', () => { + expect(findRouteMatch('/yo/something', processedTree, true)) + .toMatchInlineSnapshot(` { "params": { "*": "something", @@ -424,5 +441,5 @@ describe('findMatch', () => { }, } `) - }) + }) }) diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index f3051d23c0d..38d8ebbdbfd 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - interpolatePath, -} from '../src/path' +import { interpolatePath } from '../src/path' describe('Optional Path Parameters - Clean Comprehensive Tests', () => { describe('Optional Dynamic Parameters {-$param}', () => { diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index 51e0b12dd02..3666485ed12 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest' +import { interpolatePath } from '../src/path' import { - interpolatePath, -} from '../src/path' -import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD } from "../src/new-process-route-tree" + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, +} from '../src/new-process-route-tree' describe.skip('Optional Path Parameters', () => { type ParsePathnameTestScheme = Array<{ From eb140cd0fe478570893d632165629b33403a1959 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 13:58:21 +0100 Subject: [PATCH 033/109] fix build --- packages/react-router/src/useBlocker.tsx | 5 +---- packages/solid-router/src/useBlocker.tsx | 5 +---- packages/start-server-core/src/createStartHandler.ts | 5 +---- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/react-router/src/useBlocker.tsx b/packages/react-router/src/useBlocker.tsx index 735277d1000..6ac7ba3dcf5 100644 --- a/packages/react-router/src/useBlocker.tsx +++ b/packages/react-router/src/useBlocker.tsx @@ -177,10 +177,7 @@ export function useBlocker( location: HistoryLocation, ): AnyShouldBlockFnLocation { const parsedLocation = router.parseLocation(location) - const matchedRoutes = router.getMatchedRoutes( - parsedLocation.pathname, - undefined, - ) + const matchedRoutes = router.getMatchedRoutes(parsedLocation.pathname) if (matchedRoutes.foundRoute === undefined) { throw new Error(`No route found for location ${location.href}`) } diff --git a/packages/solid-router/src/useBlocker.tsx b/packages/solid-router/src/useBlocker.tsx index 1fef61a517f..61b942de435 100644 --- a/packages/solid-router/src/useBlocker.tsx +++ b/packages/solid-router/src/useBlocker.tsx @@ -186,10 +186,7 @@ export function useBlocker( location: HistoryLocation, ): AnyShouldBlockFnLocation { const parsedLocation = router.parseLocation(location) - const matchedRoutes = router.getMatchedRoutes( - parsedLocation.pathname, - undefined, - ) + const matchedRoutes = router.getMatchedRoutes(parsedLocation.pathname) if (matchedRoutes.foundRoute === undefined) { throw new Error(`No route found for location ${location.href}`) } diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index a304f33dfd3..b208cb66787 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -359,10 +359,7 @@ async function handleServerRoutes({ let url = new URL(request.url) url = executeRewriteInput(router.rewrite, url) const pathname = url.pathname - const { matchedRoutes, foundRoute, routeParams } = router.getMatchedRoutes( - pathname, - undefined, - ) + const { matchedRoutes, foundRoute, routeParams } = router.getMatchedRoutes(pathname) // TODO: Error handling? What happens when its `throw redirect()` vs `throw new Error()`? From 162a3d75960e80379377e0b4b979699ba55212fd Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 12:59:28 +0000 Subject: [PATCH 034/109] ci: apply automated fixes --- packages/start-server-core/src/createStartHandler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index b208cb66787..08ce599e40c 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -359,7 +359,8 @@ async function handleServerRoutes({ let url = new URL(request.url) url = executeRewriteInput(router.rewrite, url) const pathname = url.pathname - const { matchedRoutes, foundRoute, routeParams } = router.getMatchedRoutes(pathname) + const { matchedRoutes, foundRoute, routeParams } = + router.getMatchedRoutes(pathname) // TODO: Error handling? What happens when its `throw redirect()` vs `throw new Error()`? From 58baf3e8158874b96a05f0b8598881e35cd76124 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 14:06:01 +0100 Subject: [PATCH 035/109] typos --- packages/router-core/src/new-process-route-tree.ts | 2 +- packages/solid-router/src/index.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 61dbb6e7278..935df72a69a 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -541,7 +541,7 @@ export function findSingleMatch( // if we haven't seen this route before, process it now tree = createStaticNode<{ from: string }>('/') const data = new Uint16Array(6) - parseSegments(false, data, { from }, 1, tree, 1) + parseSegments(caseSensitive, data, { from }, 1, tree, 1) processedTree.singleCache.set(key, tree) } return findMatch(path, tree, fuzzy) diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 373560973f8..6d0c31f2fce 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -8,7 +8,6 @@ export { trimPathRight, trimPath, resolvePath, - parsePathname, interpolatePath, rootRouteId, defaultSerializeError, From 4dab29f8ad53cb8cbd18d50d6d7fd21a149d8320 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 14:17:56 +0100 Subject: [PATCH 036/109] fix plugin HMR --- .../src/core/route-hmr-statement.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/router-plugin/src/core/route-hmr-statement.ts b/packages/router-plugin/src/core/route-hmr-statement.ts index 3af5d70dec9..f839466455f 100644 --- a/packages/router-plugin/src/core/route-hmr-statement.ts +++ b/packages/router-plugin/src/core/route-hmr-statement.ts @@ -1,5 +1,5 @@ import * as template from '@babel/template' -import type { AnyRoute, AnyRouteMatch } from '@tanstack/router-core' +import type { AnyRoute, AnyRouteMatch, AnyRouter } from '@tanstack/router-core' type AnyRouteWithPrivateProps = AnyRoute & { _path: string @@ -22,10 +22,10 @@ function handleRouteUpdate( const router = window.__TSR_ROUTER__! router.routesById[newRoute.id] = newRoute router.routesByPath[newRoute.fullPath] = newRoute - const oldRouteIndex = router.flatRoutes.indexOf(oldRoute) - if (oldRouteIndex > -1) { - router.flatRoutes[oldRouteIndex] = newRoute - } + router.processedTree.flatCache.clear() + router.processedTree.singleCache.clear() + // TODO: how to rebuild the tree if we add a new route? + walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree) const filter = (m: AnyRouteMatch) => m.routeId === oldRoute.id if ( router.state.matches.find(filter) || @@ -35,6 +35,17 @@ function handleRouteUpdate( } } +function walkReplaceSegmentTree(route: AnyRouteWithPrivateProps, node: AnyRouter['processedTree']['segmentTree']) { + if (node.route?.id === route.id) { + node.route = route + } + node.static?.forEach((child) => walkReplaceSegmentTree(route, child)) + node.staticInsensitive?.forEach((child) => walkReplaceSegmentTree(route, child)) + node.dynamic?.forEach((child) => walkReplaceSegmentTree(route, child)) + node.optional?.forEach((child) => walkReplaceSegmentTree(route, child)) + node.wildcard?.forEach((child) => walkReplaceSegmentTree(route, child)) +} + export const routeHmrStatement = template.statement( ` if (import.meta.hot) { From 66a38b2162418d5fc1a8924fd2582a8e28733514 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 17:32:09 +0100 Subject: [PATCH 037/109] remove flatRoutes from devtools --- .../router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index 081f5f9073e..b7b48932c1a 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -339,7 +339,6 @@ export const BaseTanStackRouterDevtoolsPanel = 'state', 'routesById', 'routesByPath', - 'flatRoutes', 'options', 'manifest', ] as const From b40c320c1c043ab3df52b5293eb17f99a552baf6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 17:34:08 +0100 Subject: [PATCH 038/109] wildcard suffix can include slashes --- packages/router-core/src/new-process-route-tree.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 935df72a69a..722b939f090 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -75,12 +75,13 @@ export function parseSegment( if (wildcardBracesMatch) { const prefix = wildcardBracesMatch[1]! const suffix = wildcardBracesMatch[2]! + const total = path.length output[0] = SEGMENT_TYPE_WILDCARD output[1] = start + prefix.length output[2] = start + prefix.length - output[3] = end - suffix.length - output[4] = end - suffix.length - output[5] = end + output[3] = total - suffix.length + output[4] = total - suffix.length + output[5] = total return } From 3c10a6c822203dee746f23f06177cffb589d0048 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 17:40:43 +0100 Subject: [PATCH 039/109] explore all branches before returning --- .../router-core/src/new-process-route-tree.ts | 78 ++++++++++++++++--- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 722b939f090..b4f5679634c 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -735,26 +735,57 @@ function getNodeMatch( depth: number /** Bitmask of skipped optional segments */ skipped: number + statics: number + dynamics: number + optionals: number } // use a stack to explore all possible paths (optional params cause branching) - // we use a depth-first search, return the first result found const stack: Array = [ - { node: segmentTree, index: 0, depth: 0, skipped: 0 }, + { + node: segmentTree, + index: 0, + depth: 0, + skipped: 0, + statics: 0, + dynamics: 0, + optionals: 0, + }, ] - let stackIndex = 0 let wildcardMatch: Frame | null = null let bestFuzzy: Frame | null = null + let bestMatch: Frame | null = null - while (stackIndex < stack.length) { + while (stack.length) { // eslint-disable-next-line prefer-const - let { node, index, skipped, depth } = stack[stackIndex++]! + let { node, index, skipped, depth, statics, dynamics, optionals } = + stack.pop()! - main: while (node && index <= parts.length) { + // // not sure if we need this check? looking for an edge-case to prove it either way + // main: while (node && index <= parts.length) { + main: while (node) { if (index === parts.length) { if (!node.route) break - return { node, skipped } + if ( + !bestMatch || + statics > bestMatch.statics || + (statics === bestMatch.statics && dynamics > bestMatch.dynamics) || + (statics === bestMatch.statics && + dynamics === bestMatch.dynamics && + optionals > bestMatch.optionals) + ) { + bestMatch = { + node, + index, + depth, + skipped, + statics, + dynamics, + optionals, + } + } + break } // In fuzzy mode, track the best partial match we've found so far @@ -765,7 +796,15 @@ function getNodeMatch( index > bestFuzzy.index || (index === bestFuzzy.index && depth > bestFuzzy.depth)) ) { - bestFuzzy = { node, index, depth, skipped } + bestFuzzy = { + node, + index, + depth, + skipped, + statics, + dynamics, + optionals, + } } const part = parts[index]! @@ -784,6 +823,9 @@ function getNodeMatch( index: index + 1, skipped, depth: depth + 1, + statics, + dynamics: dynamics + 1, + optionals, }) } } @@ -799,6 +841,9 @@ function getNodeMatch( index, skipped: nextSkipped, depth: nextDepth, + statics, + dynamics, + optionals, }) // enqueue skipping the optional } for (const segment of node.optional) { @@ -813,6 +858,9 @@ function getNodeMatch( index: index + 1, skipped, depth: nextDepth, + statics, + dynamics, + optionals: optionals + 1, }) } } @@ -824,6 +872,7 @@ function getNodeMatch( node = match depth++ index++ + statics++ continue } } @@ -835,6 +884,7 @@ function getNodeMatch( node = match depth++ index++ + statics++ continue } } @@ -854,7 +904,15 @@ function getNodeMatch( } // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match if (!wildcardMatch || wildcardMatch.index < index) { - wildcardMatch = { node: segment, index, skipped, depth } + wildcardMatch = { + node: segment, + index, + skipped, + depth, + statics, + dynamics, + optionals, + } } break main } @@ -865,6 +923,8 @@ function getNodeMatch( } } + if (bestMatch) return bestMatch + if (wildcardMatch) return wildcardMatch if (fuzzy && bestFuzzy) { From d20010e2e64dd4afb0a4a5c589aed9975d15f656 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 17:41:06 +0100 Subject: [PATCH 040/109] some unit tests --- .../tests/new-process-route-tree.test.ts | 504 ++++++++---------- 1 file changed, 233 insertions(+), 271 deletions(-) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 00ad14b2490..50f6648506d 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -6,6 +6,239 @@ import { } from '../src/new-process-route-tree' import type { AnyRoute, RouteMask } from '../src' +// import { createLRUCache } from '../src/lru-cache' +// import { processRouteTree as oldProcessRouteTree } from './old-process-route-tree' +// import { matchPathname } from './old-path' +// import big from '../src/Untitled-4.json' + +function makeTree(routes: Array) { + return processRouteTree({ + id: '__root__', + fullPath: '/', + path: '/', + children: routes.map((route) => ({ + id: route, + fullPath: route, + path: route, + })), + }).processedTree +} + +// describe('foo', () => { +// it('old', () => { +// const { flatRoutes } = oldProcessRouteTree({ routeTree: big }) +// const oldFindMatch = (path: string) => { +// for (const route of flatRoutes) { +// const params = matchPathname(path, { to: route.fullPath }) +// if (params) return { route, params } +// } +// return null +// } +// expect(oldFindMatch('/')?.route.id).toMatchInlineSnapshot(`"__root__"`) +// }) +// it('comp', () => { +// const { flatRoutes } = oldProcessRouteTree({ +// routeTree: { +// id: '__root__', +// fullPath: '/', +// path: '/', +// children: [ +// { +// id: '/{-id}', +// fullPath: '/{-id}', +// path: '{-id}', +// } +// ] +// } +// }) +// const oldFindMatch = (path: string) => { +// for (const route of flatRoutes) { +// const params = matchPathname(path, { to: route.fullPath }) +// if (params) return { route, params } +// } +// return null +// } +// expect(oldFindMatch('/')?.route.id).toMatchInlineSnapshot(`"__root__"`) +// }) +// }) + +describe('findRouteMatch', () => { + describe('priority', () => { + it('/static/optional vs /static/dynamic', () => { + const tree = makeTree(['/foo/{-$id}', '/foo/$id']) + expect(findRouteMatch('/foo/123', tree)?.route.id).toBe('/foo/$id') + }) + it('/static/optional/static vs /static/dynamic/static', () => { + const tree = makeTree(['/a/{-$b}/c', '/a/$b/c']) + expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/a/$b/c') + }) + it('/static/optional/dynamic vs /static/dynamic/static', () => { + const tree = makeTree(['/a/{-$b}/$c', '/a/$b/c']) + expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/a/$b/c') + }) + it('/static/optional/static vs /static/dynamic', () => { + const tree = makeTree(['/users/{-$org}/settings', '/users/$id']) + expect(findRouteMatch('/users/settings', tree)?.route.id).toBe( + '/users/{-$org}/settings', + ) + }) + it('/optional/static/static vs /static/dynamic', () => { + const tree = makeTree(['/{-$other}/posts/new', '/posts/$id']) + expect(findRouteMatch('/posts/new', tree)?.route.id).toBe( + '/{-$other}/posts/new', + ) + }) + it('/optional/static/static/dynamic vs /static/dynamic/static/dynamic', () => { + const tree = makeTree(['/{-$other}/posts/a/b/$c', '/posts/$a/b/$c']) + expect(findRouteMatch('/posts/a/b/c', tree)?.route.id).toBe( + '/{-$other}/posts/a/b/$c', + ) + }) + }) + + describe('basic matching', () => { + it('root', () => { + const tree = makeTree([]) + expect(findRouteMatch('/', tree)?.route.id).toBe('__root__') + }) + it('single static', () => { + const tree = makeTree(['/a']) + expect(findRouteMatch('/a', tree)?.route.id).toBe('/a') + }) + it('single dynamic', () => { + const tree = makeTree(['/$id']) + expect(findRouteMatch('/123', tree)?.route.id).toBe('/$id') + }) + it('single optional', () => { + const tree = makeTree(['/{-$id}']) + expect(findRouteMatch('/123', tree)?.route.id).toBe('/{-$id}') + // expect(findRouteMatch('/', tree)?.route.id).toBe('/{-$id}') + // // ^^^ fails, returns '__root__' + }) + it('single wildcard', () => { + const tree = makeTree(['/$']) + expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/$') + }) + + it('dynamic w/ prefix', () => { + const tree = makeTree(['/{$id}.txt']) + expect(findRouteMatch('/123.txt', tree)?.route.id).toBe('/{$id}.txt') + }) + it('dynamic w/ suffix', () => { + const tree = makeTree(['/file{$id}']) + expect(findRouteMatch('/file123', tree)?.route.id).toBe('/file{$id}') + }) + it('dynamic w/ prefix and suffix', () => { + const tree = makeTree(['/file{$id}.txt']) + expect(findRouteMatch('/file123.txt', tree)?.route.id).toBe( + '/file{$id}.txt', + ) + }) + it('optional w/ prefix', () => { + const tree = makeTree(['/{-$id}.txt']) + expect(findRouteMatch('/123.txt', tree)?.route.id).toBe('/{-$id}.txt') + expect(findRouteMatch('.txt', tree)?.route.id).toBe('/{-$id}.txt') + }) + it('optional w/ suffix', () => { + const tree = makeTree(['/file{-$id}']) + expect(findRouteMatch('/file123', tree)?.route.id).toBe('/file{-$id}') + expect(findRouteMatch('/file', tree)?.route.id).toBe('/file{-$id}') + }) + it('optional w/ prefix and suffix', () => { + const tree = makeTree(['/file{-$id}.txt']) + expect(findRouteMatch('/file123.txt', tree)?.route.id).toBe( + '/file{-$id}.txt', + ) + expect(findRouteMatch('/file.txt', tree)?.route.id).toBe( + '/file{-$id}.txt', + ) + }) + it('wildcard w/ prefix', () => { + const tree = makeTree(['/file{$}']) + expect(findRouteMatch('/file/a/b/c', tree)?.route.id).toBe('/file{$}') + }) + it('wildcard w/ suffix', () => { + const tree = makeTree(['/{$}/file']) + expect(findRouteMatch('/a/b/c/file', tree)?.route.id).toBe('/{$}/file') + }) + it('wildcard w/ prefix and suffix', () => { + const tree = makeTree(['/file{$}end']) + expect(findRouteMatch('/file/a/b/c/end', tree)?.route.id).toBe( + '/file{$}end', + ) + }) + + it('edge-case: a single required param early on doesnt prevent another match further down', () => { + const tree = makeTree(['/$one/a/b', '/$two/a/c']) + expect(findRouteMatch('/1/a/b', tree)?.route.id).toBe('/$one/a/b') + expect(findRouteMatch('/2/a/c', tree)?.route.id).toBe('/$two/a/c') + }) + it('edge-case: a single static param early on doesnt prevent another match further down', () => { + const tree = makeTree(['/x/y/z', '/$id/y/w']) + expect(findRouteMatch('/x/y/z', tree)?.route.id).toBe('/x/y/z') + expect(findRouteMatch('/x/y/w', tree)?.route.id).toBe('/$id/y/w') + }) + it('edge-case: presence of a valid wildcard doesnt prevent other matches', () => { + const tree = makeTree(['/yo/foo{-$id}bar/ma', '/yo/$']) + expect(findRouteMatch('/yo/foobar/ma', tree)?.route.id).toBe( + '/yo/foo{-$id}bar/ma', + ) + expect(findRouteMatch('/yo/foo123bar/ma', tree)?.route.id).toBe( + '/yo/foo{-$id}bar/ma', + ) + }) + }) + + describe('nested routes', () => { + const routeTree = { + id: '__root__', + fullPath: '/', + path: '/', + children: [ + { + id: '/a', + fullPath: '/a', + path: 'a', + children: [ + { + id: '/a/b', + fullPath: '/a/b', + path: '/b', + children: [ + { + id: '/a/b/c/d', + fullPath: '/a/b/c/d', + path: '/c/d', + }, + ], + }, + ], + }, + ], + } + const { processedTree } = processRouteTree(routeTree) + it('matches the deepest route', () => { + expect(findRouteMatch('/a/b/c/d', processedTree)?.route.id).toBe( + '/a/b/c/d', + ) + }) + it('matches an intermediate route', () => { + expect(findRouteMatch('/a/b', processedTree)?.route.id).toBe('/a/b') + }) + it('matches the root child route', () => { + expect(findRouteMatch('/a', processedTree)?.route.id).toBe('/a') + }) + it('matches the root route', () => { + expect(findRouteMatch('/', processedTree)?.route.id).toBe('__root__') + }) + it('does not match a route that doesnt exist', () => { + expect(findRouteMatch('/a/b/c', processedTree)).toBeNull() + }) + }) + + describe.todo('fuzzy matching', () => {}) +}) + describe('processFlatRouteList', () => { it('processes a route masks list', () => { const routeTree = {} as AnyRoute @@ -172,274 +405,3 @@ describe('processFlatRouteList', () => { `) }) }) - -describe('findMatch', () => { - const testTree = { - id: '__root__', - fullPath: '/', - path: '/', - children: [ - { - id: '/yo', - fullPath: '/yo', - path: 'yo', - children: [ - { - id: '/yo/foo{-$id}bar', - fullPath: '/yo/foo{-$id}bar', - path: 'foo{-$id}bar', - children: [ - { - id: '/yo/foo{-$id}bar/ma', - fullPath: '/yo/foo{-$id}bar/ma', - path: 'ma', - }, - ], - }, - { - id: '/yo/{$}.png', - fullPath: '/yo/{$}.png', - path: '{$}.png', - }, - { - id: '/yo/$', - fullPath: '/yo/$', - path: '$', - }, - ], - }, - { - id: '/foo', - fullPath: '/foo', - path: 'foo', - children: [ - { - id: '/foo/$a/aaa', - fullPath: '/foo/$a/aaa', - path: '$a/aaa', - }, - { - id: '/foo/$b/bbb', - fullPath: '/foo/$b/bbb', - path: '$b/bbb', - }, - ], - }, - { - id: '/x/y/z', - fullPath: '/x/y/z', - path: 'x/y/z', - }, - { - id: '/$id/y/w', - fullPath: '/$id/y/w', - path: '$id/y/w', - }, - { - id: '/{-$other}/posts/new', - fullPath: '/{-$other}/posts/new', - path: '{-$other}/posts/new', - }, - { - id: '/posts/$id', - fullPath: '/posts/$id', - path: 'posts/$id', - }, - ], - } - - const { processedTree } = processRouteTree(testTree) - - it('foo', () => { - expect(findRouteMatch('/posts/new', processedTree)).toMatchInlineSnapshot(` - { - "params": { - "other": "", - }, - "route": { - "fullPath": "/{-$other}/posts/new", - "id": "/{-$other}/posts/new", - "path": "{-$other}/posts/new", - }, - } - `) - expect(findRouteMatch('/yo/posts/new', processedTree)) - .toMatchInlineSnapshot(` - { - "params": { - "other": "yo", - }, - "route": { - "fullPath": "/{-$other}/posts/new", - "id": "/{-$other}/posts/new", - "path": "{-$other}/posts/new", - }, - } - `) - expect(findRouteMatch('/x/y/w', processedTree)).toMatchInlineSnapshot(` - { - "params": { - "id": "x", - }, - "route": { - "fullPath": "/$id/y/w", - "id": "/$id/y/w", - "path": "$id/y/w", - }, - } - `) - }) - - it('works w/ optional params when param is present', () => { - expect(findRouteMatch('/yo/foo123bar/ma', processedTree)) - .toMatchInlineSnapshot(` - { - "params": { - "id": "123", - }, - "route": { - "fullPath": "/yo/foo{-$id}bar/ma", - "id": "/yo/foo{-$id}bar/ma", - "path": "ma", - }, - } - `) - }) - it('works w/ optional params when param is absent', () => { - expect(findRouteMatch('/yo/ma', processedTree)).toMatchInlineSnapshot(` - { - "params": { - "id": "", - }, - "route": { - "fullPath": "/yo/foo{-$id}bar/ma", - "id": "/yo/foo{-$id}bar/ma", - "path": "ma", - }, - } - `) - }) - it('works w/ wildcard and suffix', () => { - expect(findRouteMatch('/yo/somefile.png', processedTree)) - .toMatchInlineSnapshot(` - { - "params": { - "*": "somefile", - }, - "route": { - "fullPath": "/yo/{$}.png", - "id": "/yo/{$}.png", - "path": "{$}.png", - }, - } - `) - }) - it('works w/ wildcard alone', () => { - expect(findRouteMatch('/yo/something', processedTree)) - .toMatchInlineSnapshot(` - { - "params": { - "*": "something", - }, - "route": { - "fullPath": "/yo/$", - "id": "/yo/$", - "path": "$", - }, - } - `) - }) - it('works w/ multiple required param routes at same level, w/ different names for their param', () => { - expect(findRouteMatch('/foo/123/aaa', processedTree)) - .toMatchInlineSnapshot(` - { - "params": { - "a": "123", - }, - "route": { - "fullPath": "/foo/$a/aaa", - "id": "/foo/$a/aaa", - "path": "$a/aaa", - }, - } - `) - expect(findRouteMatch('/foo/123/bbb', processedTree)) - .toMatchInlineSnapshot(` - { - "params": { - "b": "123", - }, - "route": { - "fullPath": "/foo/$b/bbb", - "id": "/foo/$b/bbb", - "path": "$b/bbb", - }, - } - `) - }) - - it('works w/ fuzzy matching', () => { - expect(findRouteMatch('/foo/123', processedTree, true)) - .toMatchInlineSnapshot(` - { - "params": { - "**": "/123", - }, - "route": { - "children": [ - { - "fullPath": "/foo/$a/aaa", - "id": "/foo/$a/aaa", - "path": "$a/aaa", - }, - { - "fullPath": "/foo/$b/bbb", - "id": "/foo/$b/bbb", - "path": "$b/bbb", - }, - ], - "fullPath": "/foo", - "id": "/foo", - "path": "foo", - }, - } - `) - }) - it('can still return exact matches w/ fuzzy:true', () => { - expect(findRouteMatch('/yo/foobar', processedTree, true)) - .toMatchInlineSnapshot(` - { - "params": { - "id": "", - }, - "route": { - "children": [ - { - "fullPath": "/yo/foo{-$id}bar/ma", - "id": "/yo/foo{-$id}bar/ma", - "path": "ma", - }, - ], - "fullPath": "/yo/foo{-$id}bar", - "id": "/yo/foo{-$id}bar", - "path": "foo{-$id}bar", - }, - } - `) - }) - it('can still match a wildcard route w/ fuzzy:true', () => { - expect(findRouteMatch('/yo/something', processedTree, true)) - .toMatchInlineSnapshot(` - { - "params": { - "*": "something", - }, - "route": { - "fullPath": "/yo/$", - "id": "/yo/$", - "path": "$", - }, - } - `) - }) -}) From ba4cc90a441124d54100eff28e9ce9f521141b77 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 17:41:24 +0100 Subject: [PATCH 041/109] hmr plugin prettify --- packages/router-plugin/src/core/route-hmr-statement.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/router-plugin/src/core/route-hmr-statement.ts b/packages/router-plugin/src/core/route-hmr-statement.ts index f839466455f..7e41531f7df 100644 --- a/packages/router-plugin/src/core/route-hmr-statement.ts +++ b/packages/router-plugin/src/core/route-hmr-statement.ts @@ -35,12 +35,17 @@ function handleRouteUpdate( } } -function walkReplaceSegmentTree(route: AnyRouteWithPrivateProps, node: AnyRouter['processedTree']['segmentTree']) { +function walkReplaceSegmentTree( + route: AnyRouteWithPrivateProps, + node: AnyRouter['processedTree']['segmentTree'], +) { if (node.route?.id === route.id) { node.route = route } node.static?.forEach((child) => walkReplaceSegmentTree(route, child)) - node.staticInsensitive?.forEach((child) => walkReplaceSegmentTree(route, child)) + node.staticInsensitive?.forEach((child) => + walkReplaceSegmentTree(route, child), + ) node.dynamic?.forEach((child) => walkReplaceSegmentTree(route, child)) node.optional?.forEach((child) => walkReplaceSegmentTree(route, child)) node.wildcard?.forEach((child) => walkReplaceSegmentTree(route, child)) From 5f6236cce8805714047aba8a0ac1fe4bd6de9789 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 18:36:46 +0100 Subject: [PATCH 042/109] init route before processing --- packages/router-core/src/new-process-route-tree.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index b4f5679634c..a856ad43a8d 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -138,8 +138,9 @@ function parseSegments( start: number, node: AnySegmentNode, depth: number, - onRoute?: (route: TRouteLike, node: AnySegmentNode) => void, + onRoute?: (route: TRouteLike) => void, ) { + onRoute?.(route) let cursor = start { const path = route.fullPath ?? route.from @@ -291,7 +292,6 @@ function parseSegments( node = nextNode } if (route.path || !route.children) node.route = route - onRoute?.(route, node) } if (route.children) for (const child of route.children) { @@ -492,7 +492,7 @@ export type ProcessedTree< singleCache: Map> } -export function processFlatRouteList( +export function processFlatRouteList>( routeList: Array, ) { const segmentTree = createStaticNode('/') @@ -599,7 +599,7 @@ export function processRouteTree< 1, segmentTree, 0, - (route, node) => { + (route) => { initRoute?.(route, index) invariant( From 37515348a098c2723ffba8d710d6b225b89e769a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 18:37:05 +0100 Subject: [PATCH 043/109] more unit --- .../tests/new-process-route-tree.test.ts | 167 +----------------- 1 file changed, 9 insertions(+), 158 deletions(-) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 50f6648506d..40903d96c6d 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -88,6 +88,10 @@ describe('findRouteMatch', () => { '/{-$other}/posts/new', ) }) + it('/optional/static/static vs /static/static', () => { + const tree = makeTree(['/{-$other}/posts/new', '/posts/new']) + expect(findRouteMatch('/posts/new', tree)?.route.id).toBe('/posts/new') + }) it('/optional/static/static/dynamic vs /static/dynamic/static/dynamic', () => { const tree = makeTree(['/{-$other}/posts/a/b/$c', '/posts/$a/b/$c']) expect(findRouteMatch('/posts/a/b/c', tree)?.route.id).toBe( @@ -158,8 +162,8 @@ describe('findRouteMatch', () => { expect(findRouteMatch('/file/a/b/c', tree)?.route.id).toBe('/file{$}') }) it('wildcard w/ suffix', () => { - const tree = makeTree(['/{$}/file']) - expect(findRouteMatch('/a/b/c/file', tree)?.route.id).toBe('/{$}/file') + const tree = makeTree(['/{$}/c/file']) + expect(findRouteMatch('/a/b/c/file', tree)?.route.id).toBe('/{$}/c/file') }) it('wildcard w/ prefix and suffix', () => { const tree = makeTree(['/file{$}end']) @@ -236,10 +240,10 @@ describe('findRouteMatch', () => { }) }) - describe.todo('fuzzy matching', () => {}) + describe.todo('fuzzy matching', () => { }) }) -describe('processFlatRouteList', () => { +describe.todo('processFlatRouteList', () => { it('processes a route masks list', () => { const routeTree = {} as AnyRoute const routeMasks: Array> = [ @@ -249,159 +253,6 @@ describe('processFlatRouteList', () => { { from: '/a/{-$optional}/d', routeTree }, { from: '/a/b/{$}.txt', routeTree }, ] - expect(processFlatRouteList(routeMasks)).toMatchInlineSnapshot(` - { - "depth": 0, - "dynamic": null, - "fullPath": "/", - "kind": 0, - "optional": null, - "parent": null, - "route": null, - "static": null, - "staticInsensitive": Map { - "a" => { - "depth": 2, - "dynamic": [ - { - "caseSensitive": false, - "depth": 2, - "dynamic": null, - "fullPath": "/a/$param/d", - "kind": 1, - "optional": null, - "parent": [Circular], - "prefix": undefined, - "route": null, - "static": null, - "staticInsensitive": Map { - "d" => { - "depth": 3, - "dynamic": null, - "fullPath": "/a/$param/d", - "kind": 0, - "optional": null, - "parent": [Circular], - "route": { - "from": "/a/$param/d", - "routeTree": {}, - }, - "static": null, - "staticInsensitive": null, - "wildcard": null, - }, - }, - "suffix": undefined, - "wildcard": null, - }, - ], - "fullPath": "/a/b/c", - "kind": 0, - "optional": [ - { - "caseSensitive": false, - "depth": 2, - "dynamic": null, - "fullPath": "/a/{-$optional}/d", - "kind": 3, - "optional": null, - "parent": [Circular], - "prefix": undefined, - "route": null, - "static": null, - "staticInsensitive": Map { - "d" => { - "depth": 3, - "dynamic": null, - "fullPath": "/a/{-$optional}/d", - "kind": 0, - "optional": null, - "parent": [Circular], - "route": { - "from": "/a/{-$optional}/d", - "routeTree": {}, - }, - "static": null, - "staticInsensitive": null, - "wildcard": null, - }, - }, - "suffix": undefined, - "wildcard": null, - }, - ], - "parent": [Circular], - "route": null, - "static": null, - "staticInsensitive": Map { - "b" => { - "depth": 3, - "dynamic": null, - "fullPath": "/a/b/c", - "kind": 0, - "optional": null, - "parent": [Circular], - "route": null, - "static": null, - "staticInsensitive": Map { - "c" => { - "depth": 4, - "dynamic": null, - "fullPath": "/a/b/c", - "kind": 0, - "optional": null, - "parent": [Circular], - "route": { - "from": "/a/b/c", - "routeTree": {}, - }, - "static": null, - "staticInsensitive": null, - "wildcard": null, - }, - "d" => { - "depth": 2, - "dynamic": null, - "fullPath": "/a/b/d", - "kind": 0, - "optional": null, - "parent": [Circular], - "route": { - "from": "/a/b/d", - "routeTree": {}, - }, - "static": null, - "staticInsensitive": null, - "wildcard": null, - }, - }, - "wildcard": [ - { - "caseSensitive": false, - "depth": 2, - "dynamic": null, - "fullPath": "/a/b/{$}.txt", - "kind": 2, - "optional": null, - "parent": [Circular], - "prefix": undefined, - "route": { - "from": "/a/b/{$}.txt", - "routeTree": {}, - }, - "static": null, - "staticInsensitive": null, - "suffix": ".txt", - "wildcard": null, - }, - ], - }, - }, - "wildcard": null, - }, - }, - "wildcard": null, - } - `) + // expect(processFlatRouteList(routeMasks)).toMatchInlineSnapshot() }) }) From 9f609671b84129523e65a73d2c9eb8559177722f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 19:10:58 +0100 Subject: [PATCH 044/109] remove commented out --- .../router-core/src/new-process-route-tree.ts | 2 -- .../tests/new-process-route-tree.test.ts | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index a856ad43a8d..5ff3838cb46 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -762,8 +762,6 @@ function getNodeMatch( let { node, index, skipped, depth, statics, dynamics, optionals } = stack.pop()! - // // not sure if we need this check? looking for an edge-case to prove it either way - // main: while (node && index <= parts.length) { main: while (node) { if (index === parts.length) { if (!node.route) break diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 40903d96c6d..7e427587395 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -100,6 +100,23 @@ describe('findRouteMatch', () => { }) }) + describe('not found', () => { + it('returns null when no match is found', () => { + const tree = makeTree(['/a/b/c', '/d/e/f']) + expect(findRouteMatch('/x/y/z', tree)).toBeNull() + }) + it('returns something w/ fuzzy matching enabled', () => { + const tree = makeTree(['/a/b/c', '/d/e/f']) + const match = findRouteMatch('/x/y/z', tree, true) + expect(match?.route?.id).toBe('__root__') + expect(match?.params).toMatchInlineSnapshot(` + { + "**": "/x/y/z", + } + `) + }) + }) + describe('basic matching', () => { it('root', () => { const tree = makeTree([]) From caa7fe1cf048fbfedee0b591097776d759ded5be Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 19:11:29 +0100 Subject: [PATCH 045/109] _splat and * --- packages/router-core/src/new-process-route-tree.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 5ff3838cb46..9219f49d077 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -703,10 +703,12 @@ function extractParams( } } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node - params['*'] = path.substring( + const rest = path.substring( currentPathIndex + (n.prefix?.length ?? 0), path.length - (n.suffix?.length ?? 0), ) + params['*'] = rest + params._splat = rest break } } From dfa98e9cda60d6270b4223ffdaf6b3f83629bc9f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 19:12:03 +0100 Subject: [PATCH 046/109] prettier --- .../router-core/src/new-process-route-tree.ts | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 9219f49d077..7d4a0343ba7 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -492,9 +492,9 @@ export type ProcessedTree< singleCache: Map> } -export function processFlatRouteList>( - routeList: Array, -) { +export function processFlatRouteList< + TRouteLike extends Extract, +>(routeList: Array) { const segmentTree = createStaticNode('/') const data = new Uint16Array(6) for (const route of routeList) { @@ -592,33 +592,25 @@ export function processRouteTree< const routesById = {} as Record const routesByPath = {} as Record let index = 0 - parseSegments( - caseSensitive, - data, - routeTree, - 1, - segmentTree, - 0, - (route) => { - initRoute?.(route, index) + parseSegments(caseSensitive, data, routeTree, 1, segmentTree, 0, (route) => { + initRoute?.(route, index) - invariant( - !(route.id in routesById), - `Duplicate routes found with id: ${String(route.id)}`, - ) + invariant( + !(route.id in routesById), + `Duplicate routes found with id: ${String(route.id)}`, + ) - routesById[route.id] = route + routesById[route.id] = route - if (index !== 0 && route.path) { - const trimmedFullPath = trimPathRight(route.fullPath) - if (!routesByPath[trimmedFullPath] || route.fullPath.endsWith('/')) { - routesByPath[trimmedFullPath] = route - } + if (index !== 0 && route.path) { + const trimmedFullPath = trimPathRight(route.fullPath) + if (!routesByPath[trimmedFullPath] || route.fullPath.endsWith('/')) { + routesByPath[trimmedFullPath] = route } + } - index++ - }, - ) + index++ + }) sortTreeNodes(segmentTree) const processedTree: ProcessedTree = { segmentTree, From e518239cd15dba081e1f95cfec31f1658d25f05b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 20:10:06 +0100 Subject: [PATCH 047/109] fix single match and flat match --- packages/router-core/src/new-process-route-tree.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 7d4a0343ba7..992cb5a39dd 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -498,7 +498,7 @@ export function processFlatRouteList< const segmentTree = createStaticNode('/') const data = new Uint16Array(6) for (const route of routeList) { - parseSegments(false, data, route, 1, segmentTree, 1) + parseSegments(false, data, route, 1, segmentTree, 0) } sortTreeNodes(segmentTree) return segmentTree @@ -542,7 +542,7 @@ export function findSingleMatch( // if we haven't seen this route before, process it now tree = createStaticNode<{ from: string }>('/') const data = new Uint16Array(6) - parseSegments(caseSensitive, data, { from }, 1, tree, 1) + parseSegments(caseSensitive, data, { from }, 1, tree, 0) processedTree.singleCache.set(key, tree) } return findMatch(path, tree, fuzzy) From b8c1c2ea1f77e244be23fba3a215849b8b91228d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 21:46:08 +0100 Subject: [PATCH 048/109] misc optims --- .../router-core/src/new-process-route-tree.ts | 65 ++++++++++--------- .../tests/new-process-route-tree.test.ts | 54 +++++++++++++++ 2 files changed, 90 insertions(+), 29 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 992cb5a39dd..679dc0f5842 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -776,6 +776,8 @@ function getNodeMatch( dynamics, optionals, } + // perfect match, no need to continue + if (statics === parts.length) return bestMatch } break } @@ -800,13 +802,30 @@ function getNodeMatch( } const part = parts[index]! + let lowerPart: string + + // 1. Try static match + if (node.static) { + const match = node.static.get(part) + if (match) { + stack.push({ + node: match, + index: index + 1, + skipped, + depth: depth + 1, + statics: statics + 1, + dynamics, + optionals, + }) + } + } // 3. Try dynamic match if (node.dynamic) { for (const segment of node.dynamic) { const { prefix, suffix } = segment if (prefix || suffix) { - const casePart = segment.caseSensitive ? part : part.toLowerCase() + const casePart = segment.caseSensitive ? part : (lowerPart ??= part.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } @@ -841,7 +860,7 @@ function getNodeMatch( for (const segment of node.optional) { const { prefix, suffix } = segment if (prefix || suffix) { - const casePart = segment.caseSensitive ? part : part.toLowerCase() + const casePart = segment.caseSensitive ? part : (lowerPart ??= part.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } @@ -857,41 +876,17 @@ function getNodeMatch( } } - // 1. Try static match - if (node.static) { - const match = node.static.get(part) - if (match) { - node = match - depth++ - index++ - statics++ - continue - } - } - - // 2. Try case insensitive static match - if (node.staticInsensitive) { - const match = node.staticInsensitive.get(part.toLowerCase()) - if (match) { - node = match - depth++ - index++ - statics++ - continue - } - } - // 5. Try wildcard match if (node.wildcard) { for (const segment of node.wildcard) { const { prefix, suffix } = segment if (prefix) { - const casePart = segment.caseSensitive ? part : part.toLowerCase() + const casePart = segment.caseSensitive ? part : (lowerPart ??= part.toLowerCase()) if (!casePart.startsWith(prefix)) continue } if (suffix) { - const part = parts[parts.length - 1]! - const casePart = segment.caseSensitive ? part : part.toLowerCase() + const lastPart = parts[parts.length - 1]! + const casePart = segment.caseSensitive ? lastPart : lastPart.toLowerCase() if (!casePart.endsWith(suffix)) continue } // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match @@ -910,6 +905,18 @@ function getNodeMatch( } } + // 2. Try case insensitive static match + if (node.staticInsensitive) { + const match = node.staticInsensitive.get((lowerPart ??= part.toLowerCase())) + if (match) { + node = match + depth++ + index++ + statics++ + continue + } + } + // No match found break } diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 7e427587395..52e8aaf020d 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -98,6 +98,20 @@ describe('findRouteMatch', () => { '/{-$other}/posts/a/b/$c', ) }) + it('?? is this what we want?', () => { + const tree = makeTree([ + '/{-$a}/{-$b}/{-$c}/d/e', + '/$a/$b/c/d/$e' + ]) + expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe('/$a/$b/c/d/$e') + }) + it('?? is this what we want?', () => { + const tree = makeTree([ + '/$a/$b/$c/d/e', + '/$a/$b/c/d/$e' + ]) + expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe('/$a/$b/c/d/$e') + }) }) describe('not found', () => { @@ -117,6 +131,46 @@ describe('findRouteMatch', () => { }) }) + describe('case sensitivity competition', () => { + it('a case sensitive segment early on should not prevent a case insensitive match', () => { + const tree = { + id: '__root__', + fullPath: '/', + path: '/', + children: [ + { + id: '/Foo', + fullPath: '/Foo', + path: 'Foo', + options: { caseSensitive: false }, + children: [ + { + id: '/Foo/a', + fullPath: '/Foo/a', + path: '/a', + } + ] + }, { + id: '/foo', + fullPath: '/foo', + path: 'foo', + options: { caseSensitive: true }, + children: [ + { + id: '/foo/b', + fullPath: '/foo/b', + path: 'b', + } + ] + } + ] + } + const { processedTree } = processRouteTree(tree) + expect(findRouteMatch('/foo/a', processedTree)?.route.id).toBe('/Foo/a') + expect(findRouteMatch('/foo/b', processedTree)?.route.id).toBe('/foo/b') + }) + }) + describe('basic matching', () => { it('root', () => { const tree = makeTree([]) From 7e2ef5d58ccc5ac253fbf378858ed330bdbcf465 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 23:03:08 +0100 Subject: [PATCH 049/109] prettier --- .../router-core/src/new-process-route-tree.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 679dc0f5842..8ebaaf2cceb 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -825,7 +825,9 @@ function getNodeMatch( for (const segment of node.dynamic) { const { prefix, suffix } = segment if (prefix || suffix) { - const casePart = segment.caseSensitive ? part : (lowerPart ??= part.toLowerCase()) + const casePart = segment.caseSensitive + ? part + : (lowerPart ??= part.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } @@ -860,7 +862,9 @@ function getNodeMatch( for (const segment of node.optional) { const { prefix, suffix } = segment if (prefix || suffix) { - const casePart = segment.caseSensitive ? part : (lowerPart ??= part.toLowerCase()) + const casePart = segment.caseSensitive + ? part + : (lowerPart ??= part.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } @@ -881,12 +885,16 @@ function getNodeMatch( for (const segment of node.wildcard) { const { prefix, suffix } = segment if (prefix) { - const casePart = segment.caseSensitive ? part : (lowerPart ??= part.toLowerCase()) + const casePart = segment.caseSensitive + ? part + : (lowerPart ??= part.toLowerCase()) if (!casePart.startsWith(prefix)) continue } if (suffix) { const lastPart = parts[parts.length - 1]! - const casePart = segment.caseSensitive ? lastPart : lastPart.toLowerCase() + const casePart = segment.caseSensitive + ? lastPart + : lastPart.toLowerCase() if (!casePart.endsWith(suffix)) continue } // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match @@ -907,7 +915,9 @@ function getNodeMatch( // 2. Try case insensitive static match if (node.staticInsensitive) { - const match = node.staticInsensitive.get((lowerPart ??= part.toLowerCase())) + const match = node.staticInsensitive.get( + (lowerPart ??= part.toLowerCase()), + ) if (match) { node = match depth++ From 58775f7c32b56d425049cd19c46dd3ebf7dbd9af Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 23:03:58 +0100 Subject: [PATCH 050/109] cache resolvePath --- packages/router-core/src/path.ts | 15 ++++++++++++++- packages/router-core/src/router.ts | 4 ++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 7ac1b3ca650..28c44c22899 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -7,6 +7,7 @@ import { parseSegment, } from './new-process-route-tree' import type { SegmentKind } from './new-process-route-tree' +import type { LRUCache } from './lru-cache' /** Join path segments, cleaning duplicate slashes between parts. */ /** Join path segments, cleaning duplicate slashes between parts. */ @@ -106,6 +107,7 @@ interface ResolvePathOptions { base: string to: string trailingSlash?: 'always' | 'never' | 'preserve' + cache?: LRUCache } /** @@ -116,7 +118,16 @@ export function resolvePath({ base, to, trailingSlash = 'never', + cache, }: ResolvePathOptions) { + let key + if (cache) { + // `trailingSlash` is static per router, so it doesn't need to be part of the cache key + key = base + '\\\\' + to + const cached = cache.get(key) + if (cached) return cached + } + let baseSegments: Array const toSegments = to.split('/') if (toSegments[0] === '') { @@ -185,7 +196,9 @@ export function resolvePath({ } } joined = cleanPath(joined) - return joined || '/' + const result = joined || '/' + if (key && cache) cache.set(key, result) + return result } interface InterpolatePathOptions { diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index ed7cb4e4f79..a4cfefbff0f 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -22,6 +22,7 @@ import { trimPath, trimPathRight, } from './path' +import { createLRUCache } from './lru-cache' import { isNotFound } from './not-found' import { setupScrollRestoration } from './scroll-restoration' import { defaultParseSearch, defaultStringifySearch } from './searchParams' @@ -1203,12 +1204,15 @@ export class RouterCore< return location } + resolvePathCache = createLRUCache(1000) + /** Resolve a path against the router basepath and trailing-slash policy. */ resolvePathWithBase = (from: string, path: string) => { const resolvedPath = resolvePath({ base: from, to: cleanPath(path), trailingSlash: this.options.trailingSlash, + cache: this.resolvePathCache, }) return resolvedPath } From bde9344acb39d6b54f04bc560ca7a92ed4707370 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Nov 2025 23:34:34 +0100 Subject: [PATCH 051/109] resolvePath: improve cache hit rate, add common path skips --- packages/router-core/src/path.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 28c44c22899..81e338b75a1 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -120,24 +120,29 @@ export function resolvePath({ trailingSlash = 'never', cache, }: ResolvePathOptions) { + const isAbsolute = to.startsWith('/') + const isBase = !isAbsolute && to === '.' + let key if (cache) { // `trailingSlash` is static per router, so it doesn't need to be part of the cache key - key = base + '\\\\' + to + key = isAbsolute ? to : isBase ? base : base + '\\\\' + to const cached = cache.get(key) if (cached) return cached } let baseSegments: Array - const toSegments = to.split('/') - if (toSegments[0] === '') { - baseSegments = toSegments + if (isBase) { + baseSegments = base.split('/') + } else if (isAbsolute) { + baseSegments = to.split('/') } else { baseSegments = base.split('/') while (baseSegments.length > 1 && last(baseSegments) === '') { baseSegments.pop() } + const toSegments = to.split('/') for (let index = 0, length = toSegments.length; index < length; index++) { const value = toSegments[index]! if (value === '') { From 89bf3bef86dde3f1fc4d3f324b59831650becc35 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 09:47:02 +0100 Subject: [PATCH 052/109] restore some matching tests --- .../router-core/tests/match-by-path.test.ts | 29 +++++++++++++++++-- .../tests/new-process-route-tree.test.ts | 27 +++++++---------- .../tests/optional-path-params-clean.test.ts | 26 ++++++++++++++++- 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/packages/router-core/tests/match-by-path.test.ts b/packages/router-core/tests/match-by-path.test.ts index d42c1c6c887..53d721ce723 100644 --- a/packages/router-core/tests/match-by-path.test.ts +++ b/packages/router-core/tests/match-by-path.test.ts @@ -1,6 +1,29 @@ import { describe, expect, it } from 'vitest' +import { + findSingleMatch, + processRouteTree, +} from '../src/new-process-route-tree' -describe.skip('default path matching', () => { +const { processedTree } = processRouteTree({ + id: '__root__', + fullPath: '/', + path: '/', +}) +const matchByPath = ( + from: string, + options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, +) => { + const match = findSingleMatch( + options.to, + options.caseSensitive ?? false, + options.fuzzy ?? false, + from, + processedTree, + ) + return match ? match.params : undefined +} + +describe('default path matching', () => { it.each([ ['', '', {}], ['/', '', {}], @@ -75,7 +98,7 @@ describe.skip('default path matching', () => { }) }) -describe.skip('case insensitive path matching', () => { +describe('case insensitive path matching', () => { it.each([ ['', '', '', {}], ['', '/', '', {}], @@ -135,7 +158,7 @@ describe.skip('case insensitive path matching', () => { }) }) -describe.skip('fuzzy path matching', () => { +describe('fuzzy path matching', () => { it.each([ ['', '', '', {}], ['', '/', '', {}], diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 52e8aaf020d..bfb0cee316d 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -99,17 +99,11 @@ describe('findRouteMatch', () => { ) }) it('?? is this what we want?', () => { - const tree = makeTree([ - '/{-$a}/{-$b}/{-$c}/d/e', - '/$a/$b/c/d/$e' - ]) + const tree = makeTree(['/{-$a}/{-$b}/{-$c}/d/e', '/$a/$b/c/d/$e']) expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe('/$a/$b/c/d/$e') }) it('?? is this what we want?', () => { - const tree = makeTree([ - '/$a/$b/$c/d/e', - '/$a/$b/c/d/$e' - ]) + const tree = makeTree(['/$a/$b/$c/d/e', '/$a/$b/c/d/$e']) expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe('/$a/$b/c/d/$e') }) }) @@ -148,9 +142,10 @@ describe('findRouteMatch', () => { id: '/Foo/a', fullPath: '/Foo/a', path: '/a', - } - ] - }, { + }, + ], + }, + { id: '/foo', fullPath: '/foo', path: 'foo', @@ -160,10 +155,10 @@ describe('findRouteMatch', () => { id: '/foo/b', fullPath: '/foo/b', path: 'b', - } - ] - } - ] + }, + ], + }, + ], } const { processedTree } = processRouteTree(tree) expect(findRouteMatch('/foo/a', processedTree)?.route.id).toBe('/Foo/a') @@ -311,7 +306,7 @@ describe('findRouteMatch', () => { }) }) - describe.todo('fuzzy matching', () => { }) + describe.todo('fuzzy matching', () => {}) }) describe.todo('processFlatRouteList', () => { diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index 38d8ebbdbfd..f0bd6a84e5b 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' import { interpolatePath } from '../src/path' +import { + findSingleMatch, + processRouteTree, +} from '../src/new-process-route-tree' describe('Optional Path Parameters - Clean Comprehensive Tests', () => { describe('Optional Dynamic Parameters {-$param}', () => { @@ -91,7 +95,27 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { }) }) - describe.skip('matchPathname', () => { + describe('matchPathname', () => { + const { processedTree } = processRouteTree({ + id: '__root__', + fullPath: '/', + path: '/', + }) + const matchPathname = ( + from: string, + options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, + ) => { + const match = findSingleMatch( + options.to, + options.caseSensitive ?? false, + options.fuzzy ?? false, + from, + processedTree, + ) + const result = match ? match.params : undefined + if (options.to && !result) return + return result ?? {} + } it('should match optional dynamic params when present', () => { const result = matchPathname('/posts/tech', { to: '/posts/{-$category}', From cd9f4dc70a43e365be8ef134e577efd2af72cf71 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 10:01:38 +0100 Subject: [PATCH 053/109] restore some more tests --- .../tests/optional-path-params-clean.test.ts | 44 +++++++++++- .../tests/optional-path-params.test.ts | 64 ++++++++++++++++- packages/router-core/tests/path.test.ts | 72 ++++++++++++++++++- 3 files changed, 176 insertions(+), 4 deletions(-) diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index f0bd6a84e5b..b06474ce9e0 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -2,12 +2,54 @@ import { describe, expect, it } from 'vitest' import { interpolatePath } from '../src/path' import { findSingleMatch, + parseSegment, processRouteTree, + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PATHNAME, + type SegmentKind, } from '../src/new-process-route-tree' describe('Optional Path Parameters - Clean Comprehensive Tests', () => { describe('Optional Dynamic Parameters {-$param}', () => { - describe.skip('parsePathname', () => { + describe('parsePathname', () => { + type PathSegment = { + type: SegmentKind + value: string + prefixSegment?: string + suffixSegment?: string + // Indicates if there is a static segment after this required/optional param + hasStaticAfter?: boolean + } + + const parsePathname = (to: string | undefined) => { + let cursor = 0 + const data = new Uint16Array(6) + const path = to ?? '' + const segments: Array = [] + while (cursor < path.length) { + const start = cursor + parseSegment(path, start, data) + const end = data[5]! + cursor = end + 1 + const type = data[0] as SegmentKind + const value = path.substring(data[2]!, data[3]) + const prefix = path.substring(start, data[1]) + const suffix = path.substring(data[4]!, end) + const segment: PathSegment = { + type, + value, + } + if (prefix) { + segment.prefixSegment = prefix + } + if (suffix) { + segment.suffixSegment = suffix + } + segments.push(segment) + } + return segments + } + it('should parse single optional dynamic param', () => { const result = parsePathname('/posts/{-$category}') expect(result).toEqual([ diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index 3666485ed12..cde7a7d66ed 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -1,13 +1,17 @@ import { describe, expect, it } from 'vitest' import { interpolatePath } from '../src/path' import { + findSingleMatch, + parseSegment, + processRouteTree, SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, + type SegmentKind, } from '../src/new-process-route-tree' -describe.skip('Optional Path Parameters', () => { +describe('Optional Path Parameters', () => { type ParsePathnameTestScheme = Array<{ name: string to: string | undefined @@ -15,6 +19,43 @@ describe.skip('Optional Path Parameters', () => { }> describe('parsePathname with optional params', () => { + type PathSegment = { + type: SegmentKind + value: string + prefixSegment?: string + suffixSegment?: string + // Indicates if there is a static segment after this required/optional param + hasStaticAfter?: boolean + } + + const parsePathname = (to: string | undefined) => { + let cursor = 0 + const data = new Uint16Array(6) + const path = to ?? '' + const segments: Array = [] + while (cursor < path.length) { + const start = cursor + parseSegment(path, start, data) + const end = data[5]! + cursor = end + 1 + const type = data[0] as SegmentKind + const value = path.substring(data[2]!, data[3]) + const prefix = path.substring(start, data[1]) + const suffix = path.substring(data[4]!, end) + const segment: PathSegment = { + type, + value, + } + if (prefix) { + segment.prefixSegment = prefix + } + if (suffix) { + segment.suffixSegment = suffix + } + segments.push(segment) + } + return segments + } it.each([ { name: 'regular optional param', @@ -293,6 +334,27 @@ describe.skip('Optional Path Parameters', () => { }) }) + const { processedTree } = processRouteTree({ + id: '__root__', + fullPath: '/', + path: '/', + }) + const matchPathname = ( + from: string, + options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, + ) => { + const match = findSingleMatch( + options.to, + options.caseSensitive ?? false, + options.fuzzy ?? false, + from, + processedTree, + ) + const result = match ? match.params : undefined + if (options.to && !result) return + return result ?? {} + } + describe('matchPathname with optional params', () => { it.each([ { diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 126bd8d1325..a7b002bcdef 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -6,6 +6,16 @@ import { resolvePath, trimPathLeft, } from '../src/path' +import { + findSingleMatch, + parseSegment, + processRouteTree, + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, + type SegmentKind, +} from '../src/new-process-route-tree' describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( 'removeTrailingSlash with basepath $basepath', @@ -493,7 +503,27 @@ describe('interpolatePath', () => { }) }) -describe.skip('matchPathname', () => { +describe('matchPathname', () => { + const { processedTree } = processRouteTree({ + id: '__root__', + fullPath: '/', + path: '/', + }) + const matchPathname = ( + from: string, + options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, + ) => { + const match = findSingleMatch( + options.to, + options.caseSensitive ?? false, + options.fuzzy ?? false, + from, + processedTree, + ) + const result = match ? match.params : undefined + if (options.to && !result) return + return result ?? {} + } describe('path param(s) matching', () => { it.each([ { @@ -719,13 +749,51 @@ describe.skip('matchPathname', () => { }) }) -describe.skip('parsePathname', () => { +describe.only('parsePathname', () => { type ParsePathnameTestScheme = Array<{ name: string to: string | undefined expected: Array }> + type PathSegment = { + type: SegmentKind + value: string + prefixSegment?: string + suffixSegment?: string + // Indicates if there is a static segment after this required/optional param + hasStaticAfter?: boolean + } + + const parsePathname = (to: string | undefined) => { + let cursor = 0 + const data = new Uint16Array(6) + const path = to ?? '' + const segments: Array = [] + while (cursor < path.length) { + const start = cursor + parseSegment(path, start, data) + const end = data[5]! + cursor = end + 1 + const type = data[0] as SegmentKind + const value = path.substring(data[2]!, data[3]) + const prefix = path.substring(start, data[1]) + const suffix = path.substring(data[4]!, end) + const segment: PathSegment = { + type, + value, + } + if (prefix) { + segment.prefixSegment = prefix + } + if (suffix) { + segment.suffixSegment = suffix + } + segments.push(segment) + } + return segments + } + describe('regular usage', () => { it.each([ { From 8110b6ef49ea28e103cb5a79e4525be7a90c0e76 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 11:15:46 +0100 Subject: [PATCH 054/109] update unit tests to new segment format (not a fix) --- .../tests/optional-path-params-clean.test.ts | 14 ++-- .../tests/optional-path-params.test.ts | 58 ++++++------- packages/router-core/tests/path.test.ts | 82 +++++++++---------- 3 files changed, 77 insertions(+), 77 deletions(-) diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index b06474ce9e0..cb4c6f768ae 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -53,30 +53,30 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { it('should parse single optional dynamic param', () => { const result = parsePathname('/posts/{-$category}') expect(result).toEqual([ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'posts' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$category' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'category' }, ]) }) it('should parse multiple optional dynamic params', () => { const result = parsePathname('/posts/{-$category}/{-$slug}') expect(result).toEqual([ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'posts' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$category' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$slug' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'category' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'slug' }, ]) }) it('should handle prefix/suffix with optional dynamic params', () => { const result = parsePathname('/api/v{-$version}/data') expect(result).toEqual([ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'api' }, { type: SEGMENT_TYPE_OPTIONAL_PARAM, - value: '$version', + value: 'version', prefixSegment: 'v', suffixSegment: undefined, }, diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index cde7a7d66ed..d9485851842 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -61,18 +61,18 @@ describe('Optional Path Parameters', () => { name: 'regular optional param', to: '/{-$slug}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$slug' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'slug' }, ], }, { name: 'optional param with prefix', to: '/prefix{-$slug}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_OPTIONAL_PARAM, - value: '$slug', + value: 'slug', prefixSegment: 'prefix', }, ], @@ -81,10 +81,10 @@ describe('Optional Path Parameters', () => { name: 'optional param with suffix', to: '/{-$slug}suffix', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_OPTIONAL_PARAM, - value: '$slug', + value: 'slug', suffixSegment: 'suffix', }, ], @@ -93,10 +93,10 @@ describe('Optional Path Parameters', () => { name: 'optional param with prefix and suffix', to: '/prefix{-$slug}suffix', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_OPTIONAL_PARAM, - value: '$slug', + value: 'slug', prefixSegment: 'prefix', suffixSegment: 'suffix', }, @@ -106,38 +106,38 @@ describe('Optional Path Parameters', () => { name: 'multiple optional params', to: '/posts/{-$category}/{-$slug}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'posts' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$category' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$slug' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'category' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'slug' }, ], }, { name: 'mixed required and optional params', to: '/users/$id/{-$tab}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'users' }, - { type: SEGMENT_TYPE_PARAM, value: '$id' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$tab' }, + { type: SEGMENT_TYPE_PARAM, value: 'id' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'tab' }, ], }, { name: 'optional param followed by required param', to: '/{-$category}/$slug', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$category' }, - { type: SEGMENT_TYPE_PARAM, value: '$slug' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'category' }, + { type: SEGMENT_TYPE_PARAM, value: 'slug' }, ], }, { name: 'optional param with wildcard', to: '/docs/{-$version}/$', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'docs' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$version' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'version' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], }, @@ -145,12 +145,12 @@ describe('Optional Path Parameters', () => { name: 'complex path with all param types', to: '/api/{-$version}/users/$id/{-$tab}/$', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'api' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$version' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'version' }, { type: SEGMENT_TYPE_PATHNAME, value: 'users' }, - { type: SEGMENT_TYPE_PARAM, value: '$id' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$tab' }, + { type: SEGMENT_TYPE_PARAM, value: 'id' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'tab' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], }, @@ -158,18 +158,18 @@ describe('Optional Path Parameters', () => { name: 'optional param at root', to: '/{-$slug}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$slug' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'slug' }, ], }, { name: 'multiple consecutive optional params', to: '/{-$year}/{-$month}/{-$day}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$year' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$month' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$day' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'year' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'month' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'day' }, ], }, ] satisfies ParsePathnameTestScheme)('$name', ({ to, expected }) => { diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index a7b002bcdef..413812d6ea4 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -749,7 +749,7 @@ describe('matchPathname', () => { }) }) -describe.only('parsePathname', () => { +describe('parsePathname', () => { type ParsePathnameTestScheme = Array<{ name: string to: string | undefined @@ -809,13 +809,13 @@ describe.only('parsePathname', () => { { name: 'should handle pathname at root', to: '/', - expected: [{ type: SEGMENT_TYPE_PATHNAME, value: '/' }], + expected: [{ type: SEGMENT_TYPE_PATHNAME, value: '' }], }, { name: 'should handle pathname with a single segment', to: '/foo', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, ], }, @@ -823,7 +823,7 @@ describe.only('parsePathname', () => { name: 'should handle pathname with multiple segments', to: '/foo/bar/baz', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, { type: SEGMENT_TYPE_PATHNAME, value: 'bar' }, { type: SEGMENT_TYPE_PATHNAME, value: 'baz' }, @@ -833,35 +833,35 @@ describe.only('parsePathname', () => { name: 'should handle pathname with a trailing slash', to: '/foo/', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, ], }, { name: 'should handle named params', to: '/foo/$bar', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, ], }, { name: 'should handle named params at the root', to: '/$bar', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, ], }, { name: 'should handle named params followed by a segment', to: '/foo/$bar/baz', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, { type: SEGMENT_TYPE_PATHNAME, value: 'baz' }, ], }, @@ -869,19 +869,19 @@ describe.only('parsePathname', () => { name: 'should handle multiple named params', to: '/foo/$bar/$baz/qux/$quux', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, - { type: SEGMENT_TYPE_PARAM, value: '$baz' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, + { type: SEGMENT_TYPE_PARAM, value: 'baz' }, { type: SEGMENT_TYPE_PATHNAME, value: 'qux' }, - { type: SEGMENT_TYPE_PARAM, value: '$quux' }, + { type: SEGMENT_TYPE_PARAM, value: 'quux' }, ], }, { name: 'should handle splat params', to: '/foo/$', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], @@ -890,7 +890,7 @@ describe.only('parsePathname', () => { name: 'should handle splat params at the root', to: '/$', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], }, @@ -906,7 +906,7 @@ describe.only('parsePathname', () => { name: 'regular', to: '/$', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], }, @@ -914,7 +914,7 @@ describe.only('parsePathname', () => { name: 'regular curly braces', to: '/{$}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], }, @@ -922,7 +922,7 @@ describe.only('parsePathname', () => { name: 'with prefix (regular text)', to: '/foo{$}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$', @@ -934,7 +934,7 @@ describe.only('parsePathname', () => { name: 'with prefix + followed by special character', to: '/foo.{$}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$', @@ -946,7 +946,7 @@ describe.only('parsePathname', () => { name: 'with suffix', to: '/{$}-foo', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$', @@ -958,7 +958,7 @@ describe.only('parsePathname', () => { name: 'with prefix + suffix', to: '/foo{$}-bar', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$', @@ -971,7 +971,7 @@ describe.only('parsePathname', () => { name: 'with prefix + followed by special character and a segment', to: '/foo.{$}/bar', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$', @@ -992,26 +992,26 @@ describe.only('parsePathname', () => { name: 'regular', to: '/$bar', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, ], }, { name: 'regular curly braces', to: '/{$bar}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, ], }, { name: 'with prefix (regular text)', to: '/foo{$bar}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', prefixSegment: 'foo', }, ], @@ -1020,10 +1020,10 @@ describe.only('parsePathname', () => { name: 'with prefix + followed by special character', to: '/foo.{$bar}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', prefixSegment: 'foo.', }, ], @@ -1032,10 +1032,10 @@ describe.only('parsePathname', () => { name: 'with suffix', to: '/{$bar}.foo', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', suffixSegment: '.foo', }, ], @@ -1044,10 +1044,10 @@ describe.only('parsePathname', () => { name: 'with suffix + started by special character', to: '/{$bar}.foo', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', suffixSegment: '.foo', }, ], @@ -1056,10 +1056,10 @@ describe.only('parsePathname', () => { name: 'with suffix + started by special character and followed by segment', to: '/{$bar}.foo/baz', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', suffixSegment: '.foo', }, { type: SEGMENT_TYPE_PATHNAME, value: 'baz' }, @@ -1069,10 +1069,10 @@ describe.only('parsePathname', () => { name: 'with suffix + prefix', to: '/foo{$bar}.baz', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', prefixSegment: 'foo', suffixSegment: '.baz', }, From 92093b8c65baa24e8c96ed1bad612ef6f94c32e7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 11:33:24 +0100 Subject: [PATCH 055/109] some segment parsing fixes --- .../router-core/src/new-process-route-tree.ts | 29 ++++++++++--------- packages/router-core/tests/path.test.ts | 2 +- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 8ebaaf2cceb..9e64c1630f0 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -65,7 +65,7 @@ export function parseSegment( output[0] = SEGMENT_TYPE_PARAM output[1] = start output[2] = start + 1 // skip '$' - output[3] = start + part.length + output[3] = end output[4] = end output[5] = end return @@ -74,14 +74,13 @@ export function parseSegment( const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE) if (wildcardBracesMatch) { const prefix = wildcardBracesMatch[1]! - const suffix = wildcardBracesMatch[2]! - const total = path.length + const pLength = prefix.length output[0] = SEGMENT_TYPE_WILDCARD - output[1] = start + prefix.length - output[2] = start + prefix.length - output[3] = total - suffix.length - output[4] = total - suffix.length - output[5] = total + output[1] = start + pLength + output[2] = start + pLength + 1 // skip '{' + output[3] = start + pLength + 2 // '$' + output[4] = start + pLength + 3 // skip '}' + output[5] = path.length return } @@ -90,10 +89,11 @@ export function parseSegment( const prefix = optionalParamBracesMatch[1]! const paramName = optionalParamBracesMatch[2]! const suffix = optionalParamBracesMatch[3]! + const pLength = prefix.length output[0] = SEGMENT_TYPE_OPTIONAL_PARAM - output[1] = start + prefix.length - output[2] = start + prefix.length + 3 // skip '{-$' - output[3] = start + prefix.length + 3 + paramName.length + output[1] = start + pLength + output[2] = start + pLength + 3 // skip '{-$' + output[3] = start + pLength + 3 + paramName.length output[4] = end - suffix.length output[5] = end return @@ -104,10 +104,11 @@ export function parseSegment( const prefix = paramBracesMatch[1]! const paramName = paramBracesMatch[2]! const suffix = paramBracesMatch[3]! + const pLength = prefix.length output[0] = SEGMENT_TYPE_PARAM - output[1] = start + prefix.length - output[2] = start + prefix.length + 2 // skip '{$' - output[3] = start + prefix.length + 2 + paramName.length + output[1] = start + pLength + output[2] = start + pLength + 2 // skip '{$' + output[3] = start + pLength + 2 + paramName.length output[4] = end - suffix.length output[5] = end return diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 413812d6ea4..9ecd996da62 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -976,8 +976,8 @@ describe('parsePathname', () => { type: SEGMENT_TYPE_WILDCARD, value: '$', prefixSegment: 'foo.', + suffixSegment: '/bar', }, - { type: SEGMENT_TYPE_PATHNAME, value: 'bar' }, ], }, ] satisfies ParsePathnameTestScheme)('$name', ({ to, expected }) => { From f1a0b7d4bd868d4690699de6b27d5c4dc77cc589 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 11:50:11 +0100 Subject: [PATCH 056/109] fix curly-braced required param without prefix/suffix --- packages/router-core/src/new-process-route-tree.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 9e64c1630f0..17caa2c839f 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -661,9 +661,11 @@ function extractParams( if (node.kind === SEGMENT_TYPE_PARAM) { nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[nodeIndex]! + const preLength = node.prefix?.length ?? 0 + // we can't rely on the presence of prefix/suffix to know whether it's curly-braced or not, because `/{$param}/` is valid, but has no prefix/suffix + const isCurlyBraced = nodePart.charCodeAt(preLength) === 123 // '{' // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node - if (node.suffix !== undefined || node.prefix !== undefined) { - const preLength = node.prefix?.length ?? 0 + if (isCurlyBraced) { const sufLength = node.suffix?.length ?? 0 const name = nodePart.substring( preLength + 2, From 2c5d4b1aa5e285bf58fc015c64a7387851cd7c49 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 12:02:35 +0100 Subject: [PATCH 057/109] skipped params are not included at all in the params object --- packages/router-core/src/new-process-route-tree.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 17caa2c839f..2b692d05f93 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -677,6 +677,11 @@ function extractParams( params[name] = part } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + if (leaf.skipped & (1 << nodeIndex)) { + partIndex-- // stay on the same part + // params[name] = '' // ¿skipped optional params do not appear at all in the params object? + continue + } nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[nodeIndex]! const preLength = node.prefix?.length ?? 0 @@ -685,12 +690,6 @@ function extractParams( preLength + 3, nodePart.length - sufLength - 1, ) - // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node - if (leaf.skipped & (1 << nodeIndex)) { - partIndex-- // stay on the same part - params[name] = '' - continue - } if (node.suffix || node.prefix) { params[name] = part.substring(preLength, part.length - sufLength) } else { From f04d54032d1d0ea8fe0f0cd9e86069d0b6ec046c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 12:13:49 +0100 Subject: [PATCH 058/109] fix multi-segment wildcard suffix --- packages/router-core/src/new-process-route-tree.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 2b692d05f93..807a2994348 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -893,11 +893,9 @@ function getNodeMatch( if (!casePart.startsWith(prefix)) continue } if (suffix) { - const lastPart = parts[parts.length - 1]! - const casePart = segment.caseSensitive - ? lastPart - : lastPart.toLowerCase() - if (!casePart.endsWith(suffix)) continue + const end = parts.slice(index).join('/').slice(-suffix.length) + const casePart = segment.caseSensitive ? end : end.toLowerCase() + if (casePart !== suffix) continue } // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match if (!wildcardMatch || wildcardMatch.index < index) { From b8aead3f43c41bd624a076669bfcb132fed94adc Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 12:14:44 +0100 Subject: [PATCH 059/109] add failing match test --- packages/router-core/tests/new-process-route-tree.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index bfb0cee316d..e8d76e24ba7 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -223,6 +223,11 @@ describe('findRouteMatch', () => { '/file{-$id}.txt', ) }) + it('optional at the end can still be omitted', () => { + // WARN: I'm not sure this is the desired behavior (and also as of writing this, it fails) + const tree = makeTree(['/a/{-$id}']) + expect(findRouteMatch('/a', tree)?.route.id).toBe('/a/{-$id}') + }) it('wildcard w/ prefix', () => { const tree = makeTree(['/file{$}']) expect(findRouteMatch('/file/a/b/c', tree)?.route.id).toBe('/file{$}') From 3e26bcd28a464934f7554789817d80c0bdc8dac6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 15:01:56 +0100 Subject: [PATCH 060/109] skipped optional nodes can match beyond the end of the path --- .../router-core/src/new-process-route-tree.ts | 82 ++++++++++--------- .../tests/new-process-route-tree.test.ts | 9 +- 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 807a2994348..38f92098d11 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -727,7 +727,9 @@ function getNodeMatch( type Frame = { node: AnySegmentNode + /** index of the segment of path */ index: number + /** how many nodes between `node` and the root of the segment tree */ depth: number /** Bitmask of skipped optional segments */ skipped: number @@ -759,15 +761,16 @@ function getNodeMatch( stack.pop()! main: while (node) { - if (index === parts.length) { - if (!node.route) break + const isBeyondPath = index === parts.length + if (isBeyondPath) { if ( - !bestMatch || - statics > bestMatch.statics || - (statics === bestMatch.statics && dynamics > bestMatch.dynamics) || - (statics === bestMatch.statics && - dynamics === bestMatch.dynamics && - optionals > bestMatch.optionals) + node.route && + (!bestMatch || + statics > bestMatch.statics || + (statics === bestMatch.statics && + (dynamics > bestMatch.dynamics || + (dynamics === bestMatch.dynamics && + optionals > bestMatch.optionals)))) ) { bestMatch = { node, @@ -781,7 +784,8 @@ function getNodeMatch( // perfect match, no need to continue if (statics === parts.length) return bestMatch } - break + // beyond the length of the path parts, only skipped optional segments can match + if (!node.optional) break } // In fuzzy mode, track the best partial match we've found so far @@ -803,12 +807,12 @@ function getNodeMatch( } } - const part = parts[index]! + const part = isBeyondPath ? undefined : parts[index]! let lowerPart: string // 1. Try static match - if (node.static) { - const match = node.static.get(part) + if (!isBeyondPath && node.static) { + const match = node.static.get(part!) if (match) { stack.push({ node: match, @@ -823,13 +827,13 @@ function getNodeMatch( } // 3. Try dynamic match - if (node.dynamic) { + if (!isBeyondPath && node.dynamic) { for (const segment of node.dynamic) { const { prefix, suffix } = segment if (prefix || suffix) { const casePart = segment.caseSensitive - ? part - : (lowerPart ??= part.toLowerCase()) + ? part! + : (lowerPart ??= part!.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } @@ -861,35 +865,37 @@ function getNodeMatch( optionals, }) // enqueue skipping the optional } - for (const segment of node.optional) { - const { prefix, suffix } = segment - if (prefix || suffix) { - const casePart = segment.caseSensitive - ? part - : (lowerPart ??= part.toLowerCase()) - if (prefix && !casePart.startsWith(prefix)) continue - if (suffix && !casePart.endsWith(suffix)) continue + if (!isBeyondPath) { + for (const segment of node.optional) { + const { prefix, suffix } = segment + if (prefix || suffix) { + const casePart = segment.caseSensitive + ? part! + : (lowerPart ??= part!.toLowerCase()) + if (prefix && !casePart.startsWith(prefix)) continue + if (suffix && !casePart.endsWith(suffix)) continue + } + stack.push({ + node: segment, + index: index + 1, + skipped, + depth: nextDepth, + statics, + dynamics, + optionals: optionals + 1, + }) } - stack.push({ - node: segment, - index: index + 1, - skipped, - depth: nextDepth, - statics, - dynamics, - optionals: optionals + 1, - }) } } // 5. Try wildcard match - if (node.wildcard) { + if (!isBeyondPath && node.wildcard) { for (const segment of node.wildcard) { const { prefix, suffix } = segment if (prefix) { const casePart = segment.caseSensitive - ? part - : (lowerPart ??= part.toLowerCase()) + ? part! + : (lowerPart ??= part!.toLowerCase()) if (!casePart.startsWith(prefix)) continue } if (suffix) { @@ -898,7 +904,7 @@ function getNodeMatch( if (casePart !== suffix) continue } // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match - if (!wildcardMatch || wildcardMatch.index < index) { + if (!wildcardMatch || wildcardMatch.index <= index) { wildcardMatch = { node: segment, index, @@ -914,9 +920,9 @@ function getNodeMatch( } // 2. Try case insensitive static match - if (node.staticInsensitive) { + if (!isBeyondPath && node.staticInsensitive) { const match = node.staticInsensitive.get( - (lowerPart ??= part.toLowerCase()), + (lowerPart ??= part!.toLowerCase()), ) if (match) { node = match diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index e8d76e24ba7..3231de11a29 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -224,10 +224,17 @@ describe('findRouteMatch', () => { ) }) it('optional at the end can still be omitted', () => { - // WARN: I'm not sure this is the desired behavior (and also as of writing this, it fails) const tree = makeTree(['/a/{-$id}']) expect(findRouteMatch('/a', tree)?.route.id).toBe('/a/{-$id}') }) + it('multiple optionals at the end can still be omitted', () => { + const tree = makeTree(['/a/{-$b}/{-$c}/{-$d}']) + expect(findRouteMatch('/a', tree)?.route.id).toBe('/a/{-$b}/{-$c}/{-$d}') + }) + it('multiple optionals at the end -> favor earlier segments', () => { + const tree = makeTree(['/a/{-$b}/{-$c}/{-$d}/{-$e}']) + expect(findRouteMatch('/a/b/c', tree)?.params).toEqual({ b: 'b', c: 'c' }) + }) it('wildcard w/ prefix', () => { const tree = makeTree(['/file{$}']) expect(findRouteMatch('/file/a/b/c', tree)?.route.id).toBe('/file{$}') From 5c7e8c1122f8ad2cb4717e6afc0542fd4d0db842 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 15:02:13 +0100 Subject: [PATCH 061/109] add failing case sensitivity test --- .../tests/new-process-route-tree.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 3231de11a29..b3c67c4135b 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -164,6 +164,31 @@ describe('findRouteMatch', () => { expect(findRouteMatch('/foo/a', processedTree)?.route.id).toBe('/Foo/a') expect(findRouteMatch('/foo/b', processedTree)?.route.id).toBe('/foo/b') }) + it('a case sensitive segment should have priority over a case insensitive one', () => { + const tree = { + id: '__root__', + fullPath: '/', + path: '/', + children: [ + { + id: '/FOO', + fullPath: '/FOO', + path: 'FOO', + options: { caseSensitive: true }, + }, + { + id: '/foo', + fullPath: '/foo', + path: 'foo', + options: { caseSensitive: false }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + expect(findRouteMatch('/FOO', processedTree)?.route.id).toBe('/FOO') + expect(findRouteMatch('/Foo', processedTree)?.route.id).toBe('/foo') + expect(findRouteMatch('/foo', processedTree)?.route.id).toBe('/foo') + }) }) describe('basic matching', () => { From bd59a1e498a84cbb3066f09c914c9d0001aa046a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 17:03:18 +0100 Subject: [PATCH 062/109] remove 2nd while loop, everything through the stack, bye bye performance --- .../router-core/src/new-process-route-tree.ts | 262 +++++++++--------- 1 file changed, 124 insertions(+), 138 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 38f92098d11..d9458090963 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -724,6 +724,7 @@ function getNodeMatch( fuzzy: boolean, ) { parts = parts.filter(Boolean) + const partsLength = parts.length type Frame = { node: AnySegmentNode @@ -756,79 +757,120 @@ function getNodeMatch( let bestMatch: Frame | null = null while (stack.length) { + const frame = stack.pop()! // eslint-disable-next-line prefer-const - let { node, index, skipped, depth, statics, dynamics, optionals } = - stack.pop()! + let { node, index, skipped, depth, statics, dynamics, optionals } = frame - main: while (node) { - const isBeyondPath = index === parts.length - if (isBeyondPath) { + const isBeyondPath = index === partsLength + if (isBeyondPath) { + if (node.route) { if ( - node.route && - (!bestMatch || - statics > bestMatch.statics || - (statics === bestMatch.statics && - (dynamics > bestMatch.dynamics || - (dynamics === bestMatch.dynamics && - optionals > bestMatch.optionals)))) + !bestMatch || + statics > bestMatch.statics || + (statics === bestMatch.statics && + (dynamics > bestMatch.dynamics || + (dynamics === bestMatch.dynamics && + optionals > bestMatch.optionals))) ) { - bestMatch = { - node, - index, - depth, - skipped, - statics, - dynamics, - optionals, - } - // perfect match, no need to continue - if (statics === parts.length) return bestMatch + bestMatch = frame } - // beyond the length of the path parts, only skipped optional segments can match - if (!node.optional) break + + // perfect match, no need to continue + if (statics === partsLength) return bestMatch } + // beyond the length of the path parts, only skipped optional segments can match + if (!node.optional) continue + } - // In fuzzy mode, track the best partial match we've found so far - if ( - fuzzy && - node.route && - (!bestFuzzy || - index > bestFuzzy.index || - (index === bestFuzzy.index && depth > bestFuzzy.depth)) - ) { - bestFuzzy = { - node, - index, - depth, + // In fuzzy mode, track the best partial match we've found so far + if ( + fuzzy && + node.route && + (!bestFuzzy || + index > bestFuzzy.index || + (index === bestFuzzy.index && depth > bestFuzzy.depth)) + ) { + bestFuzzy = frame + } + + const part = isBeyondPath ? undefined : parts[index]! + let lowerPart: string + + // 1. Try static match + if (!isBeyondPath && node.static) { + const match = node.static.get(part!) + if (match) { + stack.unshift({ + node: match, + index: index + 1, skipped, - statics, + depth: depth + 1, + statics: statics + 1, dynamics, optionals, - } + }) } + } - const part = isBeyondPath ? undefined : parts[index]! - let lowerPart: string + // 2. Try case insensitive static match + if (!isBeyondPath && node.staticInsensitive) { + const match = node.staticInsensitive.get( + (lowerPart ??= part!.toLowerCase()), + ) + if (match) { + stack.unshift({ + node: match, + index: index + 1, + skipped, + depth: depth + 1, + statics: statics + 1, + dynamics, + optionals, + }) + } + } - // 1. Try static match - if (!isBeyondPath && node.static) { - const match = node.static.get(part!) - if (match) { - stack.push({ - node: match, - index: index + 1, - skipped, - depth: depth + 1, - statics: statics + 1, - dynamics, - optionals, - }) + // 3. Try dynamic match + if (!isBeyondPath && node.dynamic) { + for (const segment of node.dynamic) { + const { prefix, suffix } = segment + if (prefix || suffix) { + const casePart = segment.caseSensitive + ? part! + : (lowerPart ??= part!.toLowerCase()) + if (prefix && !casePart.startsWith(prefix)) continue + if (suffix && !casePart.endsWith(suffix)) continue } + stack.push({ + node: segment, + index: index + 1, + skipped, + depth: depth + 1, + statics, + dynamics: dynamics + 1, + optionals, + }) } + } - // 3. Try dynamic match - if (!isBeyondPath && node.dynamic) { - for (const segment of node.dynamic) { + // 4. Try optional match + if (node.optional) { + const nextDepth = depth + 1 + const nextSkipped = skipped | (1 << nextDepth) + for (const segment of node.optional) { + // when skipping, node and depth advance by 1, but index doesn't + stack.push({ + node: segment, + index, + skipped: nextSkipped, + depth: nextDepth, + statics, + dynamics, + optionals, + }) // enqueue skipping the optional + } + if (!isBeyondPath) { + for (const segment of node.optional) { const { prefix, suffix } = segment if (prefix || suffix) { const casePart = segment.caseSensitive @@ -841,100 +883,44 @@ function getNodeMatch( node: segment, index: index + 1, skipped, - depth: depth + 1, + depth: nextDepth, statics, - dynamics: dynamics + 1, - optionals, + dynamics, + optionals: optionals + 1, }) } } + } - // 4. Try optional match - if (node.optional) { - const nextDepth = depth + 1 - const nextSkipped = skipped | (1 << nextDepth) - for (const segment of node.optional) { - // when skipping, node and depth advance by 1, but index doesn't - stack.push({ + // 5. Try wildcard match + if (!isBeyondPath && node.wildcard) { + for (const segment of node.wildcard) { + const { prefix, suffix } = segment + if (prefix) { + const casePart = segment.caseSensitive + ? part! + : (lowerPart ??= part!.toLowerCase()) + if (!casePart.startsWith(prefix)) continue + } + if (suffix) { + const end = parts.slice(index).join('/').slice(-suffix.length) + const casePart = segment.caseSensitive ? end : end.toLowerCase() + if (casePart !== suffix) continue + } + // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match + if (!wildcardMatch || wildcardMatch.index <= index) { + wildcardMatch = { node: segment, index, - skipped: nextSkipped, - depth: nextDepth, + skipped, + depth, statics, dynamics, optionals, - }) // enqueue skipping the optional - } - if (!isBeyondPath) { - for (const segment of node.optional) { - const { prefix, suffix } = segment - if (prefix || suffix) { - const casePart = segment.caseSensitive - ? part! - : (lowerPart ??= part!.toLowerCase()) - if (prefix && !casePart.startsWith(prefix)) continue - if (suffix && !casePart.endsWith(suffix)) continue - } - stack.push({ - node: segment, - index: index + 1, - skipped, - depth: nextDepth, - statics, - dynamics, - optionals: optionals + 1, - }) } } + break } - - // 5. Try wildcard match - if (!isBeyondPath && node.wildcard) { - for (const segment of node.wildcard) { - const { prefix, suffix } = segment - if (prefix) { - const casePart = segment.caseSensitive - ? part! - : (lowerPart ??= part!.toLowerCase()) - if (!casePart.startsWith(prefix)) continue - } - if (suffix) { - const end = parts.slice(index).join('/').slice(-suffix.length) - const casePart = segment.caseSensitive ? end : end.toLowerCase() - if (casePart !== suffix) continue - } - // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match - if (!wildcardMatch || wildcardMatch.index <= index) { - wildcardMatch = { - node: segment, - index, - skipped, - depth, - statics, - dynamics, - optionals, - } - } - break main - } - } - - // 2. Try case insensitive static match - if (!isBeyondPath && node.staticInsensitive) { - const match = node.staticInsensitive.get( - (lowerPart ??= part!.toLowerCase()), - ) - if (match) { - node = match - depth++ - index++ - statics++ - continue - } - } - - // No match found - break } } From cd8bd0658158641e17e5a11ea0e42c96df87573c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 17:55:06 +0100 Subject: [PATCH 063/109] add cache to matching methods --- .../router-core/src/new-process-route-tree.ts | 54 ++++++++++++------- packages/router-core/src/router.ts | 9 +++- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index d9458090963..50cba8c9913 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -1,4 +1,6 @@ import invariant from 'tiny-invariant' +import { createLRUCache } from './lru-cache' +import type { LRUCache } from './lru-cache' export const SEGMENT_TYPE_PATHNAME = 0 export const SEGMENT_TYPE_PARAM = 1 @@ -485,45 +487,51 @@ export type ProcessedTree< TFlat extends Extract, TSingle extends Extract, > = { - /** a representation of the `routeTree` as a segment tree, for performant path matching */ + /** a representation of the `routeTree` as a segment tree */ segmentTree: AnySegmentNode - /** a cache of mini route trees generated from flat route lists, for performant route mask matching */ - flatCache: Map> + /** a mini route tree generated from the flat `routeMasks` list */ + masksTree: AnySegmentNode | null /** @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree */ singleCache: Map> + /** a cache of route matches from the `segmentTree` */ + matchCache: LRUCache>> + /** a cache of route matches from the `masksTree` */ + flatCache: LRUCache>> | null } -export function processFlatRouteList< +export function processRouteMasks< TRouteLike extends Extract, ->(routeList: Array) { +>( + routeList: Array, + processedTree: ProcessedTree, +) { const segmentTree = createStaticNode('/') const data = new Uint16Array(6) for (const route of routeList) { parseSegments(false, data, route, 1, segmentTree, 0) } sortTreeNodes(segmentTree) - return segmentTree + processedTree.masksTree = segmentTree + processedTree.flatCache = createLRUCache< + string, + ReturnType> + >(1000) } /** * Take an arbitrary list of routes, create a tree from them (if it hasn't been created already), and match a path against it. */ export function findFlatMatch>( - /** The flat list of routes to match against. This array should be stable, it comes from a route's `routeMasks` option. */ - list: Array, /** The path to match. */ path: string, /** The `processedTree` returned by the initial `processRouteTree` call. */ processedTree: ProcessedTree, ) { - let tree = processedTree.flatCache.get(list) - if (!tree) { - // flat route lists (routeMasks option) are not eagerly processed, - // if we haven't seen this list before, process it now - tree = processFlatRouteList(list) - processedTree.flatCache.set(list, tree) - } - return findMatch(path, tree) + const cached = processedTree.flatCache!.get(path) + if (cached) return cached + const result = findMatch(path, processedTree.masksTree!) + processedTree.flatCache!.set(path, result) + return result } /** @@ -559,7 +567,12 @@ export function findRouteMatch< /** If `true`, allows fuzzy matching (partial matches). */ fuzzy = false, ) { - return findMatch(path, processedTree.segmentTree, fuzzy) + const key = fuzzy ? `fuzzy|${path}` : path + const cached = processedTree.matchCache.get(key) + if (cached) return cached + const result = findMatch(path, processedTree.segmentTree, fuzzy) + processedTree.matchCache.set(key, result) + return result } /** Trim trailing slashes (except preserving root '/'). */ @@ -615,8 +628,13 @@ export function processRouteTree< sortTreeNodes(segmentTree) const processedTree: ProcessedTree = { segmentTree, - flatCache: new Map(), singleCache: new Map(), + matchCache: createLRUCache< + string, + ReturnType> + >(1000), + flatCache: null, + masksTree: null, } return { processedTree, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index a4cfefbff0f..7fbe3ee1338 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -13,6 +13,7 @@ import { findFlatMatch, findRouteMatch, findSingleMatch, + processRouteMasks, processRouteTree, } from './new-process-route-tree' import { @@ -1103,6 +1104,9 @@ export class RouterCore< }) }, ) + if (this.options.routeMasks) { + processRouteMasks(this.options.routeMasks, processedTree) + } this.routesById = routesById as RoutesById this.routesByPath = routesByPath as RoutesByPath @@ -1776,7 +1780,6 @@ export class RouterCore< if (this.options.routeMasks) { const match = findFlatMatch>( - this.options.routeMasks, next.pathname, this.processedTree, ) @@ -2627,7 +2630,7 @@ export function getMatchedRoutes({ routesById: Record processedTree: ProcessedTree }) { - let routeParams: Record = {} + let routeParams: Record const trimmedPath = trimPathRight(pathname) let foundRoute: TRouteLike | undefined = undefined @@ -2635,6 +2638,8 @@ export function getMatchedRoutes({ if (match) { foundRoute = match.route routeParams = match.params + } else { + routeParams = {} } let routeCursor: TRouteLike = foundRoute || routesById[rootRouteId]! From 9f92e6b2b8c721a84c9286c524fa4461ee252913 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 18:20:06 +0100 Subject: [PATCH 064/109] fix build --- packages/router-core/src/lru-cache.ts | 6 ++++++ packages/router-plugin/src/core/route-hmr-statement.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/lru-cache.ts b/packages/router-core/src/lru-cache.ts index 12b9fbcd8eb..4a1169c8a4f 100644 --- a/packages/router-core/src/lru-cache.ts +++ b/packages/router-core/src/lru-cache.ts @@ -1,6 +1,7 @@ export type LRUCache = { get: (key: TKey) => TValue | undefined set: (key: TKey, value: TValue) => void + clear: () => void } export function createLRUCache( @@ -64,5 +65,10 @@ export function createLRUCache( cache.set(key, entry) } }, + clear() { + cache.clear() + oldest = undefined + newest = undefined + } } } diff --git a/packages/router-plugin/src/core/route-hmr-statement.ts b/packages/router-plugin/src/core/route-hmr-statement.ts index 7e41531f7df..7188d79aff1 100644 --- a/packages/router-plugin/src/core/route-hmr-statement.ts +++ b/packages/router-plugin/src/core/route-hmr-statement.ts @@ -22,7 +22,8 @@ function handleRouteUpdate( const router = window.__TSR_ROUTER__! router.routesById[newRoute.id] = newRoute router.routesByPath[newRoute.fullPath] = newRoute - router.processedTree.flatCache.clear() + router.processedTree.matchCache.clear() + router.processedTree.flatCache?.clear() router.processedTree.singleCache.clear() // TODO: how to rebuild the tree if we add a new route? walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree) From f3e5a808c2713d26e09acfa2b8d9418a22aa10a2 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 19:12:10 +0100 Subject: [PATCH 065/109] fix fuzzy rest argument leading slash --- packages/router-core/src/lru-cache.ts | 2 +- packages/router-core/src/new-process-route-tree.ts | 2 +- packages/router-core/tests/new-process-route-tree.test.ts | 8 ++------ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/router-core/src/lru-cache.ts b/packages/router-core/src/lru-cache.ts index 4a1169c8a4f..ee3ec7d7a03 100644 --- a/packages/router-core/src/lru-cache.ts +++ b/packages/router-core/src/lru-cache.ts @@ -69,6 +69,6 @@ export function createLRUCache( cache.clear() oldest = undefined newest = undefined - } + }, } } diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 50cba8c9913..67f12377436 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -950,7 +950,7 @@ function getNodeMatch( return { node: bestFuzzy.node, skipped: bestFuzzy.skipped, - '**': '/' + parts.slice(bestFuzzy.index).join('/'), + '**': parts.slice(bestFuzzy.index).join('/'), } } diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index b3c67c4135b..3ae0077a040 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - findRouteMatch, - processFlatRouteList, - processRouteTree, -} from '../src/new-process-route-tree' +import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree' import type { AnyRoute, RouteMask } from '../src' // import { createLRUCache } from '../src/lru-cache' @@ -119,7 +115,7 @@ describe('findRouteMatch', () => { expect(match?.route?.id).toBe('__root__') expect(match?.params).toMatchInlineSnapshot(` { - "**": "/x/y/z", + "**": "x/y/z", } `) }) From 4b2df33ff37943355c5c1f2eabdc2f9cf059a32f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 19:56:06 +0100 Subject: [PATCH 066/109] add more test cases --- .../router-core/src/new-process-route-tree.ts | 2 +- .../tests/new-process-route-tree.test.ts | 173 ++++++++++++++---- 2 files changed, 136 insertions(+), 39 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 67f12377436..4fdac10f1a3 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -741,7 +741,7 @@ function getNodeMatch( segmentTree: AnySegmentNode, fuzzy: boolean, ) { - parts = parts.filter(Boolean) + parts = parts.filter(Boolean) // TODO: this is a bad idea, but idk how we're supposed to handle leading/trailing slashes const partsLength = parts.length type Frame = { diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 3ae0077a040..bb74c027f83 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -60,47 +60,132 @@ function makeTree(routes: Array) { describe('findRouteMatch', () => { describe('priority', () => { - it('/static/optional vs /static/dynamic', () => { - const tree = makeTree(['/foo/{-$id}', '/foo/$id']) - expect(findRouteMatch('/foo/123', tree)?.route.id).toBe('/foo/$id') - }) - it('/static/optional/static vs /static/dynamic/static', () => { - const tree = makeTree(['/a/{-$b}/c', '/a/$b/c']) - expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/a/$b/c') - }) - it('/static/optional/dynamic vs /static/dynamic/static', () => { - const tree = makeTree(['/a/{-$b}/$c', '/a/$b/c']) - expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/a/$b/c') - }) - it('/static/optional/static vs /static/dynamic', () => { - const tree = makeTree(['/users/{-$org}/settings', '/users/$id']) - expect(findRouteMatch('/users/settings', tree)?.route.id).toBe( - '/users/{-$org}/settings', - ) - }) - it('/optional/static/static vs /static/dynamic', () => { - const tree = makeTree(['/{-$other}/posts/new', '/posts/$id']) - expect(findRouteMatch('/posts/new', tree)?.route.id).toBe( - '/{-$other}/posts/new', - ) - }) - it('/optional/static/static vs /static/static', () => { - const tree = makeTree(['/{-$other}/posts/new', '/posts/new']) - expect(findRouteMatch('/posts/new', tree)?.route.id).toBe('/posts/new') + describe('basic permutations', () => { + it('/static/static vs /static/dynamic', () => { + const tree = makeTree(['/a/b', '/a/$b']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/b') + }) + it('/static/static vs /static/optional', () => { + const tree = makeTree(['/a/b', '/a/{-$b}']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/b') + }) + it('/static/static vs /static/wildcard', () => { + const tree = makeTree(['/a/b', '/a/$']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/b') + }) + it('/static/dynamic vs /static/optional', () => { + const tree = makeTree(['/a/$b', '/a/{-$b}']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/$b') + }) + it('/static/dynamic vs /static/wildcard', () => { + const tree = makeTree(['/a/$b', '/a/$']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/$b') + }) + it('/static/optional vs /static/wildcard', () => { + const tree = makeTree(['/a/{-$b}', '/a/$']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/{-$b}') + }) }) - it('/optional/static/static/dynamic vs /static/dynamic/static/dynamic', () => { - const tree = makeTree(['/{-$other}/posts/a/b/$c', '/posts/$a/b/$c']) - expect(findRouteMatch('/posts/a/b/c', tree)?.route.id).toBe( - '/{-$other}/posts/a/b/$c', - ) + + describe('prefix / suffix variations', () => { + it('prefix+suffix dynamic wins over plain dynamic', () => { + const tree = makeTree(['/a/b{$b}b', '/a/$b']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{$b}b') + }) + it('prefix dynamic wins over plain dynamic', () => { + const tree = makeTree(['/a/b{$b}', '/a/$b']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{$b}') + }) + it('suffix dynamic wins over plain dynamic', () => { + const tree = makeTree(['/a/{$b}b', '/a/$b']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/{$b}b') + }) + + it('prefix+suffix optional wins over plain optional', () => { + const tree = makeTree(['/a/b{-$b}b', '/a/{-$b}']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{-$b}b') + }) + it('prefix optional wins over plain optional', () => { + const tree = makeTree(['/a/b{-$b}', '/a/{-$b}']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{-$b}') + }) + it('suffix optional wins over plain optional', () => { + const tree = makeTree(['/a/{-$b}b', '/a/{-$b}']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/{-$b}b') + }) + + it('prefix+suffix wildcard wins over plain wildcard', () => { + const tree = makeTree(['/a/b{$}b', '/a/$']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{$}b') + }) + it('prefix wildcard wins over plain wildcard', () => { + const tree = makeTree(['/a/b{$}', '/a/$']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{$}') + }) + it('suffix wildcard wins over plain wildcard', () => { + const tree = makeTree(['/a/{$}b', '/a/$']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/{$}b') + }) }) - it('?? is this what we want?', () => { - const tree = makeTree(['/{-$a}/{-$b}/{-$c}/d/e', '/$a/$b/c/d/$e']) - expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe('/$a/$b/c/d/$e') + + describe('prefix / suffix lengths', () => { + it('longer overlapping prefix wins over shorter prefix', () => { + const tree = makeTree(['/a/b{$b}', '/a/bbbb{$b}']) + expect(findRouteMatch('/a/bbbbb', tree)?.route.id).toBe('/a/bbbb{$b}') + }) + it('longer overlapping suffix wins over shorter suffix', () => { + const tree = makeTree(['/a/{$b}b', '/a/{$b}bbbb']) + expect(findRouteMatch('/a/bbbbb', tree)?.route.id).toBe('/a/{$b}bbbb') + }) + it('longer prefix and shorter suffix wins over shorter prefix and longer suffix', () => { + const tree = makeTree(['/a/b{$b}bbb', '/a/bbb{$b}b']) + expect(findRouteMatch('/a/bbbbb', tree)?.route.id).toBe('/a/bbb{$b}b') + }) }) - it('?? is this what we want?', () => { - const tree = makeTree(['/$a/$b/$c/d/e', '/$a/$b/c/d/$e']) - expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe('/$a/$b/c/d/$e') + + describe('edge-case variations', () => { + it('/static/optional/static vs /static/dynamic/static', () => { + const tree = makeTree(['/a/{-$b}/c', '/a/$b/c']) + expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/a/$b/c') + }) + it('/static/optional/dynamic vs /static/dynamic/static', () => { + const tree = makeTree(['/a/{-$b}/$c', '/a/$b/c']) + expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/a/$b/c') + }) + it('/static/optional/static vs /static/dynamic', () => { + const tree = makeTree(['/users/{-$org}/settings', '/users/$id']) + expect(findRouteMatch('/users/settings', tree)?.route.id).toBe( + '/users/{-$org}/settings', + ) + }) + it('/optional/static/static vs /static/dynamic', () => { + const tree = makeTree(['/{-$other}/posts/new', '/posts/$id']) + expect(findRouteMatch('/posts/new', tree)?.route.id).toBe( + '/{-$other}/posts/new', + ) + }) + it('/optional/static/static vs /static/static', () => { + const tree = makeTree(['/{-$other}/posts/new', '/posts/new']) + expect(findRouteMatch('/posts/new', tree)?.route.id).toBe('/posts/new') + }) + it('/optional/static/static/dynamic vs /static/dynamic/static/dynamic', () => { + const tree = makeTree(['/{-$other}/posts/a/b/$c', '/posts/$a/b/$c']) + expect(findRouteMatch('/posts/a/b/c', tree)?.route.id).toBe( + '/{-$other}/posts/a/b/$c', + ) + }) + it('?? is this what we want?', () => { + const tree = makeTree(['/{-$a}/{-$b}/{-$c}/d/e', '/$a/$b/c/d/$e']) + expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe( + '/$a/$b/c/d/$e', + ) + }) + it('?? is this what we want?', () => { + const tree = makeTree(['/$a/$b/$c/d/e', '/$a/$b/c/d/$e']) + expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe( + '/$a/$b/c/d/$e', + ) + }) }) }) @@ -121,6 +206,8 @@ describe('findRouteMatch', () => { }) }) + describe.todo('trailing slashes', () => {}) + describe('case sensitivity competition', () => { it('a case sensitive segment early on should not prevent a case insensitive match', () => { const tree = { @@ -290,6 +377,16 @@ describe('findRouteMatch', () => { '/yo/foo{-$id}bar/ma', ) }) + it('edge-case: ???', () => { + // This test comes from the previous processRouteTree tests. + // > This demonstrates that `/foo/{-$p}.tsx` will be matched, not `/foo/{-$p}/{-$x}.tsx` + // > This route has 1 optional parameter, making it more specific than the route with 2 + const tree = makeTree(['/foo/{-$p}.tsx', '/foo/{-$p}/{-$x}.tsx']) + expect(findRouteMatch('/foo', tree)?.route.id).toBe('/foo/{-$p}.tsx') + expect(findRouteMatch('/foo/bar.tsx', tree)?.route.id).toBe( + '/foo/{-$p}.tsx', + ) + }) }) describe('nested routes', () => { From 0ca25b16b4f3daf704267217646b32a95a962d33 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 20:12:03 +0100 Subject: [PATCH 067/109] batch push in stack (will refactor, but at least this works) --- .../router-core/src/new-process-route-tree.ts | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 4fdac10f1a3..ba37b4522e5 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -758,7 +758,7 @@ function getNodeMatch( } // use a stack to explore all possible paths (optional params cause branching) - const stack: Array = [ + let stack: Array = [ { node: segmentTree, index: 0, @@ -769,12 +769,18 @@ function getNodeMatch( optionals: 0, }, ] + // append to the stack in batches, to preserve order of exploration + const batch: Array = [] let wildcardMatch: Frame | null = null let bestFuzzy: Frame | null = null let bestMatch: Frame | null = null - while (stack.length) { + while (stack.length || batch.length) { + if (batch.length) { + stack = stack.concat(batch.reverse()) + batch.length = 0 + } const frame = stack.pop()! // eslint-disable-next-line prefer-const let { node, index, skipped, depth, statics, dynamics, optionals } = frame @@ -818,7 +824,7 @@ function getNodeMatch( if (!isBeyondPath && node.static) { const match = node.static.get(part!) if (match) { - stack.unshift({ + batch.push({ node: match, index: index + 1, skipped, @@ -836,7 +842,7 @@ function getNodeMatch( (lowerPart ??= part!.toLowerCase()), ) if (match) { - stack.unshift({ + batch.push({ node: match, index: index + 1, skipped, @@ -859,7 +865,7 @@ function getNodeMatch( if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } - stack.push({ + batch.push({ node: segment, index: index + 1, skipped, @@ -874,19 +880,6 @@ function getNodeMatch( // 4. Try optional match if (node.optional) { const nextDepth = depth + 1 - const nextSkipped = skipped | (1 << nextDepth) - for (const segment of node.optional) { - // when skipping, node and depth advance by 1, but index doesn't - stack.push({ - node: segment, - index, - skipped: nextSkipped, - depth: nextDepth, - statics, - dynamics, - optionals, - }) // enqueue skipping the optional - } if (!isBeyondPath) { for (const segment of node.optional) { const { prefix, suffix } = segment @@ -897,7 +890,7 @@ function getNodeMatch( if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } - stack.push({ + batch.push({ node: segment, index: index + 1, skipped, @@ -908,6 +901,19 @@ function getNodeMatch( }) } } + const nextSkipped = skipped | (1 << nextDepth) + for (const segment of node.optional) { + // when skipping, node and depth advance by 1, but index doesn't + batch.push({ + node: segment, + index, + skipped: nextSkipped, + depth: nextDepth, + statics, + dynamics, + optionals, + }) // enqueue skipping the optional + } } // 5. Try wildcard match From 4647f9d9f30ed54fee28374597dfde49ae820dbf Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 20:21:58 +0100 Subject: [PATCH 068/109] remove batch stack append, iterate backwards instead --- .../router-core/src/new-process-route-tree.ts | 193 +++++++++--------- 1 file changed, 98 insertions(+), 95 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index ba37b4522e5..4b9a8b1124d 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -758,7 +758,13 @@ function getNodeMatch( } // use a stack to explore all possible paths (optional params cause branching) - let stack: Array = [ + // iterate "backwards" (low priority first) so that we can push() each candidate, and pop() the highest priority candidate first + // - pros: it is depth-first, so we find full matches faster + // - cons: each branch of the node must be iterated fully, we cannot short-circuit, because highest priority matches are at the end of the loop (for loop with i--) + // other possible approaches: + // - shift instead of pop (measure performance difference), this allows iterating "forwards" (effectively breadth-first) + // - never remove from the stack, keep a cursor instead. Then we can push "forwards" and avoid reversing the order of candidates (effectively breadth-first) + const stack: Array = [ { node: segmentTree, index: 0, @@ -769,18 +775,12 @@ function getNodeMatch( optionals: 0, }, ] - // append to the stack in batches, to preserve order of exploration - const batch: Array = [] let wildcardMatch: Frame | null = null let bestFuzzy: Frame | null = null let bestMatch: Frame | null = null - while (stack.length || batch.length) { - if (batch.length) { - stack = stack.concat(batch.reverse()) - batch.length = 0 - } + while (stack.length) { const frame = stack.pop()! // eslint-disable-next-line prefer-const let { node, index, skipped, depth, statics, dynamics, optionals } = frame @@ -820,68 +820,57 @@ function getNodeMatch( const part = isBeyondPath ? undefined : parts[index]! let lowerPart: string - // 1. Try static match - if (!isBeyondPath && node.static) { - const match = node.static.get(part!) - if (match) { - batch.push({ - node: match, - index: index + 1, - skipped, - depth: depth + 1, - statics: statics + 1, - dynamics, - optionals, - }) - } - } - - // 2. Try case insensitive static match - if (!isBeyondPath && node.staticInsensitive) { - const match = node.staticInsensitive.get( - (lowerPart ??= part!.toLowerCase()), - ) - if (match) { - batch.push({ - node: match, - index: index + 1, - skipped, - depth: depth + 1, - statics: statics + 1, - dynamics, - optionals, - }) - } - } - - // 3. Try dynamic match - if (!isBeyondPath && node.dynamic) { - for (const segment of node.dynamic) { + // 5. Try wildcard match + if (!isBeyondPath && node.wildcard) { + for (const segment of node.wildcard) { const { prefix, suffix } = segment - if (prefix || suffix) { + if (prefix) { const casePart = segment.caseSensitive ? part! : (lowerPart ??= part!.toLowerCase()) - if (prefix && !casePart.startsWith(prefix)) continue - if (suffix && !casePart.endsWith(suffix)) continue + if (!casePart.startsWith(prefix)) continue } - batch.push({ - node: segment, - index: index + 1, - skipped, - depth: depth + 1, - statics, - dynamics: dynamics + 1, - optionals, - }) + if (suffix) { + const end = parts.slice(index).join('/').slice(-suffix.length) + const casePart = segment.caseSensitive ? end : end.toLowerCase() + if (casePart !== suffix) continue + } + // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match + if (!wildcardMatch || wildcardMatch.index <= index) { + wildcardMatch = { + node: segment, + index, + skipped, + depth, + statics, + dynamics, + optionals, + } + } + break } } // 4. Try optional match if (node.optional) { const nextDepth = depth + 1 + const nextSkipped = skipped | (1 << nextDepth) + for (let i = node.optional.length - 1; i >= 0; i--) { + const segment = node.optional[i]! + // when skipping, node and depth advance by 1, but index doesn't + stack.push({ + node: segment, + index, + skipped: nextSkipped, + depth: nextDepth, + statics, + dynamics, + optionals, + }) // enqueue skipping the optional + } if (!isBeyondPath) { - for (const segment of node.optional) { + for (let i = node.optional.length - 1; i >= 0; i--) { + const segment = node.optional[i]! const { prefix, suffix } = segment if (prefix || suffix) { const casePart = segment.caseSensitive @@ -890,7 +879,7 @@ function getNodeMatch( if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } - batch.push({ + stack.push({ node: segment, index: index + 1, skipped, @@ -901,49 +890,63 @@ function getNodeMatch( }) } } - const nextSkipped = skipped | (1 << nextDepth) - for (const segment of node.optional) { - // when skipping, node and depth advance by 1, but index doesn't - batch.push({ - node: segment, - index, - skipped: nextSkipped, - depth: nextDepth, - statics, - dynamics, - optionals, - }) // enqueue skipping the optional - } } - // 5. Try wildcard match - if (!isBeyondPath && node.wildcard) { - for (const segment of node.wildcard) { + // 3. Try dynamic match + if (!isBeyondPath && node.dynamic) { + for (let i = node.dynamic.length - 1; i >= 0; i--) { + const segment = node.dynamic[i]! const { prefix, suffix } = segment - if (prefix) { + if (prefix || suffix) { const casePart = segment.caseSensitive ? part! : (lowerPart ??= part!.toLowerCase()) - if (!casePart.startsWith(prefix)) continue - } - if (suffix) { - const end = parts.slice(index).join('/').slice(-suffix.length) - const casePart = segment.caseSensitive ? end : end.toLowerCase() - if (casePart !== suffix) continue - } - // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match - if (!wildcardMatch || wildcardMatch.index <= index) { - wildcardMatch = { - node: segment, - index, - skipped, - depth, - statics, - dynamics, - optionals, - } + if (prefix && !casePart.startsWith(prefix)) continue + if (suffix && !casePart.endsWith(suffix)) continue } - break + stack.push({ + node: segment, + index: index + 1, + skipped, + depth: depth + 1, + statics, + dynamics: dynamics + 1, + optionals, + }) + } + } + + // 2. Try case insensitive static match + if (!isBeyondPath && node.staticInsensitive) { + const match = node.staticInsensitive.get( + (lowerPart ??= part!.toLowerCase()), + ) + if (match) { + stack.push({ + node: match, + index: index + 1, + skipped, + depth: depth + 1, + statics: statics + 1, + dynamics, + optionals, + }) + } + } + + // 1. Try static match + if (!isBeyondPath && node.static) { + const match = node.static.get(part!) + if (match) { + stack.push({ + node: match, + index: index + 1, + skipped, + depth: depth + 1, + statics: statics + 1, + dynamics, + optionals, + }) } } } From e9489e1bd8c4b299bd71cb17ef05de19ee8d9365 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 21:42:26 +0100 Subject: [PATCH 069/109] comments --- packages/router-core/src/new-process-route-tree.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 4b9a8b1124d..5d9b676f62e 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -544,7 +544,7 @@ export function findSingleMatch( path: string, processedTree: ProcessedTree, ) { - const key = `${caseSensitive}|${from}` + const key = caseSensitive ? `case|${from}` : from let tree = processedTree.singleCache.get(key) if (!tree) { // single flat routes (router.matchRoute) are not eagerly processed, @@ -719,6 +719,7 @@ function extractParams( currentPathIndex + (n.prefix?.length ?? 0), path.length - (n.suffix?.length ?? 0), ) + // TODO: Deprecate * params['*'] = rest params._splat = rest break @@ -757,10 +758,10 @@ function getNodeMatch( optionals: number } - // use a stack to explore all possible paths (optional params cause branching) + // use a stack to explore all possible paths (params cause branching) // iterate "backwards" (low priority first) so that we can push() each candidate, and pop() the highest priority candidate first // - pros: it is depth-first, so we find full matches faster - // - cons: each branch of the node must be iterated fully, we cannot short-circuit, because highest priority matches are at the end of the loop (for loop with i--) + // - cons: we cannot short-circuit, because highest priority matches are at the end of the loop (for loop with i--) (but we have no good short-circuiting anyway) // other possible approaches: // - shift instead of pop (measure performance difference), this allows iterating "forwards" (effectively breadth-first) // - never remove from the stack, keep a cursor instead. Then we can push "forwards" and avoid reversing the order of candidates (effectively breadth-first) From a46ce083059275bd05d4aee4c514e48bb02ef8d2 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 21:42:43 +0100 Subject: [PATCH 070/109] some more unit tests --- .../tests/new-process-route-tree.test.ts | 161 ++++++++---------- 1 file changed, 74 insertions(+), 87 deletions(-) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index bb74c027f83..fd38e407c2e 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -20,47 +20,9 @@ function makeTree(routes: Array) { }).processedTree } -// describe('foo', () => { -// it('old', () => { -// const { flatRoutes } = oldProcessRouteTree({ routeTree: big }) -// const oldFindMatch = (path: string) => { -// for (const route of flatRoutes) { -// const params = matchPathname(path, { to: route.fullPath }) -// if (params) return { route, params } -// } -// return null -// } -// expect(oldFindMatch('/')?.route.id).toMatchInlineSnapshot(`"__root__"`) -// }) -// it('comp', () => { -// const { flatRoutes } = oldProcessRouteTree({ -// routeTree: { -// id: '__root__', -// fullPath: '/', -// path: '/', -// children: [ -// { -// id: '/{-id}', -// fullPath: '/{-id}', -// path: '{-id}', -// } -// ] -// } -// }) -// const oldFindMatch = (path: string) => { -// for (const route of flatRoutes) { -// const params = matchPathname(path, { to: route.fullPath }) -// if (params) return { route, params } -// } -// return null -// } -// expect(oldFindMatch('/')?.route.id).toMatchInlineSnapshot(`"__root__"`) -// }) -// }) - describe('findRouteMatch', () => { describe('priority', () => { - describe('basic permutations', () => { + describe('basic permutations priorities', () => { it('/static/static vs /static/dynamic', () => { const tree = makeTree(['/a/b', '/a/$b']) expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/b') @@ -87,7 +49,7 @@ describe('findRouteMatch', () => { }) }) - describe('prefix / suffix variations', () => { + describe('prefix / suffix priorities', () => { it('prefix+suffix dynamic wins over plain dynamic', () => { const tree = makeTree(['/a/b{$b}b', '/a/$b']) expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{$b}b') @@ -143,6 +105,26 @@ describe('findRouteMatch', () => { }) }) + describe('root matches', () => { + it('optional at the root matches /', () => { + const tree = makeTree(['/{-$id}']) + const res = findRouteMatch('/', tree) + expect(res?.route.id).toBe('/{-$id}') + expect(res?.params).toEqual({}) + }) + it('wildcard at the root matches /', () => { + const tree = makeTree(['/$']) + const res = findRouteMatch('/', tree) + expect(res?.route.id).toBe('/$') + expect(res?.params).toEqual({ '*': '', _splat: '' }) + }) + it('dynamic at the root DOES NOT match /', () => { + const tree = makeTree(['/$id']) + const res = findRouteMatch('/', tree) + expect(res?.route.id).toBe('__root__') + }) + }) + describe('edge-case variations', () => { it('/static/optional/static vs /static/dynamic/static', () => { const tree = makeTree(['/a/{-$b}/c', '/a/$b/c']) @@ -174,13 +156,16 @@ describe('findRouteMatch', () => { '/{-$other}/posts/a/b/$c', ) }) - it('?? is this what we want?', () => { - const tree = makeTree(['/{-$a}/{-$b}/{-$c}/d/e', '/$a/$b/c/d/$e']) + it('chain of optional and static segments: favor earlier static segments', () => { + const tree = makeTree([ + '/{-$a}/{-$b}/{-$c}/d/e', + '/{-$a}/{-$b}/c/d/{-$e}', + ]) expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe( - '/$a/$b/c/d/$e', + '/{-$a}/{-$b}/c/d/{-$e}', ) }) - it('?? is this what we want?', () => { + it('chain of dynamic and static segments: favor earlier static segments', () => { const tree = makeTree(['/$a/$b/$c/d/e', '/$a/$b/c/d/$e']) expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe( '/$a/$b/c/d/$e', @@ -298,43 +283,47 @@ describe('findRouteMatch', () => { expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/$') }) - it('dynamic w/ prefix', () => { - const tree = makeTree(['/{$id}.txt']) - expect(findRouteMatch('/123.txt', tree)?.route.id).toBe('/{$id}.txt') - }) - it('dynamic w/ suffix', () => { - const tree = makeTree(['/file{$id}']) - expect(findRouteMatch('/file123', tree)?.route.id).toBe('/file{$id}') - }) - it('dynamic w/ prefix and suffix', () => { - const tree = makeTree(['/file{$id}.txt']) - expect(findRouteMatch('/file123.txt', tree)?.route.id).toBe( - '/file{$id}.txt', - ) - }) - it('optional w/ prefix', () => { - const tree = makeTree(['/{-$id}.txt']) - expect(findRouteMatch('/123.txt', tree)?.route.id).toBe('/{-$id}.txt') - expect(findRouteMatch('.txt', tree)?.route.id).toBe('/{-$id}.txt') - }) - it('optional w/ suffix', () => { - const tree = makeTree(['/file{-$id}']) - expect(findRouteMatch('/file123', tree)?.route.id).toBe('/file{-$id}') - expect(findRouteMatch('/file', tree)?.route.id).toBe('/file{-$id}') - }) - it('optional w/ prefix and suffix', () => { - const tree = makeTree(['/file{-$id}.txt']) - expect(findRouteMatch('/file123.txt', tree)?.route.id).toBe( - '/file{-$id}.txt', - ) - expect(findRouteMatch('/file.txt', tree)?.route.id).toBe( - '/file{-$id}.txt', - ) + describe('prefix / suffix variations', () => { + it('dynamic w/ prefix', () => { + const tree = makeTree(['/{$id}.txt']) + expect(findRouteMatch('/123.txt', tree)?.route.id).toBe('/{$id}.txt') + }) + it('dynamic w/ suffix', () => { + const tree = makeTree(['/file{$id}']) + expect(findRouteMatch('/file123', tree)?.route.id).toBe('/file{$id}') + }) + it('dynamic w/ prefix and suffix', () => { + const tree = makeTree(['/file{$id}.txt']) + expect(findRouteMatch('/file123.txt', tree)?.route.id).toBe( + '/file{$id}.txt', + ) + }) + it('optional w/ prefix', () => { + const tree = makeTree(['/{-$id}.txt']) + expect(findRouteMatch('/123.txt', tree)?.route.id).toBe('/{-$id}.txt') + expect(findRouteMatch('.txt', tree)?.route.id).toBe('/{-$id}.txt') + }) + it('optional w/ suffix', () => { + const tree = makeTree(['/file{-$id}']) + expect(findRouteMatch('/file123', tree)?.route.id).toBe('/file{-$id}') + expect(findRouteMatch('/file', tree)?.route.id).toBe('/file{-$id}') + }) + it('optional w/ prefix and suffix', () => { + const tree = makeTree(['/file{-$id}.txt']) + expect(findRouteMatch('/file123.txt', tree)?.route.id).toBe( + '/file{-$id}.txt', + ) + expect(findRouteMatch('/file.txt', tree)?.route.id).toBe( + '/file{-$id}.txt', + ) + }) }) + it('optional at the end can still be omitted', () => { const tree = makeTree(['/a/{-$id}']) expect(findRouteMatch('/a', tree)?.route.id).toBe('/a/{-$id}') }) + it('multiple optionals at the end can still be omitted', () => { const tree = makeTree(['/a/{-$b}/{-$c}/{-$d}']) expect(findRouteMatch('/a', tree)?.route.id).toBe('/a/{-$b}/{-$c}/{-$d}') @@ -343,15 +332,15 @@ describe('findRouteMatch', () => { const tree = makeTree(['/a/{-$b}/{-$c}/{-$d}/{-$e}']) expect(findRouteMatch('/a/b/c', tree)?.params).toEqual({ b: 'b', c: 'c' }) }) - it('wildcard w/ prefix', () => { + it('multi-segment wildcard w/ prefix', () => { const tree = makeTree(['/file{$}']) expect(findRouteMatch('/file/a/b/c', tree)?.route.id).toBe('/file{$}') }) - it('wildcard w/ suffix', () => { + it('multi-segment wildcard w/ suffix', () => { const tree = makeTree(['/{$}/c/file']) expect(findRouteMatch('/a/b/c/file', tree)?.route.id).toBe('/{$}/c/file') }) - it('wildcard w/ prefix and suffix', () => { + it('multi-segment wildcard w/ prefix and suffix', () => { const tree = makeTree(['/file{$}end']) expect(findRouteMatch('/file/a/b/c/end', tree)?.route.id).toBe( '/file{$}end', @@ -370,12 +359,10 @@ describe('findRouteMatch', () => { }) it('edge-case: presence of a valid wildcard doesnt prevent other matches', () => { const tree = makeTree(['/yo/foo{-$id}bar/ma', '/yo/$']) - expect(findRouteMatch('/yo/foobar/ma', tree)?.route.id).toBe( - '/yo/foo{-$id}bar/ma', - ) - expect(findRouteMatch('/yo/foo123bar/ma', tree)?.route.id).toBe( - '/yo/foo{-$id}bar/ma', - ) + const absent = findRouteMatch('/yo/foobar/ma', tree) + expect(absent?.route.id).toBe('/yo/foo{-$id}bar/ma') + const present = findRouteMatch('/yo/foo123bar/ma', tree) + expect(present?.route.id).toBe('/yo/foo{-$id}bar/ma') }) it('edge-case: ???', () => { // This test comes from the previous processRouteTree tests. @@ -439,7 +426,7 @@ describe('findRouteMatch', () => { describe.todo('fuzzy matching', () => {}) }) -describe.todo('processFlatRouteList', () => { +describe.todo('processRouteMasks', () => { it('processes a route masks list', () => { const routeTree = {} as AnyRoute const routeMasks: Array> = [ @@ -449,6 +436,6 @@ describe.todo('processFlatRouteList', () => { { from: '/a/{-$optional}/d', routeTree }, { from: '/a/b/{$}.txt', routeTree }, ] - // expect(processFlatRouteList(routeMasks)).toMatchInlineSnapshot() + // expect(processRouteMasks(routeMasks)).toMatchInlineSnapshot() }) }) From 947b47eab8a9e2652d7e588a23cf98f490ec07e7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Nov 2025 23:45:53 +0100 Subject: [PATCH 071/109] add comment about skipped bitmask being limited to 32 --- packages/router-core/src/new-process-route-tree.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 5d9b676f62e..aac19b07118 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -751,7 +751,12 @@ function getNodeMatch( index: number /** how many nodes between `node` and the root of the segment tree */ depth: number - /** Bitmask of skipped optional segments */ + /** + * Bitmask of skipped optional segments. + * + * This is a very performant way of storing an "array of booleans", but it means beyond 32 segments we can't track skipped optionals. + * If we really really need to support more than 32 segments we can switch to using a `BigInt` here. It's about 2x slower in worst case scenarios. + */ skipped: number statics: number dynamics: number From 3785a2e60dd094dd04de238ac39e5e56a23e1bdf Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 00:03:21 +0100 Subject: [PATCH 072/109] match wildcards beyond last segment --- packages/router-core/src/new-process-route-tree.ts | 12 +++++++----- .../router-core/tests/new-process-route-tree.test.ts | 7 ++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index aac19b07118..91558e6c153 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -808,8 +808,8 @@ function getNodeMatch( // perfect match, no need to continue if (statics === partsLength) return bestMatch } - // beyond the length of the path parts, only skipped optional segments can match - if (!node.optional) continue + // beyond the length of the path parts, only skipped optional segments or wildcard segments can match + if (!node.optional && !node.wildcard) continue } // In fuzzy mode, track the best partial match we've found so far @@ -827,16 +827,18 @@ function getNodeMatch( let lowerPart: string // 5. Try wildcard match - if (!isBeyondPath && node.wildcard) { + if (node.wildcard) { for (const segment of node.wildcard) { const { prefix, suffix } = segment if (prefix) { + if (isBeyondPath) continue const casePart = segment.caseSensitive - ? part! + ? part : (lowerPart ??= part!.toLowerCase()) - if (!casePart.startsWith(prefix)) continue + if (!casePart!.startsWith(prefix)) continue } if (suffix) { + if (isBeyondPath) continue const end = parts.slice(index).join('/').slice(-suffix.length) const casePart = segment.caseSensitive ? end : end.toLowerCase() if (casePart !== suffix) continue diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index fd38e407c2e..d8f534c0858 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -323,7 +323,6 @@ describe('findRouteMatch', () => { const tree = makeTree(['/a/{-$id}']) expect(findRouteMatch('/a', tree)?.route.id).toBe('/a/{-$id}') }) - it('multiple optionals at the end can still be omitted', () => { const tree = makeTree(['/a/{-$b}/{-$c}/{-$d}']) expect(findRouteMatch('/a', tree)?.route.id).toBe('/a/{-$b}/{-$c}/{-$d}') @@ -332,6 +331,12 @@ describe('findRouteMatch', () => { const tree = makeTree(['/a/{-$b}/{-$c}/{-$d}/{-$e}']) expect(findRouteMatch('/a/b/c', tree)?.params).toEqual({ b: 'b', c: 'c' }) }) + it('optional and wildcard at the end can still be omitted', () => { + const tree = makeTree(['/a/{-$id}/$']) + const result = findRouteMatch('/a', tree) + expect(result?.route.id).toBe('/a/{-$id}/$') + expect(result?.params).toEqual({}) + }) it('multi-segment wildcard w/ prefix', () => { const tree = makeTree(['/file{$}']) expect(findRouteMatch('/file/a/b/c', tree)?.route.id).toBe('/file{$}') From 435f4e9adb837cea47238390f1375b1db51e3c11 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 00:07:03 +0100 Subject: [PATCH 073/109] remove deprecated test file --- .../tests/processRouteTree.test.ts | 442 ------------------ 1 file changed, 442 deletions(-) delete mode 100644 packages/router-core/tests/processRouteTree.test.ts diff --git a/packages/router-core/tests/processRouteTree.test.ts b/packages/router-core/tests/processRouteTree.test.ts deleted file mode 100644 index 3a509a3376f..00000000000 --- a/packages/router-core/tests/processRouteTree.test.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { processRouteTree } from '../src/process-route-tree' -import { getMatchedRoutes } from '../src/router' -import { joinPaths } from '../src' -import type { RouteLike } from '../src/route' - -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): RouteLike { - return { - id: '__root__', - fullPath: '', - isRoot: true, - path: undefined, - children: createRoute(pathOrChildren, ''), - } -} - -describe('processRouteTree', () => { - describe('basic functionality', () => { - it('should process a simple route tree', () => { - const routeTree = createRouteTree(['/', '/about']) - - const result = processRouteTree({ routeTree }) - - expect(result.routesById).toHaveProperty('__root__') - expect(result.routesById).toHaveProperty('/') - expect(result.routesById).toHaveProperty('/about') - expect(result.routesByPath).toHaveProperty('/') - expect(result.routesByPath).toHaveProperty('/about') - expect(result.flatRoutes).toHaveLength(2) // excludes root - }) - - it('should assign ranks to routes in flatRoutes', () => { - const routeTree = createRouteTree(['/', '/about']) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes[0]).toHaveProperty('rank', 0) - expect(result.flatRoutes[1]).toHaveProperty('rank', 1) - }) - }) - - describe('route ranking - static segments vs params', () => { - it('should rank static segments higher than param segments', () => { - const routes = ['/users/profile', '/users/$id'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - expect(result.flatRoutes[0]!.rank).toBe(0) - expect(result.flatRoutes[1]!.rank).toBe(1) - }) - - it('should rank static segments higher than optional params', () => { - const routes = ['/users/settings', '/users/{-$id}'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank static segments higher than wildcards', () => { - const routes = ['/api/v1', '/api/$'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('route ranking - param variations', () => { - it('should rank params higher than optional params', () => { - const routes = ['/users/$id', '/users/{-$id}'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank optional params higher than wildcards', () => { - const routes = ['/files/{-$path}', '/files/$'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('route ranking - prefix and suffix variations', () => { - it('should rank param with prefix and suffix higher than plain param', () => { - const routes = ['/user/prefix-{$id}-suffix', '/user/$id'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank param with prefix higher than plain param', () => { - const routes = ['/user/prefix-{$id}', '/user/$id'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank param with suffix higher than plain param', () => { - const routes = ['/user/{$id}-suffix', '/user/$id'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('route ranking - path length priority', () => { - it('should rank longer paths higher when segment scores are equal', () => { - const routes = ['/api/v1', '/api'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank longer param paths higher', () => { - const routes = ['/users/$id', '/$id'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank index route higher than root catch-all', () => { - const routes = ['/', '/$'] // index route should rank higher than catch-all - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank routes with fewer optional parameters higher (more specific)', () => { - const routes = [ - '/foo', // most specific: exact match only - '/foo/{-$p1}', // less specific: matches /foo or /foo/x - '/foo/{-$p1}/{-$p2}', // least specific: matches /foo or /foo/x or /foo/x/y - ] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('route ranking - alphabetical ordering', () => { - it('should sort alphabetically when scores and lengths are equal', () => { - const routes = ['/apple', '/middle', '/zebra'] // in expected alphabetical order - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('route ranking - original index fallback', () => { - it('should use original index when all other criteria are equal', () => { - const routes = ['/first', '/second'] // in expected order (original index determines ranking) - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('complex routing scenarios', () => { - it('should correctly rank a complex mix of route types', () => { - // Define routes in expected ranking order - createRouteTree will shuffle them to test sorting - const expectedOrder = [ - '/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) - ] - - const routeTree = createRouteTree(expectedOrder) - const result = processRouteTree({ routeTree }) - const actualOrder = result.flatRoutes.map((r) => r.id) - - expect(actualOrder).toEqual(expectedOrder) - }) - }) - - describe('route matching with optional parameters', () => { - it('should match the most specific route when multiple routes could match', () => { - const routes = ['/foo/{-$p}.tsx', '/foo/{-$p}/{-$x}.tsx'] - const routeTree = createRouteTree(routes) - const result = processRouteTree({ routeTree }) - - // Verify the ranking - fewer optional parameters should rank higher - expect(result.flatRoutes.map((r) => r.id)).toEqual([ - '/foo/{-$p}.tsx', - '/foo/{-$p}/{-$x}.tsx', - ]) - - // The first route in flatRoutes is what will be matched for `/foo` - // This demonstrates that `/foo/{-$p}.tsx` will be matched, not `/foo/{-$p}/{-$x}.tsx` - const firstMatchingRoute = result.flatRoutes[0]! - expect(firstMatchingRoute.id).toBe('/foo/{-$p}.tsx') - - // This route has 1 optional parameter, making it more specific than the route with 2 - expect(firstMatchingRoute.fullPath).toBe('/foo/{-$p}.tsx') - }) - - it('should demonstrate matching priority for complex optional parameter scenarios', () => { - const routes = [ - '/foo/{-$a}', // 1 optional param - '/foo/{-$a}/{-$b}', // 2 optional params - '/foo/{-$a}/{-$b}/{-$c}', // 3 optional params - '/foo/bar', // static route (should rank highest) - '/foo/bar/{-$x}', // static + 1 optional - ] - const routeTree = createRouteTree(routes) - const result = processRouteTree({ routeTree }) - - // Expected ranking from most to least specific: - expect(result.flatRoutes.map((r) => r.id)).toEqual([ - '/foo/bar', // Static route wins - '/foo/bar/{-$x}', // Static + optional - '/foo/{-$a}', // Fewest optional params (1) - '/foo/{-$a}/{-$b}', // More optional params (2) - '/foo/{-$a}/{-$b}/{-$c}', // Most optional params (3) - ]) - - // For path `/foo/bar` - static route would match - // For path `/foo/anything` - `/foo/{-$a}` would match (not the routes with more optional params) - // For path `/foo` - `/foo/{-$a}` would match (optional param omitted) - }) - - it('should demonstrate actual path matching behavior', () => { - const routes = ['/foo/{-$p}.tsx', '/foo/{-$p}/{-$x}.tsx'] - const routeTree = createRouteTree(routes) - const result = processRouteTree({ routeTree }) - - // Test actual path matching for `/foo` - const matchResult = getMatchedRoutes({ - pathname: '/foo', - caseSensitive: false, - routesByPath: result.routesByPath, - routesById: result.routesById, - flatRoutes: result.flatRoutes, - }) - - // The foundRoute should be the more specific one (fewer optional parameters) - expect(matchResult.foundRoute?.id).toBe('/foo/{-$p}.tsx') - - // The matched route should be included in the route hierarchy - expect(matchResult.matchedRoutes.map((r) => r.id)).toContain( - '/foo/{-$p}.tsx', - ) - - // Parameters should show the optional parameter as undefined when omitted - expect(matchResult.routeParams).toEqual({ p: undefined }) - }) - }) - - describe('edge cases', () => { - it('should handle root route correctly', () => { - const routeTree = createRouteTree([]) - - const result = processRouteTree({ routeTree }) - - expect(result.routesById).toHaveProperty('__root__') - expect(result.flatRoutes).toHaveLength(0) // root is excluded from flatRoutes - }) - - it('should handle routes without paths', () => { - // This test case is more complex as it involves layout routes - // For now, let's use a simpler approach with createRouteTree - const routeTree = createRouteTree(['/layout/child']) - - const result = processRouteTree({ routeTree }) - - expect(result.routesById).toHaveProperty('/layout/child') - expect(result.flatRoutes).toHaveLength(1) - expect(result.flatRoutes[0]!.id).toBe('/layout/child') - }) - - it('should handle trailing slashes in routesByPath', () => { - const routeTree = createRouteTree(['/test', '/test/']) // without slash first - - const result = processRouteTree({ routeTree }) - - // Route with trailing slash should take precedence in routesByPath - expect(result.routesByPath['/test']).toBeDefined() - }) - - it('routes with same optional count but different static segments', () => { - const routes = [ - '/a/{-$p1}/b/{-$p1}/{-$p1}/{-$p1}', - '/b/{-$p1}/{-$p1}/{-$p1}/{-$p1}', - ] - const result = processRouteTree({ routeTree: createRouteTree(routes) }) - - // Route with more static segments (/a/{-$p1}/b) should rank higher - // than route with fewer static segments (/b) - expect(result.flatRoutes.map((r) => r.id)).toEqual([ - '/a/{-$p1}/b/{-$p1}/{-$p1}/{-$p1}', - '/b/{-$p1}/{-$p1}/{-$p1}/{-$p1}', - ]) - }) - - it('routes with different optional counts and different static segments', () => { - const routes = [ - '/foo/{-$p1}/foo/{-$p1}/{-$p1}/{-$p1}', - '/foo/{-$p1}/{-$p1}', - ] - const result = processRouteTree({ routeTree: createRouteTree(routes) }) - - // Both routes share common prefix '/foo/{-$p1}', then differ - // Route 1: '/foo/{-$p1}/b/{-$p1}/{-$p1}/{-$p1}' - has static '/b' at position 2, total 4 optional params - // Route 2: '/foo/{-$p1}/{-$p1}' - has optional param at position 2, total 2 optional params - // Since position 2 differs (static vs optional), static should win - expect(result.flatRoutes.map((r) => r.id)).toEqual([ - '/foo/{-$p1}/foo/{-$p1}/{-$p1}/{-$p1}', - '/foo/{-$p1}/{-$p1}', - ]) - }) - - it.each([ - { - routes: ['/foo/{-$p1}/bar', '/foo/{-$p1}', '/foo/$p1', '/foo/$p1/'], - expected: ['/foo/{-$p1}/bar', '/foo/$p1/', '/foo/$p1', '/foo/{-$p1}'], - }, - { - routes: ['/foo/{-$p1}/{-$p2}/bar', '/foo/$p1/$p2/'], - expected: ['/foo/{-$p1}/{-$p2}/bar', '/foo/$p1/$p2/'], - }, - { - routes: ['/foo/{-$p1}/$p2/bar', '/foo/$p1/{-$p2}/bar'], - expected: ['/foo/$p1/{-$p2}/bar', '/foo/{-$p1}/$p2/bar'], - }, - { - routes: [ - '/foo', - '/admin-levels/$adminLevelId/', - '/admin-levels/$adminLevelId', - '/about', - '/admin-levels/{-$adminLevelId}/reports', - '/admin-levels/{-$adminLevelId}', - '/', - ], - expected: [ - '/admin-levels/{-$adminLevelId}/reports', - '/admin-levels/$adminLevelId/', - '/admin-levels/$adminLevelId', - '/about', - '/foo', - '/admin-levels/{-$adminLevelId}', - '/', - ], - }, - ])( - 'static segment after param ranks param higher: $routes', - ({ routes, expected }) => { - const result = processRouteTree({ routeTree: createRouteTree(routes) }) - expect(result.flatRoutes.map((r) => r.id)).toEqual(expected) - }, - ) - - it.each([ - { - routes: ['/f{$param}', '/foo{$param}'], - expected: ['/foo{$param}', '/f{$param}'], - }, - { - routes: ['/{$param}r', '/{$param}bar'], - expected: ['/{$param}bar', '/{$param}r'], - }, - { - routes: ['/f{$param}bar', '/foo{$param}r'], - expected: ['/foo{$param}r', '/f{$param}bar'], - }, - { - routes: ['/foo{$param}r', '/f{$param}baaaaaar'], // very long suffix can "override" prefix - expected: ['/f{$param}baaaaaar', '/foo{$param}r'], - }, - ])( - 'length of prefix and suffix are considered in ranking: $routes', - ({ routes, expected }) => { - const result = processRouteTree({ routeTree: createRouteTree(routes) }) - expect(result.flatRoutes.map((r) => r.id)).toEqual(expected) - }, - ) - }) -}) From 20c5b1f7b4211d21575201ffeb8da91d6688913f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 00:30:08 +0100 Subject: [PATCH 074/109] wip test --- .../tests/new-process-route-tree.test.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index d8f534c0858..32be7ea7353 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -191,7 +191,7 @@ describe('findRouteMatch', () => { }) }) - describe.todo('trailing slashes', () => {}) + describe.todo('trailing slashes', () => { }) describe('case sensitivity competition', () => { it('a case sensitive segment early on should not prevent a case insensitive match', () => { @@ -257,6 +257,31 @@ describe('findRouteMatch', () => { expect(findRouteMatch('/Foo', processedTree)?.route.id).toBe('/foo') expect(findRouteMatch('/foo', processedTree)?.route.id).toBe('/foo') }) + it('a case sensitive prefix/suffix should have priority over a case insensitive one', () => { + const tree = { + id: '__root__', + fullPath: '/', + path: '/', + children: [ + { + id: '/aa{$id}bb', + fullPath: '/aa{$id}bb', + path: 'aa{$id}bb', + options: { caseSensitive: false }, + }, + { + id: '/{$id}', + fullPath: '/{$id}', + path: '{$id}', + options: { caseSensitive: true }, + }, + ] + } + const { processedTree } = processRouteTree(tree) + expect(findRouteMatch('/AfooB', processedTree)?.route.id).toBe('/A{$id}B') + expect(findRouteMatch('/AABB', processedTree)?.route.id).toBe('/A{$id}B') + expect(findRouteMatch('/aabb', processedTree)?.route.id).toBe('/aa{$id}bb') + }) }) describe('basic matching', () => { @@ -428,7 +453,7 @@ describe('findRouteMatch', () => { }) }) - describe.todo('fuzzy matching', () => {}) + describe.todo('fuzzy matching', () => { }) }) describe.todo('processRouteMasks', () => { From aafbcd3f0a481b755fd8703cdd2c8ccb1d696d61 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 01:04:17 +0100 Subject: [PATCH 075/109] include caseSensitivity in dynamic node sort function --- .../router-core/src/new-process-route-tree.ts | 6 ++++-- .../tests/new-process-route-tree.test.ts | 15 +++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 91558e6c153..a78204afc4c 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -311,8 +311,8 @@ function parseSegments( } function sortDynamic( - a: { prefix?: string; suffix?: string }, - b: { prefix?: string; suffix?: string }, + a: { prefix?: string; suffix?: string; caseSensitive: boolean }, + b: { prefix?: string; suffix?: string; caseSensitive: boolean }, ) { if (a.prefix && b.prefix && a.prefix !== b.prefix) { if (a.prefix.startsWith(b.prefix)) return -1 @@ -326,6 +326,8 @@ function sortDynamic( if (!a.prefix && b.prefix) return 1 if (a.suffix && !b.suffix) return -1 if (!a.suffix && b.suffix) return 1 + if (a.caseSensitive && !b.caseSensitive) return -1 + if (!a.caseSensitive && b.caseSensitive) return 1 return 0 } diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 32be7ea7353..1ed2eaadf2f 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -191,7 +191,7 @@ describe('findRouteMatch', () => { }) }) - describe.todo('trailing slashes', () => { }) + describe.todo('trailing slashes', () => {}) describe('case sensitivity competition', () => { it('a case sensitive segment early on should not prevent a case insensitive match', () => { @@ -270,17 +270,16 @@ describe('findRouteMatch', () => { options: { caseSensitive: false }, }, { - id: '/{$id}', - fullPath: '/{$id}', - path: '{$id}', + id: '/A{$id}B', + fullPath: '/A{$id}B', + path: 'A{$id}B', options: { caseSensitive: true }, }, - ] + ], } const { processedTree } = processRouteTree(tree) expect(findRouteMatch('/AfooB', processedTree)?.route.id).toBe('/A{$id}B') - expect(findRouteMatch('/AABB', processedTree)?.route.id).toBe('/A{$id}B') - expect(findRouteMatch('/aabb', processedTree)?.route.id).toBe('/aa{$id}bb') + expect(findRouteMatch('/AAABBB', processedTree)?.route.id).toBe('/A{$id}B') }) }) @@ -453,7 +452,7 @@ describe('findRouteMatch', () => { }) }) - describe.todo('fuzzy matching', () => { }) + describe.todo('fuzzy matching', () => {}) }) describe.todo('processRouteMasks', () => { From 320cdbd3908b7a5c994ed3296e8b16cd2d5a60c4 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 01:32:08 +0100 Subject: [PATCH 076/109] more unit tests --- .../tests/curly-params-smoke.test.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 packages/router-core/tests/curly-params-smoke.test.ts diff --git a/packages/router-core/tests/curly-params-smoke.test.ts b/packages/router-core/tests/curly-params-smoke.test.ts new file mode 100644 index 00000000000..04829361dc5 --- /dev/null +++ b/packages/router-core/tests/curly-params-smoke.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "vitest" +import { findRouteMatch, processRouteTree } from "../src/new-process-route-tree" + + + +const testCases = [ + { + name: 'param with braces', + path: '/$id', + nav: '/1', + params: { id: '1' }, + }, + { + name: 'param without braces', + path: '/{$id}', + nav: '/2', + params: { id: '2' }, + }, + { + name: 'param with prefix', + path: '/prefix-{$id}', + nav: '/prefix-3', + params: { id: '3' }, + }, + { + name: 'param with suffix', + path: '/{$id}-suffix', + nav: '/4-suffix', + params: { id: '4' }, + }, + { + name: 'param with prefix and suffix', + path: '/prefix-{$id}-suffix', + nav: '/prefix-5-suffix', + params: { id: '5' }, + }, + { + name: 'wildcard with no braces', + path: '/abc/$', + nav: '/abc/6', + params: { '*': '6', _splat: '6' }, + }, + { + name: 'wildcard with braces', + path: '/abc/{$}', + nav: '/abc/7', + params: { '*': '7', _splat: '7' }, + }, + { + name: 'wildcard with prefix', + path: '/abc/prefix{$}', + nav: '/abc/prefix/8', + params: { '*': '/8', _splat: '/8' }, + }, + { + name: 'wildcard with suffix', + path: '/abc/{$}suffix', + nav: '/abc/9/suffix', + params: { _splat: '9/', '*': '9/' }, + }, + { + name: 'optional param with no prefix/suffix and value', + path: '/abc/{-$id}/def', + nav: '/abc/10/def', + params: { id: '10' }, + }, + { + name: 'optional param with no prefix/suffix and requiredParam and no value', + path: '/abc/{-$id}/$foo/def', + nav: '/abc/bar/def', + params: { foo: 'bar' }, + }, + { + name: 'optional param with no prefix/suffix and requiredParam and value', + path: '/abc/{-$id}/$foo/def', + nav: '/abc/10/bar/def', + params: { id: '10', foo: 'bar' }, + }, + { + name: 'optional param with no prefix/suffix and no value', + path: '/abc/{-$id}/def', + nav: '/abc/def', + params: {}, + }, + { + name: 'optional param with prefix and value', + path: '/optional-{-$id}', + nav: '/optional-12', + params: { id: '12' }, + }, + { + name: 'optional param with prefix and no value', + path: '/optional-{-$id}', + nav: '/optional-', + params: { id: '' }, + }, + { + name: 'optional param with suffix and value', + path: '/{-$id}-optional', + nav: '/13-optional', + params: { id: '13' }, + }, + { + name: 'optional param with suffix and no value', + path: '/{-$id}-optional', + nav: '/-optional', + params: { id: '' }, + }, + { + name: 'optional param with required param, prefix, suffix, wildcard and no value', + path: `/$foo/a{-$id}-optional/$`, + nav: '/bar/a-optional/qux', + params: { foo: 'bar', _splat: 'qux', '*': 'qux', id: '' }, + }, + { + name: 'optional param with required param, prefix, suffix, wildcard and value', + path: `/$foo/a{-$id}-optional/$`, + nav: '/bar/a14-optional/qux', + params: { foo: 'bar', id: '14', _splat: 'qux', '*': 'qux' }, + }, +] + +describe('curly params smoke tests', () => { + test.each(testCases)('$name', ({ path, nav, params }) => { + const tree = { + id: '__root__', + fullPath: '/', + path: '/', + children: [ + { + id: path, + fullPath: path, + path: path, + }, + ], + } + const processed = processRouteTree(tree) + const res = findRouteMatch(nav, processed.processedTree) + expect(res?.params).toEqual(params) + }) +}) \ No newline at end of file From 896956e9b86a366263087b9ecfa39a800e7c4114 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 01:37:08 +0100 Subject: [PATCH 077/109] code comment --- .../tests/curly-params-smoke.test.ts | 271 +++++++++--------- .../tests/new-process-route-tree.test.ts | 4 +- 2 files changed, 138 insertions(+), 137 deletions(-) diff --git a/packages/router-core/tests/curly-params-smoke.test.ts b/packages/router-core/tests/curly-params-smoke.test.ts index 04829361dc5..815e3e5415a 100644 --- a/packages/router-core/tests/curly-params-smoke.test.ts +++ b/packages/router-core/tests/curly-params-smoke.test.ts @@ -1,141 +1,140 @@ -import { describe, expect, test } from "vitest" -import { findRouteMatch, processRouteTree } from "../src/new-process-route-tree" - - +import { describe, expect, test } from 'vitest' +import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree' const testCases = [ - { - name: 'param with braces', - path: '/$id', - nav: '/1', - params: { id: '1' }, - }, - { - name: 'param without braces', - path: '/{$id}', - nav: '/2', - params: { id: '2' }, - }, - { - name: 'param with prefix', - path: '/prefix-{$id}', - nav: '/prefix-3', - params: { id: '3' }, - }, - { - name: 'param with suffix', - path: '/{$id}-suffix', - nav: '/4-suffix', - params: { id: '4' }, - }, - { - name: 'param with prefix and suffix', - path: '/prefix-{$id}-suffix', - nav: '/prefix-5-suffix', - params: { id: '5' }, - }, - { - name: 'wildcard with no braces', - path: '/abc/$', - nav: '/abc/6', - params: { '*': '6', _splat: '6' }, - }, - { - name: 'wildcard with braces', - path: '/abc/{$}', - nav: '/abc/7', - params: { '*': '7', _splat: '7' }, - }, - { - name: 'wildcard with prefix', - path: '/abc/prefix{$}', - nav: '/abc/prefix/8', - params: { '*': '/8', _splat: '/8' }, - }, - { - name: 'wildcard with suffix', - path: '/abc/{$}suffix', - nav: '/abc/9/suffix', - params: { _splat: '9/', '*': '9/' }, - }, - { - name: 'optional param with no prefix/suffix and value', - path: '/abc/{-$id}/def', - nav: '/abc/10/def', - params: { id: '10' }, - }, - { - name: 'optional param with no prefix/suffix and requiredParam and no value', - path: '/abc/{-$id}/$foo/def', - nav: '/abc/bar/def', - params: { foo: 'bar' }, - }, - { - name: 'optional param with no prefix/suffix and requiredParam and value', - path: '/abc/{-$id}/$foo/def', - nav: '/abc/10/bar/def', - params: { id: '10', foo: 'bar' }, - }, - { - name: 'optional param with no prefix/suffix and no value', - path: '/abc/{-$id}/def', - nav: '/abc/def', - params: {}, - }, - { - name: 'optional param with prefix and value', - path: '/optional-{-$id}', - nav: '/optional-12', - params: { id: '12' }, - }, - { - name: 'optional param with prefix and no value', - path: '/optional-{-$id}', - nav: '/optional-', - params: { id: '' }, - }, - { - name: 'optional param with suffix and value', - path: '/{-$id}-optional', - nav: '/13-optional', - params: { id: '13' }, - }, - { - name: 'optional param with suffix and no value', - path: '/{-$id}-optional', - nav: '/-optional', - params: { id: '' }, - }, - { - name: 'optional param with required param, prefix, suffix, wildcard and no value', - path: `/$foo/a{-$id}-optional/$`, - nav: '/bar/a-optional/qux', - params: { foo: 'bar', _splat: 'qux', '*': 'qux', id: '' }, - }, - { - name: 'optional param with required param, prefix, suffix, wildcard and value', - path: `/$foo/a{-$id}-optional/$`, - nav: '/bar/a14-optional/qux', - params: { foo: 'bar', id: '14', _splat: 'qux', '*': 'qux' }, - }, + { + name: 'param with braces', + path: '/$id', + nav: '/1', + params: { id: '1' }, + }, + { + name: 'param without braces', + path: '/{$id}', + nav: '/2', + params: { id: '2' }, + }, + { + name: 'param with prefix', + path: '/prefix-{$id}', + nav: '/prefix-3', + params: { id: '3' }, + }, + { + name: 'param with suffix', + path: '/{$id}-suffix', + nav: '/4-suffix', + params: { id: '4' }, + }, + { + name: 'param with prefix and suffix', + path: '/prefix-{$id}-suffix', + nav: '/prefix-5-suffix', + params: { id: '5' }, + }, + { + name: 'wildcard with no braces', + path: '/abc/$', + nav: '/abc/6', + params: { '*': '6', _splat: '6' }, + }, + { + name: 'wildcard with braces', + path: '/abc/{$}', + nav: '/abc/7', + params: { '*': '7', _splat: '7' }, + }, + { + name: 'wildcard with prefix', + path: '/abc/prefix{$}', + nav: '/abc/prefix/8', + params: { '*': '/8', _splat: '/8' }, + }, + { + name: 'wildcard with suffix', + path: '/abc/{$}suffix', + nav: '/abc/9/suffix', + params: { _splat: '9/', '*': '9/' }, + }, + { + name: 'optional param with no prefix/suffix and value', + path: '/abc/{-$id}/def', + nav: '/abc/10/def', + params: { id: '10' }, + }, + { + name: 'optional param with no prefix/suffix and requiredParam and no value', + path: '/abc/{-$id}/$foo/def', + nav: '/abc/bar/def', + params: { foo: 'bar' }, + }, + { + name: 'optional param with no prefix/suffix and requiredParam and value', + path: '/abc/{-$id}/$foo/def', + nav: '/abc/10/bar/def', + params: { id: '10', foo: 'bar' }, + }, + { + name: 'optional param with no prefix/suffix and no value', + path: '/abc/{-$id}/def', + nav: '/abc/def', + params: {}, + }, + { + name: 'optional param with prefix and value', + path: '/optional-{-$id}', + nav: '/optional-12', + params: { id: '12' }, + }, + { + name: 'optional param with prefix and no value', + path: '/optional-{-$id}', + nav: '/optional-', + params: { id: '' }, + }, + { + name: 'optional param with suffix and value', + path: '/{-$id}-optional', + nav: '/13-optional', + params: { id: '13' }, + }, + { + name: 'optional param with suffix and no value', + path: '/{-$id}-optional', + nav: '/-optional', + params: { id: '' }, + }, + { + name: 'optional param with required param, prefix, suffix, wildcard and no value', + path: `/$foo/a{-$id}-optional/$`, + nav: '/bar/a-optional/qux', + params: { foo: 'bar', _splat: 'qux', '*': 'qux', id: '' }, + }, + { + name: 'optional param with required param, prefix, suffix, wildcard and value', + path: `/$foo/a{-$id}-optional/$`, + nav: '/bar/a14-optional/qux', + params: { foo: 'bar', id: '14', _splat: 'qux', '*': 'qux' }, + }, ] +// porting tests from https://github.com/TanStack/router/pull/5851 describe('curly params smoke tests', () => { - test.each(testCases)('$name', ({ path, nav, params }) => { - const tree = { - id: '__root__', - fullPath: '/', - path: '/', - children: [ - { - id: path, - fullPath: path, - path: path, - }, - ], - } - const processed = processRouteTree(tree) - const res = findRouteMatch(nav, processed.processedTree) - expect(res?.params).toEqual(params) - }) -}) \ No newline at end of file + test.each(testCases)('$name', ({ path, nav, params }) => { + const tree = { + id: '__root__', + fullPath: '/', + path: '/', + children: [ + { + id: path, + fullPath: path, + path: path, + }, + ], + } + const processed = processRouteTree(tree) + const res = findRouteMatch(nav, processed.processedTree) + expect(res?.params).toEqual(params) + }) +}) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 1ed2eaadf2f..6fa501674a8 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -279,7 +279,9 @@ describe('findRouteMatch', () => { } const { processedTree } = processRouteTree(tree) expect(findRouteMatch('/AfooB', processedTree)?.route.id).toBe('/A{$id}B') - expect(findRouteMatch('/AAABBB', processedTree)?.route.id).toBe('/A{$id}B') + expect(findRouteMatch('/AAABBB', processedTree)?.route.id).toBe( + '/A{$id}B', + ) }) }) From a30e03d9efae0429e6c5e9da75a22c671ee4430d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 01:43:58 +0100 Subject: [PATCH 078/109] segments without text cannot be case-sensitive --- .../router-core/src/new-process-route-tree.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index a78204afc4c..09ff1a4536d 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -194,19 +194,20 @@ function parseSegments( case SEGMENT_TYPE_PARAM: { const prefix_raw = path.substring(start, data[1]) const suffix_raw = path.substring(data[4]!, end) + const actuallyCaseSensitive = caseSensitive && !!(prefix_raw || suffix_raw) const prefix = !prefix_raw ? undefined - : caseSensitive + : actuallyCaseSensitive ? prefix_raw : prefix_raw.toLowerCase() const suffix = !suffix_raw ? undefined - : caseSensitive + : actuallyCaseSensitive ? suffix_raw : suffix_raw.toLowerCase() const existingNode = node.dynamic?.find( (s) => - s.caseSensitive === caseSensitive && + s.caseSensitive === actuallyCaseSensitive && s.prefix === prefix && s.suffix === suffix, ) @@ -216,7 +217,7 @@ function parseSegments( const next = createDynamicNode( SEGMENT_TYPE_PARAM, route.fullPath ?? route.from, - caseSensitive, + actuallyCaseSensitive, prefix, suffix, ) @@ -231,19 +232,20 @@ function parseSegments( case SEGMENT_TYPE_OPTIONAL_PARAM: { const prefix_raw = path.substring(start, data[1]) const suffix_raw = path.substring(data[4]!, end) + const actuallyCaseSensitive = caseSensitive && !!(prefix_raw || suffix_raw) const prefix = !prefix_raw ? undefined - : caseSensitive + : actuallyCaseSensitive ? prefix_raw : prefix_raw.toLowerCase() const suffix = !suffix_raw ? undefined - : caseSensitive + : actuallyCaseSensitive ? suffix_raw : suffix_raw.toLowerCase() const existingNode = node.optional?.find( (s) => - s.caseSensitive === caseSensitive && + s.caseSensitive === actuallyCaseSensitive && s.prefix === prefix && s.suffix === suffix, ) @@ -253,7 +255,7 @@ function parseSegments( const next = createDynamicNode( SEGMENT_TYPE_OPTIONAL_PARAM, route.fullPath ?? route.from, - caseSensitive, + actuallyCaseSensitive, prefix, suffix, ) @@ -268,20 +270,21 @@ function parseSegments( case SEGMENT_TYPE_WILDCARD: { const prefix_raw = path.substring(start, data[1]) const suffix_raw = path.substring(data[4]!, end) + const actuallyCaseSensitive = caseSensitive && !!(prefix_raw || suffix_raw) const prefix = !prefix_raw ? undefined - : caseSensitive + : actuallyCaseSensitive ? prefix_raw : prefix_raw.toLowerCase() const suffix = !suffix_raw ? undefined - : caseSensitive + : actuallyCaseSensitive ? suffix_raw : suffix_raw.toLowerCase() const next = createDynamicNode( SEGMENT_TYPE_WILDCARD, route.fullPath ?? route.from, - caseSensitive, + actuallyCaseSensitive, prefix, suffix, ) From 2da561848968d1aea73ceba141ceb8352f494c7a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 10:25:39 +0100 Subject: [PATCH 079/109] clean interpolatePath method --- .../router-core/src/new-process-route-tree.ts | 16 ++-- packages/router-core/src/path.ts | 93 ++++++++++--------- packages/router-core/src/router.ts | 21 +++-- 3 files changed, 69 insertions(+), 61 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 09ff1a4536d..e6e4b339391 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -53,12 +53,13 @@ export function parseSegment( // $ (wildcard) if (part === '$') { + const total = path.length output[0] = SEGMENT_TYPE_WILDCARD output[1] = start output[2] = start - output[3] = end - output[4] = end - output[5] = end + output[3] = total + output[4] = total + output[5] = total return } @@ -194,7 +195,8 @@ function parseSegments( case SEGMENT_TYPE_PARAM: { const prefix_raw = path.substring(start, data[1]) const suffix_raw = path.substring(data[4]!, end) - const actuallyCaseSensitive = caseSensitive && !!(prefix_raw || suffix_raw) + const actuallyCaseSensitive = + caseSensitive && !!(prefix_raw || suffix_raw) const prefix = !prefix_raw ? undefined : actuallyCaseSensitive @@ -232,7 +234,8 @@ function parseSegments( case SEGMENT_TYPE_OPTIONAL_PARAM: { const prefix_raw = path.substring(start, data[1]) const suffix_raw = path.substring(data[4]!, end) - const actuallyCaseSensitive = caseSensitive && !!(prefix_raw || suffix_raw) + const actuallyCaseSensitive = + caseSensitive && !!(prefix_raw || suffix_raw) const prefix = !prefix_raw ? undefined : actuallyCaseSensitive @@ -270,7 +273,8 @@ function parseSegments( case SEGMENT_TYPE_WILDCARD: { const prefix_raw = path.substring(start, data[1]) const suffix_raw = path.substring(data[4]!, end) - const actuallyCaseSensitive = caseSensitive && !!(prefix_raw || suffix_raw) + const actuallyCaseSensitive = + caseSensitive && !!(prefix_raw || suffix_raw) const prefix = !prefix_raw ? undefined : actuallyCaseSensitive diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 81e338b75a1..cd88f6f3c79 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -209,7 +209,6 @@ export function resolvePath({ interface InterpolatePathOptions { path?: string params: Record - leaveParams?: boolean // Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params decodeCharMap?: Map } @@ -219,12 +218,28 @@ type InterPolatePathResult = { usedParams: Record isMissingParams: boolean // true if any params were not available when being looked up in the params object } + +function encodeParam( + key: string, + params: InterpolatePathOptions['params'], + decodeCharMap: InterpolatePathOptions['decodeCharMap'], +): any { + const value = params[key] + if (typeof value !== 'string') return value + + if (key === '_splat') { + // the splat/catch-all routes shouldn't have the '/' encoded out + return encodeURI(value) + } else { + return encodePathParam(value, decodeCharMap) + } +} + /** * Interpolate params and wildcards into a route path template. * * - Encodes params safely (configurable allowed characters) * - Supports `{-$optional}` segments, `{prefix{$id}suffix}` and `{$}` wildcards - * - Optionally leaves placeholders or wildcards in place */ /** * Interpolate params and wildcards into a route path template. @@ -233,66 +248,62 @@ type InterPolatePathResult = { export function interpolatePath({ path, params, - leaveParams, decodeCharMap, }: InterpolatePathOptions): InterPolatePathResult { - if (!path) - return { interpolatedPath: '/', usedParams: {}, isMissingParams: false } - - function encodeParam(key: string): any { - const value = params[key] - const isValueString = typeof value === 'string' - - if (key === '*' || key === '_splat') { - // the splat/catch-all routes shouldn't have the '/' encoded out - return isValueString ? encodeURI(value) : value - } else { - return isValueString ? encodePathParam(value, decodeCharMap) : value - } - } - // Tracking if any params are missing in the `params` object // when interpolating the path let isMissingParams = false const usedParams: Record = {} + + if (!path || path === '/') + return { interpolatedPath: '/', usedParams, isMissingParams } + if (!path.includes('$')) + return { interpolatedPath: path, usedParams, isMissingParams } + let cursor = 0 const data = new Uint16Array(6) const length = path.length - const interpolatedSegments: Array = [] + let joined = '' while (cursor < length) { const start = cursor parseSegment(path, start, data) const end = data[5]! cursor = end + 1 + + if (start === end) continue + const kind = data[0] as SegmentKind if (kind === SEGMENT_TYPE_PATHNAME) { - interpolatedSegments.push(path.substring(start, end)) + if (cursor > 0) joined += '/' + joined += path.substring(start, end) continue } if (kind === SEGMENT_TYPE_WILDCARD) { - usedParams._splat = params._splat - + const splat = params._splat + usedParams._splat = splat // TODO: Deprecate * - usedParams['*'] = params._splat + usedParams['*'] = splat const prefix = path.substring(start, data[1]) const suffix = path.substring(data[4]!, end) // Check if _splat parameter is missing. _splat could be missing if undefined or an empty string or some other falsy value. - if (!params._splat) { + if (!splat) { isMissingParams = true // For missing splat parameters, just return the prefix and suffix without the wildcard // If there is a prefix or suffix, return them joined, otherwise omit the segment if (prefix || suffix) { - interpolatedSegments.push(`${prefix}${suffix}`) + if (cursor > 0) joined += '/' + joined += prefix + suffix } continue } - const value = encodeParam('_splat') - interpolatedSegments.push(`${prefix}${value}${suffix}`) + const value = encodeParam('_splat', params, decodeCharMap) + if (cursor > 0) joined += '/' + joined += prefix + value + suffix continue } @@ -305,28 +316,23 @@ export function interpolatePath({ const prefix = path.substring(start, data[1]) const suffix = path.substring(data[4]!, end) - if (leaveParams) { - const value = encodeParam(key) - interpolatedSegments.push(`${prefix}$${key}${value ?? ''}${suffix}`) - } else { - interpolatedSegments.push( - `${prefix}${encodeParam(key) ?? 'undefined'}${suffix}`, - ) - } + const value = encodeParam(key, params, decodeCharMap) ?? 'undefined' + if (cursor > 0) joined += '/' + joined += prefix + value + suffix continue } if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { const key = path.substring(data[2]!, data[3]) - const prefix = path.substring(start, data[1]) const suffix = path.substring(data[4]!, end) // Check if optional parameter is missing or undefined - if (!(key in params) || params[key] == null) { + if (params[key] == null) { if (prefix || suffix) { + if (cursor > 0) joined += '/' // For optional params with prefix/suffix, keep the prefix/suffix but omit the param - interpolatedSegments.push(`${prefix}${suffix}`) + joined += prefix + suffix } // If no prefix/suffix, omit the entire segment continue @@ -334,17 +340,14 @@ export function interpolatePath({ usedParams[key] = params[key] - const value = encodeParam(key) ?? '' - if (leaveParams) { - interpolatedSegments.push(`${prefix}${key}${value}${suffix}`) - } else { - interpolatedSegments.push(`${prefix}${value}${suffix}`) - } + const value = encodeParam(key, params, decodeCharMap) ?? '' + if (cursor > 0) joined += '/' + joined += prefix + value + suffix continue } } - const interpolatedPath = joinPaths(interpolatedSegments) || '/' + const interpolatedPath = joined || '/' return { usedParams, interpolatedPath, isMissingParams } } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 7fbe3ee1338..8a74182126a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1676,16 +1676,17 @@ export class RouterCore< } } - const nextPathname = decodePath( - interpolatePath({ - // Use the original template path for interpolation - // This preserves the original parameter syntax including optional parameters - path: nextTo, - params: nextParams, - leaveParams: opts.leaveParams, - decodeCharMap: this.pathParamsDecodeCharMap, - }).interpolatedPath, - ) + // Use the original template path for interpolation + // This preserves the original parameter syntax including optional parameters + const nextPathname = opts.leaveParams + ? nextTo + : decodePath( + interpolatePath({ + path: nextTo, + params: nextParams, + decodeCharMap: this.pathParamsDecodeCharMap, + }).interpolatedPath, + ) // Resolve the next search let nextSearch = fromSearch From e12a4858ae1fd34486d5877ca40264662e80d69b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 17:33:17 +0100 Subject: [PATCH 080/109] fix some off-by-one issue --- .../router-core/src/new-process-route-tree.ts | 37 ++++++++++--------- .../tests/curly-params-smoke.test.ts | 6 +-- .../tests/new-process-route-tree.test.ts | 21 +++++------ 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index e6e4b339391..375eb6fcedb 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -658,7 +658,7 @@ function findMatch( fuzzy = false, ): { route: T; params: Record } | null { const parts = path.split('/') - const leaf = getNodeMatch(parts, segmentTree, fuzzy) + const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null const params = extractParams(path, parts, leaf) if ('**' in leaf) params['**'] = leaf['**']! @@ -706,7 +706,6 @@ function extractParams( } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { if (leaf.skipped & (1 << nodeIndex)) { partIndex-- // stay on the same part - // params[name] = '' // ¿skipped optional params do not appear at all in the params object? continue } nodeParts ??= leaf.node.fullPath.split('/') @@ -717,11 +716,11 @@ function extractParams( preLength + 3, nodePart.length - sufLength - 1, ) - if (node.suffix || node.prefix) { - params[name] = part.substring(preLength, part.length - sufLength) - } else { - params[name] = part - } + const value = + node.suffix || node.prefix + ? part.substring(preLength, part.length - sufLength) + : part + if (value.length) params[name] = value } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node const rest = path.substring( @@ -747,11 +746,11 @@ function buildBranch(node: AnySegmentNode) { } function getNodeMatch( + path: string, parts: Array, segmentTree: AnySegmentNode, fuzzy: boolean, ) { - parts = parts.filter(Boolean) // TODO: this is a bad idea, but idk how we're supposed to handle leading/trailing slashes const partsLength = parts.length type Frame = { @@ -782,10 +781,10 @@ function getNodeMatch( const stack: Array = [ { node: segmentTree, - index: 0, - depth: 0, + index: 1, + depth: 1, skipped: 0, - statics: 0, + statics: 1, dynamics: 0, optionals: 0, }, @@ -870,8 +869,8 @@ function getNodeMatch( // 4. Try optional match if (node.optional) { + const nextSkipped = skipped | (1 << depth) const nextDepth = depth + 1 - const nextSkipped = skipped | (1 << nextDepth) for (let i = node.optional.length - 1; i >= 0; i--) { const segment = node.optional[i]! // when skipping, node and depth advance by 1, but index doesn't @@ -910,14 +909,14 @@ function getNodeMatch( } // 3. Try dynamic match - if (!isBeyondPath && node.dynamic) { + if (!isBeyondPath && node.dynamic && part) { for (let i = node.dynamic.length - 1; i >= 0; i--) { const segment = node.dynamic[i]! const { prefix, suffix } = segment if (prefix || suffix) { const casePart = segment.caseSensitive - ? part! - : (lowerPart ??= part!.toLowerCase()) + ? part + : (lowerPart ??= part.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue } @@ -968,15 +967,19 @@ function getNodeMatch( } } - if (bestMatch) return bestMatch + if (bestMatch && bestMatch.index > 1) return bestMatch if (wildcardMatch) return wildcardMatch if (fuzzy && bestFuzzy) { + let sliceIndex = bestFuzzy.index + for (let i = 0; i < bestFuzzy.index; i++) { + sliceIndex += parts[i]!.length + } return { node: bestFuzzy.node, skipped: bestFuzzy.skipped, - '**': parts.slice(bestFuzzy.index).join('/'), + '**': path.slice(sliceIndex), } } diff --git a/packages/router-core/tests/curly-params-smoke.test.ts b/packages/router-core/tests/curly-params-smoke.test.ts index 815e3e5415a..36862ac8e17 100644 --- a/packages/router-core/tests/curly-params-smoke.test.ts +++ b/packages/router-core/tests/curly-params-smoke.test.ts @@ -90,7 +90,7 @@ const testCases = [ name: 'optional param with prefix and no value', path: '/optional-{-$id}', nav: '/optional-', - params: { id: '' }, + params: {}, }, { name: 'optional param with suffix and value', @@ -102,13 +102,13 @@ const testCases = [ name: 'optional param with suffix and no value', path: '/{-$id}-optional', nav: '/-optional', - params: { id: '' }, + params: {}, }, { name: 'optional param with required param, prefix, suffix, wildcard and no value', path: `/$foo/a{-$id}-optional/$`, nav: '/bar/a-optional/qux', - params: { foo: 'bar', _splat: 'qux', '*': 'qux', id: '' }, + params: { foo: 'bar', _splat: 'qux', '*': 'qux' }, }, { name: 'optional param with required param, prefix, suffix, wildcard and value', diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 6fa501674a8..ff708f1798f 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -121,7 +121,7 @@ describe('findRouteMatch', () => { it('dynamic at the root DOES NOT match /', () => { const tree = makeTree(['/$id']) const res = findRouteMatch('/', tree) - expect(res?.route.id).toBe('__root__') + expect(res).toBeNull() }) }) @@ -176,13 +176,13 @@ describe('findRouteMatch', () => { describe('not found', () => { it('returns null when no match is found', () => { - const tree = makeTree(['/a/b/c', '/d/e/f']) + const tree = makeTree(['/', '/a/b/c', '/d/e/f']) expect(findRouteMatch('/x/y/z', tree)).toBeNull() }) it('returns something w/ fuzzy matching enabled', () => { - const tree = makeTree(['/a/b/c', '/d/e/f']) + const tree = makeTree(['/', '/a/b/c', '/d/e/f']) const match = findRouteMatch('/x/y/z', tree, true) - expect(match?.route?.id).toBe('__root__') + expect(match?.route?.id).toBe('/') expect(match?.params).toMatchInlineSnapshot(` { "**": "x/y/z", @@ -286,9 +286,9 @@ describe('findRouteMatch', () => { }) describe('basic matching', () => { - it('root', () => { + it('root itself cannot match', () => { const tree = makeTree([]) - expect(findRouteMatch('/', tree)?.route.id).toBe('__root__') + expect(findRouteMatch('/', tree)).toBeNull() }) it('single static', () => { const tree = makeTree(['/a']) @@ -301,8 +301,7 @@ describe('findRouteMatch', () => { it('single optional', () => { const tree = makeTree(['/{-$id}']) expect(findRouteMatch('/123', tree)?.route.id).toBe('/{-$id}') - // expect(findRouteMatch('/', tree)?.route.id).toBe('/{-$id}') - // // ^^^ fails, returns '__root__' + expect(findRouteMatch('/', tree)?.route.id).toBe('/{-$id}') }) it('single wildcard', () => { const tree = makeTree(['/$']) @@ -327,7 +326,7 @@ describe('findRouteMatch', () => { it('optional w/ prefix', () => { const tree = makeTree(['/{-$id}.txt']) expect(findRouteMatch('/123.txt', tree)?.route.id).toBe('/{-$id}.txt') - expect(findRouteMatch('.txt', tree)?.route.id).toBe('/{-$id}.txt') + expect(findRouteMatch('/.txt', tree)?.route.id).toBe('/{-$id}.txt') }) it('optional w/ suffix', () => { const tree = makeTree(['/file{-$id}']) @@ -446,8 +445,8 @@ describe('findRouteMatch', () => { it('matches the root child route', () => { expect(findRouteMatch('/a', processedTree)?.route.id).toBe('/a') }) - it('matches the root route', () => { - expect(findRouteMatch('/', processedTree)?.route.id).toBe('__root__') + it('nothing can match the root route', () => { + expect(findRouteMatch('/', processedTree)).toBeNull() }) it('does not match a route that doesnt exist', () => { expect(findRouteMatch('/a/b/c', processedTree)).toBeNull() From 71ae86537e821e692cd1928e6d498a7a9f6ae7a1 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 18:07:37 +0100 Subject: [PATCH 081/109] params can be extracted beyond path index --- packages/router-core/src/new-process-route-tree.ts | 14 +++++++------- .../tests/new-process-route-tree.test.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 375eb6fcedb..1ae990861bf 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -678,13 +678,13 @@ function extractParams( const params: Record = {} for ( let partIndex = 0, nodeIndex = 0, pathIndex = 0; - partIndex < parts.length && nodeIndex < list.length; + nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++ ) { const node = list[nodeIndex]! - const part = parts[partIndex]! + const part = parts[partIndex] const currentPathIndex = pathIndex - pathIndex += part.length + if (part) pathIndex += part.length if (node.kind === SEGMENT_TYPE_PARAM) { nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[nodeIndex]! @@ -698,10 +698,10 @@ function extractParams( preLength + 2, nodePart.length - sufLength - 1, ) - params[name] = part.substring(preLength, part.length - sufLength) + params[name] = part!.substring(preLength, part!.length - sufLength) } else { const name = nodePart.substring(1) - params[name] = part + params[name] = part! } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { if (leaf.skipped & (1 << nodeIndex)) { @@ -718,9 +718,9 @@ function extractParams( ) const value = node.suffix || node.prefix - ? part.substring(preLength, part.length - sufLength) + ? part!.substring(preLength, part!.length - sufLength) : part - if (value.length) params[name] = value + if (value?.length) params[name] = value } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node const rest = path.substring( diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index ff708f1798f..b66538325af 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -360,7 +360,7 @@ describe('findRouteMatch', () => { const tree = makeTree(['/a/{-$id}/$']) const result = findRouteMatch('/a', tree) expect(result?.route.id).toBe('/a/{-$id}/$') - expect(result?.params).toEqual({}) + expect(result?.params).toEqual({ '*': '', _splat: '' }) }) it('multi-segment wildcard w/ prefix', () => { const tree = makeTree(['/file{$}']) From c970698d30effbde2482307f3d808fc33ef681c8 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 18:10:58 +0100 Subject: [PATCH 082/109] remove deprecated leaveParams option from devtools --- .../router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index b7b48932c1a..67e67877d35 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -178,7 +178,6 @@ function RouteComp({ const interpolated = interpolatePath({ path: route.fullPath, params: allParams, - leaveParams: false, decodeCharMap: router().pathParamsDecodeCharMap, }) From 53a676648cf54ac3cfccbcfb23eeae212e514f62 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 21:01:05 +0100 Subject: [PATCH 083/109] root route can never be matched --- packages/router-core/src/new-process-route-tree.ts | 7 +++++-- packages/router-core/tests/curly-params-smoke.test.ts | 1 + packages/router-core/tests/match-by-path.test.ts | 1 + packages/router-core/tests/new-process-route-tree.test.ts | 5 +++++ .../router-core/tests/optional-path-params-clean.test.ts | 1 + packages/router-core/tests/optional-path-params.test.ts | 1 + packages/router-core/tests/path.test.ts | 1 + 7 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 1ae990861bf..45ed849af8a 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -151,9 +151,9 @@ function parseSegments( const length = path.length const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive while (cursor < length) { + parseSegment(path, cursor, data) let nextNode: AnySegmentNode const start = cursor - parseSegment(path, start, data) const end = data[5]! cursor = end + 1 const kind = data[0] as SegmentKind @@ -301,7 +301,9 @@ function parseSegments( } node = nextNode } - if (route.path || !route.children) node.route = route + if ((route.path || !route.children) && !route.isRoot) { + node.route = route + } } if (route.children) for (const child of route.children) { @@ -481,6 +483,7 @@ type RouteLike = { path?: string // relative path from the parent, children?: Array // child routes, parentRoute?: RouteLike // parent route, + isRoot?: boolean options?: { caseSensitive?: boolean } diff --git a/packages/router-core/tests/curly-params-smoke.test.ts b/packages/router-core/tests/curly-params-smoke.test.ts index 36862ac8e17..6184a205e7f 100644 --- a/packages/router-core/tests/curly-params-smoke.test.ts +++ b/packages/router-core/tests/curly-params-smoke.test.ts @@ -123,6 +123,7 @@ describe('curly params smoke tests', () => { test.each(testCases)('$name', ({ path, nav, params }) => { const tree = { id: '__root__', + isRoot: true, fullPath: '/', path: '/', children: [ diff --git a/packages/router-core/tests/match-by-path.test.ts b/packages/router-core/tests/match-by-path.test.ts index 53d721ce723..42d234a0140 100644 --- a/packages/router-core/tests/match-by-path.test.ts +++ b/packages/router-core/tests/match-by-path.test.ts @@ -6,6 +6,7 @@ import { const { processedTree } = processRouteTree({ id: '__root__', + isRoot: true, fullPath: '/', path: '/', }) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index b66538325af..ac4a5a6d813 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -10,6 +10,7 @@ import type { AnyRoute, RouteMask } from '../src' function makeTree(routes: Array) { return processRouteTree({ id: '__root__', + isRoot: true, fullPath: '/', path: '/', children: routes.map((route) => ({ @@ -197,6 +198,7 @@ describe('findRouteMatch', () => { it('a case sensitive segment early on should not prevent a case insensitive match', () => { const tree = { id: '__root__', + isRoot: true, fullPath: '/', path: '/', children: [ @@ -235,6 +237,7 @@ describe('findRouteMatch', () => { it('a case sensitive segment should have priority over a case insensitive one', () => { const tree = { id: '__root__', + isRoot: true, fullPath: '/', path: '/', children: [ @@ -260,6 +263,7 @@ describe('findRouteMatch', () => { it('a case sensitive prefix/suffix should have priority over a case insensitive one', () => { const tree = { id: '__root__', + isRoot: true, fullPath: '/', path: '/', children: [ @@ -409,6 +413,7 @@ describe('findRouteMatch', () => { describe('nested routes', () => { const routeTree = { id: '__root__', + isRoot: true, fullPath: '/', path: '/', children: [ diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index cb4c6f768ae..c73d4304109 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -140,6 +140,7 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { describe('matchPathname', () => { const { processedTree } = processRouteTree({ id: '__root__', + isRoot: true, fullPath: '/', path: '/', }) diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index d9485851842..f59faba5f37 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -336,6 +336,7 @@ describe('Optional Path Parameters', () => { const { processedTree } = processRouteTree({ id: '__root__', + isRoot: true, fullPath: '/', path: '/', }) diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 9ecd996da62..ac028269347 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -506,6 +506,7 @@ describe('interpolatePath', () => { describe('matchPathname', () => { const { processedTree } = processRouteTree({ id: '__root__', + isRoot: true, fullPath: '/', path: '/', }) From 8d423fbf5a955fef72879e8bb88693d178aef9b8 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 21:32:29 +0100 Subject: [PATCH 084/109] fix match-by-path test --- .../router-core/tests/match-by-path.test.ts | 128 ++++++++---------- 1 file changed, 56 insertions(+), 72 deletions(-) diff --git a/packages/router-core/tests/match-by-path.test.ts b/packages/router-core/tests/match-by-path.test.ts index 42d234a0140..1e9ad87ae83 100644 --- a/packages/router-core/tests/match-by-path.test.ts +++ b/packages/router-core/tests/match-by-path.test.ts @@ -10,19 +10,6 @@ const { processedTree } = processRouteTree({ fullPath: '/', path: '/', }) -const matchByPath = ( - from: string, - options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, -) => { - const match = findSingleMatch( - options.to, - options.caseSensitive ?? false, - options.fuzzy ?? false, - from, - processedTree, - ) - return match ? match.params : undefined -} describe('default path matching', () => { it.each([ @@ -37,10 +24,9 @@ describe('default path matching', () => { ['/a/', '/a/', {}], ['/a/', '/a', undefined], ['/b', '/a', undefined], - ])('static %s %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: true, fuzzy: false }), - ).toEqual(result) + ])('static %s %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, false, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -49,26 +35,34 @@ describe('default path matching', () => { ['/a/1/b/2', '/a/$id/b/$other', { id: '1', other: '2' }], ['/a/1_/b/2', '/a/$id/b/$other', { id: '1_', other: '2' }], ['/a/1/b/2', '/a/$id/b/$id', { id: '2' }], - ])('params %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: true, fuzzy: false }), - ).toEqual(result) + ])('params %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, false, path, processedTree) + expect(res?.params).toEqual(result) }) it('params support more than alphanumeric characters', () => { // in the value: basically everything except / and % - expect( - matchByPath( - '/a/@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', - { to: '/a/$id' }, - ), - ).toEqual({ id: '@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{' }) + const anyValueResult = findSingleMatch( + '/a/$id', + false, + false, + '/a/@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', + processedTree, + ) + expect(anyValueResult?.params).toEqual({ + id: '@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', + }) // in the key: basically everything except / and % and $ - expect( - matchByPath('/a/1', { - to: '/a/$@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', - }), - ).toEqual({ '@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{': '1' }) + const anyKeyResult = findSingleMatch( + '/a/$@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', + false, + false, + '/a/1', + processedTree, + ) + expect(anyKeyResult?.params).toEqual({ + '@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{': '1', + }) }) it.each([ @@ -81,10 +75,9 @@ describe('default path matching', () => { ['/a/1/b', '/a/{-$id}/b/{-$other}', { id: '1' }], ['/a/b', '/a/{-$id}/b/{-$other}', {}], ['/a/1/b/2', '/a/{-$id}/b/{-$id}', { id: '2' }], - ])('optional %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: true, fuzzy: false }), - ).toEqual(result) + ])('optional %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, false, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -92,10 +85,9 @@ describe('default path matching', () => { ['/a/', '/a/$', { _splat: '/', '*': '/' }], ['/a', '/a/$', { _splat: '', '*': '' }], ['/a/b/c', '/a/$/foo', { _splat: 'b/c', '*': 'b/c' }], - ])('wildcard %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: true, fuzzy: false }), - ).toEqual(result) + ])('wildcard %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, false, path, processedTree) + expect(res?.params).toEqual(result) }) }) @@ -112,10 +104,9 @@ describe('case insensitive path matching', () => { ['/', '/a/', '/A/', {}], ['/', '/a/', '/A', undefined], ['/', '/b', '/A', undefined], - ])('static %s %s => %s', (base, from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: false, fuzzy: false }), - ).toEqual(result) + ])('static %s %s => %s', (base, path, pattern, result) => { + const res = findSingleMatch(pattern, false, false, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -123,10 +114,9 @@ describe('case insensitive path matching', () => { ['/a/1/b', '/A/$id/B', { id: '1' }], ['/a/1/b/2', '/A/$id/B/$other', { id: '1', other: '2' }], ['/a/1/b/2', '/A/$id/B/$id', { id: '2' }], - ])('params %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: false, fuzzy: false }), - ).toEqual(result) + ])('params %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, false, false, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -141,10 +131,9 @@ describe('case insensitive path matching', () => { // ['/a/b', '/A/{-$id}/B/{-$other}', {}], ['/a/1/b/2', '/A/{-$id}/B/{-$id}', { id: '2' }], ['/a/1/b/2_', '/A/{-$id}/B/{-$id}', { id: '2_' }], - ])('optional %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: false, fuzzy: false }), - ).toEqual(result) + ])('optional %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, false, false, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -152,10 +141,9 @@ describe('case insensitive path matching', () => { ['/a/', '/A/$', { _splat: '/', '*': '/' }], ['/a', '/A/$', { _splat: '', '*': '' }], ['/a/b/c', '/A/$/foo', { _splat: 'b/c', '*': 'b/c' }], - ])('wildcard %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: false, fuzzy: false }), - ).toEqual(result) + ])('wildcard %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, false, false, path, processedTree) + expect(res?.params).toEqual(result) }) }) @@ -177,10 +165,9 @@ describe('fuzzy path matching', () => { ['/', '/a', '/a/b', undefined], ['/', '/b', '/a', undefined], ['/', '/a', '/b', undefined], - ])('static %s %s => %s', (base, from, to, result) => { - expect(matchByPath(from, { to, fuzzy: true, caseSensitive: true })).toEqual( - result, - ) + ])('static %s %s => %s', (base, path, pattern, result) => { + const res = findSingleMatch(pattern, true, true, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -189,10 +176,9 @@ describe('fuzzy path matching', () => { ['/a/1/', '/a/$id/', { id: '1' }], ['/a/1/b/2', '/a/$id/b/$other', { id: '1', other: '2' }], ['/a/1/b/2/c', '/a/$id/b/$other', { id: '1', other: '2', '**': 'c' }], - ])('params %s => %s', (from, to, result) => { - expect(matchByPath(from, { to, fuzzy: true, caseSensitive: true })).toEqual( - result, - ) + ])('params %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, true, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -205,10 +191,9 @@ describe('fuzzy path matching', () => { ['/a/b', '/a/{-$id}/b/{-$other}', {}], ['/a/b/2/d', '/a/{-$id}/b/{-$other}', { other: '2', '**': 'd' }], ['/a/1/b/2/c', '/a/{-$id}/b/{-$other}', { id: '1', other: '2', '**': 'c' }], - ])('optional %s => %s', (from, to, result) => { - expect(matchByPath(from, { to, fuzzy: true, caseSensitive: true })).toEqual( - result, - ) + ])('optional %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, true, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -216,9 +201,8 @@ describe('fuzzy path matching', () => { ['/a/', '/a/$', { _splat: '/', '*': '/' }], ['/a', '/a/$', { _splat: '', '*': '' }], ['/a/b/c/d', '/a/$/foo', { _splat: 'b/c/d', '*': 'b/c/d' }], - ])('wildcard %s => %s', (from, to, result) => { - expect(matchByPath(from, { to, fuzzy: true, caseSensitive: true })).toEqual( - result, - ) + ])('wildcard %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, true, path, processedTree) + expect(res?.params).toEqual(result) }) }) From 34bfca44f78c9fd58bfedc165d277c2230d265e2 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 21:52:51 +0100 Subject: [PATCH 085/109] explicit handling of index routes --- .../router-core/src/new-process-route-tree.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 45ed849af8a..926c46002d2 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -1,5 +1,6 @@ import invariant from 'tiny-invariant' import { createLRUCache } from './lru-cache' +import { last } from './utils' import type { LRUCache } from './lru-cache' export const SEGMENT_TYPE_PATHNAME = 0 @@ -303,6 +304,7 @@ function parseSegments( } if ((route.path || !route.children) && !route.isRoot) { node.route = route + node.isIndex = path.endsWith('/') } } if (route.children) @@ -385,6 +387,7 @@ function createStaticNode( route: null, fullPath, parent: null, + isIndex: false, } } @@ -416,6 +419,7 @@ function createDynamicNode( caseSensitive, prefix, suffix, + isIndex: false, } } @@ -440,30 +444,33 @@ type AnySegmentNode = type SegmentNode = { kind: SegmentKind - // Static segments (highest priority) + /** Static segments (highest priority) */ static: Map> | null - // Case insensitive static segments (second highest priority) + /** Case insensitive static segments (second highest priority) */ staticInsensitive: Map> | null - // Dynamic segments ($param) + /** Dynamic segments ($param) */ dynamic: Array> | null - // Optional dynamic segments ({-$param}) + /** Optional dynamic segments ({-$param}) */ optional: Array> | null - // Wildcard segments ($ - lowest priority) + /** Wildcard segments ($ - lowest priority) */ wildcard: Array> | null - // Terminal route (if this path can end here) + /** Terminal route (if this path can end here) */ route: T | null - // The full path for this segment node (will only be valid on leaf nodes) + /** The full path for this segment node (will only be valid on leaf nodes) */ fullPath: string parent: AnySegmentNode | null depth: number + + /** is it an index route (trailing / path), only valid for nodes with a `route` */ + isIndex: boolean } // function intoRouteLike(routeTree, parent) { @@ -539,6 +546,7 @@ export function findFlatMatch>( /** The `processedTree` returned by the initial `processRouteTree` call. */ processedTree: ProcessedTree, ) { + path ||= '/' const cached = processedTree.flatCache!.get(path) if (cached) return cached const result = findMatch(path, processedTree.masksTree!) @@ -556,6 +564,8 @@ export function findSingleMatch( path: string, processedTree: ProcessedTree, ) { + from ||= '/' + path ||= '/' const key = caseSensitive ? `case|${from}` : from let tree = processedTree.singleCache.get(key) if (!tree) { @@ -582,6 +592,7 @@ export function findRouteMatch< const key = fuzzy ? `fuzzy|${path}` : path const cached = processedTree.matchCache.get(key) if (cached) return cached + path ||= '/' const result = findMatch(path, processedTree.segmentTree, fuzzy) processedTree.matchCache.set(key, result) return result @@ -754,7 +765,9 @@ function getNodeMatch( segmentTree: AnySegmentNode, fuzzy: boolean, ) { - const partsLength = parts.length + const trailingSlash = !last(parts) + const pathIsIndex = trailingSlash && path !== '/' + const partsLength = parts.length - (trailingSlash ? 1 : 0) type Frame = { node: AnySegmentNode @@ -804,7 +817,7 @@ function getNodeMatch( const isBeyondPath = index === partsLength if (isBeyondPath) { - if (node.route) { + if (node.route && (!pathIsIndex || node.isIndex)) { if ( !bestMatch || statics > bestMatch.statics || From 28c2fb554150036385451df58ce160fe3a3eb680 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 21:53:05 +0100 Subject: [PATCH 086/109] remove unnecessary check --- packages/router-core/src/new-process-route-tree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 926c46002d2..ee34a20ac9f 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -983,7 +983,7 @@ function getNodeMatch( } } - if (bestMatch && bestMatch.index > 1) return bestMatch + if (bestMatch) return bestMatch if (wildcardMatch) return wildcardMatch From 511f2162ce999f7458ef50c9f1ef0a6bc1091615 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 22:05:32 +0100 Subject: [PATCH 087/109] just a couple comments --- packages/router-core/tests/new-process-route-tree.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index ac4a5a6d813..ceeaf95306e 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -117,7 +117,7 @@ describe('findRouteMatch', () => { const tree = makeTree(['/$']) const res = findRouteMatch('/', tree) expect(res?.route.id).toBe('/$') - expect(res?.params).toEqual({ '*': '', _splat: '' }) + expect(res?.params).toEqual({ '*': '/', _splat: '/' }) // should be '/' or '' ? }) it('dynamic at the root DOES NOT match /', () => { const tree = makeTree(['/$id']) @@ -364,7 +364,7 @@ describe('findRouteMatch', () => { const tree = makeTree(['/a/{-$id}/$']) const result = findRouteMatch('/a', tree) expect(result?.route.id).toBe('/a/{-$id}/$') - expect(result?.params).toEqual({ '*': '', _splat: '' }) + expect(result?.params).toEqual({ '*': '', _splat: '' }) // should be '/' or '' ? }) it('multi-segment wildcard w/ prefix', () => { const tree = makeTree(['/file{$}']) From ebdca6e0cf9b0bb0bc8bd637c0ec8c1437aa4b1d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 22:46:59 +0100 Subject: [PATCH 088/109] update splat tests to not expect leading / --- packages/router-core/tests/match-by-path.test.ts | 6 +++--- packages/router-core/tests/new-process-route-tree.test.ts | 4 ++-- packages/router-core/tests/path.test.ts | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/router-core/tests/match-by-path.test.ts b/packages/router-core/tests/match-by-path.test.ts index 1e9ad87ae83..ad108080381 100644 --- a/packages/router-core/tests/match-by-path.test.ts +++ b/packages/router-core/tests/match-by-path.test.ts @@ -82,7 +82,7 @@ describe('default path matching', () => { it.each([ ['/a/b/c', '/a/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/a/$', { _splat: '/', '*': '/' }], + ['/a/', '/a/$', { _splat: '', '*': '' }], ['/a', '/a/$', { _splat: '', '*': '' }], ['/a/b/c', '/a/$/foo', { _splat: 'b/c', '*': 'b/c' }], ])('wildcard %s => %s', (path, pattern, result) => { @@ -138,7 +138,7 @@ describe('case insensitive path matching', () => { it.each([ ['/a/b/c', '/A/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/A/$', { _splat: '/', '*': '/' }], + ['/a/', '/A/$', { _splat: '', '*': '' }], ['/a', '/A/$', { _splat: '', '*': '' }], ['/a/b/c', '/A/$/foo', { _splat: 'b/c', '*': 'b/c' }], ])('wildcard %s => %s', (path, pattern, result) => { @@ -198,7 +198,7 @@ describe('fuzzy path matching', () => { it.each([ ['/a/b/c', '/a/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/a/$', { _splat: '/', '*': '/' }], + ['/a/', '/a/$', { _splat: '', '*': '' }], ['/a', '/a/$', { _splat: '', '*': '' }], ['/a/b/c/d', '/a/$/foo', { _splat: 'b/c/d', '*': 'b/c/d' }], ])('wildcard %s => %s', (path, pattern, result) => { diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index ceeaf95306e..ac4a5a6d813 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -117,7 +117,7 @@ describe('findRouteMatch', () => { const tree = makeTree(['/$']) const res = findRouteMatch('/', tree) expect(res?.route.id).toBe('/$') - expect(res?.params).toEqual({ '*': '/', _splat: '/' }) // should be '/' or '' ? + expect(res?.params).toEqual({ '*': '', _splat: '' }) }) it('dynamic at the root DOES NOT match /', () => { const tree = makeTree(['/$id']) @@ -364,7 +364,7 @@ describe('findRouteMatch', () => { const tree = makeTree(['/a/{-$id}/$']) const result = findRouteMatch('/a', tree) expect(result?.route.id).toBe('/a/{-$id}/$') - expect(result?.params).toEqual({ '*': '', _splat: '' }) // should be '/' or '' ? + expect(result?.params).toEqual({ '*': '', _splat: '' }) }) it('multi-segment wildcard w/ prefix', () => { const tree = makeTree(['/file{$}']) diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index ac028269347..90acdd50bb4 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -836,7 +836,6 @@ describe('parsePathname', () => { expected: [ { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, - { type: SEGMENT_TYPE_PATHNAME, value: '' }, ], }, { From 4601efff9ea4b444e290fd3ddc8d4e346bd5dc33 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 23:15:27 +0100 Subject: [PATCH 089/109] fix fuzzy splat on index path --- .../router-core/src/new-process-route-tree.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index ee34a20ac9f..3352da79e11 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -675,7 +675,7 @@ function findMatch( const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null const params = extractParams(path, parts, leaf) - if ('**' in leaf) params['**'] = leaf['**']! + if ('**' in leaf) params['**'] = leaf['**'] return { route: leaf.node.route!, params, @@ -815,6 +815,17 @@ function getNodeMatch( // eslint-disable-next-line prefer-const let { node, index, skipped, depth, statics, dynamics, optionals } = frame + // In fuzzy mode, track the best partial match we've found so far + if ( + fuzzy && + node.route && + (!bestFuzzy || + index > bestFuzzy.index || + (index === bestFuzzy.index && depth > bestFuzzy.depth)) + ) { + bestFuzzy = frame + } + const isBeyondPath = index === partsLength if (isBeyondPath) { if (node.route && (!pathIsIndex || node.isIndex)) { @@ -836,17 +847,6 @@ function getNodeMatch( if (!node.optional && !node.wildcard) continue } - // In fuzzy mode, track the best partial match we've found so far - if ( - fuzzy && - node.route && - (!bestFuzzy || - index > bestFuzzy.index || - (index === bestFuzzy.index && depth > bestFuzzy.depth)) - ) { - bestFuzzy = frame - } - const part = isBeyondPath ? undefined : parts[index]! let lowerPart: string @@ -992,10 +992,11 @@ function getNodeMatch( for (let i = 0; i < bestFuzzy.index; i++) { sliceIndex += parts[i]!.length } + const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) return { node: bestFuzzy.node, skipped: bestFuzzy.skipped, - '**': path.slice(sliceIndex), + '**': splat, } } From 8bd870dad6b8d370cf94194e78165189629ceb7e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 13 Nov 2025 23:34:35 +0100 Subject: [PATCH 090/109] fix not found --- packages/router-core/src/router.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 8a74182126a..d23610e1ab8 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1777,7 +1777,7 @@ export class RouterCore< let maskedNext = maskedDest ? build(maskedDest) : undefined if (!maskedNext) { - let params = {} + const params = {} if (this.options.routeMasks) { const match = findFlatMatch>( @@ -1785,7 +1785,7 @@ export class RouterCore< this.processedTree, ) if (match) { - params = match.params + Object.assign(params, match.params) // Copy params, because they're cached const { from: _from, ...maskProps } = match.route maskedDest = { from: opts.from, @@ -2631,16 +2631,17 @@ export function getMatchedRoutes({ routesById: Record processedTree: ProcessedTree }) { - let routeParams: Record + const routeParams: Record = {} const trimmedPath = trimPathRight(pathname) let foundRoute: TRouteLike | undefined = undefined const match = findRouteMatch(trimmedPath, processedTree, true) if (match) { - foundRoute = match.route - routeParams = match.params - } else { - routeParams = {} + // If match is fuzzy, it can't be '/' + if (match.route.path !== '/' || !('**' in match.params)) { + foundRoute = match.route + Object.assign(routeParams, match.params) // Copy params, because they're cached + } } let routeCursor: TRouteLike = foundRoute || routesById[rootRouteId]! From 3a2c44735f8e9f60cc8fb8a2523d07b1a30c5842 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 10:14:19 +0100 Subject: [PATCH 091/109] fix fuzzy matching: never yield an index route, find corresponding layout route --- .../router-core/src/new-process-route-tree.ts | 25 ++- packages/router-core/src/router.ts | 7 +- .../tests/new-process-route-tree.test.ts | 170 ++++++++++++++++-- 3 files changed, 174 insertions(+), 28 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 3352da79e11..ad5515aa022 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -303,8 +303,13 @@ function parseSegments( node = nextNode } if ((route.path || !route.children) && !route.isRoot) { + const isIndex = path.endsWith('/') + if (isIndex && node.route) { + // we cannot fuzzy match an index route, but if there is *also* a layout route at this path, we can use it to display the notFound in it + node.notFound = node.route + } node.route = route - node.isIndex = path.endsWith('/') + node.isIndex = isIndex } } if (route.children) @@ -388,6 +393,7 @@ function createStaticNode( fullPath, parent: null, isIndex: false, + notFound: null, } } @@ -416,10 +422,11 @@ function createDynamicNode( route: null, fullPath, parent: null, + isIndex: false, + notFound: null, caseSensitive, prefix, suffix, - isIndex: false, } } @@ -471,6 +478,9 @@ type SegmentNode = { /** is it an index route (trailing / path), only valid for nodes with a `route` */ isIndex: boolean + + /** Same as `route`, but only present if both an "index route" and a "layout route" exist at this path */ + notFound: T | null } // function intoRouteLike(routeTree, parent) { @@ -586,7 +596,7 @@ export function findRouteMatch< path: string, /** The `processedTree` returned by the initial `processRouteTree` call. */ processedTree: ProcessedTree, - /** If `true`, allows fuzzy matching (partial matches). */ + /** If `true`, allows fuzzy matching (partial matches), i.e. which node in the tree would have been an exact match if the `path` had been shorter? */ fuzzy = false, ) { const key = fuzzy ? `fuzzy|${path}` : path @@ -675,9 +685,13 @@ function findMatch( const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null const params = extractParams(path, parts, leaf) - if ('**' in leaf) params['**'] = leaf['**'] + const isFuzzyMatch = '**' in leaf + if (isFuzzyMatch) params['**'] = leaf['**'] + const route = isFuzzyMatch + ? leaf.node.notFound ?? leaf.node.route! + : leaf.node.route! return { - route: leaf.node.route!, + route, params, } } @@ -819,6 +833,7 @@ function getNodeMatch( if ( fuzzy && node.route && + (!node.isIndex || node.notFound) && (!bestFuzzy || index > bestFuzzy.index || (index === bestFuzzy.index && depth > bestFuzzy.depth)) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index d23610e1ab8..2a6f49902dd 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2637,11 +2637,8 @@ export function getMatchedRoutes({ let foundRoute: TRouteLike | undefined = undefined const match = findRouteMatch(trimmedPath, processedTree, true) if (match) { - // If match is fuzzy, it can't be '/' - if (match.route.path !== '/' || !('**' in match.params)) { - foundRoute = match.route - Object.assign(routeParams, match.params) // Copy params, because they're cached - } + foundRoute = match.route + Object.assign(routeParams, match.params) // Copy params, because they're cached } let routeCursor: TRouteLike = foundRoute || routesById[rootRouteId]! diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index ac4a5a6d813..71efb6ab86e 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -175,23 +175,6 @@ describe('findRouteMatch', () => { }) }) - describe('not found', () => { - it('returns null when no match is found', () => { - const tree = makeTree(['/', '/a/b/c', '/d/e/f']) - expect(findRouteMatch('/x/y/z', tree)).toBeNull() - }) - it('returns something w/ fuzzy matching enabled', () => { - const tree = makeTree(['/', '/a/b/c', '/d/e/f']) - const match = findRouteMatch('/x/y/z', tree, true) - expect(match?.route?.id).toBe('/') - expect(match?.params).toMatchInlineSnapshot(` - { - "**": "x/y/z", - } - `) - }) - }) - describe.todo('trailing slashes', () => {}) describe('case sensitivity competition', () => { @@ -458,7 +441,158 @@ describe('findRouteMatch', () => { }) }) - describe.todo('fuzzy matching', () => {}) + describe('not found / fuzzy matching', () => { + it('returns null when no match is found', () => { + const tree = makeTree(['/', '/a/b/c', '/d/e/f']) + expect(findRouteMatch('/x/y/z', tree)).toBeNull() + }) + + it('cannot consider the root route as a fuzzy match', () => { + const tree = makeTree(['/', '/a/b/c', '/d/e/f']) + const match = findRouteMatch('/x/y/z', tree, true) + expect(match).toBeNull() + }) + + it('finds the greatest partial match', () => { + const tree = makeTree(['/a/b/c', '/a/b', '/a']) + const match = findRouteMatch('/a/b/x/y', tree, true) + expect(match?.route?.id).toBe('/a/b') + expect(match?.params).toMatchInlineSnapshot(` + { + "**": "x/y", + } + `) + }) + + it('when both a layout route and an index route exist on the node that is fuzzy-matched, it uses the layout route', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/', + fullPath: '/', + path: '/', + }, + { + id: '/dashboard', + fullPath: '/dashboard', + path: 'dashboard', + children: [ + { + id: '/dashboard/', + fullPath: '/dashboard/', + path: '/', + }, + { + id: '/dashboard/invoices', + fullPath: '/dashboard/invoices', + path: 'invoices', + }, + { + id: '/dashboard/users', + fullPath: '/dashboard/users', + path: 'users', + }, + ], + }, + { + id: '/_auth', + fullPath: '/', + children: [ + { + id: '/_auth/profile', + fullPath: '/profile', + path: 'profile', + }, + ], + }, + ], + } + const processed = processRouteTree(tree) + const match = findRouteMatch( + '/dashboard/foo', + processed.processedTree, + true, + ) + expect(match?.route.id).toBe('/dashboard') + expect(match?.params).toEqual({ '**': 'foo' }) + }) + + it('cannot use an index route as a fuzzy match', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/', + fullPath: '/', + path: '/', + }, + { + id: '/dashboard/', + fullPath: '/dashboard/', + path: 'dashboard/', + }, + { + id: '/dashboard', + fullPath: '/dashboard/invoices', + path: 'invoices', + }, + { + id: '/dashboard/users', + fullPath: '/dashboard/users', + path: 'users', + }, + ], + } + const processed = processRouteTree(tree) + const match = findRouteMatch( + '/dashboard/foo', + processed.processedTree, + true, + ) + expect(match).toBeNull() + }) + + it('edge-case: index route is not a child of layout route', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/', + fullPath: '/', + path: '/', + }, + { + id: '/dashboard/', + fullPath: '/dashboard/', + path: 'dashboard/', + }, + { + id: '/dashboard', + fullPath: '/dashboard', + path: 'dashboard', + }, + ], + } + const processed = processRouteTree(tree) + const match = findRouteMatch( + '/dashboard/foo', + processed.processedTree, + true, + ) + expect(match?.route.id).toBe('/dashboard') + expect(match?.params).toEqual({ '**': 'foo' }) + }) + }) }) describe.todo('processRouteMasks', () => { From 6c5021b52e5a051a8584bdb51ccd8b4fd213f36f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 10:33:03 +0100 Subject: [PATCH 092/109] fix order-dependant competition between layout and index routes in the same node --- packages/router-core/src/new-process-route-tree.ts | 14 +++++++------- .../tests/new-process-route-tree.test.ts | 2 ++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index ad5515aa022..f11d1f73d1a 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -304,12 +304,12 @@ function parseSegments( } if ((route.path || !route.children) && !route.isRoot) { const isIndex = path.endsWith('/') - if (isIndex && node.route) { - // we cannot fuzzy match an index route, but if there is *also* a layout route at this path, we can use it to display the notFound in it - node.notFound = node.route - } - node.route = route - node.isIndex = isIndex + // we cannot fuzzy match an index route, + // but if there is *also* a layout route at this path, save it as notFound + // we can use it when fuzzy matching to display the NotFound component in the layout route + if (!isIndex) node.notFound = route + if (!node.route || (!node.isIndex && isIndex)) node.route = route + node.isIndex ||= isIndex } } if (route.children) @@ -688,7 +688,7 @@ function findMatch( const isFuzzyMatch = '**' in leaf if (isFuzzyMatch) params['**'] = leaf['**'] const route = isFuzzyMatch - ? leaf.node.notFound ?? leaf.node.route! + ? (leaf.node.notFound ?? leaf.node.route!) : leaf.node.route! return { route, diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 71efb6ab86e..1fbaae825aa 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -591,6 +591,8 @@ describe('findRouteMatch', () => { ) expect(match?.route.id).toBe('/dashboard') expect(match?.params).toEqual({ '**': 'foo' }) + const actualMatch = findRouteMatch('/dashboard', processed.processedTree) + expect(actualMatch?.route.id).toBe('/dashboard/') }) }) }) From ce1498084081b7ebdd13ac4ba0166d4b5cb626a7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 11:47:07 +0100 Subject: [PATCH 093/109] update docs to remove reference to flatRoutes --- docs/router/framework/react/how-to/debug-router-issues.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/router/framework/react/how-to/debug-router-issues.md b/docs/router/framework/react/how-to/debug-router-issues.md index 63b3da61264..2d698121a65 100644 --- a/docs/router/framework/react/how-to/debug-router-issues.md +++ b/docs/router/framework/react/how-to/debug-router-issues.md @@ -109,7 +109,7 @@ const route = createRoute({ ```tsx // Debug route tree in console console.log('Route tree:', router.routeTree) -console.log('All routes:', router.flatRoutes) +console.log('All routes:', router.routesById) ``` 3. **Check Parent Route Configuration** From 76a5ae3db4af4faf1f2eaf4c660003708023633e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 11:47:37 +0100 Subject: [PATCH 094/109] update react-router & solid-router tests to remove references to flatRoutes --- packages/react-router/tests/router.test.tsx | 12 ------------ packages/solid-router/tests/router.test.tsx | 12 ------------ 2 files changed, 24 deletions(-) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index cd566e804ff..de0826020d3 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1697,18 +1697,6 @@ describe('route ids should be consistent after rebuilding the route tree', () => }) describe('route id uniqueness', () => { - it('flatRoute should not have routes with duplicated route ids', () => { - const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - const routeIdSet = new Set() - - router.flatRoutes.forEach((route) => { - expect(routeIdSet.has(route.id)).toBe(false) - routeIdSet.add(route.id) - }) - }) - it('routesById should not have routes duplicated route ids', () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx index aa30b2a000a..6e515d95fe9 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -1326,18 +1326,6 @@ describe('route ids should be consistent after rebuilding the route tree', () => }) describe('route id uniqueness', () => { - it('flatRoute should not have routes with duplicated route ids', () => { - const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - const routeIdSet = new Set() - - router.flatRoutes.forEach((route) => { - expect(routeIdSet.has(route.id)).toBe(false) - routeIdSet.add(route.id) - }) - }) - it('routesById should not have routes duplicated route ids', () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), From fb83182abc50ad20be8f127b0cb878888a67ba2f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 11:47:57 +0100 Subject: [PATCH 095/109] prettify --- packages/router-core/src/path.ts | 2 ++ packages/router-core/src/router.ts | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index cd88f6f3c79..a7dbcfea6d9 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -347,6 +347,8 @@ export function interpolatePath({ } } + if (path.endsWith('/')) joined += '/' + const interpolatedPath = joined || '/' return { usedParams, interpolatedPath, isMissingParams } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index cfc2582d471..7ee19908391 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1677,9 +1677,9 @@ export class RouterCore< } const nextPathname = opts.leaveParams - // Use the original template path for interpolation - // This preserves the original parameter syntax including optional parameters - ? nextTo + ? // Use the original template path for interpolation + // This preserves the original parameter syntax including optional parameters + nextTo : decodePath( interpolatePath({ path: nextTo, From 159270839f06572e71a0ced62de2fff7f3a13a6d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 11:48:45 +0100 Subject: [PATCH 096/109] fix interpolatePath preserves trailing slash --- packages/router-core/tests/path.test.ts | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 90acdd50bb4..733cc43f4f9 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -360,6 +360,41 @@ describe('interpolatePath', () => { }) }) + describe('preserve trailing slash', () => { + it.each([ + { + path: '/', + params: {}, + result: '/', + }, + { + path: '/a/b/', + params: {}, + result: '/a/b/', + }, + { + path: '/a/$id/', + params: { id: '123' }, + result: '/a/123/', + }, + { + path: '/a/{-$id}/', + params: { id: '123' }, + result: '/a/123/', + }, + ])( + 'should preserve trailing slash for $path', + ({ path, params, result }) => { + expect( + interpolatePath({ + path, + params, + }).interpolatedPath, + ).toBe(result) + }, + ) + }) + describe('wildcard (prefix + suffix)', () => { it.each([ { @@ -503,6 +538,32 @@ describe('interpolatePath', () => { }) }) +describe('resolvePath + interpolatePath', () => { + it.each(['never', 'preserve', 'always'] as const)( + 'trailing slash: %s', + (trailingSlash) => { + const tail = trailingSlash === 'always' ? '/' : '' + const defaultedFromPath = '/' + const fromPath = resolvePath({ + base: defaultedFromPath, + to: '.', + trailingSlash, + }) + const nextTo = resolvePath({ + base: fromPath, + to: '/splat/$', + trailingSlash, + }) + const nextParams = { _splat: '' } + const interpolatedNextTo = interpolatePath({ + path: nextTo, + params: nextParams, + }).interpolatedPath + expect(interpolatedNextTo).toBe(`/splat${tail}`) + }, + ) +}) + describe('matchPathname', () => { const { processedTree } = processRouteTree({ id: '__root__', From 99612d6b24ab21be68ddb4ff258b117cb55291c4 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 11:52:46 +0100 Subject: [PATCH 097/109] update react/solid-router tests: matches w/ empty optional params do not have said param in returned params object --- packages/react-router/tests/Matches.test.tsx | 8 ++++---- packages/solid-router/tests/Matches.test.tsx | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index d2ee9c4d717..dd3ca3dfa6c 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -271,7 +271,7 @@ describe('matching on different param types', () => { path: '/optional-{-$id}', nav: '/optional-', params: {}, - matchParams: { id: '' }, + matchParams: {}, }, { name: 'optional param with suffix and value', @@ -285,14 +285,14 @@ describe('matching on different param types', () => { path: '/{-$id}-optional', nav: '/-optional', params: {}, - matchParams: { id: '' }, + matchParams: {}, }, { name: 'optional param with required param, prefix, suffix, wildcard and no value', path: `/$foo/a{-$id}-optional/$`, nav: '/bar/a-optional/qux', - params: { foo: 'bar', id: '', _splat: 'qux', '*': 'qux' }, - matchParams: { foo: 'bar', id: '', _splat: 'qux', '*': 'qux' }, + params: { foo: 'bar', _splat: 'qux', '*': 'qux' }, + matchParams: { foo: 'bar', _splat: 'qux', '*': 'qux' }, }, { name: 'optional param with required param, prefix, suffix, wildcard and value', diff --git a/packages/solid-router/tests/Matches.test.tsx b/packages/solid-router/tests/Matches.test.tsx index 394980c234b..63347bd1ff0 100644 --- a/packages/solid-router/tests/Matches.test.tsx +++ b/packages/solid-router/tests/Matches.test.tsx @@ -275,8 +275,8 @@ describe('matching on different param types', () => { name: 'optional param with prefix and no value', path: '/optional-{-$id}', nav: '/optional-', - params: { id: '' }, - matchParams: { id: '' }, + params: {}, + matchParams: {}, }, { name: 'optional param with suffix and value', @@ -289,15 +289,15 @@ describe('matching on different param types', () => { name: 'optional param with suffix and no value', path: '/{-$id}-optional', nav: '/-optional', - params: { id: '' }, - matchParams: { id: '' }, + params: {}, + matchParams: {}, }, { name: 'optional param with required param, prefix, suffix, wildcard and no value', path: `/$foo/a{-$id}-optional/$`, nav: '/bar/a-optional/qux', - params: { foo: 'bar', id: '', _splat: 'qux', '*': 'qux' }, - matchParams: { foo: 'bar', id: '', _splat: 'qux', '*': 'qux' }, + params: { foo: 'bar', _splat: 'qux', '*': 'qux' }, + matchParams: { foo: 'bar', _splat: 'qux', '*': 'qux' }, }, { name: 'optional param with required param, prefix, suffix, wildcard and value', From dc0cd80b741a6fa3ec0f8cbc3834eb94ce842ff9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 14:16:34 +0100 Subject: [PATCH 098/109] fix depth issues in competing segment tree branches --- .../router-core/src/new-process-route-tree.ts | 12 ++++---- .../tests/new-process-route-tree.test.ts | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index f11d1f73d1a..d7ed1bce71b 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -157,6 +157,7 @@ function parseSegments( const start = cursor const end = data[5]! cursor = end + 1 + depth++ const kind = data[0] as SegmentKind switch (kind) { case SEGMENT_TYPE_PATHNAME: { @@ -171,7 +172,7 @@ function parseSegments( route.fullPath ?? route.from, ) next.parent = node - next.depth = ++depth + next.depth = depth nextNode = next node.static.set(value, next) } @@ -186,7 +187,7 @@ function parseSegments( route.fullPath ?? route.from, ) next.parent = node - next.depth = ++depth + next.depth = depth nextNode = next node.staticInsensitive.set(name, next) } @@ -225,7 +226,7 @@ function parseSegments( suffix, ) nextNode = next - next.depth = ++depth + next.depth = depth next.parent = node node.dynamic ??= [] node.dynamic.push(next) @@ -265,7 +266,7 @@ function parseSegments( ) nextNode = next next.parent = node - next.depth = ++depth + next.depth = depth node.optional ??= [] node.optional.push(next) } @@ -295,7 +296,7 @@ function parseSegments( ) nextNode = next next.parent = node - next.depth = ++depth + next.depth = depth node.wildcard ??= [] node.wildcard.push(next) } @@ -488,6 +489,7 @@ type SegmentNode = { // id: routeTree.id, // fullPath: routeTree.fullPath, // path: routeTree.path, +// isRoot: routeTree.isRoot, // options: routeTree.options && 'caseSensitive' in routeTree.options ? { caseSensitive: routeTree.options.caseSensitive } : undefined, // } // if (routeTree.children) { diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 1fbaae825aa..fa52ff4450c 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -391,6 +391,34 @@ describe('findRouteMatch', () => { '/foo/{-$p}.tsx', ) }) + + it('edge-case: two competing nodes at the same depth still produce a valid segment tree', () => { + // this case is not easy to explain, but at some point in the implementation + // the presence of `/a/c/{$foo}suffix` made `processRouteTree` assign an incorrect `depth` + // value to the `/a/b/$` node, causing the params extraction to return incorrect results. + const tree = { + id: '__root__', + fullPath: '/', + path: '/', + isRoot: true, + children: [ + { + id: '/a/c/{$foo}suffix', + fullPath: '/a/c/{$foo}suffix', + path: 'a/c/{$foo}suffix', + }, + { + id: '/a/b/$', + fullPath: '/a/b/$', + path: 'a/b/$', + }, + ], + } + const { processedTree } = processRouteTree(tree) + const res = findRouteMatch('/a/b/foo', processedTree, true) + expect(res?.route.id).toBe('/a/b/$') + expect(res?.params).toEqual({ _splat: 'foo', '*': 'foo' }) + }) }) describe('nested routes', () => { From 2d395d52d0d037cffbc967c81a8fc9835eae2f95 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 14:37:21 +0100 Subject: [PATCH 099/109] fix HMR inlining --- .../src/core/route-hmr-statement.ts | 27 +++++++++---------- .../snapshots/react/arrow-function@true.tsx | 19 +++++++++---- .../react/function-declaration@true.tsx | 19 +++++++++---- .../snapshots/solid/arrow-function@true.tsx | 19 +++++++++---- 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/packages/router-plugin/src/core/route-hmr-statement.ts b/packages/router-plugin/src/core/route-hmr-statement.ts index 7188d79aff1..add90858a0a 100644 --- a/packages/router-plugin/src/core/route-hmr-statement.ts +++ b/packages/router-plugin/src/core/route-hmr-statement.ts @@ -34,22 +34,21 @@ function handleRouteUpdate( ) { router.invalidate({ filter }) } -} + function walkReplaceSegmentTree( + route: AnyRouteWithPrivateProps, + node: AnyRouter['processedTree']['segmentTree'], + ) { + if (node.route?.id === route.id) node.route = route + if (node.notFound?.id === route.id) node.notFound = route -function walkReplaceSegmentTree( - route: AnyRouteWithPrivateProps, - node: AnyRouter['processedTree']['segmentTree'], -) { - if (node.route?.id === route.id) { - node.route = route + node.static?.forEach((child) => walkReplaceSegmentTree(route, child)) + node.staticInsensitive?.forEach((child) => + walkReplaceSegmentTree(route, child), + ) + node.dynamic?.forEach((child) => walkReplaceSegmentTree(route, child)) + node.optional?.forEach((child) => walkReplaceSegmentTree(route, child)) + node.wildcard?.forEach((child) => walkReplaceSegmentTree(route, child)) } - node.static?.forEach((child) => walkReplaceSegmentTree(route, child)) - node.staticInsensitive?.forEach((child) => - walkReplaceSegmentTree(route, child), - ) - node.dynamic?.forEach((child) => walkReplaceSegmentTree(route, child)) - node.optional?.forEach((child) => walkReplaceSegmentTree(route, child)) - node.wildcard?.forEach((child) => walkReplaceSegmentTree(route, child)) } export const routeHmrStatement = template.statement( diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx index b604359d348..d3a724582f3 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx @@ -20,17 +20,26 @@ if (import.meta.hot) { const router = window.__TSR_ROUTER__; router.routesById[newRoute.id] = newRoute; router.routesByPath[newRoute.fullPath] = newRoute; - const oldRouteIndex = router.flatRoutes.indexOf(oldRoute); - if (oldRouteIndex > -1) { - router.flatRoutes[oldRouteIndex] = newRoute; - } - ; + router.processedTree.matchCache.clear(); + router.processedTree.flatCache?.clear(); + router.processedTree.singleCache.clear(); + walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { router.invalidate({ filter }); } + ; + function walkReplaceSegmentTree(route, node) { + if (node.route?.id === route.id) node.route = route; + if (node.notFound?.id === route.id) node.notFound = route; + node.static?.forEach(child => walkReplaceSegmentTree(route, child)); + node.staticInsensitive?.forEach(child => walkReplaceSegmentTree(route, child)); + node.dynamic?.forEach(child => walkReplaceSegmentTree(route, child)); + node.optional?.forEach(child => walkReplaceSegmentTree(route, child)); + node.wildcard?.forEach(child => walkReplaceSegmentTree(route, child)); + } })(Route, newModule.Route); } }); diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx index 63ec83eb3aa..bd8f13b74aa 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx @@ -20,17 +20,26 @@ if (import.meta.hot) { const router = window.__TSR_ROUTER__; router.routesById[newRoute.id] = newRoute; router.routesByPath[newRoute.fullPath] = newRoute; - const oldRouteIndex = router.flatRoutes.indexOf(oldRoute); - if (oldRouteIndex > -1) { - router.flatRoutes[oldRouteIndex] = newRoute; - } - ; + router.processedTree.matchCache.clear(); + router.processedTree.flatCache?.clear(); + router.processedTree.singleCache.clear(); + walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { router.invalidate({ filter }); } + ; + function walkReplaceSegmentTree(route, node) { + if (node.route?.id === route.id) node.route = route; + if (node.notFound?.id === route.id) node.notFound = route; + node.static?.forEach(child => walkReplaceSegmentTree(route, child)); + node.staticInsensitive?.forEach(child => walkReplaceSegmentTree(route, child)); + node.dynamic?.forEach(child => walkReplaceSegmentTree(route, child)); + node.optional?.forEach(child => walkReplaceSegmentTree(route, child)); + node.wildcard?.forEach(child => walkReplaceSegmentTree(route, child)); + } })(Route, newModule.Route); } }); diff --git a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx index a06fcfefd56..2d8a9f67e7d 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx @@ -19,17 +19,26 @@ if (import.meta.hot) { const router = window.__TSR_ROUTER__; router.routesById[newRoute.id] = newRoute; router.routesByPath[newRoute.fullPath] = newRoute; - const oldRouteIndex = router.flatRoutes.indexOf(oldRoute); - if (oldRouteIndex > -1) { - router.flatRoutes[oldRouteIndex] = newRoute; - } - ; + router.processedTree.matchCache.clear(); + router.processedTree.flatCache?.clear(); + router.processedTree.singleCache.clear(); + walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { router.invalidate({ filter }); } + ; + function walkReplaceSegmentTree(route, node) { + if (node.route?.id === route.id) node.route = route; + if (node.notFound?.id === route.id) node.notFound = route; + node.static?.forEach(child => walkReplaceSegmentTree(route, child)); + node.staticInsensitive?.forEach(child => walkReplaceSegmentTree(route, child)); + node.dynamic?.forEach(child => walkReplaceSegmentTree(route, child)); + node.optional?.forEach(child => walkReplaceSegmentTree(route, child)); + node.wildcard?.forEach(child => walkReplaceSegmentTree(route, child)); + } })(Route, newModule.Route); } }); From 8df17ca79ddbf0f2047d21b518b60373bb000114 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 14:57:28 +0100 Subject: [PATCH 100/109] more cache invalidation in HMR --- packages/router-plugin/src/core/route-hmr-statement.ts | 1 + .../tests/add-hmr/snapshots/react/arrow-function@true.tsx | 1 + .../tests/add-hmr/snapshots/react/function-declaration@true.tsx | 1 + .../tests/add-hmr/snapshots/solid/arrow-function@true.tsx | 1 + 4 files changed, 4 insertions(+) diff --git a/packages/router-plugin/src/core/route-hmr-statement.ts b/packages/router-plugin/src/core/route-hmr-statement.ts index add90858a0a..a5771ebee7e 100644 --- a/packages/router-plugin/src/core/route-hmr-statement.ts +++ b/packages/router-plugin/src/core/route-hmr-statement.ts @@ -25,6 +25,7 @@ function handleRouteUpdate( router.processedTree.matchCache.clear() router.processedTree.flatCache?.clear() router.processedTree.singleCache.clear() + router.resolvePathCache.clear() // TODO: how to rebuild the tree if we add a new route? walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree) const filter = (m: AnyRouteMatch) => m.routeId === oldRoute.id diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx index d3a724582f3..f80eab81e9f 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx @@ -23,6 +23,7 @@ if (import.meta.hot) { router.processedTree.matchCache.clear(); router.processedTree.flatCache?.clear(); router.processedTree.singleCache.clear(); + router.resolvePathCache.clear(); walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx index bd8f13b74aa..1664f20bbbc 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx @@ -23,6 +23,7 @@ if (import.meta.hot) { router.processedTree.matchCache.clear(); router.processedTree.flatCache?.clear(); router.processedTree.singleCache.clear(); + router.resolvePathCache.clear(); walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx index 2d8a9f67e7d..e140124caa2 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx @@ -22,6 +22,7 @@ if (import.meta.hot) { router.processedTree.matchCache.clear(); router.processedTree.flatCache?.clear(); router.processedTree.singleCache.clear(); + router.resolvePathCache.clear(); walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { From bf328f36b0e3275da9fd34dc168e47e9da61ca91 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 17:10:09 +0100 Subject: [PATCH 101/109] remove unnecessary checks in interpolatePath --- packages/router-core/src/path.ts | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index a7dbcfea6d9..8bbaa85812d 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -275,8 +275,7 @@ export function interpolatePath({ const kind = data[0] as SegmentKind if (kind === SEGMENT_TYPE_PATHNAME) { - if (cursor > 0) joined += '/' - joined += path.substring(start, end) + joined += '/' + path.substring(start, end) continue } @@ -295,15 +294,13 @@ export function interpolatePath({ // For missing splat parameters, just return the prefix and suffix without the wildcard // If there is a prefix or suffix, return them joined, otherwise omit the segment if (prefix || suffix) { - if (cursor > 0) joined += '/' - joined += prefix + suffix + joined += '/' + prefix + suffix } continue } const value = encodeParam('_splat', params, decodeCharMap) - if (cursor > 0) joined += '/' - joined += prefix + value + suffix + joined += '/' + prefix + value + suffix continue } @@ -317,8 +314,7 @@ export function interpolatePath({ const prefix = path.substring(start, data[1]) const suffix = path.substring(data[4]!, end) const value = encodeParam(key, params, decodeCharMap) ?? 'undefined' - if (cursor > 0) joined += '/' - joined += prefix + value + suffix + joined += '/' + prefix + value + suffix continue } @@ -326,23 +322,22 @@ export function interpolatePath({ const key = path.substring(data[2]!, data[3]) const prefix = path.substring(start, data[1]) const suffix = path.substring(data[4]!, end) + const valueRaw = params[key] // Check if optional parameter is missing or undefined - if (params[key] == null) { + if (valueRaw == null) { if (prefix || suffix) { - if (cursor > 0) joined += '/' // For optional params with prefix/suffix, keep the prefix/suffix but omit the param - joined += prefix + suffix + joined += '/' + prefix + suffix } // If no prefix/suffix, omit the entire segment continue } - usedParams[key] = params[key] + usedParams[key] = valueRaw const value = encodeParam(key, params, decodeCharMap) ?? '' - if (cursor > 0) joined += '/' - joined += prefix + value + suffix + joined += '/' + prefix + value + suffix continue } } From e8c698520406f130bd50b9542f182048742ffa2e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Nov 2025 20:37:53 +0100 Subject: [PATCH 102/109] use null byte as string separator in cache keys --- packages/router-core/src/new-process-route-tree.ts | 4 ++-- packages/router-core/src/path.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index d7ed1bce71b..f1a0107c558 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -578,7 +578,7 @@ export function findSingleMatch( ) { from ||= '/' path ||= '/' - const key = caseSensitive ? `case|${from}` : from + const key = caseSensitive ? `case\0${from}` : from let tree = processedTree.singleCache.get(key) if (!tree) { // single flat routes (router.matchRoute) are not eagerly processed, @@ -601,7 +601,7 @@ export function findRouteMatch< /** If `true`, allows fuzzy matching (partial matches), i.e. which node in the tree would have been an exact match if the `path` had been shorter? */ fuzzy = false, ) { - const key = fuzzy ? `fuzzy|${path}` : path + const key = fuzzy ? `fuzzy\0${path}` : path const cached = processedTree.matchCache.get(key) if (cached) return cached path ||= '/' diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 8bbaa85812d..700b1111d1c 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -126,7 +126,7 @@ export function resolvePath({ let key if (cache) { // `trailingSlash` is static per router, so it doesn't need to be part of the cache key - key = isAbsolute ? to : isBase ? base : base + '\\\\' + to + key = isAbsolute ? to : isBase ? base : base + '\0' + to const cached = cache.get(key) if (cached) return cached } From 6cd1cacf9a0528f07cf9fde29ab49ea46b2ea65f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 15 Nov 2025 01:04:51 +0100 Subject: [PATCH 103/109] fix wildcard competition --- .../router-core/src/new-process-route-tree.ts | 31 ++++++++++++------- .../tests/new-process-route-tree.test.ts | 6 ++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index f1a0107c558..10cfedda1fd 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -868,7 +868,16 @@ function getNodeMatch( let lowerPart: string // 5. Try wildcard match - if (node.wildcard) { + if ( + node.wildcard && + (!wildcardMatch || + statics > wildcardMatch.statics || + (statics === wildcardMatch.statics && + dynamics > wildcardMatch.dynamics) || + (statics === wildcardMatch.statics && + dynamics === wildcardMatch.dynamics && + optionals > wildcardMatch.optionals)) + ) { for (const segment of node.wildcard) { const { prefix, suffix } = segment if (prefix) { @@ -884,17 +893,15 @@ function getNodeMatch( const casePart = segment.caseSensitive ? end : end.toLowerCase() if (casePart !== suffix) continue } - // a wildcard match terminates the loop, but we need to continue searching in case there's a longer match - if (!wildcardMatch || wildcardMatch.index <= index) { - wildcardMatch = { - node: segment, - index, - skipped, - depth, - statics, - dynamics, - optionals, - } + // the first wildcard match is the highest priority one + wildcardMatch = { + node: segment, + index, + skipped, + depth, + statics, + dynamics, + optionals, } break } diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index fa52ff4450c..39af51d7566 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -172,6 +172,12 @@ describe('findRouteMatch', () => { '/$a/$b/c/d/$e', ) }) + + it('a short wildcard match does not prevent a longer match', () => { + const tree = makeTree(['/a/$', '/a/b/c/$']) + expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe('/a/b/c/$') + expect(findRouteMatch('/a/d/e', tree)?.route.id).toBe('/a/$') + }) }) }) From 58e3d3957952104a3e8a7d47cb17b19b324bc83c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 15 Nov 2025 01:18:39 +0100 Subject: [PATCH 104/109] clarify parseSegment function usage --- .../router-core/src/new-process-route-tree.ts | 71 ++++++++++++------- packages/router-core/src/path.ts | 47 ++++++------ 2 files changed, 70 insertions(+), 48 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 10cfedda1fd..c054955f8b6 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -20,14 +20,36 @@ const OPTIONAL_PARAM_W_CURLY_BRACES_RE = /^([^{]*)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{-$paramName}suffix const WILDCARD_W_CURLY_BRACES_RE = /^([^{]*)\{\$\}([^}]*)$/ // prefix{$}suffix +type ParsedSegment = Uint16Array & { + /** segment type (0 = pathname, 1 = param, 2 = wildcard, 3 = optional param) */ + 0: SegmentKind + /** index of the end of the prefix */ + 1: number + /** index of the start of the value */ + 2: number + /** index of the end of the value */ + 3: number + /** index of the start of the suffix */ + 4: number + /** index of the end of the segment */ + 5: number +} + /** * Populates the `output` array with the parsed representation of the given `segment` string. - * - `output[0]` = segment type (0 = pathname, 1 = param, 2 = wildcard, 3 = optional param) - * - `output[1]` = index of the end of the prefix - * - `output[2]` = index of the start of the value - * - `output[3]` = index of the end of the value - * - `output[4]` = index of the start of the suffix - * - `output[5]` = index of the end of the segment + * + * Usage: + * ```ts + * let output + * let cursor = 0 + * while (cursor < path.length) { + * output = parseSegment(path, cursor, output) + * const end = output[5] + * cursor = end + 1 + * ``` + * + * `output` is stored outside to avoid allocations during repeated calls. It doesn't need to be typed + * or initialized, it will be done automatically. */ export function parseSegment( /** The full path string containing the segment. */ @@ -35,8 +57,8 @@ export function parseSegment( /** The starting index of the segment within the path. */ start: number, /** A Uint16Array (length: 6) to populate with the parsed segment data. */ - output: Uint16Array, -) { + output: Uint16Array = new Uint16Array(6), +): ParsedSegment { const next = path.indexOf('/', start) const end = next === -1 ? path.length : next const part = path.substring(start, end) @@ -49,7 +71,7 @@ export function parseSegment( output[3] = end output[4] = end output[5] = end - return + return output as ParsedSegment } // $ (wildcard) @@ -61,7 +83,7 @@ export function parseSegment( output[3] = total output[4] = total output[5] = total - return + return output as ParsedSegment } // $paramName @@ -72,7 +94,7 @@ export function parseSegment( output[3] = end output[4] = end output[5] = end - return + return output as ParsedSegment } const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE) @@ -85,7 +107,7 @@ export function parseSegment( output[3] = start + pLength + 2 // '$' output[4] = start + pLength + 3 // skip '}' output[5] = path.length - return + return output as ParsedSegment } const optionalParamBracesMatch = part.match(OPTIONAL_PARAM_W_CURLY_BRACES_RE) @@ -100,7 +122,7 @@ export function parseSegment( output[3] = start + pLength + 3 + paramName.length output[4] = end - suffix.length output[5] = end - return + return output as ParsedSegment } const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE) @@ -115,7 +137,7 @@ export function parseSegment( output[3] = start + pLength + 2 + paramName.length output[4] = end - suffix.length output[5] = end - return + return output as ParsedSegment } // fallback to static pathname (should never happen) @@ -125,6 +147,7 @@ export function parseSegment( output[3] = end output[4] = end output[5] = end + return output as ParsedSegment } /** @@ -152,16 +175,16 @@ function parseSegments( const length = path.length const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive while (cursor < length) { - parseSegment(path, cursor, data) + const segment = parseSegment(path, cursor, data) let nextNode: AnySegmentNode const start = cursor - const end = data[5]! + const end = segment[5] cursor = end + 1 depth++ - const kind = data[0] as SegmentKind + const kind = segment[0] switch (kind) { case SEGMENT_TYPE_PATHNAME: { - const value = path.substring(data[2]!, data[3]) + const value = path.substring(segment[2], segment[3]) if (caseSensitive) { const existingNode = node.static?.get(value) if (existingNode) { @@ -195,8 +218,8 @@ function parseSegments( break } case SEGMENT_TYPE_PARAM: { - const prefix_raw = path.substring(start, data[1]) - const suffix_raw = path.substring(data[4]!, end) + const prefix_raw = path.substring(start, segment[1]) + const suffix_raw = path.substring(segment[4], end) const actuallyCaseSensitive = caseSensitive && !!(prefix_raw || suffix_raw) const prefix = !prefix_raw @@ -234,8 +257,8 @@ function parseSegments( break } case SEGMENT_TYPE_OPTIONAL_PARAM: { - const prefix_raw = path.substring(start, data[1]) - const suffix_raw = path.substring(data[4]!, end) + const prefix_raw = path.substring(start, segment[1]) + const suffix_raw = path.substring(segment[4], end) const actuallyCaseSensitive = caseSensitive && !!(prefix_raw || suffix_raw) const prefix = !prefix_raw @@ -273,8 +296,8 @@ function parseSegments( break } case SEGMENT_TYPE_WILDCARD: { - const prefix_raw = path.substring(start, data[1]) - const suffix_raw = path.substring(data[4]!, end) + const prefix_raw = path.substring(start, segment[1]) + const suffix_raw = path.substring(segment[4], end) const actuallyCaseSensitive = caseSensitive && !!(prefix_raw || suffix_raw) const prefix = !prefix_raw diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 700b1111d1c..88b489d8aa2 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -6,7 +6,6 @@ import { SEGMENT_TYPE_WILDCARD, parseSegment, } from './new-process-route-tree' -import type { SegmentKind } from './new-process-route-tree' import type { LRUCache } from './lru-cache' /** Join path segments, cleaning duplicate slashes between parts. */ @@ -175,22 +174,22 @@ export function resolvePath({ } } - const data = new Uint16Array(6) + let segment let joined = '' for (let i = 0; i < baseSegments.length; i++) { if (i > 0) joined += '/' - const segment = baseSegments[i]! - if (!segment) continue - parseSegment(segment, 0, data) - const kind = data[0] as SegmentKind + const part = baseSegments[i]! + if (!part) continue + segment = parseSegment(part, 0, segment) + const kind = segment[0] if (kind === SEGMENT_TYPE_PATHNAME) { - joined += segment + joined += part continue } - const end = data[5]! - const prefix = segment.substring(0, data[1]) - const suffix = segment.substring(data[4]!, end) - const value = segment.substring(data[2]!, data[3]) + const end = segment[5] + const prefix = part.substring(0, segment[1]) + const suffix = part.substring(segment[4], end) + const value = part.substring(segment[2], segment[3]) if (kind === SEGMENT_TYPE_PARAM) { joined += prefix || suffix ? `${prefix}{$${value}}${suffix}` : `$${value}` } else if (kind === SEGMENT_TYPE_WILDCARD) { @@ -260,19 +259,19 @@ export function interpolatePath({ if (!path.includes('$')) return { interpolatedPath: path, usedParams, isMissingParams } - let cursor = 0 - const data = new Uint16Array(6) const length = path.length + let cursor = 0 + let segment let joined = '' while (cursor < length) { const start = cursor - parseSegment(path, start, data) - const end = data[5]! + segment = parseSegment(path, start, segment) + const end = segment[5] cursor = end + 1 if (start === end) continue - const kind = data[0] as SegmentKind + const kind = segment[0] if (kind === SEGMENT_TYPE_PATHNAME) { joined += '/' + path.substring(start, end) @@ -285,8 +284,8 @@ export function interpolatePath({ // TODO: Deprecate * usedParams['*'] = splat - const prefix = path.substring(start, data[1]) - const suffix = path.substring(data[4]!, end) + const prefix = path.substring(start, segment[1]) + const suffix = path.substring(segment[4], end) // Check if _splat parameter is missing. _splat could be missing if undefined or an empty string or some other falsy value. if (!splat) { @@ -305,23 +304,23 @@ export function interpolatePath({ } if (kind === SEGMENT_TYPE_PARAM) { - const key = path.substring(data[2]!, data[3]) + const key = path.substring(segment[2], segment[3]) if (!isMissingParams && !(key in params)) { isMissingParams = true } usedParams[key] = params[key] - const prefix = path.substring(start, data[1]) - const suffix = path.substring(data[4]!, end) + const prefix = path.substring(start, segment[1]) + const suffix = path.substring(segment[4], end) const value = encodeParam(key, params, decodeCharMap) ?? 'undefined' joined += '/' + prefix + value + suffix continue } if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { - const key = path.substring(data[2]!, data[3]) - const prefix = path.substring(start, data[1]) - const suffix = path.substring(data[4]!, end) + const key = path.substring(segment[2], segment[3]) + const prefix = path.substring(start, segment[1]) + const suffix = path.substring(segment[4], end) const valueRaw = params[key] // Check if optional parameter is missing or undefined From c91e76ef4852dda99d56fc3a62098779f0554e79 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 15 Nov 2025 01:25:27 +0100 Subject: [PATCH 105/109] fix fuzzy match competition --- packages/router-core/src/new-process-route-tree.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index c054955f8b6..995332a2784 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -37,7 +37,7 @@ type ParsedSegment = Uint16Array & { /** * Populates the `output` array with the parsed representation of the given `segment` string. - * + * * Usage: * ```ts * let output @@ -47,7 +47,7 @@ type ParsedSegment = Uint16Array & { * const end = output[5] * cursor = end + 1 * ``` - * + * * `output` is stored outside to avoid allocations during repeated calls. It doesn't need to be typed * or initialized, it will be done automatically. */ @@ -857,11 +857,13 @@ function getNodeMatch( // In fuzzy mode, track the best partial match we've found so far if ( fuzzy && - node.route && - (!node.isIndex || node.notFound) && + node.notFound && (!bestFuzzy || - index > bestFuzzy.index || - (index === bestFuzzy.index && depth > bestFuzzy.depth)) + statics > bestFuzzy.statics || + (statics === bestFuzzy.statics && + (dynamics > bestFuzzy.dynamics || + (dynamics === bestFuzzy.dynamics && + optionals > bestFuzzy.optionals)))) ) { bestFuzzy = frame } From 4d178e5f6c4caaacc2353566f24a887acc0622ed Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 15 Nov 2025 11:13:13 +0100 Subject: [PATCH 106/109] more tests --- .../tests/new-process-route-tree.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 39af51d7566..51d4644fb3b 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -126,6 +126,24 @@ describe('findRouteMatch', () => { }) }) + describe('root matches with root index', () => { + it('root index wins over root optional', () => { + const tree = makeTree(['/', '/{-$id}']) + const res = findRouteMatch('/', tree) + expect(res?.route.id).toBe('/') + }) + it('root index wins over root wildcard', () => { + const tree = makeTree(['/', '/$']) + const res = findRouteMatch('/', tree) + expect(res?.route.id).toBe('/') + }) + it('root index wins over root dynamic', () => { + const tree = makeTree(['/', '/$id']) + const res = findRouteMatch('/', tree) + expect(res?.route.id).toBe('/') + }) + }) + describe('edge-case variations', () => { it('/static/optional/static vs /static/dynamic/static', () => { const tree = makeTree(['/a/{-$b}/c', '/a/$b/c']) From 7bbd42207113ed5633aec9848df209f47d4b39c6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 15 Nov 2025 11:43:11 +0100 Subject: [PATCH 107/109] decodeURIComponent and tests --- .../router-core/src/new-process-route-tree.ts | 16 ++--- .../tests/new-process-route-tree.test.ts | 58 +++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 995332a2784..40b77a67389 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -751,10 +751,11 @@ function extractParams( preLength + 2, nodePart.length - sufLength - 1, ) - params[name] = part!.substring(preLength, part!.length - sufLength) + const value = part!.substring(preLength, part!.length - sufLength) + params[name] = decodeURIComponent(value) } else { const name = nodePart.substring(1) - params[name] = part! + params[name] = decodeURIComponent(part!) } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { if (leaf.skipped & (1 << nodeIndex)) { @@ -773,16 +774,17 @@ function extractParams( node.suffix || node.prefix ? part!.substring(preLength, part!.length - sufLength) : part - if (value?.length) params[name] = value + if (value?.length) params[name] = decodeURIComponent(value) } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node - const rest = path.substring( + const value = path.substring( currentPathIndex + (n.prefix?.length ?? 0), path.length - (n.suffix?.length ?? 0), ) + const splat = decodeURIComponent(value) // TODO: Deprecate * - params['*'] = rest - params._splat = rest + params['*'] = splat + params._splat = splat break } } @@ -1045,7 +1047,7 @@ function getNodeMatch( return { node: bestFuzzy.node, skipped: bestFuzzy.skipped, - '**': splat, + '**': decodeURIComponent(splat), } } diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 51d4644fb3b..b8f49ea4c3a 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -647,6 +647,64 @@ describe('findRouteMatch', () => { expect(actualMatch?.route.id).toBe('/dashboard/') }) }) + + describe('param extraction', () => { + describe('URI decoding', () => { + const URISyntaxCharacters = [ + [';', '%3B'], + [',', '%2C'], + ['/', '%2F'], + ['?', '%3F'], + [':', '%3A'], + ['@', '%40'], + ['&', '%26'], + ['=', '%3D'], + ['+', '%2B'], + ['$', '%24'], + ['#', '%23'], + ['\\', '%5C'], + ['%', '%25'], + ] as const + it.each(URISyntaxCharacters)( + 'decodes %s in dynamic params', + (char, encoded) => { + const tree = makeTree([`/a/$id`]) + const result = findRouteMatch(`/a/${encoded}`, tree) + expect(result?.params).toEqual({ id: char }) + }, + ) + it.each(URISyntaxCharacters)( + 'decodes %s in optional params', + (char, encoded) => { + const tree = makeTree([`/a/{-$id}`]) + const result = findRouteMatch(`/a/${encoded}`, tree) + expect(result?.params).toEqual({ id: char }) + }, + ) + it.each(URISyntaxCharacters)( + 'decodes %s in wildcard params', + (char, encoded) => { + const tree = makeTree([`/a/$`]) + const result = findRouteMatch(`/a/${encoded}`, tree) + expect(result?.params).toEqual({ '*': char, _splat: char }) + }, + ) + it('wildcard splat supports multiple URI encoded characters in multiple URL segments', () => { + const tree = makeTree([`/a/$`]) + const path = URISyntaxCharacters.map(([, encoded]) => encoded).join('/') + const decoded = URISyntaxCharacters.map(([char]) => char).join('/') + const result = findRouteMatch(`/a/${path}`, tree) + expect(result?.params).toEqual({ '*': decoded, _splat: decoded }) + }) + it('fuzzy splat supports multiple URI encoded characters in multiple URL segments', () => { + const tree = makeTree(['/a']) + const path = URISyntaxCharacters.map(([, encoded]) => encoded).join('/') + const decoded = URISyntaxCharacters.map(([char]) => char).join('/') + const result = findRouteMatch(`/a/${path}`, tree, true) + expect(result?.params).toEqual({ '**': decoded }) + }) + }) + }) }) describe.todo('processRouteMasks', () => { From f952c876b1698cb13c16a2be358f1e8d7344f1ac Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 15 Nov 2025 12:21:22 +0100 Subject: [PATCH 108/109] unit test cleanup --- .../tests/new-process-route-tree.test.ts | 5 --- .../tests/optional-path-params-clean.test.ts | 18 ++++----- .../tests/optional-path-params.test.ts | 37 +++++++++---------- packages/router-core/tests/path.test.ts | 23 +++++------- 4 files changed, 36 insertions(+), 47 deletions(-) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index b8f49ea4c3a..9b9563d98ac 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -2,11 +2,6 @@ import { describe, expect, it } from 'vitest' import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree' import type { AnyRoute, RouteMask } from '../src' -// import { createLRUCache } from '../src/lru-cache' -// import { processRouteTree as oldProcessRouteTree } from './old-process-route-tree' -// import { matchPathname } from './old-path' -// import big from '../src/Untitled-4.json' - function makeTree(routes: Array) { return processRouteTree({ id: '__root__', diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index c73d4304109..d8c1411e96b 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'vitest' import { interpolatePath } from '../src/path' import { + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PATHNAME, findSingleMatch, parseSegment, processRouteTree, - SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PATHNAME, - type SegmentKind, } from '../src/new-process-route-tree' +import type { SegmentKind } from '../src/new-process-route-tree' describe('Optional Path Parameters - Clean Comprehensive Tests', () => { describe('Optional Dynamic Parameters {-$param}', () => { @@ -23,18 +23,18 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { const parsePathname = (to: string | undefined) => { let cursor = 0 - const data = new Uint16Array(6) + let data const path = to ?? '' const segments: Array = [] while (cursor < path.length) { const start = cursor - parseSegment(path, start, data) - const end = data[5]! + data = parseSegment(path, start, data) + const end = data[5] cursor = end + 1 - const type = data[0] as SegmentKind - const value = path.substring(data[2]!, data[3]) + const type = data[0] + const value = path.substring(data[2], data[3]) const prefix = path.substring(start, data[1]) - const suffix = path.substring(data[4]!, end) + const suffix = path.substring(data[4], end) const segment: PathSegment = { type, value, diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index f59faba5f37..79a368eb520 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -1,47 +1,44 @@ import { describe, expect, it } from 'vitest' import { interpolatePath } from '../src/path' import { - findSingleMatch, - parseSegment, - processRouteTree, SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, - type SegmentKind, + findSingleMatch, + parseSegment, + processRouteTree, } from '../src/new-process-route-tree' +import type { SegmentKind } from '../src/new-process-route-tree' describe('Optional Path Parameters', () => { + type PathSegment = { + type: SegmentKind + value: string + prefixSegment?: string + suffixSegment?: string + } type ParsePathnameTestScheme = Array<{ name: string to: string | undefined - expected: Array + expected: Array }> describe('parsePathname with optional params', () => { - type PathSegment = { - type: SegmentKind - value: string - prefixSegment?: string - suffixSegment?: string - // Indicates if there is a static segment after this required/optional param - hasStaticAfter?: boolean - } - const parsePathname = (to: string | undefined) => { let cursor = 0 - const data = new Uint16Array(6) + let data const path = to ?? '' const segments: Array = [] while (cursor < path.length) { const start = cursor - parseSegment(path, start, data) - const end = data[5]! + data = parseSegment(path, start, data) + const end = data[5] cursor = end + 1 - const type = data[0] as SegmentKind - const value = path.substring(data[2]!, data[3]) + const type = data[0] + const value = path.substring(data[2], data[3]) const prefix = path.substring(start, data[1]) - const suffix = path.substring(data[4]!, end) + const suffix = path.substring(data[4], end) const segment: PathSegment = { type, value, diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 733cc43f4f9..6503eb0b755 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -7,15 +7,14 @@ import { trimPathLeft, } from '../src/path' import { - findSingleMatch, - parseSegment, - processRouteTree, - SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, - type SegmentKind, + findSingleMatch, + parseSegment, + processRouteTree, } from '../src/new-process-route-tree' +import type { SegmentKind } from '../src/new-process-route-tree' describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( 'removeTrailingSlash with basepath $basepath', @@ -823,24 +822,22 @@ describe('parsePathname', () => { value: string prefixSegment?: string suffixSegment?: string - // Indicates if there is a static segment after this required/optional param - hasStaticAfter?: boolean } const parsePathname = (to: string | undefined) => { let cursor = 0 - const data = new Uint16Array(6) + let data const path = to ?? '' const segments: Array = [] while (cursor < path.length) { const start = cursor - parseSegment(path, start, data) - const end = data[5]! + data = parseSegment(path, start, data) + const end = data[5] cursor = end + 1 - const type = data[0] as SegmentKind - const value = path.substring(data[2]!, data[3]) + const type = data[0] + const value = path.substring(data[2], data[3]) const prefix = path.substring(start, data[1]) - const suffix = path.substring(data[4]!, end) + const suffix = path.substring(data[4], end) const segment: PathSegment = { type, value, From b6570e3c3f3b9fd7cff1ecf2ff4d3f3757987541 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 15 Nov 2025 13:24:50 +0100 Subject: [PATCH 109/109] misc cleanup of main file --- .../router-core/src/new-process-route-tree.ts | 103 +++++++----------- 1 file changed, 42 insertions(+), 61 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 40b77a67389..e10be41c84d 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -368,6 +368,9 @@ function sortDynamic( if (!a.suffix && b.suffix) return 1 if (a.caseSensitive && !b.caseSensitive) return -1 if (!a.caseSensitive && b.caseSensitive) return 1 + + // we don't need a tiebreaker here + // at this point the 2 nodes cannot conflict during matching return 0 } @@ -507,20 +510,6 @@ type SegmentNode = { notFound: T | null } -// function intoRouteLike(routeTree, parent) { -// const route = { -// id: routeTree.id, -// fullPath: routeTree.fullPath, -// path: routeTree.path, -// isRoot: routeTree.isRoot, -// options: routeTree.options && 'caseSensitive' in routeTree.options ? { caseSensitive: routeTree.options.caseSensitive } : undefined, -// } -// if (routeTree.children) { -// route.children = routeTree.children.map(child => intoRouteLike(child, route)) -// } -// return route -// } - type RouteLike = { path?: string // relative path from the parent, children?: Array // child routes, @@ -774,7 +763,7 @@ function extractParams( node.suffix || node.prefix ? part!.substring(preLength, part!.length - sufLength) : part - if (value?.length) params[name] = decodeURIComponent(value) + if (value) params[name] = decodeURIComponent(value) } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node const value = path.substring( @@ -800,6 +789,24 @@ function buildBranch(node: AnySegmentNode) { return list } +type MatchStackFrame = { + node: AnySegmentNode + /** index of the segment of path */ + index: number + /** how many nodes between `node` and the root of the segment tree */ + depth: number + /** + * Bitmask of skipped optional segments. + * + * This is a very performant way of storing an "array of booleans", but it means beyond 32 segments we can't track skipped optionals. + * If we really really need to support more than 32 segments we can switch to using a `BigInt` here. It's about 2x slower in worst case scenarios. + */ + skipped: number + statics: number + dynamics: number + optionals: number +} + function getNodeMatch( path: string, parts: Array, @@ -810,23 +817,7 @@ function getNodeMatch( const pathIsIndex = trailingSlash && path !== '/' const partsLength = parts.length - (trailingSlash ? 1 : 0) - type Frame = { - node: AnySegmentNode - /** index of the segment of path */ - index: number - /** how many nodes between `node` and the root of the segment tree */ - depth: number - /** - * Bitmask of skipped optional segments. - * - * This is a very performant way of storing an "array of booleans", but it means beyond 32 segments we can't track skipped optionals. - * If we really really need to support more than 32 segments we can switch to using a `BigInt` here. It's about 2x slower in worst case scenarios. - */ - skipped: number - statics: number - dynamics: number - optionals: number - } + type Frame = MatchStackFrame // use a stack to explore all possible paths (params cause branching) // iterate "backwards" (low priority first) so that we can push() each candidate, and pop() the highest priority candidate first @@ -839,8 +830,8 @@ function getNodeMatch( { node: segmentTree, index: 1, - depth: 1, skipped: 0, + depth: 1, statics: 1, dynamics: 0, optionals: 0, @@ -857,30 +848,14 @@ function getNodeMatch( let { node, index, skipped, depth, statics, dynamics, optionals } = frame // In fuzzy mode, track the best partial match we've found so far - if ( - fuzzy && - node.notFound && - (!bestFuzzy || - statics > bestFuzzy.statics || - (statics === bestFuzzy.statics && - (dynamics > bestFuzzy.dynamics || - (dynamics === bestFuzzy.dynamics && - optionals > bestFuzzy.optionals)))) - ) { + if (fuzzy && node.notFound && isFrameMoreSpecific(bestFuzzy, frame)) { bestFuzzy = frame } const isBeyondPath = index === partsLength if (isBeyondPath) { if (node.route && (!pathIsIndex || node.isIndex)) { - if ( - !bestMatch || - statics > bestMatch.statics || - (statics === bestMatch.statics && - (dynamics > bestMatch.dynamics || - (dynamics === bestMatch.dynamics && - optionals > bestMatch.optionals))) - ) { + if (isFrameMoreSpecific(bestMatch, frame)) { bestMatch = frame } @@ -895,16 +870,7 @@ function getNodeMatch( let lowerPart: string // 5. Try wildcard match - if ( - node.wildcard && - (!wildcardMatch || - statics > wildcardMatch.statics || - (statics === wildcardMatch.statics && - dynamics > wildcardMatch.dynamics) || - (statics === wildcardMatch.statics && - dynamics === wildcardMatch.dynamics && - optionals > wildcardMatch.optionals)) - ) { + if (node.wildcard && isFrameMoreSpecific(wildcardMatch, frame)) { for (const segment of node.wildcard) { const { prefix, suffix } = segment if (prefix) { @@ -1053,3 +1019,18 @@ function getNodeMatch( return null } + +function isFrameMoreSpecific( + // the stack frame previously saved as "best match" + prev: MatchStackFrame | null, + // the candidate stack frame + next: MatchStackFrame, +): boolean { + if (!prev) return true + return ( + next.statics > prev.statics || + (next.statics === prev.statics && + (next.dynamics > prev.dynamics || + (next.dynamics === prev.dynamics && next.optionals > prev.optionals))) + ) +}