/** * The shell's one view of the address bar (ADR 0057). * * The Dashboard's URL is the source of truth for which page is open: a refresh, a bookmark, and the * browser's Back and Forward buttons all have to land on the same page that was showing. Everything * in the shell reads the current path and moves between pages through this small interface rather * than through `window.history`, for two reasons. It keeps the entry point the only file in this * package that touches a browser global — the browser implementation is handed the window's pieces, * it does not reach for them. And it lets every routing behaviour be tested against an in-memory * history whose entries a test can inspect. * * Deliberately not a routing library. The shell's pages are a handful of host-owned screens plus one * mounted module, and `react-router-dom` is refused to modules precisely because routing belongs to * the host (ADR 0026); adopting it here would prejudge how a module's own pages are addressed, which * a later decision owns. */ export interface HostHistory { /** The current path, without query or fragment. */ pathname(): string; /** Opens a new page, adding a history entry. Opening the page already showing adds none. */ push(path: string): void; /** Corrects the current entry in place, adding none — for a non-canonical spelling of the same * page. */ replace(path: string): void; /** Notifies after every change, whether made here or by the browser's Back and Forward. */ subscribe(listener: () => void): () => void; } /** The parts of a `Window` the browser history needs, passed in by the entry point. */ export interface HostHistoryWindow { readonly location: Pick; readonly history: Pick; addEventListener(type: "popstate", listener: () => void): void; removeEventListener(type: "popstate", listener: () => void): void; } /** * The address bar itself. `pushState` and `replaceState` fire no event of their own, so this * notifies its subscribers after each one; `popstate` covers Back and Forward. */ export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory { const listeners = new Set<() => void>(); const notify = (): void => { for (const listener of [...listeners]) { listener(); } }; return { pathname: () => view.location.pathname, push(path) { if (path === view.location.pathname) { return; } view.history.pushState(null, "", path); notify(); }, replace(path) { view.history.replaceState(null, "", path); notify(); }, subscribe(listener) { if (listeners.size === 0) { view.addEventListener("popstate", notify); } listeners.add(listener); return () => { listeners.delete(listener); if (listeners.size === 0) { view.removeEventListener("popstate", notify); } }; }, }; } /** An in-memory history with the same semantics, whose entries a caller can inspect and step * through. Used by tests; production always uses {@link createBrowserHostHistory}. */ export interface MemoryHostHistory extends HostHistory { readonly entries: readonly string[]; readonly index: number; /** Steps like the browser's Back (`-1`) and Forward (`1`) buttons. */ go(delta: number): void; } export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory { const entries: string[] = [initialPath]; let index = 0; const listeners = new Set<() => void>(); const notify = (): void => { for (const listener of [...listeners]) { listener(); } }; return { get entries() { return [...entries]; }, get index() { return index; }, pathname: () => entries[index] ?? "/", push(path) { if (path === entries[index]) { return; } entries.splice(index + 1, entries.length, path); index = entries.length - 1; notify(); }, replace(path) { entries[index] = path; notify(); }, go(delta) { const next = Math.min(Math.max(index + delta, 0), entries.length - 1); if (next !== index) { index = next; notify(); } }, subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; }, }; }