Add URL hash view-state routing with Back/Forward and restore (M6, §01E)

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.
This commit is contained in:
2026-06-07 17:18:10 +03:00
parent 8c0a6b9239
commit f365258fcc
6 changed files with 597 additions and 16 deletions
+23 -1
View File
@@ -247,10 +247,32 @@ export const navigate = {
**Don't** **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 `pushState` on load-restore (pollutes Back history).
- Don't throw on an unrecognized hash; degrade to the default view. - 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-<id>`) 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 ## 2. Global Event / Keyboard Routing
+127
View File
@@ -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);
}
});
});
+110
View File
@@ -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> (id is an opaque string)
* datasets → #datasets
* dataset → #datasets/dataset-<id> (id is a decimal number)
* dataset-new → #datasets/new
* dataset-build → #datasets/dataset-<id>/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-<id>
| { kind: 'datasets' } // #datasets
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
| { kind: 'dataset-new' } // #datasets/new
| { kind: 'dataset-build'; datasetId: number }; // #datasets/dataset-<id>/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));
}
+148
View File
@@ -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-<id>', () => {
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-<id>', () => {
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-<id>/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);
}
});
});
+184 -15
View File
@@ -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 * The hash is the single serialized source of the current view: selected
* Builder) becomes a shareable / back-navigable location, e.g. * snippet, open Datasets manager (list / a dataset / the new-dataset form), or
* `#datasets/dataset-<id>`. Full hash routing — view-state restore on load, * the Chart Builder. It is read on load to restore state, written as the user
* Back/Forward — is milestone M6 (spec §01E, docs/architecture/04). Until then * navigates, and Back/Forward step between prior states.
* these are intentional no-ops so the coordinator's shape is final and M6 fills *
* the bodies in without the call sites changing. * 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-<id>`) 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 { 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. */ /** Reflect an open navigable modal (and optional sub-target) in the URL hash. */
export function syncModalToUrl(name: ModalName, _arg?: string): void { export function syncModalToUrl(_name: ModalName, _arg?: string): void {
if (!getModalConfig(name)?.isUrlNavigable) return; syncUrlFromState();
// TODO (M6, spec §01E): write `#${name}` / `#${name}/dataset-${arg}` to the
// hash via the routing layer (docs/architecture/04).
} }
/** Return the hash to the underlying workspace when a navigable modal closes. */ /** Return the hash to the underlying workspace when a navigable modal closes. */
export function clearModalFromUrl(name: ModalName): void { export function clearModalFromUrl(_name: ModalName): void {
if (!getModalConfig(name)?.isUrlNavigable) return; syncUrlFromState();
// TODO (M6, spec §01E): restore the pre-modal workspace hash.
} }
+5
View File
@@ -17,6 +17,7 @@ import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { wirePersistence } from './persistence'; import { wirePersistence } from './persistence';
import { wireDatasetPersistence } from './dataset-persistence'; import { wireDatasetPersistence } from './dataset-persistence';
import { startRouting } from '../modals/UrlStateSync';
let started = false; let started = false;
@@ -67,4 +68,8 @@ export async function initApp(): Promise<void> {
// — otherwise it would redundantly re-save every record on each startup. // — otherwise it would redundantly re-save every record on each startup.
wirePersistence(); wirePersistence();
wireDatasetPersistence(); 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();
} }