Skip to content

Commit 2d1abf5

Browse files
committed
fix: handle splat params
1 parent 34703fb commit 2d1abf5

File tree

3 files changed

+66
-24
lines changed

3 files changed

+66
-24
lines changed

packages/router/src/encoding.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,13 @@ const ENC_SPACE_RE = /%20/g // }
5858
* @param text - string to encode
5959
* @returns encoded string
6060
*/
61-
export function commonEncode(text: string | number): string {
62-
return encodeURI('' + text)
63-
.replace(ENC_PIPE_RE, '|')
64-
.replace(ENC_BRACKET_OPEN_RE, '[')
65-
.replace(ENC_BRACKET_CLOSE_RE, ']')
61+
export function commonEncode(text: string | number | null | undefined): string {
62+
return text == null
63+
? ''
64+
: encodeURI('' + text)
65+
.replace(ENC_PIPE_RE, '|')
66+
.replace(ENC_BRACKET_OPEN_RE, '[')
67+
.replace(ENC_BRACKET_CLOSE_RE, ']')
6668
}
6769

6870
/**
@@ -115,7 +117,7 @@ export function encodeQueryKey(text: string | number): string {
115117
* @param text - string to encode
116118
* @returns encoded string
117119
*/
118-
export function encodePath(text: string | number): string {
120+
export function encodePath(text: string | number | null | undefined): string {
119121
return commonEncode(text).replace(HASH_RE, '%23').replace(IM_RE, '%3F')
120122
}
121123

@@ -129,7 +131,7 @@ export function encodePath(text: string | number): string {
129131
* @returns encoded string
130132
*/
131133
export function encodeParam(text: string | number | null | undefined): string {
132-
return text == null ? '' : encodePath(text).replace(SLASH_RE, '%2F')
134+
return encodePath(text).replace(SLASH_RE, '%2F')
133135
}
134136

135137
/**

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,10 @@ describe('MatcherPatternPathCustom', () => {
155155
expect(pattern.match('/teams/123/b')).toEqual({ teamId: '123' })
156156
expect(() => pattern.match('/teams/123/c')).toThrow()
157157
expect(() => pattern.match('/teams/123/b/c')).toThrow()
158+
expect(() => pattern.match('/teams//b')).toThrow()
158159
expect(pattern.build({ teamId: '123' })).toBe('/teams/123/b')
159160
expect(pattern.build({ teamId: null })).toBe('/teams/b')
161+
expect(pattern.build({ teamId: '' })).toBe('/teams/b')
160162
})
161163

162164
it('repeatable param', () => {
@@ -178,6 +180,29 @@ describe('MatcherPatternPathCustom', () => {
178180
expect(pattern.build({ teamId: ['123', '456'] })).toBe('/teams/123/456/b')
179181
})
180182

183+
it('catch all route', () => {
184+
// const pattern = new MatcherPatternPathDynamic(
185+
})
186+
187+
it('splat params with prefix', () => {
188+
const pattern = new MatcherPatternPathDynamic(
189+
/^\/teams\/(.*)$/i,
190+
{
191+
pathMatch: {},
192+
},
193+
['teams', 1]
194+
)
195+
expect(pattern.match('/teams/')).toEqual({ pathMatch: '' })
196+
expect(pattern.match('/teams/123/b')).toEqual({ pathMatch: '123/b' })
197+
expect(() => pattern.match('/teams')).toThrow()
198+
expect(() => pattern.match('/teamso/123/c')).toThrow()
199+
200+
expect(pattern.build({ pathMatch: null })).toBe('/teams/')
201+
expect(pattern.build({ pathMatch: '' })).toBe('/teams/')
202+
expect(pattern.build({ pathMatch: '124' })).toBe('/teams/124')
203+
expect(pattern.build({ pathMatch: '124/b' })).toBe('/teams/124/b')
204+
})
205+
181206
it('repeatable optional param', () => {
182207
const pattern = new MatcherPatternPathDynamic(
183208
/^\/teams(?:\/(.+?))?\/b$/i,

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

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { identityFn } from '../../../utils'
2-
import { decode, encodeParam } from '../../../encoding'
2+
import { decode, encodeParam, encodePath } from '../../../encoding'
33
import { warn } from '../../../warning'
44
import { miss } from './errors'
55
import { ParamParser } from './param-parsers/types'
@@ -92,7 +92,11 @@ export interface MatcherPatternPathDynamic_ParamOptions<
9292
TIn extends string | string[] | null = string | string[] | null,
9393
TOut = string | string[] | null,
9494
> extends ParamParser<TOut, TIn> {
95+
/**
96+
* Is tha param a repeatable param and should be converted to an array
97+
*/
9598
repeat?: boolean
99+
96100
// NOTE: not needed because in the regexp, the value is undefined if
97101
// the group is optional and not given
98102
// optional?: boolean
@@ -133,9 +137,7 @@ export class MatcherPatternPathDynamic<
133137
// otherwise, we need to use a factory function: https://github.com/microsoft/TypeScript/issues/40451
134138
readonly params: TParamsOptions &
135139
Record<string, MatcherPatternPathDynamic_ParamOptions<any, any>>,
136-
// A better version could be using all the parts to join them
137-
// .e.g ['users', 0, 'profile', 1] -> /users/123/profile/456
138-
// numbers are indexes of the params in the params object keys
140+
// 0 means a regular param, 1 means a splat, the order comes from the keys in params
139141
readonly pathParts: Array<string | number | Array<string | number>>
140142
) {
141143
this.paramsKeys = Object.keys(this.params) as Array<keyof TParamsOptions>
@@ -174,34 +176,40 @@ export class MatcherPatternPathDynamic<
174176

175177
build(params: ExtractParamTypeFromOptions<TParamsOptions>): string {
176178
let paramIndex = 0
177-
return (
179+
let paramName: keyof TParamsOptions
180+
let paramOptions: (TParamsOptions &
181+
Record<
182+
string,
183+
MatcherPatternPathDynamic_ParamOptions<any, any>
184+
>)[keyof TParamsOptions]
185+
let lastParamPart: number | undefined
186+
let value: ReturnType<NonNullable<ParamParser['set']>> | undefined
187+
const path =
178188
'/' +
179189
this.pathParts
180190
.map(part => {
181191
if (typeof part === 'string') {
182192
return part
183193
} else if (typeof part === 'number') {
184-
const paramName = this.paramsKeys[paramIndex++]
185-
const paramOptions = this.params[paramName]
186-
const value: ReturnType<NonNullable<ParamParser['set']>> = (
187-
paramOptions.set || identityFn
188-
)(params[paramName])
194+
paramName = this.paramsKeys[paramIndex++]
195+
paramOptions = this.params[paramName]
196+
lastParamPart = part
197+
value = (paramOptions.set || identityFn)(params[paramName])
189198

190199
return Array.isArray(value)
191200
? value.map(encodeParam).join('/')
192-
: encodeParam(value)
201+
: // part == 0 means a regular param, 1 means a splat
202+
(part /* part !== 0 */ ? encodePath : encodeParam)(value)
193203
} else {
194204
return part
195205
.map(subPart => {
196206
if (typeof subPart === 'string') {
197207
return subPart
198208
}
199209

200-
const paramName = this.paramsKeys[paramIndex++]
201-
const paramOptions = this.params[paramName]
202-
const value: ReturnType<NonNullable<ParamParser['set']>> = (
203-
paramOptions.set || identityFn
204-
)(params[paramName])
210+
paramName = this.paramsKeys[paramIndex++]
211+
paramOptions = this.params[paramName]
212+
value = (paramOptions.set || identityFn)(params[paramName])
205213

206214
return Array.isArray(value)
207215
? value.map(encodeParam).join('/')
@@ -212,7 +220,14 @@ export class MatcherPatternPathDynamic<
212220
})
213221
.filter(identityFn) // filter out empty values
214222
.join('/')
215-
)
223+
224+
/**
225+
* If the last part of the path is a splat param and its value is empty, it gets
226+
* filteretd out, resulting in a path that doesn't end with a `/` and doesn't even match
227+
* with the original splat path: e.g. /teams/[...pathMatch] does not match /teams, so it makes
228+
* no sense to build a path it cannot match.
229+
*/
230+
return lastParamPart && !value ? path + '/' : path
216231
}
217232
}
218233

0 commit comments

Comments
 (0)