Skip to content

Commit 1706e55

Browse files
committed
feat(router-core): add variadic path parameters ({...$param})
Non-terminal, named, multi-segment path parameters: {...$param} matches zero or more URL segments mid-path, binds them as Array<string> with per-segment decoding, and allows child routes to continue after it, unlike the terminal $ splat. Matching is non-greedy, resolved by the existing backtracking matcher, and ranks below optional params and above the terminal wildcard. A route path supports up to three variadics; consecutive unbounded segments (variadic-variadic or variadic-splat) must be separated by a static segment, since an unanchored boundary would leave the first structurally empty. No prefix/suffix. Per-variadic consumption counts pack into 17-bit lanes of one float64-exact integer on the match frame (at most 131071 segments per variadic; longer URLs fail to match rather than corrupt counts). Type layer resolves variadic params as Array<string> via a fourth ParsePathParams bucket. The generator's file-route tokenizer keeps the dots of a variadic token instead of treating them as flat-route separators, and generator and eslint-plugin param-name validation accept the new token.
1 parent ecbbd9a commit 1706e55

13 files changed

Lines changed: 881 additions & 44 deletions

File tree

.changeset/variadic-path-params.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/router-core': minor
3+
'@tanstack/router-generator': patch
4+
'@tanstack/eslint-plugin-router': patch
5+
---
6+
7+
Add non-terminal variadic path parameters: `{...$param}` matches zero or more URL segments in the middle of a path and binds them as `Array<string>` (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.

packages/eslint-plugin-router/src/__tests__/route-param-names.utils.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,28 @@ describe('extractParamsFromSegment', () => {
103103
})
104104
})
105105

