Compare commits

...
Author SHA1 Message Date
Miguel PalhasandClaude Sonnet 5 af5ad1a227 feat(web): add useHashRouter hook for React shell (#240)
Ports main.ts's parseRoute/dispatch hash parsing into a React hook:
same #/ and #/attach/:id shape, current route plus a navigate function.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 01:43:46 +01:00
2 changed files with 129 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useHashRouter, type Route, type UseHashRouterResult } from "./useHashRouter.js";
let controls: UseHashRouterResult;
function Probe() {
controls = useHashRouter();
return <div>{controls.route.kind === "attach" ? `attach:${controls.route.sessionId}` : "catalog"}</div>;
}
beforeEach(() => {
location.hash = "";
});
afterEach(cleanup);
describe("useHashRouter", () => {
it("starts on catalog for an empty hash", () => {
const view = render(<Probe />);
expect(view.container.textContent).toBe("catalog");
});
it("resolves #/attach/:id to the attach route", () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
expect(view.container.textContent).toBe("attach:s1");
});
it("decodes an encoded session id", () => {
location.hash = `#/attach/${encodeURIComponent("weird id/x")}`;
const view = render(<Probe />);
expect(view.container.textContent).toBe("attach:weird id/x");
});
it("falls back to catalog for an unknown route", () => {
location.hash = "#/nonsense";
const view = render(<Probe />);
expect(view.container.textContent).toBe("catalog");
});
it("follows a hashchange fired outside the hook", async () => {
const view = render(<Probe />);
// happy-dom dispatches `hashchange` on a real timer, like a browser — not
// synchronously with the assignment.
location.hash = "#/attach/s9";
await waitFor(() => expect(view.container.textContent).toBe("attach:s9"));
});
it("navigate updates the hash and the route", async () => {
const view = render(<Probe />);
act(() => controls.navigate({ kind: "attach", sessionId: "s2" } satisfies Route));
expect(location.hash).toBe("#/attach/s2");
await waitFor(() => expect(view.container.textContent).toBe("attach:s2"));
});
it("navigate back to catalog sets the root hash", async () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
act(() => controls.navigate({ kind: "catalog" }));
expect(location.hash).toBe("#/");
await waitFor(() => expect(view.container.textContent).toBe("catalog"));
});
it("navigating to the already-current route still updates state", () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
act(() => controls.navigate({ kind: "attach", sessionId: "s1" } satisfies Route));
expect(view.container.textContent).toBe("attach:s1");
});
it("stops listening after unmount", async () => {
const view = render(<Probe />);
view.unmount();
location.hash = "#/attach/s3";
// No assertion beyond "doesn't throw" — a leaked listener would only ever
// surface as noise in a later test's act() warnings.
await new Promise((r) => setTimeout(r, 0));
});
});
+47
View File
@@ -0,0 +1,47 @@
// React port of main.ts's `parseRoute`/`dispatch` pair (issue #240). Same two
// routes, same hash shape (`#/`, `#/attach/:id`) — kept so a deep link or a
// bookmark from the vanilla shell still resolves.
import { useCallback, useEffect, useState } from "react";
export type Route = { kind: "catalog" } | { kind: "attach"; sessionId: string };
function parseHash(hash: string): Route {
const raw = hash.replace(/^#/, "");
if (raw === "" || raw === "/") return { kind: "catalog" };
const m = raw.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "catalog" };
}
function routeToHash(route: Route): string {
return route.kind === "catalog" ? "#/" : `#/attach/${encodeURIComponent(route.sessionId)}`;
}
export interface UseHashRouterResult {
route: Route;
navigate(route: Route): void;
}
export function useHashRouter(): UseHashRouterResult {
const [route, setRoute] = useState<Route>(() => parseHash(location.hash));
useEffect(() => {
const onHashChange = () => setRoute(parseHash(location.hash));
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
const navigate = useCallback((next: Route) => {
const hash = routeToHash(next);
// Setting an unchanged hash never fires `hashchange`, so the state update
// that would otherwise ride on that event has to happen here instead.
if (location.hash === hash) {
setRoute(next);
} else {
location.hash = hash;
}
}, []);
return { route, navigate };
}