From 64388c19b4d9e1b32390711227a2920f86af37a2 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 21 Jan 2026 17:12:17 +0100 Subject: [PATCH 1/4] fix(router-core): pathless nodes do not consume path segments during param extraction --- .../router-core/src/new-process-route-tree.ts | 42 +++++++++++++++---- .../tests/new-process-route-tree.test.ts | 34 +++++++++++++++ 2 files changed, 68 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 2dcaceebd35..0acaca518ef 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -10,6 +10,23 @@ export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 const SEGMENT_TYPE_INDEX = 4 const SEGMENT_TYPE_PATHLESS = 5 // only used in matching to represent pathless routes that need to carry more information +function nodeTypeToString(kind: ExtendedSegmentKind) { + switch (kind) { + case SEGMENT_TYPE_PATHNAME: + return 'PATHNAME' + case SEGMENT_TYPE_PARAM: + return 'PARAM' + case SEGMENT_TYPE_WILDCARD: + return 'WILDCARD' + case SEGMENT_TYPE_OPTIONAL_PARAM: + return 'OPTIONAL_PARAM' + case SEGMENT_TYPE_INDEX: + return 'INDEX' + case SEGMENT_TYPE_PATHLESS: + return 'PATHLESS' + } +} + /** * All the kinds of segments that can be present in a route path. */ @@ -846,6 +863,8 @@ function findMatch( } } +type ParamExtractionState = { part: number; node: number; path: number } + /** * This function is "resumable": * - the `leaf` input can contain `extract` and `rawParams` properties from a previous `extractParams` call @@ -859,27 +878,34 @@ function extractParams( leaf: { node: AnySegmentNode skipped: number - extract?: { part: number; node: number; path: number } + extract?: ParamExtractionState rawParams?: Record }, -): [ - rawParams: Record, - state: { part: number; node: number; path: number }, -] { +): [rawParams: Record, state: ParamExtractionState] { const list = buildBranch(leaf.node) let nodeParts: Array | null = null const rawParams: Record = {} + /** which segment of the path we're currently processing */ let partIndex = leaf.extract?.part ?? 0 + /** which node of the route tree branch we're currently processing */ let nodeIndex = leaf.extract?.node ?? 0 + /** index of the 1st character of the segment we're processing in the path string */ let pathIndex = leaf.extract?.path ?? 0 for (; nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++) { const node = list[nodeIndex]! + if (node.kind === SEGMENT_TYPE_INDEX) continue + if (node.kind === SEGMENT_TYPE_PATHLESS) { + // pathless nodes do not consume a path segment + partIndex-- + pathIndex-- + continue + } const part = parts[partIndex] const currentPathIndex = pathIndex if (part) pathIndex += part.length if (node.kind === SEGMENT_TYPE_PARAM) { nodeParts ??= leaf.node.fullPath.split('/') - const nodePart = nodeParts[nodeIndex]! + const nodePart = nodeParts[partIndex]! 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 // '{' @@ -903,7 +929,7 @@ function extractParams( continue } nodeParts ??= leaf.node.fullPath.split('/') - const nodePart = nodeParts[nodeIndex]! + const nodePart = nodeParts[partIndex]! const preLength = node.prefix?.length ?? 0 const sufLength = node.suffix?.length ?? 0 const name = nodePart.substring( @@ -968,7 +994,7 @@ type MatchStackFrame = { dynamics: number optionals: number /** intermediary state for param extraction */ - extract?: { part: number; node: number; path: number } + extract?: ParamExtractionState /** intermediary params from param extraction */ rawParams?: Record parsedParams?: Record 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 6c2007cd0ec..5bcb11e9218 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -1109,6 +1109,40 @@ describe('findRouteMatch', () => { } `) }) + it('does not consume a path segment during param extraction', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$foo/_layout', + fullPath: '/$foo', + path: '$foo', + options: { + params: { + parse: (params: Record) => params, + }, + // force the creation of a pathless node + skipRouteOnParseError: { params: true }, + }, + children: [ + { + id: '/$foo/_layout/$bar', + fullPath: '/$foo/$bar', + path: '$bar', + options: {}, + }, + ], + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/abc/def', processedTree) + expect(result?.route.id).toBe('/$foo/_layout/$bar') + expect(result?.rawParams).toEqual({ foo: 'abc', bar: 'def' }) + }) }) }) From ec8b8bf9e548cfaac66a1af9001b25a6792bd2d8 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 21 Jan 2026 17:16:34 +0100 Subject: [PATCH 2/4] woops, didnt mean to commit this --- .../router-core/src/new-process-route-tree.ts | 17 ----------------- 1 file changed, 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 0acaca518ef..5a1fe6135c0 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -10,23 +10,6 @@ export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 const SEGMENT_TYPE_INDEX = 4 const SEGMENT_TYPE_PATHLESS = 5 // only used in matching to represent pathless routes that need to carry more information -function nodeTypeToString(kind: ExtendedSegmentKind) { - switch (kind) { - case SEGMENT_TYPE_PATHNAME: - return 'PATHNAME' - case SEGMENT_TYPE_PARAM: - return 'PARAM' - case SEGMENT_TYPE_WILDCARD: - return 'WILDCARD' - case SEGMENT_TYPE_OPTIONAL_PARAM: - return 'OPTIONAL_PARAM' - case SEGMENT_TYPE_INDEX: - return 'INDEX' - case SEGMENT_TYPE_PATHLESS: - return 'PATHLESS' - } -} - /** * All the kinds of segments that can be present in a route path. */ From a8e27f77c47803c981b54316c8a444e1ad64d983 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 21 Jan 2026 18:13:12 +0100 Subject: [PATCH 3/4] better tests, and better solution --- .../router-core/src/new-process-route-tree.ts | 30 +++++++------ .../tests/new-process-route-tree.test.ts | 43 +++++++++++++++++++ 2 files changed, 60 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 5a1fe6135c0..e514b5d5730 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -548,16 +548,16 @@ function createDynamicNode( type StaticSegmentNode = SegmentNode & { kind: - | typeof SEGMENT_TYPE_PATHNAME - | typeof SEGMENT_TYPE_PATHLESS - | typeof SEGMENT_TYPE_INDEX + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PATHLESS + | typeof SEGMENT_TYPE_INDEX } type DynamicSegmentNode = SegmentNode & { kind: - | typeof SEGMENT_TYPE_PARAM - | typeof SEGMENT_TYPE_WILDCARD - | typeof SEGMENT_TYPE_OPTIONAL_PARAM + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM prefix?: string suffix?: string caseSensitive: boolean @@ -846,7 +846,7 @@ function findMatch( } } -type ParamExtractionState = { part: number; node: number; path: number } +type ParamExtractionState = { part: number; node: number; path: number; segment: number } /** * This function is "resumable": @@ -874,11 +874,15 @@ function extractParams( let nodeIndex = leaf.extract?.node ?? 0 /** index of the 1st character of the segment we're processing in the path string */ let pathIndex = leaf.extract?.path ?? 0 - for (; nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++) { + /** which fullPath segment we're currently processing */ + let segmentCount = leaf.extract?.segment ?? 0 + for (; nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++, segmentCount++) { const node = list[nodeIndex]! - if (node.kind === SEGMENT_TYPE_INDEX) continue + // index nodes are terminating nodes, nothing to extract, just leave + if (node.kind === SEGMENT_TYPE_INDEX) break + // pathless nodes do not consume a path segment if (node.kind === SEGMENT_TYPE_PATHLESS) { - // pathless nodes do not consume a path segment + segmentCount-- partIndex-- pathIndex-- continue @@ -888,7 +892,7 @@ function extractParams( if (part) pathIndex += part.length if (node.kind === SEGMENT_TYPE_PARAM) { nodeParts ??= leaf.node.fullPath.split('/') - const nodePart = nodeParts[partIndex]! + const nodePart = nodeParts[segmentCount]! 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 // '{' @@ -912,7 +916,7 @@ function extractParams( continue } nodeParts ??= leaf.node.fullPath.split('/') - const nodePart = nodeParts[partIndex]! + const nodePart = nodeParts[segmentCount]! const preLength = node.prefix?.length ?? 0 const sufLength = node.suffix?.length ?? 0 const name = nodePart.substring( @@ -938,7 +942,7 @@ function extractParams( } } if (leaf.rawParams) Object.assign(rawParams, leaf.rawParams) - return [rawParams, { part: partIndex, node: nodeIndex, path: pathIndex }] + return [rawParams, { part: partIndex, node: nodeIndex, path: pathIndex, segment: segmentCount }] } function buildRouteBranch(route: T) { 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 5bcb11e9218..936df988c14 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -1143,6 +1143,49 @@ describe('findRouteMatch', () => { expect(result?.route.id).toBe('/$foo/_layout/$bar') expect(result?.rawParams).toEqual({ foo: 'abc', bar: 'def' }) }) + it('skipped optional uses the correct node index with pathless nodes', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/one_{-$foo}/_layout', + fullPath: '/one_{-$foo}', + path: 'one_{-$foo}', + options: { + params: { + parse: (params: Record) => params, + }, + // force the creation of a pathless node + skipRouteOnParseError: { params: true }, + }, + children: [ + { + id: '/one_{-$foo}/_layout/two_{-$bar}/baz', + fullPath: '/one_{-$foo}/two_{-$bar}/baz', + path: 'two_{-$bar}/baz', + options: {}, + }, + ], + }, + ], + } + const { processedTree } = processRouteTree(tree) + // match first, skip second + { + const result = findRouteMatch('/one_value/baz', processedTree) + expect(result?.route.id).toBe('/one_{-$foo}/_layout/two_{-$bar}/baz') + expect(result?.rawParams).toEqual({ foo: 'value' }) + } + // skip first, match second + { + const result = findRouteMatch('/two_value/baz', processedTree) + expect(result?.route.id).toBe('/one_{-$foo}/_layout/two_{-$bar}/baz') + expect(result?.rawParams).toEqual({ bar: 'value' }) + } + }) }) }) From 60da609fc402d912c31a26db1fd880799340d6de Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:14:30 +0000 Subject: [PATCH 4/4] ci: apply automated fixes --- .../router-core/src/new-process-route-tree.ts | 35 ++++++++++++++----- 1 file changed, 26 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 e514b5d5730..f6397275e80 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -548,16 +548,16 @@ function createDynamicNode( type StaticSegmentNode = SegmentNode & { kind: - | typeof SEGMENT_TYPE_PATHNAME - | typeof SEGMENT_TYPE_PATHLESS - | typeof SEGMENT_TYPE_INDEX + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PATHLESS + | typeof SEGMENT_TYPE_INDEX } type DynamicSegmentNode = SegmentNode & { kind: - | typeof SEGMENT_TYPE_PARAM - | typeof SEGMENT_TYPE_WILDCARD - | typeof SEGMENT_TYPE_OPTIONAL_PARAM + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM prefix?: string suffix?: string caseSensitive: boolean @@ -846,7 +846,12 @@ function findMatch( } } -type ParamExtractionState = { part: number; node: number; path: number; segment: number } +type ParamExtractionState = { + part: number + node: number + path: number + segment: number +} /** * This function is "resumable": @@ -876,7 +881,11 @@ function extractParams( let pathIndex = leaf.extract?.path ?? 0 /** which fullPath segment we're currently processing */ let segmentCount = leaf.extract?.segment ?? 0 - for (; nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++, segmentCount++) { + for ( + ; + nodeIndex < list.length; + partIndex++, nodeIndex++, pathIndex++, segmentCount++ + ) { const node = list[nodeIndex]! // index nodes are terminating nodes, nothing to extract, just leave if (node.kind === SEGMENT_TYPE_INDEX) break @@ -942,7 +951,15 @@ function extractParams( } } if (leaf.rawParams) Object.assign(rawParams, leaf.rawParams) - return [rawParams, { part: partIndex, node: nodeIndex, path: pathIndex, segment: segmentCount }] + return [ + rawParams, + { + part: partIndex, + node: nodeIndex, + path: pathIndex, + segment: segmentCount, + }, + ] } function buildRouteBranch(route: T) {