diff --git a/packages/viewer/src/client/hash.ts b/packages/viewer/src/client/hash.ts new file mode 100644 index 0000000..2810b1a --- /dev/null +++ b/packages/viewer/src/client/hash.ts @@ -0,0 +1,16 @@ +// The URL hash carries the deep-linked scope (#project/agent). It's user-/copy-paste-/redirect- +// controlled, so it can contain a malformed %-escape ("#%", "#%zz", "#foo%2") — and +// decodeURIComponent throws URIError on those. Since the hash is read at module top level during +// boot, an unguarded decode would abort the ENTIRE client (page frozen in its static shell: +// "connecting…", no boards). Decode defensively: an undecodable hash degrades to "" (treated as +// no deep-link), so boot continues and the app auto-selects / shows the gallery. + +/** Decode a `location.hash` fragment to a scope key, tolerating a malformed %-escape (→ ""). */ +export function safeDecodeHash(hash: string): string { + const raw = hash.replace(/^#/, ""); + try { + return decodeURIComponent(raw); + } catch { + return ""; // malformed escape — treat as no deep-link rather than crashing boot + } +} diff --git a/packages/viewer/src/client/viewer.ts b/packages/viewer/src/client/viewer.ts index f87d4e9..f609c0b 100644 --- a/packages/viewer/src/client/viewer.ts +++ b/packages/viewer/src/client/viewer.ts @@ -1,6 +1,7 @@ import { toPng } from "html-to-image"; import { renderInto, teardownTree, patchInto } from "./registry.js"; import { INTRO_PANES } from "./intro.js"; +import { safeDecodeHash } from "./hash.js"; import { setActiveScope } from "./interact.js"; import { initConsole, setConsoleScope, onInboxEvent, onInboxRead, onSuggest, resyncConsole } from "./console.js"; import type { PatchOp } from "../flow-patch.js"; @@ -74,8 +75,9 @@ function setFollowing(on: boolean): void { document.getElementById("follow")?.setAttribute("aria-pressed", on ? "true" : "false"); } -// Deep-link target: the scope named in the URL hash (#project/agent), if any. -const readHash = () => decodeURIComponent(location.hash.replace(/^#/, "")); +// Deep-link target: the scope named in the URL hash (#project/agent), if any. Decoded defensively +// — a malformed %-escape in the hash must not throw here and abort boot (see hash.ts). +const readHash = () => safeDecodeHash(location.hash); let wanted = readHash(); const EMPTY_STATE = diff --git a/packages/viewer/test/hash.test.ts b/packages/viewer/test/hash.test.ts new file mode 100644 index 0000000..44e3fde --- /dev/null +++ b/packages/viewer/test/hash.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { safeDecodeHash } from "../src/client/hash.js"; + +// Regression: a malformed %-escape in the URL hash (e.g. "#%", "#%zz", "#foo%2") makes +// decodeURIComponent throw URIError. readHash() runs that at module top level, so an +// undecodable hash used to abort the ENTIRE client boot — leaving the page frozen in its +// static shell ("connecting…", no boards, console-toggle never removed). safeDecodeHash must +// never throw: an undecodable hash degrades to "" (no deep-link) so boot continues. +describe("safeDecodeHash", () => { + it("decodes a normal scope hash", () => { + expect(safeDecodeHash("#ops%2Forder-fulfillment")).toBe("ops/order-fulfillment"); + }); + + it("returns a plain (unescaped) fragment unchanged", () => { + expect(safeDecodeHash("#welcome")).toBe("welcome"); + }); + + it("treats an empty / bare hash as no deep-link", () => { + expect(safeDecodeHash("")).toBe(""); + expect(safeDecodeHash("#")).toBe(""); + }); + + it("does not throw on a malformed %-escape — degrades to ''", () => { + for (const bad of ["#%", "#%zz", "#foo%2", "#%E0%A4%A", "#%C3%28"]) { + expect(() => safeDecodeHash(bad)).not.toThrow(); + expect(safeDecodeHash(bad)).toBe(""); + } + }); +});