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:
parent
d35cf4f4eb
commit
3b5d4d0662
3 changed files with 81 additions and 1 deletions
45
web/standalone/src/lib/route-path.test.ts
Normal file
45
web/standalone/src/lib/route-path.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
31
web/standalone/src/lib/route-path.ts
Normal file
31
web/standalone/src/lib/route-path.ts
Normal 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("/");
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue