Skip to content

Commit 2c0168a

Browse files
committed
fix: do not encode slashes in catch-all routes
This applies to all custom regex routes which match a slash or contain one in their literal part fixes vuejs#1638
1 parent 37f6cbd commit 2c0168a

File tree

6 files changed

+137
-20
lines changed

6 files changed

+137
-20
lines changed

packages/router/__tests__/router.spec.ts

+48
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,54 @@ describe('Router', () => {
931931
})
932932
})
933933

934+
it('escapes slashes in standard params', async () => {
935+
const router = createRouter({
936+
history: createMemoryHistory(),
937+
routes: [{ path: '/home/:path', component: components.Home }],
938+
})
939+
await router.push('/home/pathparam')
940+
await router.replace({ params: { path: 'test/this/is/escaped' } })
941+
expect(router.currentRoute.value).toMatchObject({
942+
fullPath: '/home/test%2Fthis%2Fis%2Fescaped',
943+
})
944+
})
945+
946+
it('keeps slashes in star params', async () => {
947+
const router = createRouter({
948+
history: createMemoryHistory(),
949+
routes: [{ path: '/home/:path(.*)', component: components.Home }],
950+
})
951+
await router.push('/home/pathparam')
952+
await router.replace({ params: { path: 'test/this/is/not/escaped' } })
953+
expect(router.currentRoute.value).toMatchObject({
954+
fullPath: '/home/test/this/is/not/escaped',
955+
})
956+
await router.replace({ hash: '#test' })
957+
expect(router.currentRoute.value).toMatchObject({
958+
fullPath: '/home/test/this/is/not/escaped#test',
959+
})
960+
})
961+
962+
it('keeps slashes in params containing slashes', async () => {
963+
const router = createRouter({
964+
history: createMemoryHistory(),
965+
routes: [
966+
{
967+
path: '/home/:slug(.*)*/:path(test/deep/about\\.html(?!.*\\/\\).*)',
968+
component: components.Foo,
969+
},
970+
],
971+
})
972+
await router.push('/home/slug/test/deep/about.html')
973+
await router.replace({
974+
params: { slug: 'another/slug' },
975+
})
976+
await router.replace({ hash: '#hash' })
977+
expect(router.currentRoute.value).toMatchObject({
978+
fullPath: '/home/another/slug/test/deep/about.html#hash',
979+
})
980+
})
981+
934982
describe('Dynamic Routing', () => {
935983
it('resolves new added routes', async () => {
936984
const { router } = await newRouter({ routes: [] })

packages/router/src/encoding.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,20 @@ export function encodePath(text: string | number): string {
119119
return commonEncode(text).replace(HASH_RE, '%23').replace(IM_RE, '%3F')
120120
}
121121

122+
/**
123+
* Encode characters that need to be encoded on the path section of the URL as a
124+
* path param. This function does exactly what {@link encodePath} does, but if `text` is `null` or `undefined`, returns an empty
125+
* string instead.
126+
*
127+
* @param text - string to encode
128+
* @returns encoded string
129+
*/
130+
export function encodePathParam(
131+
text: string | number | null | undefined
132+
): string {
133+
return text == null ? '' : encodePath(text)
134+
}
135+
122136
/**
123137
* Encode characters that need to be encoded on the path section of the URL as a
124138
* param. This function encodes everything {@link encodePath} does plus the
@@ -129,7 +143,7 @@ export function encodePath(text: string | number): string {
129143
* @returns encoded string
130144
*/
131145
export function encodeParam(text: string | number | null | undefined): string {
132-
return text == null ? '' : encodePath(text).replace(SLASH_RE, '%2F')
146+
return encodePathParam(text).replace(SLASH_RE, '%2F')
133147
}
134148

135149
/**

packages/router/src/matcher/index.ts

+44-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
MatcherLocationRaw,
44
MatcherLocation,
55
isRouteName,
6+
RouteParamsGeneric,
67
} from '../types'
78
import { createRouterError, ErrorTypes, MatcherError } from '../errors'
89
import { createRouteRecordMatcher, RouteRecordMatcher } from './pathMatcher'
@@ -17,8 +18,9 @@ import type {
1718
import { comparePathParserScore } from './pathParserRanker'
1819

1920
import { warn } from '../warning'
20-
import { assign, noop } from '../utils'
21+
import { applyToParam, assign, noop } from '../utils'
2122
import type { RouteRecordNameGeneric, _RouteRecordProps } from '../typed-routes'
23+
import { encodeParam, encodePathParam } from '../encoding'
2224

2325
/**
2426
* Internal RouterMatcher
@@ -40,10 +42,12 @@ export interface RouterMatcher {
4042
*
4143
* @param location - MatcherLocationRaw to resolve to a url
4244
* @param currentLocation - MatcherLocation of the current location
45+
* @param encodeParams - Whether to encode parameters or not. Defaults to `false`
4346
*/
4447
resolve: (
4548
location: MatcherLocationRaw,
46-
currentLocation: MatcherLocation
49+
currentLocation: MatcherLocation,
50+
encodeParams?: boolean
4751
) => MatcherLocation
4852
}
4953

@@ -230,15 +234,48 @@ export function createRouterMatcher(
230234
matcherMap.set(matcher.record.name, matcher)
231235
}
232236

237+
function encodeParams(
238+
matcher: RouteRecordMatcher,
239+
params: RouteParamsGeneric | undefined
240+
): MatcherLocation['params'] {
241+
const newParams = {} as MatcherLocation['params']
242+
if (params) {
243+
for (let paramKey of Object.keys(params)) {
244+
let matcherKey = matcher.keys.find(k => k.name == paramKey)
245+
246+
let keepSlash = matcherKey?.keepSlash ?? false
247+
newParams[paramKey] = keepSlash
248+
? applyToParam(encodePathParam, params[paramKey])
249+
: applyToParam(encodeParam, params[paramKey])
250+
}
251+
}
252+
return newParams
253+
}
254+
233255
function resolve(
234256
location: Readonly<MatcherLocationRaw>,
235-
currentLocation: Readonly<MatcherLocation>
257+
currentLocation: Readonly<MatcherLocation>,
258+
doEncodeParams: boolean = false
236259
): MatcherLocation {
237260
let matcher: RouteRecordMatcher | undefined
238261
let params: PathParams = {}
239262
let path: MatcherLocation['path']
240263
let name: MatcherLocation['name']
241264

265+
// Encode params
266+
let encodeLocationsParams = (matcher: RouteRecordMatcher) => {
267+
if (doEncodeParams) {
268+
if ('params' in location) {
269+
location = assign(location, {
270+
params: encodeParams(matcher, location.params),
271+
})
272+
}
273+
currentLocation = assign(currentLocation, {
274+
params: encodeParams(matcher, currentLocation.params),
275+
})
276+
}
277+
}
278+
242279
if ('name' in location && location.name) {
243280
matcher = matcherMap.get(location.name)
244281

@@ -247,6 +284,8 @@ export function createRouterMatcher(
247284
location,
248285
})
249286

287+
encodeLocationsParams(matcher)
288+
250289
// warn if the user is passing invalid params so they can debug it better when they get removed
251290
if (__DEV__) {
252291
const invalidParams: string[] = Object.keys(
@@ -301,6 +340,7 @@ export function createRouterMatcher(
301340
// matcher should have a value after the loop
302341

303342
if (matcher) {
343+
encodeLocationsParams(matcher)
304344
// we know the matcher works because we tested the regexp
305345
params = matcher.parse(path)!
306346
name = matcher.record.name
@@ -316,6 +356,7 @@ export function createRouterMatcher(
316356
location,
317357
currentLocation,
318358
})
359+
encodeLocationsParams(matcher)
319360
name = matcher.record.name
320361
// since we are navigating to the same location, we don't need to pick the
321362
// params like when `name` is provided

packages/router/src/matcher/pathParserRanker.ts

+14-5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface PathParserParamKey {
1010
name: string
1111
repeatable: boolean
1212
optional: boolean
13+
keepSlash: boolean
1314
}
1415

1516
export interface PathParser {
@@ -156,11 +157,6 @@ export function tokensToParser(
156157
subSegmentScore += PathScore.Static
157158
} else if (token.type === TokenType.Param) {
158159
const { value, repeatable, optional, regexp } = token
159-
keys.push({
160-
name: value,
161-
repeatable,
162-
optional,
163-
})
164160
const re = regexp ? regexp : BASE_PARAM_PATTERN
165161
// the user provided a custom regexp /:id(\\d+)
166162
if (re !== BASE_PARAM_PATTERN) {
@@ -176,6 +172,19 @@ export function tokensToParser(
176172
}
177173
}
178174

175+
// Keep slash if it matches regex
176+
// Or if a slash is litterally contained outside of lookaheads, lookbehinds and negative ranges
177+
let keepSlash =
178+
new RegExp(`(${re})`).test('/') ||
179+
/\//.test(re.replace(/\(\?<?[=!].*\)|\[\^.*\]/, ''))
180+
181+
keys.push({
182+
name: value,
183+
repeatable,
184+
optional,
185+
keepSlash,
186+
})
187+
179188
// when we repeat we must take care of the repeating leading slash
180189
let subPattern = repeatable ? `((?:${re})(?:/(?:${re}))*)` : `(${re})`
181190

packages/router/src/router.ts

+6-8
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isRouteName,
66
RouteLocationOptions,
77
MatcherLocationRaw,
8+
RouteParamsGeneric,
89
} from './types'
910
import type {
1011
RouteLocation,
@@ -40,7 +41,7 @@ import {
4041
} from './errors'
4142
import { applyToParams, isBrowser, assign, noop, isArray } from './utils'
4243
import { useCallbacks } from './utils/callbacks'
43-
import { encodeParam, decode, encodeHash } from './encoding'
44+
import { decode, encodeHash } from './encoding'
4445
import {
4546
normalizeQuery,
4647
parseQuery as originalParseQuery,
@@ -410,7 +411,6 @@ export function createRouter(options: RouterOptions): Router {
410411
null,
411412
paramValue => '' + paramValue
412413
)
413-
const encodeParams = applyToParams.bind(null, encodeParam)
414414
const decodeParams: (params: RouteParams | undefined) => RouteParams =
415415
// @ts-expect-error: intentionally avoid the type check
416416
applyToParams.bind(null, decode)
@@ -529,16 +529,14 @@ export function createRouter(options: RouterOptions): Router {
529529
delete targetParams[key]
530530
}
531531
}
532-
// pass encoded values to the matcher, so it can produce encoded path and fullPath
532+
533+
// matcher handles param encoding by itself, just pass cleaned params
533534
matcherLocation = assign({}, rawLocation, {
534-
params: encodeParams(targetParams),
535+
params: targetParams as RouteParamsGeneric,
535536
})
536-
// current location params are decoded, we need to encode them in case the
537-
// matcher merges the params
538-
currentLocation.params = encodeParams(currentLocation.params)
539537
}
540538

541-
const matchedRoute = matcher.resolve(matcherLocation, currentLocation)
539+
const matchedRoute = matcher.resolve(matcherLocation, currentLocation, true)
542540
const hash = rawLocation.hash || ''
543541

544542
if (__DEV__ && hash && !hash.startsWith('#')) {

packages/router/src/utils/index.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ export function isESModule(obj: any): obj is { default: RouteComponent } {
1313

1414
export const assign = Object.assign
1515

16+
export function applyToParam(
17+
fn: (v: string | number | null | undefined) => string,
18+
param: RouteParamValueRaw | Exclude<RouteParamValueRaw, null | undefined>[]
19+
): string | string[] {
20+
return isArray(param)
21+
? param.map(fn)
22+
: fn(param as Exclude<RouteParamValueRaw, any[]>)
23+
}
24+
1625
export function applyToParams(
1726
fn: (v: string | number | null | undefined) => string,
1827
params: RouteParamsRawGeneric | undefined
@@ -21,9 +30,7 @@ export function applyToParams(
2130

2231
for (const key in params) {
2332
const value = params[key]
24-
newParams[key] = isArray(value)
25-
? value.map(fn)
26-
: fn(value as Exclude<RouteParamValueRaw, any[]>)
33+
newParams[key] = applyToParam(fn, value)
2734
}
2835

2936
return newParams

0 commit comments

Comments
 (0)