Skip to content

Commit c5cf922

Browse files
nowellsclaude
andcommitted
fix: encode path params per RFC 3986 path-segment rules in href/generatePath
Follow-up to #15277, which fixed `href()` to URL-encode param values to match `generatePath()`. Both now encode with `encodeURIComponent`, which implements *query-string* escaping — a stricter rule than URL paths require. This over-escapes characters that RFC 3986 explicitly allows literally in a path segment: pchar = unreserved / pct-encoded / sub-delims / ":" / "@" sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 `$ & + , ; = : @` only act as delimiters in a query string; in a path segment they carry no special meaning, and browsers keep them literal in `location.pathname`. Encoding them needlessly rewrites URLs: href("/releases/:v", { v: "1.0.0+1" }) // before: /releases/1.0.0%2B1 // after: /releases/1.0.0+1 which breaks apps that compare generated URLs against `window.location.pathname` (the browser reports the literal `+`), churns canonical/shareable URLs, and makes `href()` output disagree with what users see in the address bar. Changes: - Add an internal `encodePathParam()` that escapes structural/unsafe characters (`/ ? # %`, whitespace, non-ASCII) exactly as before, but restores the RFC 3986 pchar set that `encodeURIComponent` over-escapes - Use it for named params in `generatePath()` and for named params and splat segments in `href()` - Document path-segment vs query-string encoding semantics (with the RFC reference) in the `href()` and `generatePath()` JSDoc - Update and extend unit tests; add a change file Behavior is unchanged for every character outside `$ & + , ; = : @`, and `generatePath()`'s splat values remain un-encoded, as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ac5d9d5 commit c5cf922

5 files changed

Lines changed: 123 additions & 5 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Encode path params in `href`/`generatePath` per RFC 3986 path-segment rules instead of `encodeURIComponent`
2+
3+
- Characters that are valid literally in a path segment (`$ & + , ; = : @` — RFC 3986 `pchar`) are no longer percent-encoded, so values like a semver build `1.0.0+1` interpolate unchanged instead of becoming `1.0.0%2B1`
4+
- Structural/unsafe characters (`/ ? # %`, whitespace, non-ASCII) are still escaped exactly as before

packages/react-router/__tests__/generatePath-test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,28 @@ describe("generatePath", () => {
141141
});
142142
});
143143

