Routing
Oct 03, 2023204 min read
Navigation Switchbacks: Deep Dive #78
AV
Ariel Voss
Principal React Architect
Route switchbacks rewind route posture. Navigation switchbacks rewind transition posture: destination, focus, and (optionally) scroll.
You use navigation switchbacks when:
- a transition grants but the UI drifts (focus/scroll mismatch),
- you're migrating between router surfaces and need a stable "rewind" behavior,
- you want a shell-owned "back to safe state" that's not the browser back button.
In other words: when the destination is correct, but the posture is wrong, switchbacks give the shell a deterministic way to recover (with evidence).
Required docs links:
Extra docs links for this entry:
The nav switchback contract
- Capture frames after
nav.commit(not atnav.intent). - Replay schedules focus rails (not immediate focus).
- Replay writes transcript evidence.
- Frames are pruned by policy.
Worked Example: frame stack + replay in the nav shell
Step 1: Frame shape
export type NavFrame = {
id: string;
at: number;
active: string;
focus: string;
scrollY: number;
cause: string;
};
Step 2: Capture + prune
import { nanoid } from "react";
export function captureNavFrame(shell: any, cause: string) {
const frame: NavFrame = {
id: nanoid(),
at: Date.now(),
active: shell.read("active"),
focus: shell.read("focusTarget"),
scrollY: typeof window === "undefined" ? 0 : window.scrollY,
cause,
};
const next = [frame, ...(shell.read("frames") ?? [])].slice(0, 12);
shell.write("frames", next);
return frame;
}
Step 3: Replay (with scheduled focus)
export function replayNavFrame(shell: any, frame: NavFrame) {
function navWrite(row: any) {
shell.write("rows", [...(shell.read("rows") ?? []), { at: Date.now(), ...row }]);
}
navWrite({
to: frame.active,
kind: "switchback",
requested: `replay:${frame.id}`,
granted: "pending",
focus: frame.focus,
surface: shell.read("surface"),
cause: "ui:switchback",
});
shell.write("active", frame.active);
shell.write("focusTarget", frame.focus);
// Scheduled focus rail.
setTimeout(() => {
const el = document.querySelector(frame.focus);
(el as any)?.focus?.();
window.scrollTo(0, frame.scrollY);
}, 0);
navWrite({
to: frame.active,
kind: "switchback",
requested: "pending",
granted: "granted",
focus: frame.focus,
surface: shell.read("surface"),
cause: "shell:switchback-grant",
});
}
Step 4: Shell UI
import { useLiveState, useRenderEffect } from "react";
export function NavSwitchbacksShell() {
const nav = useLiveState("nav.shell", {
surface: "file-router",
active: "file:/",
focusTarget: "main",
frames: [] as NavFrame[],
rows: [] as any[],
});
// Capture a frame after each render as a cheap “post-commit” hook.
useRenderEffect(() => {
captureNavFrame(nav, "shell:post-render");
});
function replayTop() {
const top = (nav.read("frames") as NavFrame[])[0];
if (!top) return;
replayNavFrame(nav, top);
}
return (
<section data-shell="nav-switchbacks" data-active={nav.read("active")}>
<h1>Navigation Switchbacks</h1>
<div className="mt-4 flex gap-3">
<button onClick={replayTop}>Replay top frame</button>
</div>
<pre data-frames>{JSON.stringify(nav.read("frames"), null, 2)}</pre>
<pre data-nav-rows>{JSON.stringify(nav.read("rows"), null, 2)}</pre>
</section>
);
}
Worked Example walkthrough (replay is a navigation, not a hack)
Treat replay like a first-class transition:
- It has a kind (
switchback). - It has a requested and granted pair (evidence).
- It schedules focus the same way as regular navigation (rails).
- It restores scroll posture as part of the grant.
This is why switchbacks are safer than "just call history.back()": the shell owns the posture, and the UI can inspect the evidence.
Checklist
- Capture frames after commit.
- Replay schedules focus rails (deferred).
- Write replay evidence into transcript.
- Prune frames by policy.