Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

[2.0.0-next.38 / next] Route params stay percent-encoded, so matchFilters miss non-ASCII values and paths don't round-trip

Aperta
#670 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
42/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
typescript

Direzione di ricerca

Read createMatcher in src/utils.ts and the parameter encoding in src/paths.ts, then inspect the router's matcher and route parameter tests. Verify how decoded values affect segment matching, matchFilters, splat parameters, and malformed escapes; add coverage for the reproduction cases. Done means params and filters have a consistent documented behavior, with tests covering encoded values and round-tripping through paths.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

Summary

useParams() and props.params return dynamic segments still percent-encoded: on /item/猫 the param is "%E7%8C%AB", on /item/a%20b it is "a%20b". Route matchFilters are checked against the same encoded text:

  • A filter written with the plain value silently never matches. With matchFilters: { k: ["猫"] }, /kanji/猫 doesn't match the route, with no error or warning, because the filter compares "猫" with "%E7%8C%AB". It only works when written pre-encoded (["%E7%8C%AB"]), and regex and predicate filters get the same encoded string (see Cause). The README's filter examples are all ASCII (["mom", "dad"], /^\d+$/), so they never show this.
  • paths doesn't round-trip. paths.item("猫") builds /item/%E7%8C%AB, and the page reads back "%E7%8C%AB", not "猫".
  • None of this is documented. The README and the docs only say params are strings.

Every other widely used router tested below hands back decoded params. Params, and the value filters see, should be decoded per segment after matching. If raw values are intended, that should be documented on useParams, RouteProps.params and matchFilters.

Reproduction

@solidjs/[email protected], solid-js and @solidjs/web 2.0.0-rc.14, Node 24.21. A server render through the url prop; Router.match(url) returns the same params.

import { createComponent, renderToString } from "@solidjs/web";
import { createRouter, useParams } from "@solidjs/router";

let params;
const Page = () => {
  params = { ...useParams() };
  return null;
};

const Router = createRouter({
  routes: [
    { path: "/item/:id", component: Page },
    { path: "/:name/view", component: Page },
    { path: "/kanji/:k", component: Page, matchFilters: { k: ["猫"] } },
  ],
});

const built = String(Router.paths.item("猫"));
console.log(`paths.item("猫") = ${built}`);

for (const url of [
  "/item/%E7%8C%AB",
  "/item/猫",
  built,
  "/item/a%20b",
  "/item/%25",
  "/item/%E0%A4%A",
  "/hello%2Fworld/view",
  "/kanji/%E7%8C%AB",
]) {
  params = undefined;
  renderToString(() => createComponent(Router, { url }));
  console.log(url, "→", JSON.stringify(params));
}
URL Params
/item/%E7%8C%AB, also what paths.item("猫") builds { id: "%E7%8C%AB" }
/item/猫 { id: "%E7%8C%AB" }
/item/a%20b { id: "a%20b" }
/item/%25 { id: "%25" }
/hello%2Fworld/view { name: "hello%2Fworld" }
/kanji/%E7%8C%AB with matchFilters: { k: ["猫"] } no match

The value doesn't depend on how the URL was written: a raw /item/猫 is normalized to the encoded form first, so a param is always the encoded segment.

Other routers

Each router was installed at the version shown and given /item/%E7%8C%AB%20a%2Fb (猫, a space and an encoded slash) for an /item/:id-style route:

Router Version Checked through Param
React Router 8.4.0 matchRoutes, createMemoryRouter 猫 a/b
TanStack Router router-core 1.171.35 router-core matcher (findSingleMatch) 猫 a/b
Vue Router (Nuxt pages) 5.4.0 router.resolve 猫 a/b
SvelteKit 3.0.1 exec on decode_pathname(pathname), as its client and server do 猫 a/b
Angular Router 22.2.2 URL segments from DefaultUrlSerializer.parse 猫 a/b
preact-iso 2.12.2 exec 猫 a/b
Express 5.3.0 req.params 猫 a/b
Hono 4.13.13 c.req.param() 猫 a/b
Fastify 5.12.5 req.params 猫 a/b
h3 v1 (Nitro 2, Nuxt server routes) 1.15.11 getRouterParam 猫 a%2Fb
h3 v2 2.0.2 getRouterParam %E7%8C%AB%20a%2Fb; with { decode: true }: 猫 a%2Fb
Solid Router 2.0.0-next.38 useParams, Router.match %E7%8C%AB%20a%2Fb

h3 is the only other one that doesn't fully decode, and it isn't a consistent model. Within each version it treats the slash differently from every other character: v1 decodes 猫 and the space by default but leaves %2F encoded, and v2's opt-in decode does the same. So one param can come back partly decoded (猫 a%2Fb), and code reading it still has to know which escapes survived. It also differs between majors: v1 decodes by default and v2 doesn't. Every other router decodes a single-segment param completely, %2F included, without treating it as a separator.

Because this is the common convention, code written for a router (by developers and by coding agents alike) reads params.id as decoded text. With Solid Router that works for ASCII slugs and silently breaks for non-ASCII text, spaces and reserved characters, and nothing in the types or docs points at it.

Why #220 doesn't settle it

Decoding was removed in #222 for #220. There, decoding the whole pathname before matching turned %2F into a separator, so /hello%2Fworld/view stopped matching /:name/view; the reasoning was also that the router shouldn't decode what it didn't encode. Neither holds for 2.0:

  • Decoding each matched segment after matching can't create a separator. With the change below, /hello%2Fworld/view still matches /:name/view and gives name: "hello/world", which is what the routers above return.
  • The router now does encode: paths.item("猫") runs the value through encodeURIComponent, so the param read back is one the router itself encoded.
Cause

createMatcher in src/utils.ts (unchanged on next) stores the raw segment, and filters see it raw too:

if (dynamic && matchSegment(locSegment, filters?.[key])) {
  match.params[key] = locSegment;

while src/paths.ts encodes what it is given:

const encodeParam = (value: unknown) => String(value).split("/").map(encodeURIComponent).join("/");
Expected

Params decoded once per segment after matching, as in the routers above, with matchFilters receiving the decoded value. If raw values are intended instead, document it on useParams, RouteProps.params and matchFilters, including how to decode safely: a malformed escape such as %E0%A4%A makes decodeURIComponent throw.

Related: #220, #222.

Possible fix

Tested only against the reproduction above, by editing the published 2.0.0-next.38 dist/index.js; the router's own test suite was not run.

const decodeParam = (s) => {
  try {
    return decodeURIComponent(s);
  } catch {
    return s;
  }
};

// in createMatcher's segment loop:
const value = dynamic ? decodeParam(locSegment) : locSegment;
if (dynamic && matchSegment(value, filters?.[key])) {
  match.params[key] = value;
URL Before After
/item/%E7%8C%AB (= paths.item("猫")) { id: "%E7%8C%AB" } { id: "猫" }
/item/猫 { id: "%E7%8C%AB" } { id: "猫" }
/item/a%20b { id: "a%20b" } { id: "a b" }
/item/%25 { id: "%25" } { id: "%" }
/item/%E0%A4%A (malformed) { id: "%E0%A4%A" } { id: "%E0%A4%A" }
/hello%2Fworld/view { name: "hello%2Fworld" } { name: "hello/world" }
/kanji/%E7%8C%AB, filter ["猫"] no match { k: "猫" }

The edit leaves match.path, which is built from the raw segment, untouched. Code that already decodes params itself would decode twice (for /item/%25, decodeURIComponent("%") throws), so this is a breaking change that fits a pre-release major. If a breaking change isn't acceptable, an opt-in would still help: for example a createRouter option that turns this decoding on, for both params and the values matchFilters see.

Catch-all params

The edit above doesn't touch splat params, which stay fully encoded. Other routers decode those too. Given /files/a%2Fb/c%20d/%E7%8C%AB (an encoded slash, a space and 猫) on a catch-all route:

Router Catch-all param
React Router 8.4.0 (*) "a/b/c d/猫"
TanStack Router 1.171 ($) "a/b/c d/猫"
SvelteKit 3.0.1 ([...rest]) "a/b/c d/猫"
preact-iso 2.12 (:rest*) "a/b/c d/猫"
Hono 4.13 (:rest{.+}) "a/b/c d/猫"
Fastify 5.12.5 (*) "a/b/c d/猫"
Vue Router 5.4.0 (:rest(.*)) "a/b/c d/猫"
Vue Router 5.4.0 (:rest(.*)*) ["a/b", "c d", "猫"]
Express 5.3 (*rest) ["a/b", "c d", "猫"]
Angular 22.2 (** has no param; URL segments) ["files", "a/b", "c d", "猫"]
h3 v1 1.15.11 (**:rest) "a%2Fb/c d/猫"; with decode: true: "a/b/c d/猫"
h3 v2 2.0.2 (**:rest) raw; with decode: true: "a%2Fb/c d/猫"
Solid Router 2.0.0-next.38 (*rest) "a%2Fb/c%20d/%E7%8C%AB"

Most decode the splat into one string, accepting that a%2Fb/c and a/b/c both read as "a/b/c". Vue's repeatable params, Express 5 and Angular return the decoded segments as an array instead, which keeps an encoded slash distinguishable from a real one. Either would fit Solid Router: a decoded string like most routers, or an array of decoded segments if telling %2F apart from / matters.

(submitted by Claude Opus 5.5 on behalf of rvlzzr)

Lingua principale
TypeScript
Stelle
1.3k
Fork
182
Merge medio
16h 9m
PR unite (30g)
42

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di solidjs/solid-router

Tutte le issue di solidjs/solid-router

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.