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
- Rows have stable kinds (
push,replace,switchback,deny). - Rows contain
requestedandgranted. - Rows include focus posture (target selector or landmark id).
- Rows include a
surfacetag so migrations stay measurable. - 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:
- Run the transition on the current surface (
surface: file-router). - Run the transition on the candidate surface (
surface: component-router). - 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
surfaceandfocus. - Append-only transcript rows.
- Render rows with stable
data-*attributes. - Assert behavior by reading the DOM.