Route Switchbacks: Deep Dive #73
Principal React Architect
Choreography makes transitions explicit. Transcripts make them provable. Switchbacks make them reversible.
Even with a perfect routine (#71) and perfect evidence (#72), real apps drift:
- a draft exists, but it’s for the wrong invoice,
- the shell granted
detail, but the route still renders the previous track, - a router migration changes the timing of a commit,
- or a recovery path replays half a transition.
A switchback is the shell-owned answer: capture a "last known good" frame after commit, then treat replay as a first-class request/grant sequence (with transcript evidence). When drift happens, you don’t guess — you rewind to a stable route + track posture.
Required docs links:
Extra docs links for this entry:
The switchback contract
- Capture frames after a commit grant (not before).
- Store frames in the shell ledger so they survive route/module swaps.
- Replay is a request; shell grants replay by policy.
- Replay produces transcript evidence (request + grant).
Worked Example: a switchback stack for the Invoice Explorer
Step 1: Frame shape (keep it small and policy-owned)
export type SwitchbackFrame = {
id: string;
at: number;
routeId: string;
track: "summary" | "detail";
params: Record<string, string>;
draft: null | { invoiceId: string; mode: string };
cause: string;
};
Step 2: Capture after commit
import { nanoid } from "react";
export function captureSwitchback(shell: any, cause: string) {
const frame: SwitchbackFrame = {
id: nanoid(),
at: Date.now(),
routeId: shell.read("routeId"),
track: shell.read("track"),
params: shell.read("params") ?? {},
draft: shell.read("draft"),
cause,
};
shell.write("switchbacks", [frame, ...(shell.read("switchbacks") ?? [])]);
return frame;
}
Step 2b: Retention and pruning (policy, not convenience)
Switchbacks work because they stay boring: you keep just enough history to recover from drift, and you throw away everything else.
export function pruneSwitchbacks(args: {
shell: any;
maxFrames: number;
paramsVersion: string;
transcript?: ReturnType<typeof createRouteTranscript>;
intentId?: string;
}) {
const frames = (args.shell.read("switchbacks") ?? []) as SwitchbackFrame[];
const pruned = frames.slice(0, args.maxFrames);
if (args.transcript && args.intentId && pruned.length !== frames.length) {
transcriptGrant({
transcript: args.transcript,
intentId: args.intentId,
routeId: args.shell.read("routeId"),
track: args.shell.read("track"),
stepId: "switchback.prune",
granted: "pruned",
cause: "shell:switchback-retention",
surface: "shell",
meta: { before: frames.length, after: pruned.length, paramsVersion: args.paramsVersion },
});
}
args.shell.write("switchbacks", pruned);
args.shell.write("switchbacks.paramsVersion", args.paramsVersion);
}
Step 3: Replay (request + grant evidence)
export function replaySwitchback(args: {
shell: any;
transcript: ReturnType<typeof createRouteTranscript>;
intentId: string;
frame: SwitchbackFrame;
}) {
const { shell, transcript, intentId, frame } = args;
transcriptRequest({
transcript,
intentId,
routeId: frame.routeId,
track: frame.track,
stepId: "switchback.replay",
requested: `replay:${frame.id}`,
cause: "ui:switchback",
surface: "route",
meta: { frameAt: frame.at },
});
shell.write("routeId", frame.routeId);
shell.write("track", frame.track);
shell.write("params", frame.params);
shell.write("draft", frame.draft);
transcriptGrant({
transcript,
intentId,
routeId: frame.routeId,
track: frame.track,
stepId: "switchback.replay",
granted: "granted",
cause: "shell:switchback-policy",
surface: "shell",
meta: { frameId: frame.id },
});
}
Step 4: Shell UI (capture + replay)
import { useLiveState } from "react";
export function InvoiceSwitchbackPanel() {
const shell = useLiveState("invoice.shell", {
routeId: "file:/invoices/[invoiceId]",
track: "detail",
params: { invoiceId: "92" },
draft: { invoiceId: "92", mode: "read" },
transcript: [],
switchbacks: [] as SwitchbackFrame[],
});
const transcript = createRouteTranscript(shell);
function replayTop() {
const top = (shell.read("switchbacks") as SwitchbackFrame[])[0];
if (!top) return;
replaySwitchback({
shell,
transcript,
intentId: `switchback:${top.id}`,
frame: top,
});
}
return (
<section data-panel="switchbacks">
<h2>Route Switchbacks</h2>
<div className="mt-3 flex gap-3">
<button onClick={() => captureSwitchback(shell, "ui:capture")}>
Capture frame
</button>
<button onClick={replayTop}>Replay top</button>
</div>
<pre data-frames>{JSON.stringify(shell.read("switchbacks"), null, 2)}</pre>
<RouteTranscriptPanel shell={shell} />
</section>
);
}
Wiring switchbacks into the #71 routine (capture after commit)
The key is where you capture. You don't capture on intent, and you don't capture on request. You capture after the shell commits the route posture.
// Inside the shell's choreography routine (Deep Dive #71):
requestStep(shell, "commit", "commit route");
grantStep(shell, "commit", "granted", { status: "committed" });
captureSwitchback(shell, "shell:post-commit");
pruneSwitchbacks({
shell,
maxFrames: 10,
paramsVersion: "invoice.params.v1",
transcript,
intentId: shell.read("activeIntent")?.intentId,
});
Retention policy (don't keep frames forever)
Switchbacks are not history; they're safety rails. Keep a short, policy-owned retention:
- keep last N frames per route group (e.g. 10)
- drop frames when params schema changes
- record drops as evidence (you can log a "frame.pruned" transcript row)
Checklist
- Capture after commit.
- Keep frames small and shell-owned.
- Replay writes transcript request+grant rows.
- Prune frames by policy.