144+
describe("param encoding", () => {
145+
it("encodes characters that would change the URL structure", () => {
146+
expect(generatePath("/courses/:id", { id: "a b?c#d%e" })).toBe(
147+
"/courses/a%20b%3Fc%23d%25e",
148+
);
149+
expect(generatePath("/courses/:id", { id: "café…" })).toBe(
150+
"/courses/caf%C3%A9%E2%80%A6",
151+
);
152+
});
153+
154+
it("preserves characters RFC 3986 allows literally in a path segment", () => {
155+
// pchar sub-delims plus ":" and "@" — see RFC 3986 §3.3
156+
expect(generatePath("/courses/:id", { id: "$&+,;=:@" })).toBe(
157+
"/courses/$&+,;=:@",
158+
);
159+
// e.g. a semver build suffix survives untouched
160+
expect(generatePath("/releases/:version", { version: "1.0.0+1" })).toBe(
161+
"/releases/1.0.0+1",
162+
);
163+
});
164+
});
165+
144166
it("throws only on missing named parameters, but not missing splat params", () => {
145167
expect(() => generatePath(":foo")).toThrow();
146168
expect(() => generatePath("/:foo")).toThrow();

packages/react-router/__tests__/href-test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,43 @@ describe("href", () => {
5252
expect(href("/products/:id", { id: "abc#frag" })).toBe(
5353
"/products/abc%23frag",
5454
);
55+
// "?" is escaped (it would start the query string), "=" is not (it is a
56+
// valid pchar in a path segment, unlike in a query string)
5557
expect(href("/products/:id", { id: "abc?x=1" })).toBe(
56-
"/products/abc%3Fx%3D1",
58+
"/products/abc%3Fx=1",
59+
);
60+
});
61+
62+
it("preserves characters RFC 3986 allows literally in a path segment", () => {
63+
// pchar sub-delims plus ":" and "@" — see RFC 3986 §3.3
64+
expect(href("/products/:id", { id: "$&+,;=:@" })).toBe(
65+
"/products/$&+,;=:@",
66+
);
67+
// e.g. a semver build suffix survives untouched
68+
expect(href("/releases/:version", { version: "1.0.0+1" })).toBe(
69+
"/releases/1.0.0+1",
5770
);
5871
});
5972

6073
it("encodes splat param values while preserving segments", () => {
6174
expect(href("/:param/*", { param: "a?b/c#d", "*": "e?f/g#h" })).toBe(
6275
"/a%3Fb%2Fc%23d/e%3Ff/g%23h",
6376
);
77+
expect(href("/releases/*", { "*": "v1/1.0.0+1" })).toBe(
78+
"/releases/v1/1.0.0+1",
79+
);
6480
});
6581

6682
it("round-trips through matchPath for param values with special characters", () => {
6783
let pattern = "/products/:id";
68-
for (let id of ["shoes/2026-summer", "abc#frag", "abc?x=1", "a b"]) {
84+
for (let id of [
85+
"shoes/2026-summer",
86+
"abc#frag",
87+
"abc?x=1",
88+
"a b",
89+
"$&+,;=:@",
90+
"1.0.0+1",
91+
]) {
6992
let result = href(pattern, { id });
7093
// before the fix, href()'s own output didn't match its own pattern
7194
let match = matchPath(pattern, result);

packages/react-router/lib/href.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { encodePathParam } from "./router/utils";
12
import type { Pages } from "./types/register";
23
import type { Equal } from "./types/utils";
34

@@ -25,6 +26,16 @@ function stringify(p: any) {
2526
2627
<Link to={href("/products/:id", { id: "abc123" })} />
2728
```
29+
30+
Param values are percent-encoded for use in a path segment: characters that
31+
would change the URL structure (`/`, `?`, `#`, `%`, whitespace, non-ASCII)
32+
are escaped, while characters that RFC 3986 allows literally in a path
33+
segment (`$ & + , ; = : @` — see
34+
[RFC 3986 §3.3](https://datatracker.ietf.org/doc/html/rfc3986#section-3.3))
35+
are kept as-is. Note this differs from query-string encoding
36+
(`encodeURIComponent`/`URLSearchParams`), where those characters are
37+
delimiters and must be escaped. Splat (`*`) values are encoded per segment,
38+
preserving `/` separators.
2839
*/
2940
export function href<Path extends keyof Args>(
3041
path: Path,
@@ -42,7 +53,7 @@ export function href<Path extends keyof Args>(
4253
`Path '${path}' requires param '${param}' but it was not provided`,
4354
);
4455
}
45-
return value == null ? "" : "/" + encodeURIComponent(stringify(value));
56+
return value == null ? "" : "/" + encodePathParam(stringify(value));
4657
},
4758
);
4859

@@ -53,7 +64,7 @@ export function href<Path extends keyof Args>(
5364
const value = params?.["*"];
5465
if (value !== undefined) {
5566
result +=
56-
"/" + stringify(value).split("/").map(encodeURIComponent).join("/");
67+
"/" + stringify(value).split("/").map(encodePathParam).join("/");
5768
}
5869
}
5970

packages/react-router/lib/router/utils.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1480,13 +1480,71 @@ function matchRouteBranch<
14801480
return matches;
14811481
}
14821482

1483+
/**
1484+
* Characters that `encodeURIComponent` escapes but that are valid literally in
1485+
* a URL path segment. Per RFC 3986 §3.3, a path segment is made of `pchar`:
1486+
*
1487+
* ```
1488+
* pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
1489+
* sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
1490+
* ```
1491+
*
1492+
* `encodeURIComponent` targets query-string values, where `$ & + , ; = : @`
1493+
* are delimiters and must be escaped — but in a path segment they carry no
1494+
* special meaning, and browsers keep them literal in `location.pathname`.
1495+
* (`! ' ( ) *` and the unreserved set are already left alone by
1496+
* `encodeURIComponent`, so they need no restoring.)
1497+
*/
1498+
const PATH_PARAM_OVERESCAPED: Record<string, string> = {
1499+
"%24": "$",
1500+
"%26": "&",
1501+
"%2B": "+",
1502+
"%2C": ",",
1503+
"%3A": ":",
1504+
"%3B": ";",
1505+
"%3D": "=",
1506+
"%40": "@",
1507+
};
1508+
1509+
/**
1510+
* Encodes a param value for interpolation into a single URL path segment.
1511+
*
1512+
* Escapes characters that would break the path (`/ ? # %`, whitespace,
1513+
* non-ASCII, …) while leaving characters that RFC 3986 permits literally in a
1514+
* path segment (see {@link PATH_PARAM_OVERESCAPED}) untouched. Escaping those
1515+
* would needlessly rewrite URLs — e.g. a semver build param `1.0.0+1` would
1516+
* become `1.0.0%2B1` even though browsers display and match the `+` literally
1517+
* in `location.pathname`.
1518+
*
1519+
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-3.3
1520+
* @param value The param value to encode.
1521+
* @returns The encoded value, safe for use as a single path segment.
1522+
*/
1523+
export function encodePathParam(value: string): string {
1524+
return encodeURIComponent(value).replace(
1525+
/%(?:24|26|2B|2C|3A|3B|3D|40)/g,
1526+
(match) => PATH_PARAM_OVERESCAPED[match],
1527+
);
1528+
}
1529+
14831530
/**
14841531
* Returns a path with params interpolated.
14851532
*
1533+
* Param values are percent-encoded for use in a path segment: characters that
1534+
* would change the URL structure (`/`, `?`, `#`, `%`, whitespace, non-ASCII)
1535+
* are escaped, while characters that RFC 3986 allows literally in a path
1536+
* segment (`$ & + , ; = : @` — see
1537+
* [RFC 3986 §3.3](https://datatracker.ietf.org/doc/html/rfc3986#section-3.3))
1538+
* are kept as-is. Note this differs from query-string encoding
1539+
* (`encodeURIComponent`/`URLSearchParams`), where those characters are
1540+
* delimiters and must be escaped.
1541+
*
14861542
* @example
14871543
* import { generatePath } from "react-router";
14881544
*
14891545
* generatePath("/users/:id", { id: "123" }); // "/users/123"
1546+
* generatePath("/files/:name", { name: "a b" }); // "/files/a%20b"
1547+
* generatePath("/releases/:v", { v: "1.0.0+1" }); // "/releases/1.0.0+1"
14901548
*
14911549
* @public
14921550
* @category Utils
@@ -1532,7 +1590,7 @@ export function generatePath<Path extends string>(
15321590
const [, key, optional, suffix] = keyMatch;
15331591
let param = params[key as keyof typeof params];
15341592
invariant(optional === "?" || param != null, `Missing ":${key}" param`);
1535-
return encodeURIComponent(stringify(param)) + suffix;
1593+
return encodePathParam(stringify(param)) + suffix;
15361594
}
15371595

15381596
// Remove any optional markers from optional static segments

0 commit comments

Comments
 (0)