Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/decode-route-params.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Show>`:

```tsx
Expand Down
17 changes: 14 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,16 +160,17 @@ export function createMatcher<S extends string>(
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;
}
match.path += `/${locSegment}`;
}

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 {
Expand All @@ -181,6 +182,16 @@ export function createMatcher<S extends string>(
};
}

// 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 ||
Expand Down
8 changes: 8 additions & 0 deletions test/factory.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
34 changes: 34 additions & 0 deletions test/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading