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

  1. Capture frames after nav.commit (not at nav.intent).
  2. Replay schedules focus rails (not immediate focus).
  3. Replay writes transcript evidence.
  4. 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.