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
+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);
}
});
});