From f365258fcc061caa85561c3a2b2e46196b6b7966 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sun, 7 Jun 2026 17:18:10 +0300 Subject: [PATCH] =?UTF-8?q?Add=20URL=20hash=20view-state=20routing=20with?= =?UTF-8?q?=20Back/Forward=20and=20restore=20(M6,=20=C2=A701E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hash becomes the single serialized view (snippet / Datasets list / a dataset / new-dataset form / Chart Builder). url-hash is the sole reader/writer of location+history (total, never-throwing parse; hash-only canonical URLs). UrlStateSync grows from the modal↔URL seam into the full router: state→hash is derived from the stores (so in-modal dataset sub-views reach the hash), hash→state restores on load and on Back/Forward, validating ids and cleaning stale links. startRouting() runs after store hydrate. --- docs/architecture/04-routing-and-events.md | 24 ++- src/app/infrastructure/url-hash.test.ts | 127 +++++++++++++ src/app/infrastructure/url-hash.ts | 110 ++++++++++++ src/app/modals/UrlStateSync.test.ts | 148 +++++++++++++++ src/app/modals/UrlStateSync.ts | 199 +++++++++++++++++++-- src/app/orchestration/startup.ts | 5 + 6 files changed, 597 insertions(+), 16 deletions(-) create mode 100644 src/app/infrastructure/url-hash.test.ts create mode 100644 src/app/infrastructure/url-hash.ts create mode 100644 src/app/modals/UrlStateSync.test.ts diff --git a/docs/architecture/04-routing-and-events.md b/docs/architecture/04-routing-and-events.md index 812f6a6..647c8aa 100644 --- a/docs/architecture/04-routing-and-events.md +++ b/docs/architecture/04-routing-and-events.md @@ -247,10 +247,32 @@ export const navigate = { **Don't** -- Don't read or write `location.hash` from components — call `navigate.*`. +- Don't read or write `location.hash` from components. - Don't `pushState` on load-restore (pollutes Back history). - Don't throw on an unrecognized hash; degrade to the default view. +### Shipped (M6) — how the implementation refines this sketch + +The routing mediator lives in **`modals/UrlStateSync.ts`**, not a new +`orchestration/UrlStateSync.ts`: that file was already the coordinator's modal↔URL +seam (`syncModalToUrl` / `clearModalFromUrl`), so M6 grew it into the whole router +rather than splitting routing across two modules. It must **not** import the +ModalCoordinator (cycle); restore drives `useAppStore.setActiveModal` directly, as +this sketch's `applyView` already does. + +**state → hash is derived from the stores, not pushed by `navigate.*` calls.** A +`deriveViewState()` reads `activeModal` + `DatasetStore.view`/`selectedId` + +`ChartBuilderStore.datasetId` + `activeSnippetId`; a subscription on each of those +stores calls `pushView` when the derived view differs from the URL. Components never +call a navigate helper — they just mutate stores (select a snippet, open a dataset), +and the subscriber reflects it. This is **required**, not stylistic: the in-modal +dataset sub-views (`#datasets/new`, `#datasets/dataset-`) are `DatasetStore` view +changes, not modal-open events, so only a derive-from-state writer captures them. Only +modals flagged `isUrlNavigable` in the registry own the hash; a non-navigable modal +(extract / about / donate) leaves the underlying snippet view in the URL. On load, +after restore, the active view is reflected with **`replaceView`** (not `pushView`) so +there's no dead Back step. `startRouting()` runs in `startup.ts` after store hydrate. + --- ## 2. Global Event / Keyboard Routing diff --git a/src/app/infrastructure/url-hash.test.ts b/src/app/infrastructure/url-hash.test.ts new file mode 100644 index 0000000..6bca42b --- /dev/null +++ b/src/app/infrastructure/url-hash.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + parseHash, + serializeHash, + readView, + replaceView, + pushView, + type ViewState, +} from './url-hash'; + +/** One value per `ViewState` variant — the round-trip and serialize coverage set. */ +const ALL_VIEWS: ViewState[] = [ + { kind: 'snippets' }, + { kind: 'snippet', snippetId: 'abc123' }, + { kind: 'datasets' }, + { kind: 'dataset', datasetId: 7 }, + { kind: 'dataset-new' }, + { kind: 'dataset-build', datasetId: 42 }, +]; + +describe('serializeHash', () => { + it('produces the spec grammar form for each variant', () => { + expect(serializeHash({ kind: 'snippets' })).toBe(''); + expect(serializeHash({ kind: 'snippet', snippetId: 'abc123' })).toBe('#snippet-abc123'); + expect(serializeHash({ kind: 'datasets' })).toBe('#datasets'); + expect(serializeHash({ kind: 'dataset', datasetId: 7 })).toBe('#datasets/dataset-7'); + expect(serializeHash({ kind: 'dataset-new' })).toBe('#datasets/new'); + expect(serializeHash({ kind: 'dataset-build', datasetId: 42 })).toBe( + '#datasets/dataset-42/build', + ); + }); +}); + +describe('parseHash', () => { + it('parses each grammar row to the right ViewState', () => { + expect(parseHash('')).toEqual({ kind: 'snippets' }); + expect(parseHash('#snippet-abc123')).toEqual({ kind: 'snippet', snippetId: 'abc123' }); + expect(parseHash('#datasets')).toEqual({ kind: 'datasets' }); + expect(parseHash('#datasets/dataset-7')).toEqual({ kind: 'dataset', datasetId: 7 }); + expect(parseHash('#datasets/new')).toEqual({ kind: 'dataset-new' }); + expect(parseHash('#datasets/dataset-42/build')).toEqual({ + kind: 'dataset-build', + datasetId: 42, + }); + }); + + it('tolerates a missing leading "#"', () => { + expect(parseHash('datasets')).toEqual({ kind: 'datasets' }); + expect(parseHash('snippet-x')).toEqual({ kind: 'snippet', snippetId: 'x' }); + }); + + it('treats a snippet id as an opaque string (slashes, dashes, etc.)', () => { + expect(parseHash('#snippet-a-b-c')).toEqual({ kind: 'snippet', snippetId: 'a-b-c' }); + expect(parseHash('#snippet-2026-06-07')).toEqual({ kind: 'snippet', snippetId: '2026-06-07' }); + }); + + it('falls back to the default snippets view on malformed / unknown hashes', () => { + const fallback: ViewState = { kind: 'snippets' }; + expect(parseHash('#snippet-')).toEqual(fallback); // empty snippet id + expect(parseHash('#datasets/dataset-abc')).toEqual(fallback); // non-numeric dataset id + expect(parseHash('#datasets/dataset-')).toEqual(fallback); // empty dataset id + expect(parseHash('#datasets/dataset-7/extra')).toEqual(fallback); // unknown trailing segment + expect(parseHash('#datasets/bogus')).toEqual(fallback); // unknown datasets sub-route + expect(parseHash('#garbage')).toEqual(fallback); // unknown top-level + }); + + it('never throws for arbitrary input', () => { + for (const raw of ['#', '#/', '#//', '#datasets//', '#snippet', '###']) { + expect(() => parseHash(raw)).not.toThrow(); + expect(parseHash(raw).kind).toBeDefined(); + } + }); +}); + +describe('round-trip identity', () => { + it('parseHash(serializeHash(v)) deep-equals v for every variant', () => { + for (const v of ALL_VIEWS) { + expect(parseHash(serializeHash(v))).toEqual(v); + } + }); +}); + +describe('readView / replaceView / pushView (window + history)', () => { + beforeEach(() => { + // Reset to a clean, hash-less location before each case. + window.history.replaceState({}, '', '/'); + }); + + it('readView reflects the current location.hash', () => { + window.location.hash = '#datasets/dataset-7'; + expect(readView()).toEqual({ kind: 'dataset', datasetId: 7 }); + }); + + it('readView returns the default for an empty hash', () => { + expect(readView()).toEqual({ kind: 'snippets' }); + }); + + it('replaceView writes the hash without growing history', () => { + const before = window.history.length; + replaceView({ kind: 'snippet', snippetId: 'xyz' }); + expect(window.location.hash).toBe('#snippet-xyz'); + expect(readView()).toEqual({ kind: 'snippet', snippetId: 'xyz' }); + expect(window.history.length).toBe(before); + }); + + it('replaceView clears the hash for the default view', () => { + window.location.hash = '#datasets'; + replaceView({ kind: 'snippets' }); + expect(window.location.hash).toBe(''); + expect(readView()).toEqual({ kind: 'snippets' }); + }); + + it('pushView writes the hash and adds a history entry', () => { + const before = window.history.length; + pushView({ kind: 'dataset-build', datasetId: 42 }); + expect(window.location.hash).toBe('#datasets/dataset-42/build'); + expect(readView()).toEqual({ kind: 'dataset-build', datasetId: 42 }); + expect(window.history.length).toBe(before + 1); + }); + + it('round-trips through the real history API for every variant', () => { + for (const v of ALL_VIEWS) { + replaceView(v); + expect(readView()).toEqual(v); + } + }); +}); diff --git a/src/app/infrastructure/url-hash.ts b/src/app/infrastructure/url-hash.ts new file mode 100644 index 0000000..742679a --- /dev/null +++ b/src/app/infrastructure/url-hash.ts @@ -0,0 +1,110 @@ +/** + * URL hash as view-state (spec §01 → E "Navigation & Shareable URL State"; + * architecture §04 → "1. URL Hash as View-State"). The current view — selected + * snippet, open Datasets modal, a specific dataset, the new-dataset form, or the + * Chart Builder — is serialized into `location.hash` so every meaningful view is + * shareable, bookmarkable, and reload-safe, and Back/Forward steps between states. + * + * This is the ONLY module that reads or writes `window.location` / `history`. + * Parsing is a total pure function (no side effects, never throws — an unknown or + * malformed hash degrades to the default snippets view). Writing is the only place + * `history.replaceState` / `pushState` is called. + * + * Hash grammar (matches the spec table exactly): + * + * snippets (default) → '' (empty / absent) + * snippet → #snippet- (id is an opaque string) + * datasets → #datasets + * dataset → #datasets/dataset- (id is a decimal number) + * dataset-new → #datasets/new + * dataset-build → #datasets/dataset-/build + */ + +/** The serialized view. Snippet id is opaque; dataset id is the numeric id. */ +export type ViewState = + | { kind: 'snippets' } // empty hash + | { kind: 'snippet'; snippetId: string } // #snippet- + | { kind: 'datasets' } // #datasets + | { kind: 'dataset'; datasetId: number } // #datasets/dataset- + | { kind: 'dataset-new' } // #datasets/new + | { kind: 'dataset-build'; datasetId: number }; // #datasets/dataset-/build + +/** + * Parse a raw hash (with or without the leading `#`) into a typed `ViewState`. + * Total and pure: anything unrecognized or malformed degrades to the default + * snippets view rather than throwing. + */ +export function parseHash(rawHash: string): ViewState { + const hash = rawHash.replace(/^#/, ''); + if (hash === '') return { kind: 'snippets' }; + + const snippet = /^snippet-(.+)$/.exec(hash); + if (snippet) return { kind: 'snippet', snippetId: snippet[1] }; + + const parts = hash.split('/').filter(Boolean); + if (parts[0] === 'datasets') { + if (parts.length === 1) return { kind: 'datasets' }; + if (parts[1] === 'new') return { kind: 'dataset-new' }; + const m = /^dataset-(\d+)$/.exec(parts[1]); + if (m) { + const id = Number(m[1]); + if (parts[2] === 'build') return { kind: 'dataset-build', datasetId: id }; + if (parts.length === 2) return { kind: 'dataset', datasetId: id }; + } + } + // Unknown / malformed hash → fall back to default rather than throwing. + return { kind: 'snippets' }; +} + +/** + * Serialize a `ViewState` into a hash string (round-trips with `parseHash`). + * The exhaustive switch uses a `never`-typed default so a newly added `ViewState` + * variant without a case becomes a compile-time error. + */ +export function serializeHash(view: ViewState): string { + switch (view.kind) { + case 'snippets': + return ''; + case 'snippet': + return `#snippet-${view.snippetId}`; + case 'datasets': + return '#datasets'; + case 'dataset': + return `#datasets/dataset-${view.datasetId}`; + case 'dataset-new': + return '#datasets/new'; + case 'dataset-build': + return `#datasets/dataset-${view.datasetId}/build`; + default: { + const _exhaustive: never = view; + return _exhaustive; + } + } +} + +/** Read the current view from `window.location.hash`. */ +export function readView(): ViewState { + return parseHash(window.location.hash); +} + +/** + * Build the next URL for `view`. The hash is the app's only view-state channel, + * so we normalize to a hash-only canonical URL: any stray query string (a shared + * link's `?utm=…`, a dev-server param) is dropped rather than carried forward. + */ +function nextUrl(view: ViewState): string { + const url = new URL(window.location.href); + url.hash = serializeHash(view); + url.search = ''; + return url.toString(); +} + +/** Write without adding a history entry (in-place correction, load-restore). */ +export function replaceView(view: ViewState): void { + window.history.replaceState({}, '', nextUrl(view)); +} + +/** Write and add a history entry (deliberate user navigation → Back works). */ +export function pushView(view: ViewState): void { + window.history.pushState({}, '', nextUrl(view)); +} diff --git a/src/app/modals/UrlStateSync.test.ts b/src/app/modals/UrlStateSync.test.ts new file mode 100644 index 0000000..58c5d82 --- /dev/null +++ b/src/app/modals/UrlStateSync.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { createDataset } from '@core/dataset'; +import { createSnippet } from '@core/snippet'; +import { useAppStore } from '../stores/AppStore'; +import { useChartBuilderStore } from '../stores/ChartBuilderStore'; +import { useDatasetStore } from '../stores/DatasetStore'; +import { useSnippetStore } from '../stores/SnippetStore'; +import { applyView, deriveViewState } from './UrlStateSync'; + +const T = new Date('2026-06-01T00:00:00Z'); + +const seedDataset = (id: number, name: string) => + createDataset({ id, name, data: [{ a: 1 }], format: 'json', source: 'inline', now: T }); + +beforeEach(() => { + useSnippetStore.getState().reset(); + useDatasetStore.getState().reset(); + useChartBuilderStore.setState({ datasetId: null }); + useAppStore.getState().setActiveModal(null); + window.location.hash = ''; +}); + +describe('deriveViewState — the URL is derived from store state', () => { + test('no snippet, no modal → default snippets view', () => { + useSnippetStore.getState().hydrate([], null); + expect(deriveViewState()).toEqual({ kind: 'snippets' }); + }); + + test('an active snippet → #snippet-', () => { + useSnippetStore.getState().hydrate([createSnippet({ id: 's1', now: T })], 's1'); + expect(deriveViewState()).toEqual({ kind: 'snippet', snippetId: 's1' }); + }); + + test('Datasets manager (list) → #datasets', () => { + useAppStore.getState().setActiveModal('datasets'); + expect(deriveViewState()).toEqual({ kind: 'datasets' }); + }); + + test('a selected dataset → #datasets/dataset-', () => { + useDatasetStore.getState().hydrate([seedDataset(7, 'Sales')]); + useDatasetStore.getState().select(7); + useAppStore.getState().setActiveModal('datasets'); + expect(deriveViewState()).toEqual({ kind: 'dataset', datasetId: 7 }); + }); + + test('the new-dataset form → #datasets/new', () => { + useDatasetStore.getState().startCreate(); + useAppStore.getState().setActiveModal('datasets'); + expect(deriveViewState()).toEqual({ kind: 'dataset-new' }); + }); + + test('the Chart Builder → #datasets/dataset-/build', () => { + useChartBuilderStore.setState({ datasetId: 42 }); + useAppStore.getState().setActiveModal('chartBuilder'); + expect(deriveViewState()).toEqual({ kind: 'dataset-build', datasetId: 42 }); + }); + + test('a non-navigable modal (extract) leaves the underlying snippet view in the URL', () => { + useSnippetStore.getState().hydrate([createSnippet({ id: 's1', now: T })], 's1'); + useAppStore.getState().setActiveModal('extract'); + expect(deriveViewState()).toEqual({ kind: 'snippet', snippetId: 's1' }); + }); +}); + +describe('applyView — the hash drives the stores (restore / Back-Forward)', () => { + test('restoring a snippet selects it and closes any modal', () => { + useSnippetStore.getState().hydrate([createSnippet({ id: 's1', now: T })], null); + useAppStore.getState().setActiveModal('datasets'); + + applyView({ kind: 'snippet', snippetId: 's1' }); + + expect(useAppStore.getState().activeModal).toBeNull(); + expect(useSnippetStore.getState().activeSnippetId).toBe('s1'); + }); + + test('a dead snippet id falls back to the default view and cleans the URL', () => { + useSnippetStore.getState().hydrate([], null); + window.location.hash = '#snippet-gone'; + + applyView({ kind: 'snippet', snippetId: 'gone' }); + + expect(useSnippetStore.getState().activeSnippetId).toBeNull(); + expect(window.location.hash).toBe(''); + }); + + test('restoring the Datasets list opens the manager on its list view', () => { + applyView({ kind: 'datasets' }); + expect(useAppStore.getState().activeModal).toBe('datasets'); + expect(useDatasetStore.getState().view).toBe('list'); + }); + + test('restoring a specific dataset selects it in the open manager', () => { + useDatasetStore.getState().hydrate([seedDataset(7, 'Sales')]); + + applyView({ kind: 'dataset', datasetId: 7 }); + + expect(useAppStore.getState().activeModal).toBe('datasets'); + expect(useDatasetStore.getState().selectedId).toBe(7); + expect(useDatasetStore.getState().view).toBe('detail'); + }); + + test('a dead dataset id degrades to the Datasets list and cleans the URL', () => { + useDatasetStore.getState().hydrate([]); + window.location.hash = '#datasets/dataset-99'; + + applyView({ kind: 'dataset', datasetId: 99 }); + + expect(useAppStore.getState().activeModal).toBe('datasets'); + expect(useDatasetStore.getState().selectedId).toBeNull(); + expect(window.location.hash).toBe('#datasets'); + }); + + test('restoring the new-dataset form opens the manager on its create view', () => { + applyView({ kind: 'dataset-new' }); + expect(useAppStore.getState().activeModal).toBe('datasets'); + expect(useDatasetStore.getState().view).toBe('new'); + }); + + test('restoring the Chart Builder loads its dataset and opens the builder', () => { + useDatasetStore.getState().hydrate([seedDataset(7, 'Sales')]); + + applyView({ kind: 'dataset-build', datasetId: 7 }); + + expect(useAppStore.getState().activeModal).toBe('chartBuilder'); + expect(useChartBuilderStore.getState().datasetId).toBe(7); + }); +}); + +describe('round-trip: applyView then deriveViewState is identity', () => { + test('every store-representable view survives the round trip', () => { + useSnippetStore.getState().hydrate([createSnippet({ id: 's1', now: T })], null); + useDatasetStore.getState().hydrate([seedDataset(7, 'Sales')]); + + const views = [ + { kind: 'snippets' as const }, + { kind: 'snippet' as const, snippetId: 's1' }, + { kind: 'datasets' as const }, + { kind: 'dataset' as const, datasetId: 7 }, + { kind: 'dataset-new' as const }, + { kind: 'dataset-build' as const, datasetId: 7 }, + ]; + + for (const view of views) { + applyView(view); + expect(deriveViewState()).toEqual(view); + } + }); +}); diff --git a/src/app/modals/UrlStateSync.ts b/src/app/modals/UrlStateSync.ts index 3b8ab73..fb61d5e 100644 --- a/src/app/modals/UrlStateSync.ts +++ b/src/app/modals/UrlStateSync.ts @@ -1,26 +1,195 @@ /** - * Modal ↔ URL hash sync (docs/architecture/03 → "URL & Keyboard Integration"). + * URL hash ↔ view-state routing (spec §01E, docs/architecture/04 → "Routing"). * - * The coordinator calls these so a navigable modal (Datasets, Settings, Chart - * Builder) becomes a shareable / back-navigable location, e.g. - * `#datasets/dataset-`. Full hash routing — view-state restore on load, - * Back/Forward — is milestone M6 (spec §01E, docs/architecture/04). Until then - * these are intentional no-ops so the coordinator's shape is final and M6 fills - * the bodies in without the call sites changing. + * The hash is the single serialized source of the current view: selected + * snippet, open Datasets manager (list / a dataset / the new-dataset form), or + * the Chart Builder. It is read on load to restore state, written as the user + * navigates, and Back/Forward step between prior states. + * + * Two directions, one guard: + * + * - **state → hash** (`syncUrlFromState`): a derive-from-stores writer. Subscribed + * to every store that can change the view, it computes the current `ViewState` + * and `pushView`s it when it differs from the URL. Deriving from state (rather + * than reacting to individual navigation calls) is what lets the *in-modal* + * dataset sub-views (`#datasets/new`, `#datasets/dataset-`) reach the hash: + * those are `DatasetStore` view changes, not modal-open events. + * - **hash → state** (`applyView`): restore-on-load and Back/Forward. Drives the + * stores to match a parsed view, validating ids and cleaning a stale hash. + * + * The `applying` flag suppresses the state→hash writer while `applyView` is + * driving the stores, so a restore/Back never echoes back into a navigation. + * + * `applyView` writes `activeModal` with the bare `setActiveModal` primitive + * rather than the coordinator's `openModal`/`closeModal`: it is reflecting the + * URL *into* the stores, so it must not re-sync the URL or run the unsaved-change + * discard prompt (docs/architecture/04 §1.3). + * + * The coordinator's original `syncModalToUrl` / `clearModalFromUrl` seam is kept + * (now thin wrappers over `syncUrlFromState`) so its call sites are unchanged. */ -import type { ModalName } from './types'; +import { useAppStore } from '../stores/AppStore'; +import { useChartBuilderStore } from '../stores/ChartBuilderStore'; +import { useDatasetStore } from '../stores/DatasetStore'; +import { useSnippetStore } from '../stores/SnippetStore'; +import { + pushView, + readView, + replaceView, + serializeHash, + type ViewState, +} from '../infrastructure/url-hash'; import { getModalConfig } from './modal-registry'; +import type { ModalName } from './types'; + +let applying = false; +let started = false; + +/** + * Derive the current view from store state — the URL's single source of truth. + * Only URL-navigable modals own the hash; `extract` / `about` / `donate` are not + * navigable (spec §01E lists no hash for them), so while one is open the URL + * keeps showing the underlying snippet view. + */ +export function deriveViewState(): ViewState { + const modal = useAppStore.getState().activeModal; + if (modal && getModalConfig(modal)?.isUrlNavigable) { + if (modal === 'chartBuilder') { + const datasetId = useChartBuilderStore.getState().datasetId; + return datasetId !== null ? { kind: 'dataset-build', datasetId } : { kind: 'datasets' }; + } + if (modal === 'datasets') { + const ds = useDatasetStore.getState(); + if (ds.view === 'new') return { kind: 'dataset-new' }; + if (ds.selectedId !== null) return { kind: 'dataset', datasetId: ds.selectedId }; + return { kind: 'datasets' }; + } + } + const snippetId = useSnippetStore.getState().activeSnippetId; + return snippetId ? { kind: 'snippet', snippetId } : { kind: 'snippets' }; +} + +/** True when `view` already matches the current URL hash. */ +function isCurrent(view: ViewState): boolean { + return serializeHash(view) === window.location.hash; +} + +/** + * state → hash. Computes the derived view and pushes it when it differs from the + * URL. Suppressed while `applyView` is running (the `applying` guard) so a + * Back/Forward or load-restore never echoes a redundant navigation. + */ +function syncUrlFromState(): void { + if (applying) return; + const view = deriveViewState(); + if (!isCurrent(view)) pushView(view); +} + +/** + * hash → state. Drives the stores to reflect `view`; falls back and cleans the + * URL (`replaceView`, no history entry) when a referenced id no longer exists + * (deleted record, stale shared link). + */ +export function applyView(view: ViewState): void { + applying = true; + try { + const app = useAppStore.getState(); + switch (view.kind) { + case 'snippets': + app.setActiveModal(null); + return; + case 'snippet': { + const exists = useSnippetStore.getState().snippets.some((s) => s.id === view.snippetId); + if (!exists) { + replaceView({ kind: 'snippets' }); + return; + } + app.setActiveModal(null); + useSnippetStore.getState().selectSnippet(view.snippetId); + return; + } + case 'datasets': + useDatasetStore.getState().select(null); + app.setActiveModal('datasets'); + return; + case 'dataset': + case 'dataset-build': { + const ds = useDatasetStore.getState(); + if (!ds.datasets.some((d) => d.id === view.datasetId)) { + ds.select(null); + app.setActiveModal('datasets'); + replaceView({ kind: 'datasets' }); + return; + } + ds.select(view.datasetId); + if (view.kind === 'dataset-build') { + useChartBuilderStore.getState().init(view.datasetId); + app.setActiveModal('chartBuilder'); + } else { + app.setActiveModal('datasets'); + } + return; + } + case 'dataset-new': + app.setActiveModal('datasets'); + useDatasetStore.getState().startCreate(); + return; + } + } finally { + applying = false; + } +} + +/** + * Start routing. Restores the view from the hash (must run *after* the stores + * are hydrated so ids resolve), then keeps hash ↔ state in sync via a + * `hashchange` listener (Back/Forward) and store subscriptions (navigation). + * Idempotent. + */ +export function startRouting(): void { + if (started) return; + started = true; + + applyView(readView()); + + // Reflect the view the stores actually settled on (e.g. the snippet hydrate + // auto-selected) back into the URL with `replaceView` — no history entry. This + // replaces the blank load entry rather than letting the first store tick + // `pushView` it, which would leave a dead Back step at startup. + const initial = deriveViewState(); + if (!isCurrent(initial)) replaceView(initial); + + window.addEventListener('hashchange', () => { + if (applying) return; + applyView(readView()); + }); + + // Any store that can change the view writes the hash. A change that leaves the + // derived view unchanged (e.g. a draft keystroke) is a cheap no-op in + // `syncUrlFromState`'s equality check. + useAppStore.subscribe(syncUrlFromState); + useSnippetStore.subscribe(syncUrlFromState); + useDatasetStore.subscribe(syncUrlFromState); + useChartBuilderStore.subscribe(syncUrlFromState); +} + +/** Test seam: reset the module's start/applying flags between cases. */ +export function resetRoutingForTest(): void { + started = false; + applying = false; +} + +// --- Coordinator seam (unchanged call sites) ------------------------------- +// The coordinator calls these on modal open/close; both now just re-derive the +// hash from state. Kept as named exports so ModalCoordinator needs no edit. /** Reflect an open navigable modal (and optional sub-target) in the URL hash. */ -export function syncModalToUrl(name: ModalName, _arg?: string): void { - if (!getModalConfig(name)?.isUrlNavigable) return; - // TODO (M6, spec §01E): write `#${name}` / `#${name}/dataset-${arg}` to the - // hash via the routing layer (docs/architecture/04). +export function syncModalToUrl(_name: ModalName, _arg?: string): void { + syncUrlFromState(); } /** Return the hash to the underlying workspace when a navigable modal closes. */ -export function clearModalFromUrl(name: ModalName): void { - if (!getModalConfig(name)?.isUrlNavigable) return; - // TODO (M6, spec §01E): restore the pre-modal workspace hash. +export function clearModalFromUrl(_name: ModalName): void { + syncUrlFromState(); } diff --git a/src/app/orchestration/startup.ts b/src/app/orchestration/startup.ts index 09ee396..0d47cfb 100644 --- a/src/app/orchestration/startup.ts +++ b/src/app/orchestration/startup.ts @@ -17,6 +17,7 @@ import { useSnippetStore } from '../stores/SnippetStore'; import { useDatasetStore } from '../stores/DatasetStore'; import { wirePersistence } from './persistence'; import { wireDatasetPersistence } from './dataset-persistence'; +import { startRouting } from '../modals/UrlStateSync'; let started = false; @@ -67,4 +68,8 @@ export async function initApp(): Promise { // — otherwise it would redundantly re-save every record on each startup. wirePersistence(); wireDatasetPersistence(); + + // Routing starts AFTER hydrate so the on-load hash restore can resolve snippet + // / dataset ids against the loaded stores (spec §01E, docs/architecture/04). + startRouting(); }