Files
astrolabe/docs/architecture/04-routing-and-events.md
T

18 KiB

04 · Routing & Global Events

Two small, related subsystems govern how the app talks to the browser shell:

  1. URL hash as view-state — the current view (selected snippet, open dataset modal, etc.) lives in location.hash. It is read on load to restore state, written on navigation, and Back/Forward step between prior states. Result: every meaningful view is shareable, bookmarkable, and reload-safe.
  2. Global event / keyboard routing — a single router owns the document-level keydown / paste / click listeners. It runs an Escape priority chain, dispatches shortcuts, and consults a single isInInteractiveContext() helper so global shortcuts and paste handlers never fire while the user is typing in an input or the Monaco editor.

Both are layered the same way:

src/app/infrastructure/url-hash.ts     adapter: owns window.location & history
src/app/orchestration/UrlStateSync.ts  mediator: hash  <->  Zustand stores
src/app/orchestration/EventRouter.ts   mediator: DOM events -> store actions
src/app/orchestration/focus-utils.ts   single-source isInInteractiveContext()

Infrastructure modules touch browser globals; orchestration modules touch the Zustand stores. Components never read location.hash or attach window.addEventListener themselves — they go through these mediators.


1. URL Hash as View-State

1.1 The hash grammar

The hash is the serialized view. Astrolabe's forms:

State Hash
Default snippets view (empty / absent)
A selected snippet #snippet-<id>
Datasets manager (list) #datasets
A specific dataset #datasets/dataset-<id>
New-dataset form #datasets/new
Chart Builder for a dataset #datasets/dataset-<id>/build

Snippet id is an opaque string; dataset id is the numeric dataset id rendered as a decimal string. The hash is the only persisted view-routing state — there is no in-memory "current route" that can drift from it.

1.2 The adapter: infrastructure/url-hash.ts

This is the only file that reads or writes window.location / history. It exposes a parse function (hash string → typed ViewState), a serialize function (ViewState → hash string), and write helpers. Keep it pure-ish: parsing is a total function with no side effects; writing is the only place history.replaceState is called.

