Skip to content

Commit 4d35542

Browse files
fix(router-core): allow %25 in path params (#3419)
fixes #3402 --------- Signed-off-by: leesb971204 <[email protected]> Co-authored-by: Sean Cassiere <[email protected]>
1 parent 4cb2531 commit 4d35542

File tree

5 files changed

+273
-1
lines changed

5 files changed

+273
-1
lines changed

e2e/react-router/basic-file-based/src/routeTree.gen.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { Route as RedirectPreloadFirstImport } from './routes/redirect/preload/f
3636
import { Route as RedirectTargetViaLoaderImport } from './routes/redirect/$target/via-loader'
3737
import { Route as RedirectTargetViaBeforeLoadImport } from './routes/redirect/$target/via-beforeLoad'
3838
import { Route as PostsPostIdEditImport } from './routes/posts_.$postId.edit'
39+
import { Route as ParamsSingleValueImport } from './routes/params.single.$value'
3940
import { Route as LayoutLayout2LayoutBImport } from './routes/_layout/_layout-2/layout-b'
4041
import { Route as LayoutLayout2LayoutAImport } from './routes/_layout/_layout-2/layout-a'
4142
import { Route as groupSubfolderInsideImport } from './routes/(group)/subfolder/inside'
@@ -191,6 +192,12 @@ const PostsPostIdEditRoute = PostsPostIdEditImport.update({
191192
getParentRoute: () => rootRoute,
192193
} as any)
193194

195+
const ParamsSingleValueRoute = ParamsSingleValueImport.update({
196+
id: '/params/single/$value',
197+
path: '/params/single/$value',
198+
getParentRoute: () => rootRoute,
199+
} as any)
200+
194201
const LayoutLayout2LayoutBRoute = LayoutLayout2LayoutBImport.update({
195202
id: '/layout-b',
196203
path: '/layout-b',
@@ -366,6 +373,13 @@ declare module '@tanstack/react-router' {
366373
preLoaderRoute: typeof LayoutLayout2LayoutBImport
367374
parentRoute: typeof LayoutLayout2Import
368375
}
376+
'/params/single/$value': {
377+
id: '/params/single/$value'
378+
path: '/params/single/$value'
379+
fullPath: '/params/single/$value'
380+
preLoaderRoute: typeof ParamsSingleValueImport
381+
parentRoute: typeof rootRoute
382+
}
369383
'/posts_/$postId/edit': {
370384
id: '/posts_/$postId/edit'
371385
path: '/posts/$postId/edit'
@@ -520,6 +534,7 @@ export interface FileRoutesByFullPath {
520534
'/subfolder/inside': typeof groupSubfolderInsideRoute
521535
'/layout-a': typeof LayoutLayout2LayoutARoute
522536
'/layout-b': typeof LayoutLayout2LayoutBRoute
537+
'/params/single/$value': typeof ParamsSingleValueRoute
523538
'/posts/$postId/edit': typeof PostsPostIdEditRoute
524539
'/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
525540
'/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute
@@ -546,6 +561,7 @@ export interface FileRoutesByTo {
546561
'/subfolder/inside': typeof groupSubfolderInsideRoute
547562
'/layout-a': typeof LayoutLayout2LayoutARoute
548563
'/layout-b': typeof LayoutLayout2LayoutBRoute
564+
'/params/single/$value': typeof ParamsSingleValueRoute
549565
'/posts/$postId/edit': typeof PostsPostIdEditRoute
550566
'/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
551567
'/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute
@@ -578,6 +594,7 @@ export interface FileRoutesById {
578594
'/(group)/subfolder/inside': typeof groupSubfolderInsideRoute
579595
'/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute
580596
'/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute
597+
'/params/single/$value': typeof ParamsSingleValueRoute
581598
'/posts_/$postId/edit': typeof PostsPostIdEditRoute
582599
'/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
583600
'/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute
@@ -608,6 +625,7 @@ export interface FileRouteTypes {
608625
| '/subfolder/inside'
609626
| '/layout-a'
610627
| '/layout-b'
628+
| '/params/single/$value'
611629
| '/posts/$postId/edit'
612630
| '/redirect/$target/via-beforeLoad'
613631
| '/redirect/$target/via-loader'
@@ -633,6 +651,7 @@ export interface FileRouteTypes {
633651
| '/subfolder/inside'
634652
| '/layout-a'
635653
| '/layout-b'
654+
| '/params/single/$value'
636655
| '/posts/$postId/edit'
637656
| '/redirect/$target/via-beforeLoad'
638657
| '/redirect/$target/via-loader'
@@ -663,6 +682,7 @@ export interface FileRouteTypes {
663682
| '/(group)/subfolder/inside'
664683
| '/_layout/_layout-2/layout-a'
665684
| '/_layout/_layout-2/layout-b'
685+
| '/params/single/$value'
666686
| '/posts_/$postId/edit'
667687
| '/redirect/$target/via-beforeLoad'
668688
| '/redirect/$target/via-loader'
@@ -685,6 +705,7 @@ export interface RootRouteChildren {
685705
RedirectTargetRoute: typeof RedirectTargetRouteWithChildren
686706
StructuralSharingEnabledRoute: typeof StructuralSharingEnabledRoute
687707
RedirectIndexRoute: typeof RedirectIndexRoute
708+
ParamsSingleValueRoute: typeof ParamsSingleValueRoute
688709
PostsPostIdEditRoute: typeof PostsPostIdEditRoute
689710
RedirectPreloadFirstRoute: typeof RedirectPreloadFirstRoute
690711
RedirectPreloadSecondRoute: typeof RedirectPreloadSecondRoute
@@ -703,6 +724,7 @@ const rootRouteChildren: RootRouteChildren = {
703724
RedirectTargetRoute: RedirectTargetRouteWithChildren,
704725
StructuralSharingEnabledRoute: StructuralSharingEnabledRoute,
705726
RedirectIndexRoute: RedirectIndexRoute,
727+
ParamsSingleValueRoute: ParamsSingleValueRoute,
706728
PostsPostIdEditRoute: PostsPostIdEditRoute,
707729
RedirectPreloadFirstRoute: RedirectPreloadFirstRoute,
708730
RedirectPreloadSecondRoute: RedirectPreloadSecondRoute,
@@ -730,6 +752,7 @@ export const routeTree = rootRoute
730752
"/redirect/$target",
731753
"/structural-sharing/$enabled",
732754
"/redirect/",
755+
"/params/single/$value",
733756
"/posts_/$postId/edit",
734757
"/redirect/preload/first",
735758
"/redirect/preload/second",
@@ -834,6 +857,9 @@ export const routeTree = rootRoute
834857
"filePath": "_layout/_layout-2/layout-b.tsx",
835858
"parent": "/_layout/_layout-2"
836859
},
860+
"/params/single/$value": {
861+
"filePath": "params.single.$value.tsx"
862+
},
837863
"/posts_/$postId/edit": {
838864
"filePath": "posts_.$postId.edit.tsx"
839865
},
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as React from 'react'
2+
import { Link, createFileRoute } from '@tanstack/react-router'
3+
4+
export const Route = createFileRoute('/params/single/$value')({
5+
component: RouteComponent,
6+
})
7+
8+
function RouteComponent() {
9+
const value = Route.useParams({ select: (s) => s.value })
10+
11+
return (
12+
<div className="p-2 grid gap-4">
13+
<div>
14+
<h2>What's the value???</h2>
15+
<p>Check path param value is printed correctly for a single param.</p>
16+
</div>
17+
<p>
18+
Value: <span data-testid="parsed-param-value">{value}</span>
19+
</p>
20+
<div className="flex gap-2">
21+
<Link
22+
to="/params/single/$value"
23+
params={{ value }}
24+
reloadDocument
25+
className="border p-2"
26+
data-testid="self-link-same"
27+
>
28+
Self link to same
29+
</Link>
30+
<Link
31+
to="/params/single/$value"
32+
params={{ value: `e2e${value}` }}
33+
reloadDocument
34+
className="border p-2"
35+
data-testid="self-link-amended"
36+
>
37+
Self link to amended value {`e2e${value}`}
38+
</Link>
39+
</div>
40+
</div>
41+
)
42+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { expect, test } from '@playwright/test'
2+
import type { Page } from '@playwright/test'
3+
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('/')
6+
})
7+
8+
test.describe('ensure single params have been parsed correctly whilst being stable in the browser', () => {
9+
const cases = [
10+
{ value: 'hello', expected: 'hello' },
11+
{
12+
value: '100%25',
13+
expected: '100%',
14+
},
15+
{
16+
value: '100%2525',
17+
expected: '100%25',
18+
},
19+
{
20+
value: '100%26',
21+
expected: '100&',
22+
},
23+
]
24+
25+
function getParsedValue(page: Page) {
26+
return page.getByTestId('parsed-param-value').textContent()
27+
}
28+
29+
for (const { value, expected } of cases) {
30+
test(`navigating to /params/single/${value}`, async ({ page, baseURL }) => {
31+
await page.goto(`/params/single/${value}`)
32+
33+
// on the first run, the value should be the same as the expected value
34+
const valueOnFirstRun = await getParsedValue(page)
35+
expect(valueOnFirstRun).toBe(expected)
36+
37+
// the url/pathname should be the same as the expected value
38+
const urlOnFirstRun = page.url().replace(baseURL!, '')
39+
expect(urlOnFirstRun).toBe(`/params/single/${value}`)
40+
41+
// click on the self link to the same value
42+
await page.getByTestId('self-link-same').click()
43+
const valueOnSecondRun = await getParsedValue(page)
44+
expect(valueOnSecondRun).toBe(expected)
45+
46+
// click on the self link to the amended value
47+
await page.getByTestId('self-link-amended').click()
48+
const valueOnThirdRun = await getParsedValue(page)
49+
expect(valueOnThirdRun).toBe(`e2e${expected}`)
50+
51+
// the url/pathname should be the same as the expected value
52+
const urlOnThirdRun = page.url().replace(baseURL!, '')
53+
expect(urlOnThirdRun).toBe(`/params/single/e2e${value}`)
54+
})
55+
}
56+
})

packages/react-router/tests/router.test.tsx

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,26 @@ describe('encoding: URL param segment for /posts/$slug', () => {
333333
expect(router.state.location.pathname).toBe('/posts/🚀')
334334
})
335335

336+
it('state.location.pathname, should have the params.slug value of "100%25"', async () => {
337+
const { router } = createTestRouter({
338+
history: createMemoryHistory({ initialEntries: ['/posts/100%25'] }),
339+
})
340+
341+
await act(() => router.load())
342+
343+
expect(router.state.location.pathname).toBe('/posts/100%25')
344+
})
345+
346+
it('state.location.pathname, should have the params.slug value of "100%26"', async () => {
347+
const { router } = createTestRouter({
348+
history: createMemoryHistory({ initialEntries: ['/posts/100%26'] }),
349+
})
350+
351+
await act(() => router.load())
352+
353+
expect(router.state.location.pathname).toBe('/posts/100%26')
354+
})
355+
336356
it('state.location.pathname, should have the params.slug value of "%F0%9F%9A%80"', async () => {
337357
const { router } = createTestRouter({
338358
history: createMemoryHistory({ initialEntries: ['/posts/%F0%9F%9A%80'] }),
@@ -413,6 +433,78 @@ describe('encoding: URL param segment for /posts/$slug', () => {
413433
expect((match.params as unknown as any).slug).toBe('🚀')
414434
})
415435

436+
it('params.slug for the matched route, should be "100%"', async () => {
437+
const { router, routes } = createTestRouter({
438+
history: createMemoryHistory({ initialEntries: ['/posts/100%25'] }),
439+
})
440+
441+
await act(() => router.load())
442+
443+
const match = router.state.matches.find(
444+
(r) => r.routeId === routes.postIdRoute.id,
445+
)
446+
447+
if (!match) {
448+
throw new Error('No match found')
449+
}
450+
451+
expect((match.params as unknown as any).slug).toBe('100%')
452+
})
453+
454+
it('params.slug for the matched route, should be "100&"', async () => {
455+
const { router, routes } = createTestRouter({
456+
history: createMemoryHistory({ initialEntries: ['/posts/100%26'] }),
457+
})
458+
459+
await act(() => router.load())
460+
461+
const match = router.state.matches.find(
462+
(r) => r.routeId === routes.postIdRoute.id,
463+
)
464+
465+
if (!match) {
466+
throw new Error('No match found')
467+
}
468+
469+
expect((match.params as unknown as any).slug).toBe('100&')
470+
})
471+
472+
it('params.slug for the matched route, should be "100%100"', async () => {
473+
const { router, routes } = createTestRouter({
474+
history: createMemoryHistory({ initialEntries: ['/posts/100%25100'] }),
475+
})
476+
477+
await act(() => router.load())
478+
479+
const match = router.state.matches.find(
480+
(r) => r.routeId === routes.postIdRoute.id,
481+
)
482+
483+
if (!match) {
484+
throw new Error('No match found')
485+
}
486+
487+
expect((match.params as unknown as any).slug).toBe('100%100')
488+
})
489+
490+
it('params.slug for the matched route, should be "100&100"', async () => {
491+
const { router, routes } = createTestRouter({
492+
history: createMemoryHistory({ initialEntries: ['/posts/100%26100'] }),
493+
})
494+
495+
await act(() => router.load())
496+
497+
const match = router.state.matches.find(
498+
(r) => r.routeId === routes.postIdRoute.id,
499+
)
500+
501+
if (!match) {
502+
throw new Error('No match found')
503+
}
504+
505+
expect((match.params as unknown as any).slug).toBe('100&100')
506+
})
507+
416508
it('params.slug for the matched route, should be "framework/react/guide/file-based-routing tanstack" instead of it being "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => {
417509
const { router, routes } = createTestRouter({
418510
history: createMemoryHistory({
@@ -490,6 +582,26 @@ describe('encoding: URL splat segment for /$', () => {
490582
expect(router.state.location.pathname).toBe('/🚀')
491583
})
492584

585+
it('state.location.pathname, should have the params._splat value of "100%25"', async () => {
586+
const { router } = createTestRouter({
587+
history: createMemoryHistory({ initialEntries: ['/100%25'] }),
588+
})
589+
590+
await router.load()
591+
592+
expect(router.state.location.pathname).toBe('/100%25')
593+
})
594+
595+
it('state.location.pathname, should have the params._splat value of "100%26"', async () => {
596+
const { router } = createTestRouter({
597+
history: createMemoryHistory({ initialEntries: ['/100%26'] }),
598+
})
599+
600+
await router.load()
601+
602+
expect(router.state.location.pathname).toBe('/100%26')
603+
})
604+
493605
it('state.location.pathname, should have the params._splat value of "%F0%9F%9A%80"', async () => {
494606
const { router } = createTestRouter({
495607
history: createMemoryHistory({ initialEntries: ['/%F0%9F%9A%80'] }),
@@ -619,6 +731,21 @@ describe('encoding: URL path segment', () => {
619731
output: '/path-segment/é',
620732
type: 'not encoded',
621733
},
734+
{
735+
input: '/path-segment/100%25', // `%25` = `%`
736+
output: '/path-segment/100%25',
737+
type: 'not encoded',
738+
},
739+
{
740+
input: '/path-segment/100%25%25',
741+
output: '/path-segment/100%25%25',
742+
type: 'not encoded',
743+
},
744+
{
745+
input: '/path-segment/100%26', // `%26` = `&`
746+
output: '/path-segment/100%26',
747+
type: 'not encoded',
748+
},
622749
{
623750
input: '/path-segment/%F0%9F%9A%80',
624751
output: '/path-segment/🚀',
@@ -629,6 +756,22 @@ describe('encoding: URL path segment', () => {
629756
output: '/path-segment/🚀to%2Fthe%2Fmoon',
630757
type: 'encoded',
631758
},
759+
{
760+
input: '/path-segment/%25%F0%9F%9A%80to%2Fthe%2Fmoon',
761+
output: '/path-segment/%25🚀to%2Fthe%2Fmoon',
762+
type: 'encoded',
763+
},
764+
{
765+
input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25',
766+
output: '/path-segment/🚀to%2Fthe%2Fmoon%25',
767+
type: 'encoded',
768+
},
769+
{
770+
input:
771+
'/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon',
772+
output: '/path-segment/🚀to%2Fthe%2Fmoon%25🚀to%2Fthe%2Fmoon',
773+
type: 'encoded',
774+
},
632775
{
633776
input: '/path-segment/🚀',
634777
output: '/path-segment/🚀',

0 commit comments

Comments
 (0)