From 9cf22304e5b3131f57abd6714bc28559ecd05fd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 22:03:49 +0000 Subject: [PATCH 1/2] fix(matching): decode route params per segment after matching useParams(), props.params and Router.match() return each dynamic segment decoded, and matchFilters see the decoded value. Decoding after matching keeps an encoded slash inside its param (#220); a malformed escape stays raw. Splats decode segment by segment. match.path and location.pathname keep the URL's encoding. Fixes #670. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01R2FCNsvaipU3xXx9p83VUs --- .changeset/decode-route-params.md | 7 +++++++ README.md | 2 ++ src/utils.ts | 17 +++++++++++++--- test/factory.spec.tsx | 8 ++++++++ test/utils.spec.ts | 34 +++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 .changeset/decode-route-params.md 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..665bc3e1 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; + const value = dynamic ? decodeParam(locSegment) : locSegment; + if (dynamic && matchSegment(value, 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", () => { From 79a2aef81e80afa58a844853a800e2772caf9ee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 22:12:06 +0000 Subject: [PATCH 2/2] refactor(matching): decode a param only on the dynamic branch Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01R2FCNsvaipU3xXx9p83VUs --- src/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 665bc3e1..d7abdaa6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -160,8 +160,8 @@ export function createMatcher( const locSegment = dynamic ? locSegments[i] : locSegments[i].toLowerCase(); const key = dynamic ? segment.slice(1) : segment.toLowerCase(); - const value = dynamic ? decodeParam(locSegment) : locSegment; - if (dynamic && matchSegment(value, filters?.[key])) { + let value: string; + if (dynamic && matchSegment((value = decodeParam(locSegment)), filters?.[key])) { match.params[key] = value; } else if (dynamic || !matchSegment(locSegment, key)) { return null;