Skip to content

Commit a5ae72a

Browse files
committed
refactor: simplify param parsers and allow trailingSlash in dynamic params
1 parent edd3326 commit a5ae72a

File tree

4 files changed

+113
-61
lines changed

4 files changed

+113
-61
lines changed

packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ describe('MatcherPatternPathDynamic', () => {
132132
/^\/teams\/([^/]+?)\/b$/i,
133133
{
134134
// all defaults
135-
teamId: {},
135+
teamId: [{}],
136136
},
137137
['teams', 1, 'b']
138138
)
@@ -153,7 +153,7 @@ describe('MatcherPatternPathDynamic', () => {
153153
const pattern = new MatcherPatternPathDynamic(
154154
/^\/teams\/([^/]+?)$/i,
155155
{
156-
teamId: {},
156+
teamId: [{}],
157157
},
158158
['teams', 1]
159159
)
@@ -165,7 +165,7 @@ describe('MatcherPatternPathDynamic', () => {
165165
const pattern = new MatcherPatternPathDynamic(
166166
/^\/teams(?:\/([^/]+?))?\/b$/i,
167167
{
168-
teamId: {},
168+
teamId: [{}, false, true],
169169
},
170170
['teams', 1, 'b']
171171
)
@@ -180,30 +180,30 @@ describe('MatcherPatternPathDynamic', () => {
180180
expect(pattern.build({ teamId: '' })).toBe('/teams/b')
181181
})
182182

183-
it.todo('optional param in the end', () => {
183+
it('optional param in the end', () => {
184184
const pattern = new MatcherPatternPathDynamic(
185-
/^\/teams(?:\/([^/]+?))?\/b$/i,
185+
/^\/teams(?:\/([^/]+?))?$/i,
186186
{
187-
teamId: {},
187+
teamId: [{}, false, true],
188188
},
189-
['teams', 1, 'b']
189+
['teams', 1]
190190
)
191191

192-
expect(pattern.match('/teams/b')).toEqual({ teamId: null })
193-
expect(pattern.match('/teams/123/b')).toEqual({ teamId: '123' })
192+
expect(pattern.match('/teams')).toEqual({ teamId: null })
193+
expect(() => pattern.match('/teams/')).toThrow()
194+
expect(pattern.match('/teams/123')).toEqual({ teamId: '123' })
194195
expect(() => pattern.match('/teams/123/c')).toThrow()
195-
expect(() => pattern.match('/teams/123/b/c')).toThrow()
196196
expect(() => pattern.match('/teams//b')).toThrow()
197-
expect(pattern.build({ teamId: '123' })).toBe('/teams/123/b')
198-
expect(pattern.build({ teamId: null })).toBe('/teams/b')
199-
expect(pattern.build({ teamId: '' })).toBe('/teams/b')
197+
expect(pattern.build({ teamId: '123' })).toBe('/teams/123')
198+
expect(pattern.build({ teamId: null })).toBe('/teams')
199+
expect(pattern.build({ teamId: '' })).toBe('/teams')
200200
})
201201

202202
it('repeatable param', () => {
203203
const pattern = new MatcherPatternPathDynamic(
204204
/^\/teams\/(.+?)\/b$/i,
205205
{
206-
teamId: { repeat: true },
206+
teamId: [{}, true],
207207
},
208208
['teams', 1, 'b']
209209
)
@@ -218,6 +218,25 @@ describe('MatcherPatternPathDynamic', () => {
218218
expect(pattern.build({ teamId: ['123', '456'] })).toBe('/teams/123/456/b')
219219
})
220220

221+
it('repeatable param in the end', () => {
222+
const pattern = new MatcherPatternPathDynamic(
223+
/^\/teams\/(.+?)$/i,
224+
{
225+
teamId: [{}, true],
226+
},
227+
['teams', 1]
228+
)
229+
230+
expect(pattern.match('/teams/123')).toEqual({ teamId: ['123'] })
231+
expect(pattern.match('/teams/123/456')).toEqual({ teamId: ['123', '456'] })
232+
expect(() => pattern.match('/teams')).toThrow()
233+
expect(() => pattern.match('/teams/')).toThrow()
234+
expect(() => pattern.match('/teams/123/')).toThrow()
235+
expect(pattern.build({ teamId: ['123'] })).toBe('/teams/123')
236+
expect(pattern.build({ teamId: ['123', '456'] })).toBe('/teams/123/456')
237+
expect(() => pattern.build({ teamId: [] })).toThrow()
238+
})
239+
221240
it.todo('catch all route', () => {
222241
// const pattern = new MatcherPatternPathDynamic(
223242
})
@@ -226,9 +245,10 @@ describe('MatcherPatternPathDynamic', () => {
226245
const pattern = new MatcherPatternPathDynamic(
227246
/^\/teams\/(.*)$/i,
228247
{
229-
pathMatch: {},
248+
pathMatch: [{}],
230249
},
231-
['teams', 0]
250+
['teams', 0],
251+
null
232252
)
233253
expect(pattern.match('/teams/')).toEqual({ pathMatch: '' })
234254
expect(pattern.match('/teams/123/b')).toEqual({ pathMatch: '123/b' })
@@ -245,7 +265,7 @@ describe('MatcherPatternPathDynamic', () => {
245265
const pattern = new MatcherPatternPathDynamic(
246266
/^\/teams(?:\/(.+?))?\/b$/i,
247267
{
248-
teamId: { repeat: true },
268+
teamId: [{}, true, true],
249269
},
250270
['teams', 1, 'b']
251271
)
@@ -268,8 +288,8 @@ describe('MatcherPatternPathDynamic', () => {
268288
const pattern = new MatcherPatternPathDynamic(
269289
/^\/teams\/([^/]+?)\/([^/]+?)$/i,
270290
{
271-
teamId: {},
272-
otherId: {},
291+
teamId: [{}],
292+
otherId: [{}],
273293
},
274294
['teams', 1, 1]
275295
)
@@ -290,8 +310,8 @@ describe('MatcherPatternPathDynamic', () => {
290310
const pattern = new MatcherPatternPathDynamic(
291311
/^\/teams\/([^/]+?)-b-([^/]+?)$/i,
292312
{
293-
teamId: {},
294-
otherId: {},
313+
teamId: [{}],
314+
otherId: [{}],
295315
},
296316
['teams', [1, '-b-', 1]]
297317
)
@@ -312,9 +332,10 @@ describe('MatcherPatternPathDynamic', () => {
312332
const pattern = new MatcherPatternPathDynamic(
313333
/^\/teams\/([^/]+?)\/$/i,
314334
{
315-
teamId: {},
335+
teamId: [{}],
316336
},
317-
['teams', [1, '/']]
337+
['teams', 1],
338+
true
318339
)
319340

320341
expect(pattern.match('/teams/123/')).toEqual({
@@ -326,10 +347,11 @@ describe('MatcherPatternPathDynamic', () => {
326347
expect(pattern.build({ teamId: '123' })).toBe('/teams/123/')
327348
})
328349

329-
it('can have a trailing slash after a static segment', () => {
350+
it.todo('can have a trailing slash after a static segment', () => {
330351
const pattern = new MatcherPatternPathDynamic(/^\/teams\/b\/$/i, {}, [
331352
'teams',
332-
['b', '/'],
353+
'b',
354+
['/'],
333355
])
334356

335357
expect(pattern.match('/teams/b/')).toEqual({})
@@ -343,9 +365,10 @@ describe('MatcherPatternPathDynamic', () => {
343365
const pattern = new MatcherPatternPathDynamic(
344366
/^\/teams\/(.+?)\/$/,
345367
{
346-
teamId: { repeat: true },
368+
teamId: [{}, true],
347369
},
348-
['teams', [1, '/']]
370+
['teams', 1],
371+
true
349372
)
350373

351374
expect(pattern.match('/teams/123/')).toEqual({ teamId: ['123'] })
@@ -359,13 +382,14 @@ describe('MatcherPatternPathDynamic', () => {
359382
expect(pattern.build({ teamId: ['123', '456'] })).toBe('/teams/123/456/')
360383
})
361384

362-
it.todo('can have a trailing slash after optional repeatable param', () => {
385+
it('can have a trailing slash after optional repeatable param', () => {
363386
const pattern = new MatcherPatternPathDynamic(
364387
/^\/teams(?:\/(.+?))?\/$/,
365388
{
366-
teamId: { repeat: true },
389+
teamId: [{}, true, true],
367390
},
368-
['teams', [1, '/']]
391+
['teams', 1],
392+
true
369393
)
370394

371395
expect(pattern.match('/teams/123/')).toEqual({ teamId: ['123'] })

packages/router/src/experimental/route-resolver/matchers/matcher-pattern.test-d.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ describe('MatcherPatternPathDynamic', () => {
88
it('can be generic', () => {
99
const matcher = new MatcherPatternPathDynamic(
1010
/^\/users\/([^/]+)$/i,
11-
{ userId: { ...PATH_PARAM_PARSER_DEFAULTS } },
11+
{ userId: [PATH_PARAM_PARSER_DEFAULTS] },
1212
['users', 1]
1313
)
1414

@@ -33,7 +33,7 @@ describe('MatcherPatternPathDynamic', () => {
3333
it('can be a simple param', () => {
3434
const matcher = new MatcherPatternPathDynamic(
3535
/^\/users\/([^/]+)\/([^/]+)$/i,
36-
{ userId: { ...PATH_PARAM_SINGLE_DEFAULT, repeat: true } },
36+
{ userId: [PATH_PARAM_SINGLE_DEFAULT, true] },
3737
['users', 1]
3838
)
3939
expectTypeOf(matcher.match('/users/123/456')).toEqualTypeOf<{
@@ -52,10 +52,10 @@ describe('MatcherPatternPathDynamic', () => {
5252
const matcher = new MatcherPatternPathDynamic(
5353
/^\/profiles\/([^/]+)$/i,
5454
{
55-
userId: {
56-
...PARAM_INTEGER_SINGLE,
55+
userId: [
56+
PARAM_INTEGER_SINGLE,
5757
// parser: PATH_PARAM_DEFAULT_PARSER,
58-
},
58+
],
5959
},
6060
['profiles', 1]
6161
)

packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -88,19 +88,25 @@ export class MatcherPatternPathStatic
8888
/**
8989
* Options for param parsers in {@link MatcherPatternPathDynamic}.
9090
*/
91-
export interface MatcherPatternPathDynamic_ParamOptions<
91+
export type MatcherPatternPathDynamic_ParamOptions<
9292
TIn extends string | string[] | null = string | string[] | null,
9393
TOut = string | string[] | null,
94-
> extends ParamParser<TOut, TIn> {
94+
> = [
95+
/**
96+
* Param parser to use for this param.
97+
*/
98+
parser: ParamParser<TOut, TIn>,
99+
95100
/**
96101
* Is tha param a repeatable param and should be converted to an array
97102
*/
98-
repeat?: boolean
103+
repeatable?: boolean,
99104

100-
// NOTE: not needed because in the regexp, the value is undefined if
101-
// the group is optional and not given
102-
// optional?: boolean
103-
}
105+
/**
106+
* Can this parameter be omitted or empty (for repeatable params, an empty array).
107+
*/
108+
optional?: boolean,
109+
]
104110

105111
/**
106112
* Helper type to extract the params from the options object.
@@ -115,6 +121,13 @@ type ExtractParamTypeFromOptions<TParamsOptions> = {
115121
: never
116122
}
117123

124+
/**
125+
* Regex to remove trailing slashes from a path.
126+
*
127+
* @internal
128+
*/
129+
const RE_TRAILING_SLASHES = /\/*$/
130+
118131
/**
119132
* Handles the `path` part of a URL with dynamic parameters.
120133
*/
@@ -138,12 +151,21 @@ export class MatcherPatternPathDynamic<
138151
readonly params: TParamsOptions &
139152
Record<string, MatcherPatternPathDynamic_ParamOptions<any, any>>,
140153
// 1 means a regular param, 0 means a splat, the order comes from the keys in params
141-
readonly pathParts: Array<string | number | Array<string | number>>
154+
readonly pathParts: Array<string | number | Array<string | number>>,
155+
// null means "do not care", it's only for splat params
156+
readonly trailingSlash: boolean | null = false
142157
) {
143158
this.paramsKeys = Object.keys(this.params) as Array<keyof TParamsOptions>
144159
}
145160

146161
match(path: string): ExtractParamTypeFromOptions<TParamsOptions> {
162+
if (
163+
this.trailingSlash != null &&
164+
this.trailingSlash === !path.endsWith('/')
165+
) {
166+
throw miss()
167+
}
168+
147169
const match = path.match(this.re)
148170
if (!match) {
149171
throw miss()
@@ -152,14 +174,14 @@ export class MatcherPatternPathDynamic<
152174
for (var i = 0; i < this.paramsKeys.length; i++) {
153175
// var for performance in for loop
154176
var paramName = this.paramsKeys[i]
155-
var paramOptions = this.params[paramName]
177+
var [parser, repeatable] = this.params[paramName]
156178
var currentMatch = (match[i + 1] as string | undefined) ?? null
157179

158-
var value = paramOptions.repeat
180+
var value = repeatable
159181
? (currentMatch?.split('/') || []).map<string>(decode)
160182
: decode(currentMatch)
161183

162-
params[paramName] = (paramOptions.get || identityFn)(value)
184+
params[paramName] = (parser.get || identityFn)(value)
163185
}
164186

165187
if (
@@ -177,11 +199,13 @@ export class MatcherPatternPathDynamic<
177199
build(params: ExtractParamTypeFromOptions<TParamsOptions>): string {
178200
let paramIndex = 0
179201
let paramName: keyof TParamsOptions
180-
let paramOptions: (TParamsOptions &
202+
let parser: (TParamsOptions &
181203
Record<
182204
string,
183205
MatcherPatternPathDynamic_ParamOptions<any, any>
184-
>)[keyof TParamsOptions]
206+
>)[keyof TParamsOptions][0]
207+
let repeatable: boolean | undefined
208+
let optional: boolean | undefined
185209
let lastParamPart: number | undefined
186210
let value: ReturnType<NonNullable<ParamParser['set']>> | undefined
187211
const path =
@@ -192,9 +216,13 @@ export class MatcherPatternPathDynamic<
192216
return part
193217
} else if (typeof part === 'number') {
194218
paramName = this.paramsKeys[paramIndex++]
195-
paramOptions = this.params[paramName]
219+
;[parser, repeatable, optional] = this.params[paramName]
196220
lastParamPart = part
197-
value = (paramOptions.set || identityFn)(params[paramName])
221+
value = (parser.set || identityFn)(params[paramName])
222+
223+
if (Array.isArray(value) && !value.length && !optional) {
224+
throw miss()
225+
}
198226

199227
return Array.isArray(value)
200228
? value.map(encodeParam).join('/')
@@ -208,11 +236,11 @@ export class MatcherPatternPathDynamic<
208236
}
209237

210238
paramName = this.paramsKeys[paramIndex++]
211-
paramOptions = this.params[paramName]
212-
value = (paramOptions.set || identityFn)(params[paramName])
239+
;[parser, repeatable, optional] = this.params[paramName]
240+
value = (parser.set || identityFn)(params[paramName])
213241

214242
// param cannot be repeatable when in a sub segment
215-
if (__DEV__ && paramOptions.repeat) {
243+
if (__DEV__ && repeatable) {
216244
warn(
217245
`Param "${String(paramName)}" is repeatable, but used in a sub segment of the path: "${this.pathParts.join('')}". Repeated params can only be used as a full path segment: "/file/[ids]+/something-else". This will break in production.`
218246
)
@@ -235,9 +263,9 @@ export class MatcherPatternPathDynamic<
235263
* with the original splat path: e.g. /teams/[...pathMatch] does not match /teams, so it makes
236264
* no sense to build a path it cannot match.
237265
*/
238-
return !lastParamPart /** lastParamPart == 0 */ && !value
239-
? path + '/'
240-
: path
266+
return this.trailingSlash == null
267+
? path + (!value ? '/' : '')
268+
: path.replace(RE_TRAILING_SLASHES, this.trailingSlash ? '/' : '')
241269
}
242270
}
243271

0 commit comments

Comments
 (0)