Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 93 additions & 25 deletions packages/router-core/src/new-process-route-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ function parseSegments<TRouteLike extends RouteLike>(
const path = route.fullPath ?? route.from
const length = path.length
const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive
const parse = route.options?.params?.parse ?? null
while (cursor < length) {
const segment = parseSegment(path, cursor, data)
let nextNode: AnySegmentNode<TRouteLike>
Expand Down Expand Up @@ -232,12 +233,15 @@ function parseSegments<TRouteLike extends RouteLike>(
: actuallyCaseSensitive
? suffix_raw
: suffix_raw.toLowerCase()
const existingNode = node.dynamic?.find(
(s) =>
s.caseSensitive === actuallyCaseSensitive &&
s.prefix === prefix &&
s.suffix === suffix,
)
const existingNode =
!parse &&
node.dynamic?.find(
(s) =>
!s.parse &&
s.caseSensitive === actuallyCaseSensitive &&
s.prefix === prefix &&
s.suffix === suffix,
)
if (existingNode) {
nextNode = existingNode
} else {
Expand Down Expand Up @@ -271,12 +275,15 @@ function parseSegments<TRouteLike extends RouteLike>(
: actuallyCaseSensitive
? suffix_raw
: suffix_raw.toLowerCase()
const existingNode = node.optional?.find(
(s) =>
s.caseSensitive === actuallyCaseSensitive &&
s.prefix === prefix &&
s.suffix === suffix,
)
const existingNode =
!parse &&
node.optional?.find(
(s) =>
!s.parse &&
s.caseSensitive === actuallyCaseSensitive &&
s.prefix === prefix &&
s.suffix === suffix,
)
if (existingNode) {
nextNode = existingNode
} else {
Expand Down Expand Up @@ -326,6 +333,7 @@ function parseSegments<TRouteLike extends RouteLike>(
}
node = nextNode
}
node.parse = parse
if ((route.path || !route.children) && !route.isRoot) {
const isIndex = path.endsWith('/')
// we cannot fuzzy match an index route,
Expand All @@ -351,9 +359,21 @@ function parseSegments<TRouteLike extends RouteLike>(
}

function sortDynamic(
a: { prefix?: string; suffix?: string; caseSensitive: boolean },
b: { prefix?: string; suffix?: string; caseSensitive: boolean },
a: {
prefix?: string
suffix?: string
caseSensitive: boolean
parse: null | ((params: Record<string, string>) => any)
},
b: {
prefix?: string
suffix?: string
caseSensitive: boolean
parse: null | ((params: Record<string, string>) => any)
},
) {
if (a.parse && !b.parse) return -1
if (!a.parse && b.parse) return 1
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
Expand Down Expand Up @@ -421,6 +441,7 @@ function createStaticNode<T extends RouteLike>(
parent: null,
isIndex: false,
notFound: null,
parse: null,
}
}

Expand Down Expand Up @@ -451,6 +472,7 @@ function createDynamicNode<T extends RouteLike>(
parent: null,
isIndex: false,
notFound: null,
parse: null,
caseSensitive,
prefix,
suffix,
Expand Down Expand Up @@ -508,6 +530,9 @@ type SegmentNode<T extends RouteLike> = {

/** Same as `route`, but only present if both an "index route" and a "layout route" exist at this path */
notFound: T | null

/** route.options.params.parse function, set on the last node of the route */
parse: null | ((params: Record<string, string>) => any)
}

type RouteLike = {
Expand All @@ -517,6 +542,9 @@ type RouteLike = {
isRoot?: boolean
options?: {
caseSensitive?: boolean
params?: {
parse?: (params: Record<string, string>) => any
}
}
} &
// router tree
Expand Down Expand Up @@ -706,7 +734,7 @@ function findMatch<T extends RouteLike>(
const parts = path.split('/')
const leaf = getNodeMatch(path, parts, segmentTree, fuzzy)
if (!leaf) return null
const params = extractParams(path, parts, leaf)
const [params] = extractParams(path, parts, leaf)
const isFuzzyMatch = '**' in leaf
if (isFuzzyMatch) params['**'] = leaf['**']
const route = isFuzzyMatch
Expand All @@ -721,16 +749,23 @@ function findMatch<T extends RouteLike>(
function extractParams<T extends RouteLike>(
path: string,
parts: Array<string>,
leaf: { node: AnySegmentNode<T>; skipped: number },
) {
leaf: {
node: AnySegmentNode<T>
skipped: number
extract?: { part: number; node: number; path: number }
params?: Record<string, string>
},
): [
params: Record<string, string>,
state: { part: number; node: number; path: number },
] {
const list = buildBranch(leaf.node)
let nodeParts: Array<string> | null = null
const params: Record<string, string> = {}
for (
let partIndex = 0, nodeIndex = 0, pathIndex = 0;
nodeIndex < list.length;
partIndex++, nodeIndex++, pathIndex++
) {
let partIndex = leaf.extract?.part ?? 0
let nodeIndex = leaf.extract?.node ?? 0
let pathIndex = leaf.extract?.path ?? 0
for (; nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++) {
const node = list[nodeIndex]!
const part = parts[partIndex]
const currentPathIndex = pathIndex
Expand Down Expand Up @@ -785,7 +820,8 @@ function extractParams<T extends RouteLike>(
break
}
}
return params
if (leaf.params) Object.assign(params, leaf.params)
return [params, { part: partIndex, node: nodeIndex, path: pathIndex }]
}

function buildRouteBranch<T extends RouteLike>(route: T) {
Expand Down Expand Up @@ -823,6 +859,10 @@ type MatchStackFrame<T extends RouteLike> = {
statics: number
dynamics: number
optionals: number
/** intermediary state for param extraction */
extract?: { part: number; node: number; path: number }
/** intermediary params from param extraction */
params?: Record<string, string>
}

function getNodeMatch<T extends RouteLike>(
Expand Down Expand Up @@ -862,8 +902,22 @@ function getNodeMatch<T extends RouteLike>(

while (stack.length) {
const frame = stack.pop()!
// eslint-disable-next-line prefer-const
let { node, index, skipped, depth, statics, dynamics, optionals } = frame
const { node, index, skipped, depth, statics, dynamics, optionals } = frame
let { extract, params } = frame

if (node.parse) {
// if there is a parse function, we need to extract the params that we have so far and run it.
// if this function throws, we cannot consider this a valid match
try {
;[params, extract] = extractParams(path, parts, frame)
// TODO: can we store the parsed value somewhere to avoid re-parsing later?
node.parse(params)
frame.extract = extract
frame.params = params
} catch {
continue
}
}
Comment on lines +908 to +920
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Parse function return value is ignored.

The parse function is typed to return any (line 546), but the return value is discarded on line 914. This creates ambiguity about whether parse should validate (throw on error) or transform (return modified params). The current implementation only supports validation.

Consider either:

  • Documenting that parse is validation-only and updating the return type to void
  • Or storing and using the return value to support param transformation

The TODO on line 913 hints at storing results, which would enable transformation. If transformation isn't needed, clarify this in the type signature.


🌐 Web query:

Does TanStack Router support parameter transformation or only validation in parse functions?

💡 Result:

Short answer: parse functions can transform (coerce/convert) parameters — not just validate them. The router calls your params.parse with the raw string values and uses whatever typed/transformed values you return (stringify is the reverse). That said, there are a few reported type-inference/coercion issues in some versions/adapters (see the Zod issue). [1][2]

Sources:

  • TanStack Router docs — custom parameter parsing (params.parse / params.stringify) and search‑param parsing/validation. [1]
  • GitHub issue reporting type/coercion/inference problems with validators/adapters (example with Zod). [2]

Parse function return value must be captured and used for parameter transformation.

The web search confirms that TanStack Router parse functions can transform (coerce/convert) parameters, and the router uses whatever typed/transformed values the parse function returns. However, the PR code on line 914 calls node.parse(params) but discards the return value, preventing parameter transformation.

Fix required at line 908-920: Capture the parse return value and use it to update params:

try {
  ;[params, extract] = extractParams(path, parts, frame)
  params = node.parse(params) ?? params  // Capture and use transformed value
  frame.extract = extract
  frame.params = params
} catch {
  continue
}

Without this fix, routes with parse functions cannot perform parameter type coercion/conversion.

🤖 Prompt for AI Agents
In packages/router-core/src/new-process-route-tree.ts around lines 908 to 920,
the parse function's return value is ignored so transformed/coerced params are
not applied; update the try block to capture node.parse's return and assign it
back to params (using a nullish fallback to the original params if parse returns
undefined), then set frame.extract and frame.params to the updated values; keep
the existing error handling (catch/continue) intact.


// In fuzzy mode, track the best partial match we've found so far
if (fuzzy && node.notFound && isFrameMoreSpecific(bestFuzzy, frame)) {
Expand Down Expand Up @@ -913,6 +967,8 @@ function getNodeMatch<T extends RouteLike>(
statics,
dynamics,
optionals,
extract,
params,
}
break
}
Expand All @@ -933,6 +989,8 @@ function getNodeMatch<T extends RouteLike>(
statics,
dynamics,
optionals,
extract,
params,
}) // enqueue skipping the optional
}
if (!isBeyondPath) {
Expand All @@ -954,6 +1012,8 @@ function getNodeMatch<T extends RouteLike>(
statics,
dynamics,
optionals: optionals + 1,
extract,
params,
})
}
}
Expand All @@ -979,6 +1039,8 @@ function getNodeMatch<T extends RouteLike>(
statics,
dynamics: dynamics + 1,
optionals,
extract,
params,
})
}
}
Expand All @@ -997,6 +1059,8 @@ function getNodeMatch<T extends RouteLike>(
statics: statics + 1,
dynamics,
optionals,
extract,
params,
})
}
}
Expand All @@ -1013,6 +1077,8 @@ function getNodeMatch<T extends RouteLike>(
statics: statics + 1,
dynamics,
optionals,
extract,
params,
})
}
}
Expand All @@ -1031,6 +1097,8 @@ function getNodeMatch<T extends RouteLike>(
return {
node: bestFuzzy.node,
skipped: bestFuzzy.skipped,
extract: bestFuzzy.extract,
params: bestFuzzy.params,
'**': decodeURIComponent(splat),
}
}
Expand Down
Loading