diff --git a/.changeset/variadic-path-params.md b/.changeset/variadic-path-params.md new file mode 100644 index 0000000000..2834be805d --- /dev/null +++ b/.changeset/variadic-path-params.md @@ -0,0 +1,7 @@ +--- +'@tanstack/router-core': minor +'@tanstack/router-generator': patch +'@tanstack/eslint-plugin-router': patch +--- + +Add non-terminal variadic path parameters: `{...$param}` matches zero or more URL segments in the middle of a path and binds them as `Array` (each segment decoded independently). Unlike the terminal `$` splat, routes can continue after a variadic segment, enabling paths where a variable-depth hierarchy sits between fixed parts of the URL (`/$bucket/{...$folders}/$file/versions/$versionId`). Matching is non-greedy (the variadic consumes as few segments as the rest of the pattern allows) and ranks below optional parameters and above the terminal wildcard. Consecutive variadic segments (and a variadic followed by a wildcard) must be separated by at least one static segment, since an unanchored boundary between two unbounded segments would leave the first structurally empty. Variadic segments cannot carry a prefix or suffix. A route path supports at most three variadic segments. diff --git a/docs/router/guide/path-params.md b/docs/router/guide/path-params.md index b1ae4f5d2f..7829db8d36 100644 --- a/docs/router/guide/path-params.md +++ b/docs/router/guide/path-params.md @@ -721,6 +721,97 @@ function PostsComponent() { +## Variadic Path Parameters + +A regular path param matches exactly one segment. A variadic path parameter, written `{...$paramName}`, matches **zero or more** segments and provides them back to you as an array. The path can keep going after it, so it also works between fixed parts of the URL: + +- `files/{...$path}/preview` +- `$bucket/{...$folders}/$file` + +Let's build a route for files that can be nested in arbitrarily deep folders: + + + +# React + +```tsx title="src/routes/$bucket/{...$folders}/$file.tsx" +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$bucket/{...$folders}/$file')({ + loader: ({ params }) => { + // params.folders is an Array + return fetchFile([params.bucket, ...params.folders, params.file]) + }, + component: FileComponent, +}) + +function FileComponent() { + const { bucket, folders, file } = Route.useParams() + return
{[bucket, ...folders, file].join(' / ')}
+} +``` + +# Solid + +```tsx title="src/routes/$bucket/{...$folders}/$file.tsx" +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/$bucket/{...$folders}/$file')({ + loader: ({ params }) => { + // params.folders is an Array + return fetchFile([params.bucket, ...params.folders, params.file]) + }, + component: FileComponent, +}) + +function FileComponent() { + const params = Route.useParams() + return ( +
+ {[params().bucket, ...params().folders, params().file].join(' / ')} +
+ ) +} +``` + + + +This one route matches `/media/kyoto.jpg`, `/media/photos/kyoto.jpg` and `/media/photos/2026/kyoto.jpg`, binding `folders` to `[]`, `['photos']` and `['photos', '2026']` respectively. The array is never `undefined` since a variadic that matched nothing gives you an empty array. Matching is non-greedy, so the variadic consumes as few segments as the rest of the path allows, and static, required or optional segments in the same position always win over it. Each matched segment is percent-decoded on its own, so an encoded `/` inside a folder name stays inside that folder's value. + +When navigating, pass an array for the variadic param. An empty (or omitted) array drops the segment entirely: + +```tsx +function Component() { + return ( +
+ {/* navigates to /media/photos/2026/kyoto.jpg */} + + Kyoto Photo + + + {/* navigates to /media/kyoto.jpg */} + + Kyoto Photo (root) + +
+ ) +} +``` + +A route path can contain up to three variadic segments, as long as consecutive ones are separated by at least one static segment; they resolve left to right, each consuming as little as possible. Prefixes and suffixes are not supported on variadic segments, and a variadic must be separated from a splat by at least one static segment. + +> 🧠 Use a variadic parameter when segments _between_ two known parts of the URL vary in depth. If you just want to capture "the rest of the URL" with no routes after it, a [splat route](../routing/routing-concepts.md#splat--catch-all-routes) (`$`) is simpler. + ## Internationalization (i18n) with Optional Path Parameters Optional path parameters are excellent for implementing internationalization (i18n) routing patterns. You can use prefix patterns to handle multiple languages while maintaining clean, SEO-friendly URLs. diff --git a/docs/router/routing/route-matching.md b/docs/router/routing/route-matching.md index 3c40de642f..59c79d3b0f 100644 --- a/docs/router/routing/route-matching.md +++ b/docs/router/routing/route-matching.md @@ -9,6 +9,8 @@ When TanStack Router processes your route tree, all of your routes are automatic - Index Route - Static Routes (most specific to least specific) - Dynamic Routes (longest to shortest) +- Optional Parameter Routes +- Variadic Parameter Routes - Splat/Wildcard Routes Consider the following pseudo route tree: diff --git a/docs/router/routing/routing-concepts.md b/docs/router/routing/routing-concepts.md index 88eebbd5f0..d8985da27e 100644 --- a/docs/router/routing/routing-concepts.md +++ b/docs/router/routing/routing-concepts.md @@ -325,6 +325,58 @@ This route matches `/posts`, `/posts/tech`, and `/posts/tech/hello-world`. > 🧠 Routes with optional parameters are ranked lower in priority than exact matches, ensuring that more specific routes like `/posts/featured` are matched before `/posts/{-$category}`. +## Variadic Path Parameters + +Variadic path parameters match **zero or more** URL segments in the middle of a path and bind them as an `Array`. They use the `{...$paramName}` syntax. + + + +# React + +```tsx title="src/routes/files.{...$path}.preview.tsx" +// `{...$path}` matches zero or more segments, so this route matches +// `/files/preview`, `/files/docs/preview` and `/files/a/b/c/preview` +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/files/{...$path}/preview')({ + component: PreviewComponent, +}) + +function PreviewComponent() { + const { path } = Route.useParams() + + // `path` is an Array: [] for `/files/preview`, + // ['a', 'b', 'c'] for `/files/a/b/c/preview` + return
Previewing {path.join('/') || 'the root folder'}
+} +``` + +# Solid + +```tsx title="src/routes/files.{...$path}.preview.tsx" +// `{...$path}` matches zero or more segments, so this route matches +// `/files/preview`, `/files/docs/preview` and `/files/a/b/c/preview` +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/files/{...$path}/preview')({ + component: PreviewComponent, +}) + +function PreviewComponent() { + const { path } = Route.useParams() + + // `path` is an Array: [] for `/files/preview`, + // ['a', 'b', 'c'] for `/files/a/b/c/preview` + return
Previewing {path().join('/') || 'the root folder'}
+} +``` + + + +A variadic segment consumes as few segments as the rest of the pattern allows, and each matched segment is percent-decoded independently. A route path may contain up to three variadic segments as long as consecutive ones are separated by at least one static segment. Prefixes and suffixes are not supported on variadic segments. See [Path Params](../guide/path-params.md#variadic-path-parameters) for details. + +> 🧠 Variadic parameter routes are ranked below dynamic and optional parameter routes and above splat routes: `/docs/$section/end` and `/docs/{-$section}/end` both win over `/docs/{...$rest}/end` for `/docs/guide/end`. + ## Layout Routes Layout routes are used to wrap child routes with additional components and logic. They are useful for: diff --git a/docs/start/framework/react/migrate-from-next-js.md b/docs/start/framework/react/migrate-from-next-js.md index 2f4d988449..1c9a7ad438 100644 --- a/docs/start/framework/react/migrate-from-next-js.md +++ b/docs/start/framework/react/migrate-from-next-js.md @@ -338,14 +338,15 @@ Now that you have migrated the basic structure of your Next.js application to Ta ### Routing Concepts -| Route Example | Next.js | TanStack Start | -| ------------------------------ | ---------------------------------- | ------------------------- | -| Root Layout | `src/app/layout.tsx` | `src/app/__root.tsx` | -| `/` (Home Page) | `src/app/page.tsx` | `src/app/index.tsx` | -| `/posts` (Static Route) | `src/app/posts/page.tsx` | `src/app/posts.tsx` | -| `/posts/[slug]` (Dynamic) | `src/app/posts/[slug]/page.tsx` | `src/app/posts/$slug.tsx` | -| `/posts/[...slug]` (Catch-All) | `src/app/posts/[...slug]/page.tsx` | `src/app/posts/$.tsx` | -| `/api/endpoint` (API Route) | `src/app/api/endpoint/route.ts` | `src/app/api/endpoint.ts` | +| Route Example | Next.js | TanStack Start | +| ----------------------------------------- | ------------------------------------ | ------------------------------ | +| Root Layout | `src/app/layout.tsx` | `src/app/__root.tsx` | +| `/` (Home Page) | `src/app/page.tsx` | `src/app/index.tsx` | +| `/posts` (Static Route) | `src/app/posts/page.tsx` | `src/app/posts.tsx` | +| `/posts/[slug]` (Dynamic) | `src/app/posts/[slug]/page.tsx` | `src/app/posts/$slug.tsx` | +| `/posts/[...slug]` (Catch-All) | `src/app/posts/[...slug]/page.tsx` | `src/app/posts/$.tsx` | +| `/posts/[[...slug]]` (Optional Catch-All) | `src/app/posts/[[...slug]]/page.tsx` | `src/app/posts.{...$slug}.tsx` | +| `/api/endpoint` (API Route) | `src/app/api/endpoint/route.ts` | `src/app/api/endpoint.ts` | Learn more about the [Routing Concepts](/router/latest/docs/framework/react/routing/routing-concepts). diff --git a/packages/eslint-plugin-router/src/__tests__/route-param-names.utils.test.ts b/packages/eslint-plugin-router/src/__tests__/route-param-names.utils.test.ts index 67d0488b99..bbe519c925 100644 --- a/packages/eslint-plugin-router/src/__tests__/route-param-names.utils.test.ts +++ b/packages/eslint-plugin-router/src/__tests__/route-param-names.utils.test.ts @@ -103,6 +103,28 @@ describe('extractParamsFromSegment', () => { }) }) + it('should extract variadic {...$param} format', () => { + const result = extractParamsFromSegment('{...$items}') + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + fullParam: '...$items', + paramName: 'items', + isOptional: false, + isValid: true, + }) + }) + + it('should mark invalid variadic param names', () => { + const result = extractParamsFromSegment('{...$123invalid}') + expect(result).toHaveLength(1) + expect(result[0]?.isValid).toBe(false) + expect(result[0]?.paramName).toBe('123invalid') + }) + + it('should skip a nameless variadic {...$}', () => { + expect(extractParamsFromSegment('{...$}')).toEqual([]) + }) + it('should mark invalid param names', () => { const result = extractParamsFromSegment('$123invalid') expect(result).toHaveLength(1) diff --git a/packages/eslint-plugin-router/src/rules/route-param-names/route-param-names.utils.ts b/packages/eslint-plugin-router/src/rules/route-param-names/route-param-names.utils.ts index 825c9eed96..359223e3e7 100644 --- a/packages/eslint-plugin-router/src/rules/route-param-names/route-param-names.utils.ts +++ b/packages/eslint-plugin-router/src/rules/route-param-names/route-param-names.utils.ts @@ -1,7 +1,7 @@ import { VALID_PARAM_NAME_REGEX } from './constants' export interface ExtractedParam { - /** The full param string including $ prefix (e.g., "$userId", "-$optional") */ + /** The full param string including $ prefix (e.g., "$userId", "-$optional", "...$items") */ fullParam: string /** The param name without $ prefix (e.g., "userId", "optional") */ paramName: string @@ -51,17 +51,17 @@ export function extractParamsFromSegment( return params } - // Pattern 2: Braces pattern {$paramName} or {-$paramName} with optional prefix/suffix - // Match patterns like: prefix{$param}suffix, {$param}, {-$param} - const bracePattern = /\{(-?\$)([^}]*)\}/g + // Pattern 2: Braces pattern {$paramName}, {-$paramName} or {...$paramName} + // Match patterns like: prefix{$param}suffix, {$param}, {-$param}, {...$param} + const bracePattern = /\{((?:-|\.\.\.)?\$)([^}]*)\}/g let match while ((match = bracePattern.exec(segment)) !== null) { - const prefix = match[1] // "$" or "-$" - const paramName = match[2] // The param name after $ or -$ + const prefix = match[1] // "$", "-$", or "...$" + const paramName = match[2] // The param name after $, -$, or ...$ if (!paramName) { - // This is a wildcard {$} or {-$}, skip + // This is a wildcard {$}, {-$}, or {...$}, skip continue } diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts index 55d5a79ce8..7b272ba6d0 100644 --- a/packages/router-core/src/link.ts +++ b/packages/router-core/src/link.ts @@ -34,36 +34,53 @@ export interface ParsePathParamsResult< in out TRequired, in out TOptional, in out TRest, + in out TVariadic = never, > { required: TRequired optional: TOptional rest: TRest + variadic: TVariadic } export type AnyParsePathParamsResult = ParsePathParamsResult< + string, string, string, string > export type ParsePathParamsBoundaryStart = - T extends `${infer TLeft}{-${infer TRight}` + T extends `${infer TLeft}{...${infer TRight}` ? ParsePathParamsResult< ParsePathParams['required'], | ParsePathParams['optional'] - | ParsePathParams['required'] | ParsePathParams['optional'], - ParsePathParams['rest'] + ParsePathParams['rest'], + | ParsePathParams['variadic'] + | ParsePathParams['required'] + | ParsePathParams['variadic'] > - : T extends `${infer TLeft}{${infer TRight}` + : T extends `${infer TLeft}{-${infer TRight}` ? ParsePathParamsResult< - | ParsePathParams['required'] - | ParsePathParams['required'], + ParsePathParams['required'], | ParsePathParams['optional'] + | ParsePathParams['required'] | ParsePathParams['optional'], - ParsePathParams['rest'] + ParsePathParams['rest'], + | ParsePathParams['variadic'] + | ParsePathParams['variadic'] > - : never + : T extends `${infer TLeft}{${infer TRight}` + ? ParsePathParamsResult< + | ParsePathParams['required'] + | ParsePathParams['required'], + | ParsePathParams['optional'] + | ParsePathParams['optional'], + ParsePathParams['rest'], + | ParsePathParams['variadic'] + | ParsePathParams['variadic'] + > + : never export type ParsePathParamsSymbol = T extends `${string}$${infer TRight}` @@ -73,12 +90,14 @@ export type ParsePathParamsSymbol = ? ParsePathParamsResult< ParsePathParams['required'], '_splat' | ParsePathParams['optional'], - ParsePathParams['rest'] + ParsePathParams['rest'], + ParsePathParams['variadic'] > : ParsePathParamsResult< TParam | ParsePathParams['required'], ParsePathParams['optional'], - ParsePathParams['rest'] + ParsePathParams['rest'], + ParsePathParams['variadic'] > : never : TRight extends '' @@ -93,7 +112,8 @@ export type ParsePathParamsBoundaryEnd = | ParsePathParams['required'], | ParsePathParams['optional'] | ParsePathParams['optional'], - ParsePathParams['rest'] + ParsePathParams['rest'], + ParsePathParams['variadic'] | ParsePathParams['variadic'] > : never @@ -104,7 +124,8 @@ export type ParsePathParamsEscapeStart = | ParsePathParams['required'], | ParsePathParams['optional'] | ParsePathParams['optional'], - ParsePathParams['rest'] + ParsePathParams['rest'], + ParsePathParams['variadic'] | ParsePathParams['variadic'] > : never diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 6978b071ce..1c03f8d4ca 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -7,9 +7,16 @@ 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 const SEGMENT_TYPE_VARIADIC = 6 const SEGMENT_TYPE_INDEX = 4 const SEGMENT_TYPE_PATHLESS = 5 // only used in matching to represent pathless routes that need to carry more information +// per-variadic consumption counts pack into 17-bit lanes of one float64-exact +// integer (3 lanes × 17 bits stays below the 2**53 integer limit) +const VARIADIC_MAX_PER_PATH = 3 +const VARIADIC_LANE_SIZE = 2 ** 17 +const VARIADIC_LANE_MAX = VARIADIC_LANE_SIZE - 1 + /** * All the kinds of segments that can be present in a route path. */ @@ -18,6 +25,7 @@ export type SegmentKind = | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM + | typeof SEGMENT_TYPE_VARIADIC /** * All the kinds of segments that can be present in the segment tree. @@ -143,6 +151,29 @@ export function parseSegment( return output as ParsedSegment } } + } else if (firstChar === 46) { + // '.' + // Check for {...$paramName} (variadic param, matches 0..n segments) + // /^\{\.\.\.\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}$/ (no prefix/suffix) + if ( + openBrace + 4 < part.length && + part.charCodeAt(openBrace + 2) === 46 && // '.' + part.charCodeAt(openBrace + 3) === 46 && // '.' + part.charCodeAt(openBrace + 4) === 36 // '$' + ) { + const paramStart = openBrace + 5 + const paramEnd = closeBrace + // Validate param name exists + if (paramStart < paramEnd) { + output[0] = SEGMENT_TYPE_VARIADIC + output[1] = start + openBrace + output[2] = start + paramStart + output[3] = start + paramEnd + output[4] = start + closeBrace + 1 + output[5] = end + return output as ParsedSegment + } + } } else if (firstChar === 36) { // '$' const dollarPos = openBrace + 1 @@ -183,6 +214,23 @@ export function parseSegment( return output as ParsedSegment } +// Skips segments that cannot anchor the boundary between two unbounded +// segments: optionals are skippable and params match any single segment. +function nearestAnchor( + node: AnySegmentNode, +): AnySegmentNode | null { + let barrier: AnySegmentNode | null = node + while ( + barrier && + (barrier.kind === SEGMENT_TYPE_OPTIONAL_PARAM || + barrier.kind === SEGMENT_TYPE_PARAM || + barrier.kind === SEGMENT_TYPE_PATHLESS) + ) { + barrier = barrier.parent + } + return barrier +} + /** * Recursively parses the segments of the given route tree and populates a segment trie. * @@ -336,7 +384,72 @@ function parseSegments( } break } + case SEGMENT_TYPE_VARIADIC: { + const prefix_raw = path.substring(start, segment[1]) + const suffix_raw = path.substring(segment[4], end) + if (prefix_raw || suffix_raw) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + `Invariant failed: variadic path parameters cannot have a prefix or suffix: ${path}`, + ) + } + invariant() + } + if (nearestAnchor(node)?.kind === SEGMENT_TYPE_VARIADIC) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + `Invariant failed: consecutive variadic path parameters must be separated by a static segment: ${path}`, + ) + } + invariant() + } + let ordinal = 0 + for ( + let ancestor: AnySegmentNode | null = node; + ancestor; + ancestor = ancestor.parent + ) { + if (ancestor.kind === SEGMENT_TYPE_VARIADIC) ordinal++ + } + if (ordinal >= VARIADIC_MAX_PER_PATH) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + `Invariant failed: a route path supports at most ${VARIADIC_MAX_PER_PATH} variadic path parameters: ${path}`, + ) + } + invariant() + } + const existingNode = + !parseParams && + node.variadic?.find( + (s) => !s.parse && s.variadicOrdinal === ordinal, + ) + if (existingNode) { + nextNode = existingNode + } else { + const next = createDynamicNode( + SEGMENT_TYPE_VARIADIC, + route.fullPath ?? route.from, + false, + ) + next.variadicOrdinal = ordinal + nextNode = next + next.depth = depth + next.parent = node + node.variadic ??= [] + node.variadic.push(next) + } + break + } case SEGMENT_TYPE_WILDCARD: { + if (nearestAnchor(node)?.kind === SEGMENT_TYPE_VARIADIC) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + `Invariant failed: a wildcard cannot follow a variadic path parameter without a static segment between them: ${path}`, + ) + } + invariant() + } const prefix_raw = path.substring(start, segment[1]) const suffix_raw = path.substring(segment[4], end) const actuallyCaseSensitive = @@ -492,6 +605,12 @@ function sortTreeNodes(node: SegmentNode) { sortTreeNodes(child) } } + if (node.variadic?.length) { + node.variadic.sort(sortDynamic) + for (const child of node.variadic) { + sortTreeNodes(child) + } + } if (node.wildcard?.length) { node.wildcard.sort(sortDynamic) for (const child of node.wildcard) { @@ -512,12 +631,14 @@ function createStaticNode( staticInsensitive: null, dynamic: null, optional: null, + variadic: null, wildcard: null, route: null, fullPath, parent: null, parse: null, priority: 0, + variadicOrdinal: 0, } } @@ -529,7 +650,8 @@ function createDynamicNode( kind: | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD - | typeof SEGMENT_TYPE_OPTIONAL_PARAM, + | typeof SEGMENT_TYPE_OPTIONAL_PARAM + | typeof SEGMENT_TYPE_VARIADIC, fullPath: string, caseSensitive: boolean, prefix?: string, @@ -544,12 +666,14 @@ function createDynamicNode( staticInsensitive: null, dynamic: null, optional: null, + variadic: null, wildcard: null, route: null, fullPath, parent: null, parse: null, priority: 0, + variadicOrdinal: 0, caseSensitive, prefix, suffix, @@ -568,6 +692,7 @@ type DynamicSegmentNode = SegmentNode & { | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM + | typeof SEGMENT_TYPE_VARIADIC prefix?: string suffix?: string caseSensitive: boolean @@ -597,6 +722,9 @@ type SegmentNode = { /** Optional dynamic segments ({-$param}) */ optional: Array> | null + /** Variadic segments ({...$param}) */ + variadic: Array> | null + /** Wildcard segments ($ - lowest priority) */ wildcard: Array> | null @@ -615,6 +743,9 @@ type SegmentNode = { /** route.options.params.priority ?? 0 */ priority: number + + /** ordinal among the path's variadic segments; lane index into a frame's `variadicCounts` */ + variadicOrdinal: number } type RouteLike = { @@ -716,9 +847,12 @@ export function findSingleMatch( return findMatch(path, tree, fuzzy) } +/** Raw params from a matched path; variadic params are arrays of decoded segments. */ +export type RawRouteParams = Record> + type RouteMatch> = { route: T - rawParams: Record + rawParams: RawRouteParams branch: ReadonlyArray } @@ -841,7 +975,7 @@ function findMatch( * The raw (unparsed) params extracted from the path. * This will be the exhaustive list of all params defined in the route's path. */ - rawParams: Record + rawParams: RawRouteParams } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) @@ -874,12 +1008,13 @@ function extractParams( node: AnySegmentNode skipped: number extract?: ParamExtractionState - rawParams?: Record + rawParams?: RawRouteParams + variadicCounts?: number }, -): [rawParams: Record, state: ParamExtractionState] { +): [rawParams: RawRouteParams, state: ParamExtractionState] { const list = buildBranch(leaf.node) let nodeParts: Array | null = null - const rawParams: Record = Object.create(null) + const rawParams: RawRouteParams = Object.create(null) /** 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 */ @@ -944,6 +1079,29 @@ function extractParams( ? part!.substring(preLength, part!.length - sufLength) : part if (value) rawParams[name] = decodeURIComponent(value) + } else if (node.kind === SEGMENT_TYPE_VARIADIC) { + const count = + Math.floor( + (leaf.variadicCounts ?? 0) / + VARIADIC_LANE_SIZE ** node.variadicOrdinal, + ) % VARIADIC_LANE_SIZE + nodeParts ??= leaf.node.fullPath.split('/') + const nodePart = nodeParts[segmentCount]! + const name = nodePart.substring(5, nodePart.length - 1) + if (count === 0) { + rawParams[name] = [] + partIndex-- // stay on the same part + pathIndex = currentPathIndex - 1 // undo pathIndex advancement; -1 to account for loop increment + continue + } + const values: Array = [decodeURIComponent(part!)] + for (let extra = 1; extra < count; extra++) { + const extraPart = parts[partIndex + extra]! + values.push(decodeURIComponent(extraPart)) + pathIndex += 1 + extraPart.length + } + partIndex += count - 1 + rawParams[name] = values } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node const value = path.substring( @@ -1005,10 +1163,13 @@ type MatchStackFrame = { statics: number dynamics: number optionals: number + variadics: number + /** URL segments consumed by the variadic segment, if any */ + variadicCounts: number /** intermediary state for param extraction */ extract?: ParamExtractionState /** intermediary params from param extraction */ - rawParams?: Record + rawParams?: RawRouteParams } function getNodeMatch( @@ -1047,6 +1208,8 @@ function getNodeMatch( statics: 0, dynamics: 0, optionals: 0, + variadics: 0, + variadicCounts: 0, }, ] @@ -1055,7 +1218,17 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! - const { node, index, skipped, depth, statics, dynamics, optionals } = frame + const { + node, + index, + skipped, + depth, + statics, + dynamics, + optionals, + variadics, + variadicCounts, + } = frame let { extract, rawParams } = frame // Wildcard candidates are pushed speculatively as fallbacks in case a @@ -1099,7 +1272,13 @@ function getNodeMatch( bestMatch = frame } // beyond the length of the path parts, only some segment types can match - if (!node.optional && !node.wildcard && !node.index && !node.pathless) + if ( + !node.optional && + !node.variadic && + !node.wildcard && + !node.index && + !node.pathless + ) continue } @@ -1116,6 +1295,8 @@ function getNodeMatch( statics, dynamics, optionals, + variadics, + variadicCounts, extract, rawParams, } @@ -1142,7 +1323,7 @@ function getNodeMatch( } } - // 5. Try wildcard match + // 6. Try wildcard match if (node.wildcard) { for (let i = node.wildcard.length - 1; i >= 0; i--) { const segment = node.wildcard[i]! @@ -1169,12 +1350,45 @@ function getNodeMatch( statics, dynamics, optionals, + variadics, + variadicCounts, extract, rawParams, }) } } + // 5. Try variadic match + if (node.variadic) { + const nextDepth = depth + 1 + // a URL beyond the lane maximum fails to match rather than corrupt counts + const maxConsume = isBeyondPath + ? 0 + : Math.min(partsLength - index, VARIADIC_LANE_MAX) + // sum of segmentScore over k consumed segments, in closed form: topScore - 2 ** (partsLength - index - k) + const topScore = 2 ** (partsLength - index) + for (let i = node.variadic.length - 1; i >= 0; i--) { + const segment = node.variadic[i]! + const lane = VARIADIC_LANE_SIZE ** segment.variadicOrdinal + // larger consumptions pushed first so the smallest pops first + for (let k = maxConsume; k >= 0; k--) { + stack.push({ + node: segment, + index: index + k, + skipped, + depth: nextDepth, + statics, + dynamics, + optionals, + variadics: variadics + topScore - 2 ** (partsLength - index - k), + variadicCounts: variadicCounts + k * lane, + extract, + rawParams, + }) + } + } + } + // 4. Try optional match if (node.optional) { const nextSkipped = skipped | (1 << depth) @@ -1190,6 +1404,8 @@ function getNodeMatch( statics, dynamics, optionals, + variadics, + variadicCounts, extract, rawParams, }) // enqueue skipping the optional @@ -1213,6 +1429,8 @@ function getNodeMatch( statics, dynamics, optionals: optionals + segmentScore(partsLength, index), + variadics, + variadicCounts, extract, rawParams, }) @@ -1240,6 +1458,8 @@ function getNodeMatch( statics, dynamics: dynamics + segmentScore(partsLength, index), optionals, + variadics, + variadicCounts, extract, rawParams, }) @@ -1260,6 +1480,8 @@ function getNodeMatch( statics: statics + segmentScore(partsLength, index), dynamics, optionals, + variadics, + variadicCounts, extract, rawParams, }) @@ -1278,6 +1500,8 @@ function getNodeMatch( statics: statics + segmentScore(partsLength, index), dynamics, optionals, + variadics, + variadicCounts, extract, rawParams, }) @@ -1297,6 +1521,8 @@ function getNodeMatch( statics, dynamics, optionals, + variadics, + variadicCounts, extract, rawParams, }) @@ -1338,7 +1564,7 @@ function validateParseParams( parts: Array, frame: MatchStackFrame, ) { - let rawParams: Record + let rawParams: RawRouteParams let state: ParamExtractionState try { @@ -1353,7 +1579,10 @@ function validateParseParams( if (!frame.node.parse) return true try { - if (frame.node.parse(rawParams) === false) return null + // widening parse's input in RouteLike breaks route-type variance; each + // route's own params.parse already types variadic keys as arrays + if (frame.node.parse(rawParams as Record) === false) + return null } catch { // Thrown parse errors should be surfaced on the selected match by // extractStrictParams, not used as fallback route selection. @@ -1376,10 +1605,12 @@ function isFrameMoreSpecific( (next.dynamics === prev.dynamics && (next.optionals > prev.optionals || (next.optionals === prev.optionals && - ((next.node.kind === SEGMENT_TYPE_INDEX) > - (prev.node.kind === SEGMENT_TYPE_INDEX) || - ((next.node.kind === SEGMENT_TYPE_INDEX) === - (prev.node.kind === SEGMENT_TYPE_INDEX) && - next.depth > prev.depth))))))) + (next.variadics > prev.variadics || + (next.variadics === prev.variadics && + ((next.node.kind === SEGMENT_TYPE_INDEX) > + (prev.node.kind === SEGMENT_TYPE_INDEX) || + ((next.node.kind === SEGMENT_TYPE_INDEX) === + (prev.node.kind === SEGMENT_TYPE_INDEX) && + next.depth > prev.depth))))))))) ) } diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 577ce00ecf..dbad6a0e17 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -4,6 +4,7 @@ import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_VARIADIC, SEGMENT_TYPE_WILDCARD, parseSegment, } from './new-process-route-tree' @@ -239,7 +240,7 @@ function encodeParam( * Interpolate params and wildcards into a route path template. * * - Encodes params safely (configurable allowed characters) - * - Supports `{-$optional}` segments, `{prefix{$id}suffix}` and `{$}` wildcards + * - Supports `{-$optional}` segments, `{...$variadic}` segments, `{prefix{$id}suffix}` and `{$}` wildcards */ export function interpolatePath({ path, @@ -377,6 +378,24 @@ export function interpolatePath({ continue } + if (kind === SEGMENT_TYPE_VARIADIC) { + const key = path.substring(segment[2], segment[3]) + const valueRaw = params[key] + + // absent or empty variadic omits the segment, but a present param + // is still used + if (valueRaw == null) continue + usedParams[key] = valueRaw + const values = Array.isArray(valueRaw) ? valueRaw : [valueRaw] + if (values.length === 0) continue + + const value = values + .map((entry) => encodePathParam(String(entry), decoder)) + .join('/') + joined += '/' + value + continue + } + if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { const key = path.substring(segment[2], segment[3]) const valueRaw = params[key] diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 2de7dc57f0..c72bf6ab48 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -174,10 +174,16 @@ export type ResolveOptionalParams = { [K in ParsePathParams['optional']]?: T | undefined } +export type ResolveVariadicParams = { + [K in ParsePathParams['variadic']]: Array +} + export type ResolveParams< TPath extends string, T = string, -> = ResolveRequiredParams & ResolveOptionalParams +> = ResolveRequiredParams & + ResolveOptionalParams & + ResolveVariadicParams export type ParseParamsFn = ( rawParams: Expand>, diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index d93581355e..6e2f1dd42c 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -1428,6 +1428,8 @@ describe('findRouteMatch', () => { }, "static": null, "staticInsensitive": null, + "variadic": null, + "variadicOrdinal": 0, "wildcard": null, }, "kind": 5, @@ -1481,9 +1483,13 @@ describe('findRouteMatch', () => { }, "static": null, "staticInsensitive": null, + "variadic": null, + "variadicOrdinal": 0, "wildcard": null, }, }, + "variadic": null, + "variadicOrdinal": 0, "wildcard": null, }, ], @@ -1511,10 +1517,14 @@ describe('findRouteMatch', () => { }, "static": null, "staticInsensitive": null, + "variadic": null, + "variadicOrdinal": 0, "wildcard": null, }, }, "suffix": undefined, + "variadic": null, + "variadicOrdinal": 0, "wildcard": null, }, ], @@ -1538,6 +1548,8 @@ describe('findRouteMatch', () => { }, "static": null, "staticInsensitive": null, + "variadic": null, + "variadicOrdinal": 0, "wildcard": null, }, "kind": 0, @@ -1549,6 +1561,8 @@ describe('findRouteMatch', () => { "route": null, "static": null, "staticInsensitive": null, + "variadic": null, + "variadicOrdinal": 0, "wildcard": null, } `) diff --git a/packages/router-core/tests/variadic-path-params.test-d.ts b/packages/router-core/tests/variadic-path-params.test-d.ts new file mode 100644 index 0000000000..33c6f8b5b0 --- /dev/null +++ b/packages/router-core/tests/variadic-path-params.test-d.ts @@ -0,0 +1,60 @@ +import { describe, expectTypeOf, it } from 'vitest' +import type { ParsePathParams } from '../src/link' +import type { ResolveParams } from '../src/route' + +type Flatten = { [K in keyof T]: T[K] } + +describe('variadic path param types', () => { + it('buckets {...$param} names into variadic', () => { + expectTypeOf< + ParsePathParams<'/$bucket/{...$folders}/$file'>['variadic'] + >().toEqualTypeOf<'folders'>() + expectTypeOf< + ParsePathParams<'/$bucket/{...$folders}/$file'>['required'] + >().toEqualTypeOf<'bucket' | 'file'>() + expectTypeOf< + ParsePathParams<'/$bucket/{...$folders}/$file'>['optional'] + >().toEqualTypeOf() + }) + + it('keeps optional and variadic buckets distinct', () => { + type P = ParsePathParams<'/a/{-$opt}/{...$rest}/$id'> + expectTypeOf().toEqualTypeOf<'opt'>() + expectTypeOf().toEqualTypeOf<'rest'>() + expectTypeOf().toEqualTypeOf<'id'>() + }) + + it('resolves variadic params as Array', () => { + type Params = ResolveParams<'/$bucket/{...$folders}/$file'> + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + it('does not affect paths without a variadic', () => { + expectTypeOf< + ParsePathParams<'/posts/{-$category}/$id'>['variadic'] + >().toEqualTypeOf() + type Params = ResolveParams<'/posts/$id'> + expectTypeOf>().toEqualTypeOf<{ id: string }>() + }) + + it('buckets multiple variadics together, each typed as an array', () => { + type P = ParsePathParams<'/{...$a}/mid/{...$b}'> + expectTypeOf().toEqualTypeOf<'a' | 'b'>() + type Params = ResolveParams<'/{...$a}/mid/{...$b}'> + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf>() + }) + + it('types a nested storage path end to end', () => { + type Params = + ResolveParams<'/$bucket/{...$folders}/$file/versions/$versionId'> + expectTypeOf>().toEqualTypeOf<{ + bucket: string + file: string + versionId: string + folders: Array + }>() + }) +}) diff --git a/packages/router-core/tests/variadic-path-params.test.ts b/packages/router-core/tests/variadic-path-params.test.ts new file mode 100644 index 0000000000..2f5799e639 --- /dev/null +++ b/packages/router-core/tests/variadic-path-params.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, it } from 'vitest' +import { interpolatePath } from '../src/path' +import { + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_VARIADIC, + findRouteMatch, + parseSegment, + processRouteTree, +} from '../src/new-process-route-tree' + +function makeTree(routes: Array) { + return processRouteTree({ + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: routes.map((route) => ({ + id: route, + fullPath: route, + path: route, + })), + }).processedTree +} + +describe('Variadic Path Parameters', () => { + describe('parseSegment', () => { + it('parses {...$param} as a variadic segment', () => { + const path = '/{...$folders}' + const data = parseSegment(path, 1) + expect(data[0]).toBe(SEGMENT_TYPE_VARIADIC) + expect(path.substring(data[2], data[3])).toBe('folders') + }) + + it('treats {...$} (missing name) as a static segment', () => { + const path = '/{...$}' + const data = parseSegment(path, 1) + expect(data[0]).toBe(SEGMENT_TYPE_PATHNAME) + }) + + it('treats {.$param} and {..$param} (too few dots) as static segments', () => { + for (const path of ['/{.$folders}', '/{..$folders}']) { + const data = parseSegment(path, 1) + expect(data[0]).toBe(SEGMENT_TYPE_PATHNAME) + } + }) + }) + + describe('invariants', () => { + it('rejects a prefix or suffix on a variadic segment', () => { + expect(() => makeTree(['/files/pre{...$path}/end'])).toThrow( + /prefix or suffix/, + ) + expect(() => makeTree(['/files/{...$path}post/end'])).toThrow( + /prefix or suffix/, + ) + }) + + it('rejects adjacent variadic segments', () => { + expect(() => makeTree(['/{...$a}/{...$b}'])).toThrow( + /separated by a static segment/, + ) + }) + + it('rejects variadic segments separated only by a param', () => { + expect(() => makeTree(['/{...$a}/$x/{...$b}'])).toThrow( + /separated by a static segment/, + ) + }) + + it('rejects a wildcard separated from a variadic only by a param', () => { + expect(() => makeTree(['/files/{...$path}/$x/$'])).toThrow( + /wildcard cannot follow a variadic/, + ) + }) + + it('rejects more than three variadic segments in one route path', () => { + expect(() => + makeTree(['/{...$v1}/a/{...$v2}/b/{...$v3}/c/{...$v4}']), + ).toThrow(/at most 3/) + }) + + it('accepts three variadic segments in one route path', () => { + const tree = makeTree(['/{...$v1}/a/{...$v2}/b/{...$v3}']) + expect(findRouteMatch('/x/a/y/b/z', tree)?.rawParams).toEqual({ + v1: ['x'], + v2: ['y'], + v3: ['z'], + }) + }) + + it('rejects a wildcard immediately after a variadic', () => { + expect(() => makeTree(['/files/{...$path}/$'])).toThrow( + /wildcard cannot follow a variadic/, + ) + }) + + it('rejects a wildcard separated from a variadic only by optionals', () => { + expect(() => makeTree(['/files/{...$path}/{-$x}/$'])).toThrow( + /wildcard cannot follow a variadic/, + ) + }) + + it('rejects an adjacent variadic across route levels', () => { + expect(() => + processRouteTree({ + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: 'parent', + fullPath: '/{...$a}', + path: '{...$a}', + children: [ + { + id: 'child', + fullPath: '/{...$a}/{...$b}', + path: '{...$b}', + }, + ], + }, + ], + }), + ).toThrow(/separated by a static segment/) + }) + }) + + describe('matching', () => { + it('matches zero segments', () => { + const tree = makeTree(['/files/{...$path}/preview']) + const match = findRouteMatch('/files/preview', tree) + expect(match?.route.id).toBe('/files/{...$path}/preview') + expect(match?.rawParams).toEqual({ path: [] }) + }) + + it('matches one and many segments', () => { + const tree = makeTree(['/$bucket/{...$folders}/$file']) + expect(findRouteMatch('/media/kyoto.jpg', tree)?.rawParams).toEqual({ + bucket: 'media', + folders: [], + file: 'kyoto.jpg', + }) + expect( + findRouteMatch('/media/photos/kyoto.jpg', tree)?.rawParams, + ).toEqual({ + bucket: 'media', + folders: ['photos'], + file: 'kyoto.jpg', + }) + expect( + findRouteMatch('/media/photos/2026/raw/kyoto.jpg', tree)?.rawParams, + ).toEqual({ + bucket: 'media', + folders: ['photos', '2026', 'raw'], + file: 'kyoto.jpg', + }) + }) + + it('matches a variable-depth folder path with a trailing surface', () => { + const tree = makeTree([ + '/$bucket/{...$folders}/$file/versions/$versionId', + '/$bucket/{...$folders}/$file', + ]) + const match = findRouteMatch( + '/media/photos/2026/kyoto.jpg/versions/42', + tree, + ) + expect(match?.route.id).toBe( + '/$bucket/{...$folders}/$file/versions/$versionId', + ) + expect(match?.rawParams).toEqual({ + bucket: 'media', + folders: ['photos', '2026'], + file: 'kyoto.jpg', + versionId: '42', + }) + }) + + it('decodes each consumed segment independently', () => { + const tree = makeTree(['/files/{...$path}/x']) + const match = findRouteMatch('/files/a%2Fb/c/x', tree) + expect(match?.rawParams).toEqual({ path: ['a/b', 'c'] }) + }) + + it('composes with a terminal wildcard in the same route path', () => { + const tree = makeTree(['/$bucket/{...$folders}/$file/blob/$']) + const match = findRouteMatch( + '/media/photos/2026/album/blob/src/main.rs', + tree, + ) + expect(match?.rawParams).toEqual({ + bucket: 'media', + folders: ['photos', '2026'], + file: 'album', + _splat: 'src/main.rs', + '*': 'src/main.rs', + }) + expect( + findRouteMatch('/media/album/blob/README.md', tree)?.rawParams, + ).toEqual({ + bucket: 'media', + folders: [], + file: 'album', + _splat: 'README.md', + '*': 'README.md', + }) + }) + + it('binds the whole path on a leaf variadic', () => { + const tree = makeTree(['/tree/{...$all}']) + expect(findRouteMatch('/tree/a/b/c', tree)?.rawParams).toEqual({ + all: ['a', 'b', 'c'], + }) + expect(findRouteMatch('/tree', tree)?.rawParams).toEqual({ all: [] }) + }) + + it('consumes up to 131071 segments', () => { + const tree = makeTree(['/tree/{...$all}/end']) + const segments = Array.from({ length: 131071 }, (_, i) => `s${i}`) + const match = findRouteMatch(`/tree/${segments.join('/')}/end`, tree) + expect(match?.rawParams).toEqual({ all: segments }) + }) + + it('does not match when a variadic would need to consume more than 131071 segments', () => { + const tree = makeTree(['/tree/{...$all}/end']) + const segments = Array.from({ length: 131072 }, (_, i) => `s${i}`) + expect(findRouteMatch(`/tree/${segments.join('/')}/end`, tree)).toBe(null) + }) + }) + + describe('multiple variadics', () => { + it('matches variadics separated by a static segment at every depth combination', () => { + const tree = makeTree(['/{...$a}/mid/{...$b}']) + expect(findRouteMatch('/mid', tree)?.rawParams).toEqual({ a: [], b: [] }) + expect(findRouteMatch('/x/y/mid', tree)?.rawParams).toEqual({ + a: ['x', 'y'], + b: [], + }) + expect(findRouteMatch('/mid/x/y', tree)?.rawParams).toEqual({ + a: [], + b: ['x', 'y'], + }) + expect(findRouteMatch('/x/mid/y/z', tree)?.rawParams).toEqual({ + a: ['x'], + b: ['y', 'z'], + }) + }) + + it('anchors on the first separator occurrence when it repeats', () => { + const tree = makeTree(['/{...$a}/mid/{...$b}']) + expect(findRouteMatch('/x/mid/y/mid/z', tree)?.rawParams).toEqual({ + a: ['x'], + b: ['y', 'mid', 'z'], + }) + }) + + it('composes across route levels with a static between', () => { + const tree = processRouteTree({ + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: 'parent', + fullPath: '/{...$a}', + path: '{...$a}', + children: [ + { + id: 'child', + fullPath: '/{...$a}/x/{...$b}', + path: 'x/{...$b}', + }, + ], + }, + ], + }).processedTree + expect(findRouteMatch('/p/x/q', tree)?.rawParams).toEqual({ + a: ['p'], + b: ['q'], + }) + }) + + it('interpolates and round-trips two arrays', () => { + const path = '/{...$a}/mid/{...$b}' + const params = { a: ['x'], b: ['y', 'z'] } + const { interpolatedPath } = interpolatePath({ path, params }) + expect(interpolatedPath).toBe('/x/mid/y/z') + const tree = makeTree([path]) + expect(findRouteMatch(interpolatedPath, tree)?.rawParams).toEqual(params) + }) + + it('keeps a zero-segment variadic in usedParams', () => { + const { interpolatedPath, usedParams } = interpolatePath({ + path: '/$bucket/{...$folders}/$file', + params: { bucket: 'media', folders: [], file: 'kyoto.jpg' }, + }) + expect(interpolatedPath).toBe('/media/kyoto.jpg') + expect(usedParams).toEqual({ + bucket: 'media', + folders: [], + file: 'kyoto.jpg', + }) + }) + }) + + describe('ranking', () => { + it('static segments beat variadic consumption', () => { + const tree = makeTree(['/docs/{...$rest}/end', '/docs/guide/end']) + expect(findRouteMatch('/docs/guide/end', tree)?.route.id).toBe( + '/docs/guide/end', + ) + expect(findRouteMatch('/docs/other/end', tree)?.route.id).toBe( + '/docs/{...$rest}/end', + ) + }) + + it('required params beat variadic consumption', () => { + const tree = makeTree(['/docs/{...$rest}/end', '/docs/$section/end']) + const match = findRouteMatch('/docs/guide/end', tree) + expect(match?.route.id).toBe('/docs/$section/end') + }) + + it('optional params beat variadic consumption', () => { + const tree = makeTree(['/docs/{...$rest}/end', '/docs/{-$section}/end']) + expect(findRouteMatch('/docs/guide/end', tree)?.route.id).toBe( + '/docs/{-$section}/end', + ) + }) + + it('variadic beats the terminal wildcard', () => { + const tree = makeTree(['/docs/$', '/docs/{...$rest}/edit']) + expect(findRouteMatch('/docs/a/b/edit', tree)?.route.id).toBe( + '/docs/{...$rest}/edit', + ) + // no /edit tail: only the wildcard can match + expect(findRouteMatch('/docs/a/b', tree)?.route.id).toBe('/docs/$') + }) + + it('consumes as few segments as possible when the continuation allows both', () => { + const tree = makeTree(['/{...$dirs}/{-$x}/$leaf']) + const match = findRouteMatch('/a/b', tree) + expect(match?.rawParams).toEqual({ dirs: [], x: 'a', leaf: 'b' }) + }) + }) + + describe('params.parse interplay', () => { + it('passes the array value to params.parse and honors rejection', () => { + const seen: Array = [] + const routeTree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: 'guarded', + fullPath: '/v/{...$dirs}/$leaf', + path: 'v/{...$dirs}/$leaf', + options: { + params: { + parse: (params: Record) => { + seen.push(params) + return (params.dirs as unknown as Array).length <= 1 + }, + }, + }, + }, + { + id: 'fallback', + fullPath: '/v/$', + path: 'v/$', + }, + ], + } + const { processedTree } = processRouteTree(routeTree) + + const ok = findRouteMatch('/v/photos/kyoto.jpg', processedTree) + expect(ok?.route.id).toBe('guarded') + expect(ok?.rawParams).toEqual({ dirs: ['photos'], leaf: 'kyoto.jpg' }) + + // parse rejects depth 2 -> falls through to the wildcard route + const rejected = findRouteMatch('/v/a/b/kyoto.jpg', processedTree) + expect(rejected?.route.id).toBe('fallback') + expect(seen.some((p) => Array.isArray((p as any).dirs))).toBe(true) + }) + }) + + describe('interpolatePath', () => { + it('joins array values with slashes, encoding each segment', () => { + const result = interpolatePath({ + path: '/$bucket/{...$folders}/$file', + params: { + bucket: 'media', + folders: ['photos', 'a/b'], + file: 'kyoto.jpg', + }, + }) + expect(result.interpolatedPath).toBe('/media/photos/a%2Fb/kyoto.jpg') + expect(result.isMissingParams).toBe(false) + }) + + it('omits the segment for an empty or absent variadic', () => { + expect( + interpolatePath({ + path: '/$bucket/{...$folders}/$file', + params: { bucket: 'media', folders: [], file: 'kyoto.jpg' }, + }).interpolatedPath, + ).toBe('/media/kyoto.jpg') + const absent = interpolatePath({ + path: '/$bucket/{...$folders}/$file', + params: { bucket: 'media', file: 'kyoto.jpg' }, + }) + expect(absent.interpolatedPath).toBe('/media/kyoto.jpg') + expect(absent.isMissingParams).toBe(false) + }) + + it('round-trips with matching', () => { + const tree = makeTree([ + '/$bucket/{...$folders}/$file/versions/$versionId', + ]) + const params = { + bucket: 'media', + folders: ['photos', '2026'], + file: 'kyoto.jpg', + versionId: '42', + } + const { interpolatedPath } = interpolatePath({ + path: '/$bucket/{...$folders}/$file/versions/$versionId', + params, + }) + expect(interpolatedPath).toBe('/media/photos/2026/kyoto.jpg/versions/42') + expect(findRouteMatch(interpolatedPath, tree)?.rawParams).toEqual(params) + }) + }) +}) diff --git a/packages/router-generator/src/utils.ts b/packages/router-generator/src/utils.ts index 2354c5ed1c..425adde496 100644 --- a/packages/router-generator/src/utils.ts +++ b/packages/router-generator/src/utils.ts @@ -141,7 +141,11 @@ export function removeTrailingSlash(s: string) { } const BRACKET_CONTENT_RE = /\[(.*?)\]/g -const SPLIT_REGEX = /(? { return params } - // Pattern 2: Braces pattern {$paramName} or {-$paramName} with optional prefix/suffix - // Match patterns like: prefix{$param}suffix, {$param}, {-$param} - const bracePattern = /\{(-?\$)([^}]*)\}/g + // Pattern 2: Braces pattern {$paramName}, {-$paramName} or {...$paramName} + // Match patterns like: prefix{$param}suffix, {$param}, {-$param}, {...$param} + const bracePattern = /\{((?:-|\.\.\.)?\$)([^}]*)\}/g let match while ((match = bracePattern.exec(segment)) !== null) { - const paramName = match[2] // The param name after $ or -$ + const paramName = match[2] // The param name after $, -$, or ...$ if (!paramName) { - // This is a wildcard {$} or {-$}, skip + // This is a wildcard {$}, {-$}, or {...$}, skip continue } diff --git a/packages/router-generator/tests/utils.test.ts b/packages/router-generator/tests/utils.test.ts index 0cdce3cfae..4f9590affa 100644 --- a/packages/router-generator/tests/utils.test.ts +++ b/packages/router-generator/tests/utils.test.ts @@ -257,6 +257,22 @@ describe('determineInitialRoutePath', () => { }) }) + it('keeps the dots of a variadic param token', () => { + expect( + determineInitialRoutePath('$bucket.{...$folders}.$file'), + ).toStrictEqual({ + routePath: '/$bucket/{...$folders}/$file', + originalRoutePath: '/$bucket/{...$folders}/$file', + }) + }) + + it('keeps a variadic directory segment intact', () => { + expect(determineInitialRoutePath('/{...$folders}/index')).toStrictEqual({ + routePath: '/{...$folders}/index', + originalRoutePath: '/{...$folders}/index', + }) + }) + it('errors on disallowed escaped character', () => { const consoleSpy = vi.spyOn(console, 'error')