mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user