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,
|
useSourceDescriptor,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { docSourceConfig } from "@/lib/config";
|
import { docSourceConfig } from "@/lib/config";
|
||||||
|
import { decodeRoutePath } from "@/lib/route-path";
|
||||||
import { resolveReadOnly } from "@/lib/read-only-mode";
|
import { resolveReadOnly } from "@/lib/read-only-mode";
|
||||||
import { WasmTool } from "@/components/WasmTool";
|
import { WasmTool } from "@/components/WasmTool";
|
||||||
import { PreflightGate } from "@/preflight/PreflightGate";
|
import { PreflightGate } from "@/preflight/PreflightGate";
|
||||||
|
|
@ -19,7 +20,10 @@ export function ToolPage() {
|
||||||
// Two shapes render here: a fileless tool boot (`…/-/:tool`) sets params.tool;
|
// 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
|
// a file route (`…/*`) sets the splat — the tool is inferred from its extension
|
||||||
// unless `?tool=` overrides it.
|
// 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
|
const tool: Tool | null = params.tool
|
||||||
? parseToolParam(params.tool)
|
? parseToolParam(params.tool)
|
||||||
: (parseToolParam(search.get("tool")) ?? (splat ? toolForFile(splat) : null));
|
: (parseToolParam(search.get("tool")) ?? (splat ? toolForFile(splat) : null));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue