From 3b5d4d06624defb2c43a213e9ab863f5018f41c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Fri, 31 Jul 2026 09:47:59 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM --- web/standalone/src/lib/route-path.test.ts | 45 +++++++++++++++++++++++ web/standalone/src/lib/route-path.ts | 31 ++++++++++++++++ web/standalone/src/pages/ToolPage.tsx | 6 ++- 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 web/standalone/src/lib/route-path.test.ts create mode 100644 web/standalone/src/lib/route-path.ts diff --git a/web/standalone/src/lib/route-path.test.ts b/web/standalone/src/lib/route-path.test.ts new file mode 100644 index 0000000..5e38e14 --- /dev/null +++ b/web/standalone/src/lib/route-path.test.ts @@ -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", + ); + }); +}); diff --git a/web/standalone/src/lib/route-path.ts b/web/standalone/src/lib/route-path.ts new file mode 100644 index 0000000..a110b5d --- /dev/null +++ b/web/standalone/src/lib/route-path.ts @@ -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("/"); +} diff --git a/web/standalone/src/pages/ToolPage.tsx b/web/standalone/src/pages/ToolPage.tsx index 91e5a1c..8d35217 100644 --- a/web/standalone/src/pages/ToolPage.tsx +++ b/web/standalone/src/pages/ToolPage.tsx @@ -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));