fix(routing): decode the router splat for file deep-links

React Router 6.30 decodes named params but leaves the `*` splat
percent-encoded, so a deep-link like

  /:scope/projects/arduino/Repo-main/KiCad%20Projects/Mega.kicad_pcb

reached ToolPage as `name: "arduino"` (decoded) alongside a splat still
reading `KiCad%20Projects/...`. Every consumer of the resulting targetPath
expects the decoded form: the project file list carries real spaces, and
project-source's encodePath re-encodes per segment when building API URLs,
so a %20 target double-encodes to %2520.

Decodes per SEGMENT so an encoded separator can never silently become a path
boundary, and keeps a malformed segment verbatim rather than throwing during
a route render. The unit test drives matchPath directly so a future router
upgrade that changes this behaviour fails loudly instead of silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
This commit is contained in:
Gergő Törcsvári 2026-07-31 09:47:59 +02:00
commit 3b5d4d0662
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 81 additions and 1 deletions

View file

@ -0,0 +1,45 @@
import { matchPath } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { decodeRoutePath } from "./route-path";
describe("decodeRoutePath", () => {
it("decodes percent-encoded spaces in every segment", () => {
expect(
decodeRoutePath("Repo-main/KiCad%20Projects/Arduino%20Mega%202560/Mega.kicad_pcb"),
).toBe("Repo-main/KiCad Projects/Arduino Mega 2560/Mega.kicad_pcb");
});
it("leaves an already-decoded path alone", () => {
expect(decodeRoutePath("KiCad Projects/Mega.kicad_pcb")).toBe(
"KiCad Projects/Mega.kicad_pcb",
);
});
it("decodes non-ASCII names", () => {
expect(decodeRoutePath("t%C3%A9st/b%C3%B6ard.kicad_sch")).toBe("tést/böard.kicad_sch");
});
it("keeps a malformed segment verbatim instead of throwing", () => {
expect(decodeRoutePath("ok/100%/board.kicad_pcb")).toBe("ok/100%/board.kicad_pcb");
});
it("does not let an encoded separator become a path boundary", () => {
// Per-segment decoding keeps the "/" inside the name rather than splitting
// the path — the segment count must not change.
expect(decodeRoutePath("dir/a%2Fb.kicad_pcb").split("/").length).toBe(3);
});
// The regression itself: the router hands ToolPage an ENCODED splat, so
// without this decode `targetPath` matches no entry in the project file list.
it("restores the path the router failed to decode", () => {
const url =
"/e2e-1/projects/arduino/Repo-main/KiCad%20Projects/Arduino%20Mega%202560/Mega.kicad_pcb";
const match = matchPath("/:scope/projects/:name/*", url);
const splat = match?.params["*"] ?? "";
expect(splat).toContain("%20"); // router leaves the splat encoded…
expect(match?.params.name).toBe("arduino"); // …while named params are decoded
expect(decodeRoutePath(splat)).toBe(
"Repo-main/KiCad Projects/Arduino Mega 2560/Mega.kicad_pcb",
);
});
});

View file

@ -0,0 +1,31 @@
/**
* React Router (v6) decodes NAMED params but leaves the `*` splat
* percent-encoded, so a file deep-link like
*
* /:scope/projects/arduino/Repo-main/KiCad%20Projects/Mega.kicad_pcb
*
* yields `name: "arduino"` (decoded) and a splat that still reads
* `Repo-main/KiCad%20Projects/Mega.kicad_pcb`. Every consumer of the resulting
* `targetPath` expects the DECODED form the project file list carries real
* spaces, `encodePath` (project-source) re-encodes per segment when building
* API URLs, and MEMFS paths are raw. Left encoded, the target matches no file:
* the API URL double-encodes to `%2520`, the open fails, and KiCad quits
* which the quit hook turns into a bounce back to the project page.
*
* Decodes per SEGMENT so an encoded separator (`%2F`) inside a name can never
* silently become a path boundary, and falls back to the raw segment when the
* encoding is malformed (a lone `%`), since a wrong-but-stable path is easier
* to diagnose than a thrown route render.
*/
export function decodeRoutePath(splat: string): string {
return splat
.split("/")
.map((seg) => {
try {
return decodeURIComponent(seg);
} catch {
return seg;
}
})
.join("/");
}

View file

@ -8,6 +8,7 @@ import {
useSourceDescriptor,
} from "@/lib/api";
import { docSourceConfig } from "@/lib/config";
import { decodeRoutePath } from "@/lib/route-path";
import { resolveReadOnly } from "@/lib/read-only-mode";
import { WasmTool } from "@/components/WasmTool";
import { PreflightGate } from "@/preflight/PreflightGate";
@ -19,7 +20,10 @@ export function ToolPage() {
// Two shapes render here: a fileless tool boot (`…/-/:tool`) sets params.tool;
// a file route (`…/*`) sets the splat — the tool is inferred from its extension
// unless `?tool=` overrides it.
const splat = params["*"] || undefined;
// Router leaves the splat percent-encoded (named params are decoded) — see
// decodeRoutePath. Everything downstream wants the decoded path.
const rawSplat = params["*"] || undefined;
const splat = rawSplat ? decodeRoutePath(rawSplat) : undefined;
const tool: Tool | null = params.tool
? parseToolParam(params.tool)
: (parseToolParam(search.get("tool")) ?? (splat ? toolForFile(splat) : null));