diff --git a/docs/framework/react/api/router/RouterOptionsType.md b/docs/framework/react/api/router/RouterOptionsType.md index be87b6b7e3..d2fee5f02f 100644 --- a/docs/framework/react/api/router/RouterOptionsType.md +++ b/docs/framework/react/api/router/RouterOptionsType.md @@ -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. diff --git a/docs/framework/react/guide/path-params.md b/docs/framework/react/guide/path-params.md index 71655ffa11..ba2d77df03 100644 --- a/docs/framework/react/guide/path-params.md +++ b/docs/framework/react/guide/path-params.md @@ -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: +`;` `:` `@` `&` `=` `+` `$` `,` diff --git a/packages/react-router/src/path.ts b/packages/react-router/src/path.ts index 53c3ffe00e..cf4b99b9c6 100644 --- a/packages/react-router/src/path.ts +++ b/packages/react-router/src/path.ts @@ -204,6 +204,8 @@ interface InterpolatePathOptions { params: Record leaveWildcards?: boolean leaveParams?: boolean + // Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params + decodeCharMap?: Map } export function interpolatePath({ @@ -211,6 +213,7 @@ export function interpolatePath({ params, leaveWildcards, leaveParams, + decodeCharMap, }: InterpolatePathOptions) { const interpolatedPathSegments = parsePathname(path) const encodedParams: any = {} @@ -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 } } @@ -247,6 +252,16 @@ export function interpolatePath({ ) } +function encodePathParam(value: string, decodeCharMap?: Map) { + 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, diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 6b2e693e34..24e47c1229 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -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 { @@ -711,6 +720,7 @@ export class Router< routesByPath!: RoutesByPath flatRoutes!: Array isServer!: boolean + pathParamsDecodeCharMap?: Map /** * @deprecated Use the `createRouter` function instead @@ -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) @@ -1187,6 +1206,7 @@ export class Router< const interpolatedPath = interpolatePath({ path: route.fullPath, params: routeParams, + decodeCharMap: this.pathParamsDecodeCharMap, }) const matchId = @@ -1194,6 +1214,7 @@ export class Router< path: route.id, params: routeParams, leaveWildcards: true, + decodeCharMap: this.pathParamsDecodeCharMap, }) + loaderDepsHash // Waste not, want not. If we already have a match for this route, @@ -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 @@ -1467,6 +1489,7 @@ export class Router< params: nextParams ?? {}, leaveWildcards: false, leaveParams: opts.leaveParams, + decodeCharMap: this.pathParamsDecodeCharMap, }) let search = fromSearch diff --git a/packages/react-router/tests/path.test.ts b/packages/react-router/tests/path.test.ts index 5e3f559b43..78fa6d347d 100644 --- a/packages/react-router/tests/path.test.ts +++ b/packages/react-router/tests/path.test.ts @@ -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) }) }) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 68a27dbcf2..4cc9055331 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -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: ['/posts/@jane'] }), + }) + + await router.load() + render() + + 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: ['/posts/@jane'] }), + pathParamsAllowedCharacters: ['@'], + }) + + await router.load() + render() + + await act(() => + router.navigate({ to: '/posts/$slug', params: { slug: '@jane' } }), + ) + + expect(router.state.location.pathname).toBe('/posts/@jane') + }) }) describe('encoding: URL splat segment for /$', () => { diff --git a/packages/router-generator/src/config.ts b/packages/router-generator/src/config.ts index 629688bbd8..ec7ddeb9b8 100644 --- a/packages/router-generator/src/config.ts +++ b/packages/router-generator/src/config.ts @@ -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