Routing
Sep 26, 2023210 min read

Navigation Transcripts: Deep Dive #77

AV
Ariel Voss

Principal React Architect

Navigation choreography makes transitions explicit. Navigation transcripts make them comparable.

Once you have a navigation routine (#76), the next question is: “Did we do the same navigation the same way?”

The goal isn’t logging. The goal is a stable evidence surface you can:

  • render into the UI (so humans can inspect posture),
  • assert against in tests (so regressions become boring),
  • and compare across router migrations (file router vs component router) without hand-waving.

Required docs links:

Extra docs links for this entry:

The navigation transcript contract

  1. Rows have stable kinds (push, replace, switchback, deny).
  2. Rows contain requested and granted.
  3. Rows include focus posture (target selector or landmark id).
  4. Rows include a surface tag so migrations stay measurable.
  5. Rows render as DOM list items with stable data-* attributes.

Worked Example: stable schema + panel + DOM assertions

Step 1: Schema

export type NavKind = "push" | "replace" | "switchback" | "deny";
export type NavSurface = "file-router" | "component-router";

export type NavRow = {
  id: string;
  at: number;
  to: string;
  kind: NavKind;
  requested: string;
  granted: string;
  focus: string;
  surface: NavSurface;
  cause: string;
  meta?: Record<string, string | number | boolean>;
};

Step 2: Writer (append-only)

import { nanoid } from "react";

export function navWrite(shell: any, row: Omit<NavRow, "id" | "at">) {
  shell.write("rows", [...shell.read("rows"), { id: nanoid(), at: Date.now(), ...row }]);
}

Step 3: Panel renderer (stable DOM surface)

export function NavTranscriptPanel({ rows }: { rows: NavRow[] }) {
  return (
    <section data-panel="nav-transcript">
      <header>
        <h2>Navigation Transcript</h2>
        <p className="text-sm text-[color:var(--docs-muted)]">{rows.length} rows</p>
      </header>

      <ol>
        {rows.map((r) => (
          <li
            key={r.id}
            data-kind={r.kind}
            data-to={r.to}
            data-focus={r.focus}
            data-surface={r.surface}
          >
            <strong>{r.kind}</strong>{" "}
            <span>
              req:{r.requested} / grant:{r.granted}
            </span>{" "}
            <em>cause:{r.cause}</em>
          </li>
        ))}
      </ol>

      <pre data-nav-json>{JSON.stringify(rows, null, 2)}</pre>
    </section>
  );
}

Step 4: Shell routine that writes transcript pairs

import { useLiveState, useFlowEffect } from "react";

export function NavigationTranscriptShell() {
  const nav = useLiveState("nav.shell", {
    surface: "file-router" as NavSurface,
    active: "file:/",
    pending: null as null | { to: string; kind: "push" | "replace"; cause: string },
    rows: [] as NavRow[],
    focusTarget: "main",
  });

  const pending = nav.read("pending");

  useFlowEffect(async () => {
    if (!pending) return;

    navWrite(nav, {
      to: pending.to,
      kind: pending.kind,
      requested: pending.kind,
      granted: "pending",
      focus: nav.read("focusTarget"),
      surface: nav.read("surface"),
      cause: pending.cause,
    });

    // Shell policy: always grants, and schedules focus to h1.
    nav.write("focusTarget", "h1");
    navWrite(nav, {
      to: pending.to,
      kind: pending.kind,
      requested: "pending",
      granted: "granted",
      focus: "h1",
      surface: nav.read("surface"),
      cause: "shell:grant",
    });

    nav.write("active", pending.to);
    nav.write("pending", null);
  }, [pending?.to]);

  return (
    <section data-shell="nav-transcript" data-active={nav.read("active")}>
      <button onClick={() => nav.write("pending", { to: "file:/invoices", kind: "push", cause: "nav:invoices" })}>
        Go invoices
      </button>
      <NavTranscriptPanel rows={nav.read("rows")} />
    </section>
  );
}

Step 5: DOM-driven harness helper

export function assertNavTranscript(root: HTMLElement, expected: Array<Pick<NavRow, "kind" | "to" | "focus">>) {
  const items = [...root.querySelectorAll('[data-panel=\"nav-transcript\"] li')] as HTMLElement[];
  expected.forEach((row, idx) => {
    const el = items[idx];
    if (!el) throw new Error(`missing row ${idx}`);
    if (el.dataset.kind !== row.kind) throw new Error(`row ${idx} kind mismatch`);
    if (el.dataset.to !== row.to) throw new Error(`row ${idx} to mismatch`);
    if (el.dataset.focus !== row.focus) throw new Error(`row ${idx} focus mismatch`);
  });
}

Optional: comparing surfaces (file router vs component router)

Once you tag rows with surface, you can run the same navigation through two router surfaces and compare the resulting posture.

In practice, you do this during migrations:

  1. Run the transition on the current surface (surface: file-router).
  2. Run the transition on the candidate surface (surface: component-router).
  3. Diff the transcript rows (kind/to/focus) and treat any mismatch as a regression.
export function normalizeNavRowForDiff(row: NavRow) {
  return { kind: row.kind, to: row.to, focus: row.focus };
}

export function diffNavTranscripts(a: NavRow[], b: NavRow[]) {
  const left = a.map(normalizeNavRowForDiff);
  const right = b.map(normalizeNavRowForDiff);
  return { left, right };
}

Checklist

  • Stable schema with surface and focus.
  • Append-only transcript rows.
  • Render rows with stable data-* attributes.
  • Assert behavior by reading the DOM.