diff --git a/.changeset/decode-route-params.md b/.changeset/decode-route-params.md new file mode 100644 index 00000000..e7dcd95a --- /dev/null +++ b/.changeset/decode-route-params.md @@ -0,0 +1,7 @@ +--- +"@solidjs/router": patch +--- + +Route params are decoded (#670). `useParams()`, `props.params` and `Router.match()` return each dynamic segment decoded after matching, so `/item/%E7%8C%AB` gives `id: "猫"` and `paths.item("猫")` round-trips. `matchFilters` see the decoded value, so `{ k: ["猫"] }` now matches. An encoded slash stays inside its param (`/a%2Fb/view` gives `name: "a/b"` for `/:name/view`), splat params are decoded segment by segment, and a malformed escape is left raw. `match.path` and `location.pathname` keep the URL's encoding. + +Breaking: code that already calls `decodeURIComponent` on params decodes twice; drop the extra decode. Encode a param (or use `paths`) when interpolating it into a URL by hand. diff --git a/README.md b/README.md index 6c29510c..ff133f39 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,8 @@ const routes = defineRoutes([ As long as the URL fits the pattern, the `User` component shows, and `id` is available via `useParams`. +Params are decoded: on `/users/%E7%8C%AB` (`/users/猫`), `id` is `"猫"`, and `matchFilters` see the decoded value too. Each segment is decoded after matching, so an encoded slash stays inside its param (`/users/a%2Fb` gives `id: "a/b"`), and a malformed escape is left as-is. `paths` encodes params on the way out, so `paths.users("猫")` round-trips. When building a URL from a param by hand, encode it (`encodeURIComponent`) or use `paths`. `location.pathname` keeps the URL's own encoding. + **Note on Animation/Transitions**: routes that share the same path match are treated as the same route. To force a re-render, wrap your component in a keyed ``: ```tsx diff --git a/src/utils.ts b/src/utils.ts index 7c3a9caa..d7abdaa6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -160,8 +160,9 @@ export function createMatcher( const locSegment = dynamic ? locSegments[i] : locSegments[i].toLowerCase(); const key = dynamic ? segment.slice(1) : segment.toLowerCase(); - if (dynamic && matchSegment(locSegment, filters?.[key])) { - match.params[key] = locSegment; + let value: string; + if (dynamic && matchSegment((value = decodeParam(locSegment)), filters?.[key])) { + match.params[key] = value; } else if (dynamic || !matchSegment(locSegment, key)) { return null; } @@ -169,7 +170,7 @@ export function createMatcher( } if (splat) { - const remainder = lenDiff ? locSegments.slice(-lenDiff).join("/") : ""; + const remainder = lenDiff ? locSegments.slice(-lenDiff).map(decodeParam).join("/") : ""; if (matchSegment(remainder, filters?.[splat])) { match.params[splat] = remainder; } else { @@ -181,6 +182,16 @@ export function createMatcher( }; } +// Params are decoded per segment after matching, so an encoded `%2F` can't +// split a segment (#220) and `paths` round-trips. A malformed escape stays raw. +const decodeParam = (segment: string) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +}; + // a string filter is the segment itself; anything else not listed never matches const matchSegment = (input: string, filter?: string | MatchFilter): boolean => filter === undefined || diff --git a/test/factory.spec.tsx b/test/factory.spec.tsx index e6e09160..7a90b244 100644 --- a/test/factory.spec.tsx +++ b/test/factory.spec.tsx @@ -122,6 +122,14 @@ describe("createRouter factory", () => { expect(matches[0].path).toBe("*404"); }); + test("decodes params, so paths round-trips (#670)", () => { + const Users = createRouter({ routes: [{ path: "/users/:id" }] as const }); + const url = String(Users.paths.users("猫 a%")); + expect(url).toBe("/users/%E7%8C%AB%20a%25"); + expect(Users.match(url)[0].params).toEqual({ id: "猫 a%" }); + expect(Users.match(url)[0].match).toBe(url); + }); + test("returns an empty array when nothing matches", () => { const Bare = createRouter({ routes: [{ path: "/only" }] as const }); expect(Bare.match("/other")).toEqual([]); diff --git a/test/utils.spec.ts b/test/utils.spec.ts index e9266283..2a5dba47 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -358,6 +358,40 @@ describe("createMatcher should", () => { const failure = matcher("tree/left/up/right/12"); expect(failure).toBe(null); }); + describe("decode params (#670)", () => { + test("per segment after matching", () => { + const matcher = createMatcher("/item/:id"); + expect(matcher("/item/%E7%8C%AB")!.params).toEqual({ id: "猫" }); + expect(matcher("/item/a%20b")!.params).toEqual({ id: "a b" }); + expect(matcher("/item/%25")!.params).toEqual({ id: "%" }); + }); + + test("once, so an escaped escape survives", () => { + expect(createMatcher("/item/:id")("/item/%252F")!.params).toEqual({ id: "%2F" }); + }); + + test("without letting an encoded slash split the segment", () => { + const matcher = createMatcher("/:name/view"); + const match = matcher("/hello%2Fworld/view")!; + expect(match.params).toEqual({ name: "hello/world" }); + expect(match.path).toBe("/hello%2Fworld/view"); + }); + + test("leaving a malformed escape raw", () => { + expect(createMatcher("/item/:id")("/item/%E0%A4%A")!.params).toEqual({ id: "%E0%A4%A" }); + }); + + test("before matchFilters see them", () => { + const matcher = createMatcher("/kanji/:k", false, { k: ["猫"] }); + expect(matcher("/kanji/%E7%8C%AB")!.params).toEqual({ k: "猫" }); + expect(matcher("/kanji/%E7%8A%AC")).toBe(null); + }); + + test("in a splat", () => { + const matcher = createMatcher("/files/*rest"); + expect(matcher("/files/a%2Fb/c%20d/%E7%8C%AB")!.params).toEqual({ rest: "a/b/c d/猫" }); + }); + }); }); describe("joinPaths should", () => {