106+
it('should extract variadic {...$param} format', () => {
107+
const result = extractParamsFromSegment('{...$items}')
108+
expect(result).toHaveLength(1)
109+
expect(result[0]).toEqual({
110+
fullParam: '...$items',
111+
paramName: 'items',
112+
isOptional: false,
113+
isValid: true,
114+
})
115+
})
116+
117+
it('should mark invalid variadic param names', () => {
118+
const result = extractParamsFromSegment('{...$123invalid}')
119+
expect(result).toHaveLength(1)
120+
expect(result[0]?.isValid).toBe(false)
121+
expect(result[0]?.paramName).toBe('123invalid')
122+
})
123+
124+
it('should skip a nameless variadic {...$}', () => {
125+
expect(extractParamsFromSegment('{...$}')).toEqual([])
126+
})
127+
106128
it('should mark invalid param names', () => {
107129
const result = extractParamsFromSegment('$123invalid')
108130
expect(result).toHaveLength(1)

packages/eslint-plugin-router/src/rules/route-param-names/route-param-names.utils.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { VALID_PARAM_NAME_REGEX } from './constants'
22

33
export interface ExtractedParam {
4-
/** The full param string including $ prefix (e.g., "$userId", "-$optional") */
4+
/** The full param string including $ prefix (e.g., "$userId", "-$optional", "...$items") */
55
fullParam: string
66
/** The param name without $ prefix (e.g., "userId", "optional") */
77
paramName: string
@@ -51,17 +51,17 @@ export function extractParamsFromSegment(
5151
return params
5252
}
5353

54-
// Pattern 2: Braces pattern {$paramName} or {-$paramName} with optional prefix/suffix
55-
// Match patterns like: prefix{$param}suffix, {$param}, {-$param}
56-
const bracePattern = /\{(-?\$)([^}]*)\}/g
54+
// Pattern 2: Braces pattern {$paramName}, {-$paramName} or {...$paramName}
55+
// Match patterns like: prefix{$param}suffix, {$param}, {-$param}, {...$param}
56+
const bracePattern = /\{((?:-|\.\.\.)?\$)([^}]*)\}/g
5757
let match
5858

5959
while ((match = bracePattern.exec(segment)) !== null) {
60-
const prefix = match[1] // "$" or "-$"
61-
const paramName = match[2] // The param name after $ or -$
60+
const prefix = match[1] // "$", "-$", or "...$"
61+
const paramName = match[2] // The param name after $, -$, or ...$
6262

6363
if (!paramName) {
64-
// This is a wildcard {$} or {-$}, skip
64+
// This is a wildcard {$}, {-$}, or {...$}, skip
6565
continue
6666
}
6767

packages/router-core/src/link.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,36 +34,53 @@ export interface ParsePathParamsResult<
3434
in out TRequired,
3535
in out TOptional,
3636
in out TRest,
37+
in out TVariadic = never,
3738
> {
3839
required: TRequired
3940
optional: TOptional
4041
rest: TRest
42+
variadic: TVariadic
4143
}
4244

4345
export type AnyParsePathParamsResult = ParsePathParamsResult<
46+
string,
4447
string,
4548
string,
4649
string
4750
>
4851

4952
export type ParsePathParamsBoundaryStart<T extends string> =
50-
T extends `${infer TLeft}{-${infer TRight}`
53+
T extends `${infer TLeft}{...${infer TRight}`
5154
? ParsePathParamsResult<
5255
ParsePathParams<TLeft>['required'],
5356
| ParsePathParams<TLeft>['optional']
54-
| ParsePathParams<TRight>['required']
5557
| ParsePathParams<TRight>['optional'],
56-
ParsePathParams<TRight>['rest']
58+
ParsePathParams<TRight>['rest'],
59+
| ParsePathParams<TLeft>['variadic']
60+
| ParsePathParams<TRight>['required']
61+
| ParsePathParams<TRight>['variadic']
5762
>
58-
: T extends `${infer TLeft}{${infer TRight}`
63+
: T extends `${infer TLeft}{-${infer TRight}`
5964
? ParsePathParamsResult<
60-
| ParsePathParams<TLeft>['required']
61-
| ParsePathParams<TRight>['required'],
65+
ParsePathParams<TLeft>['required'],
6266
| ParsePathParams<TLeft>['optional']
67+
| ParsePathParams<TRight>['required']
6368
| ParsePathParams<TRight>['optional'],
64-
ParsePathParams<TRight>['rest']
69+
ParsePathParams<TRight>['rest'],
70+
| ParsePathParams<TLeft>['variadic']
71+
| ParsePathParams<TRight>['variadic']
6572
>
66-
: never
73+
: T extends `${infer TLeft}{${infer TRight}`
74+
? ParsePathParamsResult<
75+
| ParsePathParams<TLeft>['required']
76+
| ParsePathParams<TRight>['required'],
77+
| ParsePathParams<TLeft>['optional']
78+
| ParsePathParams<TRight>['optional'],
79+
ParsePathParams<TRight>['rest'],
80+
| ParsePathParams<TLeft>['variadic']
81+
| ParsePathParams<TRight>['variadic']
82+
>
83+
: never
6784

6885
export type ParsePathParamsSymbol<T extends string> =
6986
T extends `${string}$${infer TRight}`
@@ -73,12 +90,14 @@ export type ParsePathParamsSymbol<T extends string> =
7390
? ParsePathParamsResult<
7491
ParsePathParams<TRest>['required'],
7592
'_splat' | ParsePathParams<TRest>['optional'],
76-
ParsePathParams<TRest>['rest']
93+
ParsePathParams<TRest>['rest'],
94+
ParsePathParams<TRest>['variadic']
7795
>
7896
: ParsePathParamsResult<
7997
TParam | ParsePathParams<TRest>['required'],
8098
ParsePathParams<TRest>['optional'],
81-
ParsePathParams<TRest>['rest']
99+
ParsePathParams<TRest>['rest'],
100+
ParsePathParams<TRest>['variadic']
82101
>
83102
: never
84103
: TRight extends ''
@@ -93,7 +112,8 @@ export type ParsePathParamsBoundaryEnd<T extends string> =
93112
| ParsePathParams<TRight>['required'],
94113
| ParsePathParams<TLeft>['optional']
95114
| ParsePathParams<TRight>['optional'],
96-
ParsePathParams<TRight>['rest']
115+
ParsePathParams<TRight>['rest'],
116+
ParsePathParams<TLeft>['variadic'] | ParsePathParams<TRight>['variadic']
97117
>
98118
: never
99119

@@ -104,7 +124,8 @@ export type ParsePathParamsEscapeStart<T extends string> =
104124
| ParsePathParams<TRight>['required'],
105125
| ParsePathParams<TLeft>['optional']
106126
| ParsePathParams<TRight>['optional'],
107-
ParsePathParams<TRight>['rest']
127+
ParsePathParams<TRight>['rest'],
128+
ParsePathParams<TLeft>['variadic'] | ParsePathParams<TRight>['variadic']
108129
>
109130
: never
110131

0 commit comments

Comments
 (0)