Skip to content

Commit

Permalink
feat(react-router): allow unencoded characters in dynamic path segmen…
Browse files Browse the repository at this point in the history
…ts (#2677)
  • Loading branch information
alma-lp authored Nov 5, 2024
1 parent d13de07 commit cc05ad8
Show file tree
Hide file tree
Showing 7 changed files with 115 additions and 2 deletions.
6 changes: 6 additions & 0 deletions docs/framework/react/api/router/RouterOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,9 @@ const router = createRouter({
- Optional
- Defaults to `never`
- Configures how trailing slashes are treated. `'always'` will add a trailing slash if not present, `'never'` will remove the trailing slash if present and `'preserve'` will not modify the trailing slash.

### `pathParamsAllowedCharacters` property

- Type: `Array<';' | ':' | '@' | '&' | '=' | '+' | '$' | ','>`
- Optional
- Configures which URI characters are allowed in path params that would ordinarily be escaped by encodeURIComponent.
16 changes: 16 additions & 0 deletions docs/framework/react/guide/path-params.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,19 @@ function Component() {
```

Notice that the function style is useful when you need to persist params that are already in the URL for other routes. This is because the function style will receive the current params as an argument, allowing you to modify them as needed and return the final params object.

## Allowed Characters

By default, path params are escaped with `encodeURIComponent`. If you want to allow other valid URI characters (e.g. `@` or `+`), you can specify that in your [RouterOptions](../api/router/RouterOptionsType.md#pathparamsallowedcharacters-property)

Example usage:

```tsx
const router = createRouter({
...
pathParamsAllowedCharacters: ['@']
})
```

The following is the list of accepted allowed characters:
`;` `:` `@` `&` `=` `+` `$` `,`
17 changes: 16 additions & 1 deletion packages/react-router/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,16 @@ interface InterpolatePathOptions {
params: Record<string, unknown>
leaveWildcards?: boolean
leaveParams?: boolean
// Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params
decodeCharMap?: Map<string, string>
}

export function interpolatePath({
path,
params,
leaveWildcards,
leaveParams,
decodeCharMap,
}: InterpolatePathOptions) {
const interpolatedPathSegments = parsePathname(path)
const encodedParams: any = {}
Expand All @@ -222,7 +225,9 @@ export function interpolatePath({
// the splat/catch-all routes shouldn't have the '/' encoded out
encodedParams[key] = isValueString ? encodeURI(value) : value
} else {
encodedParams[key] = isValueString ? encodeURIComponent(value) : value
encodedParams[key] = isValueString
? encodePathParam(value, decodeCharMap)
: value
}
}

Expand All @@ -247,6 +252,16 @@ export function interpolatePath({
)
}

function encodePathParam(value: string, decodeCharMap?: Map<string, string>) {
let encoded = encodeURIComponent(value)
if (decodeCharMap) {
for (const [encodedChar, char] of decodeCharMap) {
encoded = encoded.replaceAll(encodedChar, char)
}
}
return encoded
}

export function matchPathname(
basepath: string,
currentPathname: string,
Expand Down
23 changes: 23 additions & 0 deletions packages/react-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,15 @@ export interface RouterOptions<
*/
strict?: boolean
}
/**
* Configures which URI characters are allowed in path params that would ordinarily be escaped by encodeURIComponent.
*
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#pathparamsallowedcharacters-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/path-params#allowed-characters)
*/
pathParamsAllowedCharacters?: Array<
';' | ':' | '@' | '&' | '=' | '+' | '$' | ','
>
}

export interface RouterErrorSerializer<TSerializedError> {
Expand Down Expand Up @@ -711,6 +720,7 @@ export class Router<
routesByPath!: RoutesByPath<TRouteTree>
flatRoutes!: Array<AnyRoute>
isServer!: boolean
pathParamsDecodeCharMap?: Map<string, string>

/**
* @deprecated Use the `createRouter` function instead
Expand Down Expand Up @@ -768,6 +778,15 @@ export class Router<

this.isServer = this.options.isServer ?? typeof document === 'undefined'

this.pathParamsDecodeCharMap = this.options.pathParamsAllowedCharacters
? new Map(
this.options.pathParamsAllowedCharacters.map((char) => [
encodeURIComponent(char),
char,
]),
)
: undefined

if (
!this.basepath ||
(newOptions.basepath && newOptions.basepath !== previousOptions.basepath)
Expand Down Expand Up @@ -1187,13 +1206,15 @@ export class Router<
const interpolatedPath = interpolatePath({
path: route.fullPath,
params: routeParams,
decodeCharMap: this.pathParamsDecodeCharMap,
})

const matchId =
interpolatePath({
path: route.id,
params: routeParams,
leaveWildcards: true,
decodeCharMap: this.pathParamsDecodeCharMap,
}) + loaderDepsHash

// Waste not, want not. If we already have a match for this route,
Expand Down Expand Up @@ -1431,6 +1452,7 @@ export class Router<
const interpolatedPath = interpolatePath({
path: route.fullPath,
params: matchedRoutesResult?.routeParams ?? {},
decodeCharMap: this.pathParamsDecodeCharMap,
})
const pathname = joinPaths([this.basepath, interpolatedPath])
return pathname === fromPath
Expand Down Expand Up @@ -1467,6 +1489,7 @@ export class Router<
params: nextParams ?? {},
leaveWildcards: false,
leaveParams: opts.leaveParams,
decodeCharMap: this.pathParamsDecodeCharMap,
})

let search = fromSearch
Expand Down
21 changes: 20 additions & 1 deletion packages/react-router/tests/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,28 @@ describe('interpolatePath', () => {
params: { id: 0 },
result: '/users/0',
},
{
name: 'should interpolate the path with URI component encoding',
path: '/users/$id',
params: { id: '?#@john+smith' },
result: '/users/%3F%23%40john%2Bsmith',
},
{
name: 'should interpolate the path without URI encoding characters in decodeCharMap',
path: '/users/$id',
params: { id: '?#@john+smith' },
result: '/users/%3F%23@john+smith',
decodeCharMap: new Map(
['@', '+'].map((char) => [encodeURIComponent(char), char]),
),
},
].forEach((exp) => {
it(exp.name, () => {
const result = interpolatePath({ path: exp.path, params: exp.params })
const result = interpolatePath({
path: exp.path,
params: exp.params,
decodeCharMap: exp.decodeCharMap,
})
expect(result).toBe(exp.result)
})
})
Expand Down
31 changes: 31 additions & 0 deletions packages/react-router/tests/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,37 @@ describe('encoding: URL param segment for /posts/$slug', () => {
'framework/react/guide/file-based-routing tanstack',
)
})

it('params.slug should be encoded in the final URL', async () => {
const { router } = createTestRouter({
history: createMemoryHistory({ initialEntries: ['/'] }),
})

await router.load()
render(<RouterProvider router={router} />)

await act(() =>
router.navigate({ to: '/posts/$slug', params: { slug: '@jane' } }),
)

expect(router.state.location.pathname).toBe('/posts/%40jane')
})

it('params.slug should be encoded in the final URL except characters in pathParamsAllowedCharacters', async () => {
const { router } = createTestRouter({
history: createMemoryHistory({ initialEntries: ['/'] }),
pathParamsAllowedCharacters: ['@'],
})

await router.load()
render(<RouterProvider router={router} />)

await act(() =>
router.navigate({ to: '/posts/$slug', params: { slug: '@jane' } }),
)

expect(router.state.location.pathname).toBe('/posts/@jane')
})
})

describe('encoding: URL splat segment for /$', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/router-generator/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export const configSchema = z.object({
autoCodeSplitting: z.boolean().optional(),
indexToken: z.string().optional().default('index'),
routeToken: z.string().optional().default('route'),
pathParamsAllowedCharacters: z
.array(z.enum([';', ':', '@', '&', '=', '+', '$', ',']))
.optional(),
customScaffolding: z
.object({
routeTemplate: z
Expand Down

0 comments on commit cc05ad8

Please sign in to comment.