Routing
Oct 10, 2023214 min read

Navigation Treaties: Deep Dive #79

AV
Ariel Voss

Principal React Architect

Navigation treaties are contracts for transition posture:

  • pacing (how long the shell may hold a transition window),
  • focus rails (what gets focused and when),
  • and scheduling permissions (scroll, announce, preload).

They exist for the same reason route treaties exist (#74): posture is policy, and policy needs a single negotiation surface. Treaties let you change posture without rewriting every navigation call site.

Required docs links:

Extra docs links for this entry:

Treaty doctrine for navigation

  1. A navigation request is negotiated into a grant posture.
  2. Terms are deterministic and recorded as evidence.
  3. Grants contain focus + pace window.
  4. Fallback grants are explicit (never implicit).

Worked Example: treaty:navigation (terms + grant + fallback)

Step 1: Types

export type NavTreatyTerm =
  | { ok: true; id: string }
  | { ok: false; id: string; reason: string };

export type NavTreatyGrant = {
  focus: string;
  paceMs: number;
};

export type NavTreaty = {
  id: string;
  terms: Array<(args: { to: string; shell: any }) => NavTreatyTerm>;
  grant: (args: { to: string; shell: any }) => NavTreatyGrant;
  fallback: (args: { to: string; shell: any; failed: NavTreatyTerm[] }) => NavTreatyGrant;
};

Step 2: Definition

export function defineNavTreaty(): NavTreaty {
  return {
    id: "treaty:navigation",
    terms: [
      ({ to }) => (to ? { ok: true, id: "term.to.present" } : { ok: false, id: "term.to.present", reason: "missing" }),
      ({ shell }) =>
        typeof shell.read("budgetMs") === "number"
          ? { ok: true, id: "term.budget.present" }
          : { ok: false, id: "term.budget.present", reason: "missing" },
    ],
    grant: ({ shell }) => ({ focus: "h1", paceMs: shell.read("budgetMs") }),
    fallback: () => ({ focus: "main", paceMs: 0 }),
  };
}

Step 3: Negotiate + write evidence

import { useLiveState } from "react";

export function NavigationTreatyShell() {
  const nav = useLiveState("nav.shell", {
    surface: "file-router",
    active: "file:/",
    budgetMs: 160,
    focusTarget: "main",
    rows: [] as any[],
  });

  const treaty = defineNavTreaty();

  function navigate(to: string, cause: string) {
    function navWrite(row: any) {
      nav.write("rows", [...nav.read("rows"), { at: Date.now(), ...row }]);
    }

    navWrite({
      to,
      kind: "push",
      requested: `treaty:${treaty.id}`,
      granted: "pending",
      focus: nav.read("focusTarget"),
      surface: nav.read("surface"),
      cause,
    });

    const results = treaty.terms.map((t) => t({ to, shell: nav }));
    const failed = results.filter((r) => r.ok === false);
    results.forEach((r) =>
      navWrite({
        to,
        kind: "push",
        requested: r.id,
        granted: r.ok ? "ok" : "failed",
        focus: nav.read("focusTarget"),
        surface: nav.read("surface"),
        cause: "shell:treaty-eval",
      }),
    );

    const grant = failed.length ? treaty.fallback({ to, shell: nav, failed }) : treaty.grant({ to, shell: nav });

    navWrite({
      to,
      kind: "push",
      requested: "treaty.grant",
      granted: failed.length ? "fallback" : "granted",
      focus: grant.focus,
      surface: nav.read("surface"),
      cause: "shell:treaty-grant",
      meta: { paceMs: grant.paceMs },
    });

    nav.write("active", to);
    nav.write("focusTarget", grant.focus);
  }

  return (
    <section data-shell="nav-treaty" data-active={nav.read("active")}>
      <h1>Navigation Treaty</h1>
      <div className="mt-4 flex gap-3">
        <button onClick={() => navigate("file:/invoices", "nav:invoices")}>Go invoices</button>
        <button onClick={() => navigate("", "nav:bad")}>Request empty destination</button>
      </div>
      <pre data-nav-rows>{JSON.stringify(nav.read("rows"), null, 2)}</pre>
    </section>
  );
}

Worked Example walkthrough (treaties shape posture, not destination)

Notice what the treaty does not decide:

  • It doesn’t decide which route exists.
  • It doesn’t decide which component renders.

It decides the posture of the transition: how long the shell is allowed to pace the grant (paceMs) and what the shell will focus (focus).

That’s what makes treaties team-scalable: you can change posture (pacing, focus rails, preload) without rewriting every route and every click handler.

Checklist

  • Negotiate posture, don’t hardcode it.
  • Record term results as evidence.
  • Grant includes focus + pace.
  • Fallback is explicit.