Routing
Aug 29, 2023188 min read

Route Switchbacks: Deep Dive #73

AV
Ariel Voss

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

  1. Capture frames after a commit grant (not before).
  2. Store frames in the shell ledger so they survive route/module swaps.
  3. Replay is a request; shell grants replay by policy.
  4. 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.