// src/app/infrastructure/url-hash.ts
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 };          // .../build

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 };
      return { kind: 'dataset', datasetId: id };
    }
  }
  // Unknown hash -> fall back to default rather than throwing.
  return { kind: 'snippets' };
}

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`;
  }
}

export function readView(): ViewState {
  return parseHash(window.location.hash);
}

/** Write without adding a history entry (in-place correction, restore). */
export function replaceView(view: ViewState): void {
  const url = new URL(window.location.href);
  url.hash = serializeHash(view);
  url.search = '';
  window.history.replaceState({}, '', url.toString());
}

/** Write and add a history entry (user navigation -> Back works). */
export function pushView(view: ViewState): void {
  const url = new URL(window.location.href);
  url.hash = serializeHash(view);
  url.search = '';
  window.history.pushState({}, '', url.toString());
}

pushState vs replaceState is the lever that makes Back/Forward feel right. Use pushView for deliberate user navigation (selecting a snippet, opening a dataset) so each becomes a Back-able step. Use replaceView for restoring on load and for correcting a stale/invalid hash, where you do not want to litter history.

1.3 The mediator: orchestration/UrlStateSync.ts

UrlStateSync is the bridge between the hash and the Zustand stores. It does three jobs:

  • On load — restore: read the view, validate referenced ids against the stores, and drive the stores to match. If an id no longer exists, fall back to the default view and replaceView to clean the URL.
  • Hash → state (Back/Forward): listen for hashchange and reconcile the stores to the new view. This is what makes the browser buttons work.
  • State → hash: expose typed navigate* helpers the rest of the app calls when the user moves around. These pushView (or replaceView).

Guard against feedback loops: writing the hash fires no hashchange when you use the History API the way above, but a defensive applying flag keeps the hashchange reconciler from re-triggering navigation it just caused.

// src/app/orchestration/UrlStateSync.ts
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useAppStore } from '../stores/AppStore';     // activeModal, etc.
import { readView, replaceView, pushView, type ViewState } from '../infrastructure/url-hash';

let applying = false;   // suppress re-entrancy while we drive the stores
let started = false;

// Restore/reconcile is the one path that writes `activeModal` with the bare
// `setActiveModal` primitive instead of the coordinator's openModal/closeModal:
// we are reflecting the URL *into* the stores, so we must NOT re-sync the URL or
// run the unsaved-change discard prompt (the `applying` guard blocks re-entrancy).
/** Make the stores reflect `view`. Falls back + cleans URL on dead ids. */
function applyView(view: ViewState): void {
  applying = true;
  try {
    switch (view.kind) {
      case 'snippets':
        useAppStore.getState().setActiveModal(null);
        return;
      case 'snippet': {
        const snippet = useSnippetStore.getState().byId(view.snippetId);
        if (!snippet) { replaceView({ kind: 'snippets' }); return; }
        useAppStore.getState().setActiveModal(null);
        useSnippetStore.getState().select(view.snippetId);
        return;
      }
      case 'datasets':
        useAppStore.getState().setActiveModal('datasets');
        return;
      case 'dataset':
      case 'dataset-build': {
        const ds = useDatasetStore.getState().byId(view.datasetId);
        if (!ds) { replaceView({ kind: 'datasets' }); return; }
        useAppStore.getState().setActiveModal('datasets');
        useDatasetStore.getState().select(view.datasetId);
        if (view.kind === 'dataset-build') useAppStore.getState().setActiveModal('chartBuilder');
        return;
      }
      case 'dataset-new':
        useAppStore.getState().setActiveModal('datasets');
        useDatasetStore.getState().beginNew();
        return;
    }
  } finally {
    applying = false;
  }
}

export function startUrlStateSync(): void {
  if (started) return;
  started = true;

  // 1. Restore from the URL on load.
  applyView(readView());

  // 2. Back/Forward -> reconcile stores.
  window.addEventListener('hashchange', () => {
    if (applying) return;
    applyView(readView());
  });

  // 3. State -> hash. A store subscription keeps the URL honest if any code path
  //    changes the active view without calling a navigate* helper. Optional;
  //    explicit navigate* calls are the primary writer.
  useAppStore.subscribe((state, prev) => {
    if (applying) return;
    // derive ViewState from state and replaceView(...) here if desired
  });
}

// --- State -> hash: the API the app calls on user navigation -------------
export const navigate = {
  toSnippet: (id: string) => pushView({ kind: 'snippet', snippetId: id }),
  toSnippets: () => pushView({ kind: 'snippets' }),
  toDatasets: () => pushView({ kind: 'datasets' }),
  toDataset: (id: number) => pushView({ kind: 'dataset', datasetId: id }),
  toNewDataset: () => pushView({ kind: 'dataset-new' }),
  toChartBuilder: (id: number) => pushView({ kind: 'dataset-build', datasetId: id }),
};

Do

  • Restore on load with replaceView; navigate at runtime with pushView.
  • Validate every id from the hash against the stores; fall back + clean URL on a miss (deleted/shared-stale ids are normal, not exceptional).
  • Keep parseHash / serializeHash pure and round-trippable — unit-test that parseHash(serializeHash(v)) === v for every ViewState.

Don't

  • Don't read or write location.hash from components — call navigate.*.
  • Don't pushState on load-restore (pollutes Back history).
  • Don't throw on an unrecognized hash; degrade to the default view.

2. Global Event / Keyboard Routing

2.1 The router: orchestration/EventRouter.ts

One module binds the document-level listeners (keydown, paste, click) and routes them. Centralizing this keeps ordering explicit and gives one place to reason about priority. The router owns two things in particular:

  • the Escape priority chain, and
  • shortcut dispatch, gated by isInInteractiveContext().
// src/app/orchestration/EventRouter.ts
import { useAppStore } from '../stores/AppStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { navigate } from './UrlStateSync';
import { openModal, closeModal, toggleDatasets } from '../modals/ModalCoordinator';
import { isInInteractiveContext } from './focus-utils';

let started = false;

export function startEventRouter(): void {
  if (started) return;
  started = true;
  window.addEventListener('keydown', onKeyDown);
  window.addEventListener('paste', onPaste);
}

export function stopEventRouter(): void {
  window.removeEventListener('keydown', onKeyDown);
  window.removeEventListener('paste', onPaste);
  started = false;
}

const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);

function onKeyDown(e: KeyboardEvent): void {
  // --- Escape: highest priority, runs even inside editors/inputs ----------
  if (e.key === 'Escape') {
    if (handleEscapeChain()) e.preventDefault();
    return;
  }

  const mod = isMac ? e.metaKey : e.ctrlKey;

  // --- Shortcuts: never fire while typing in an input or Monaco ----------
  if (isInInteractiveContext()) return;

  // Cmd/Ctrl + Shift + N -> new snippet
  if (mod && e.shiftKey && e.key.toLowerCase() === 'n') {
    e.preventDefault();
    const created = useSnippetStore.getState().create();
    navigate.toSnippet(created.id);
    return;
  }
  // Cmd/Ctrl + K -> toggle Datasets manager (coordinator owns open/close + URL)
  if (mod && !e.shiftKey && e.key.toLowerCase() === 'k') {
    e.preventDefault();
    toggleDatasets();
    return;
  }
  // Cmd/Ctrl + S -> publish current draft
  if (mod && e.key.toLowerCase() === 's') {
    e.preventDefault();             // override the browser "save page" dialog
    useSnippetStore.getState().publishDraft();
    return;
  }
  // Cmd/Ctrl + , -> settings (through the coordinator: snapshot + URL sync)
  if (mod && e.key === ',') {
    e.preventDefault();
    openModal('settings');
    return;
  }
}

/** Returns true if it consumed the Escape (caller should preventDefault). */
function handleEscapeChain(): boolean {
  // 1. Toast/message box would go here if it grew a blocking variant.
  // 2. Active modal — route through the coordinator so the unsaved-change
  //    discard prompt runs and the URL is cleared. NEVER setActiveModal(null)
  //    here: that would silently drop in-progress dataset/chart-builder edits.
  if (useAppStore.getState().activeModal) {
    void closeModal();
    return true;
  }
  // 3. Open menu / popover.
  if (useAppStore.getState().openMenu) {
    useAppStore.getState().setOpenMenu(null);
    return true;
  }
  // 4. Active selection (e.g. selected snippet in the library).
  if (useSnippetStore.getState().selectionId) {
    useSnippetStore.getState().clearSelection();
    return true;
  }
  return false;
}

function onPaste(e: ClipboardEvent): void {
  // Paste-to-import (e.g. paste a Vega-Lite spec) must NOT hijack a paste the
  // user makes inside the editor or an input.
  if (isInInteractiveContext()) return;
  // ... route clipboard text to the import handler ...
}

The Escape chain is an explicit, ordered ladder, top-down. Each rung returns as soon as it consumes the event, so only the topmost active layer reacts. Order matters: a blocking message box outranks a modal, a modal outranks an open menu, a menu outranks a selection. Add new dismissible layers by inserting a rung at the right priority — never by sprinkling document.addEventListener('keydown', …Escape…) in a component.

Shortcuts override browser defaults. Each handled combo calls e.preventDefault() so Cmd/Ctrl+S does not trigger "save page", Cmd/Ctrl+K does not focus the browser search bar, etc.

Note the asymmetry: Escape is checked before the interactive-context gate (you want Escape to dismiss a modal even while focus is in the editor), whereas all other shortcuts are checked after the gate (so they don't fire mid-typing).

2.2 The single-source helper: orchestration/focus-utils.ts

There is exactly one function that answers "is the user currently typing in an editable surface?" Every shortcut path and the paste handler call it. Never inline element-type checks — one place to get it right, one place to fix it when the DOM changes.

Monaco difference (important): Astrolabe's spec editor is Monaco, not CodeMirror. Monaco renders into a .monaco-editor container and keeps focus on a hidden <textarea class="inputarea"> inside it. The detector must match Monaco's DOM — a .monaco-editor ancestor (and/or the inputarea) — not a .cm-editor / .cm-content selector. If you copy a CodeMirror check here it will silently fail and global shortcuts will fire while the user edits a spec.

// src/app/orchestration/focus-utils.ts

/**
 * True when focus is in an editable surface where global shortcuts and
 * paste-to-import must be suppressed: <input>, <textarea>, <select>,
 * contenteditable, or the Monaco editor.
 *
 * This is the SINGLE source of truth — do not inline these checks elsewhere.
 */
export function isInInteractiveContext(): boolean {
  const el = document.activeElement as HTMLElement | null;
  if (!el) return false;

  const tag = el.tagName.toLowerCase();
  if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
  if (el.isContentEditable) return true;

  // Monaco renders into a .monaco-editor container; its focused element is a
  // hidden <textarea class="inputarea"> (already caught above) but guard the
  // container explicitly so focus on any inner node still counts.
  if (el.closest?.('.monaco-editor')) return true;

  return false;
}

Do

  • Route all global keyboard/paste/click through EventRouter; bind listeners in exactly one place, started once at app init.
  • Express Escape as an ordered chain that returns on first consumption.
  • Call isInInteractiveContext() everywhere a global handler might collide with typing; keep it the only definition.
  • preventDefault() on every shortcut the app claims, so it overrides the browser default.

Don't

  • Don't add ad-hoc window/document keydown listeners in components.
  • Don't inline tagName === 'textarea' / editor-class checks at call sites — call the helper.
  • Don't match a CodeMirror selector for the editor; Astrolabe is Monaco.
  • Don't gate Escape behind isInInteractiveContext() — Escape should still close a modal while the editor has focus.

3. Wiring at startup

Both subsystems start once, after the stores are hydrated from persistence, in the app's init/orchestration step:

// src/app/orchestration/bootstrap.ts (sketch)
import { startUrlStateSync } from './UrlStateSync';
import { startEventRouter } from './EventRouter';

export function initApp(): void {
  // ... load settings + hydrate snippet/dataset stores from IndexedDB/localStorage ...
  startUrlStateSync();   // restore view from hash, then keep hash <-> stores in sync
  startEventRouter();    // bind global keyboard/paste routing
}

Order: hydrate stores first (so hash-restore can resolve ids), then startUrlStateSync (it reads the hash and may drive the stores), then startEventRouter. Each start* is idempotent and has a matching stop* for teardown in tests.


4. Testing notes

  • parseHash / serializeHash: pure, so test directly. Cover every ViewState, the empty hash, and at least one malformed hash → default. Assert the round-trip identity.
  • isInInteractiveContext: happy-dom test (the project's Vitest env). Mount an <input>, a contenteditable div, and a <div class="monaco-editor"><textarea/></div>; focus each and assert true; assert false for a focused <button>.
  • Escape chain: with stores in known states, dispatch a synthetic Escape and assert only the top active layer changed.
  • Restore-on-load with dead id: seed an empty store, set location.hash = '#snippet-gone', call startUrlStateSync(), assert the view fell back to default and the hash was cleaned.