Concepts — A Client-Side Router

Phase 5 · Spec area §34. Prerequisites: fw-02.

1. Why this matters

Routing looks trivial and is not. It is a state machine over asynchronous, cancellable, user-interruptible transitions, layered on a History API with quirks. Almost every hard router bug is a concurrency bug: two navigations in flight, a loader resolving after its navigation was abandoned, or the back button racing a redirect.

This is the module where §35's race-condition skills first appear, and the two share a root cause.

2. Build order

  1. URL parsing and route matching (static, params, wildcards); ranking by specificity.
  2. History API: pushState, replaceState, popstate. Note what popstate does not tell you.
  3. Nested routes and a matched-route chain.
  4. Redirects, including loops — detect and fail loudly.
  5. Loaders: async data per route, resolved before the transition commits.
  6. Cancellation: a navigation superseded by another must abort its loaders.
  7. Error routes and error boundaries per level.
  8. Back/forward, including during an in-flight navigation.
  9. Scroll restoration.
  10. Blocking navigation on unsaved changes.

3. Failure Lab

  1. Stale loader. Navigate A→B→C quickly with random loader latencies. Land on C, show B's data. Reproduce it deterministically — if your reproduction is "click fast," you have observed it, not reproduced it.
  2. Back during load. Press back while a loader is pending. Where does the state end up?
  3. Redirect loop. A→B→A. What does the user see, and what should they see?
  4. Scroll restoration vs async content. Restore scroll before the content that made the page tall has loaded.
  5. Double submit. Two rapid clicks on a link with a side-effecting loader.

For (1), implement three fixes — AbortController, a navigation sequence number, and render-time guarding — then rank them. Ask which is correct when the loader has a server side effect. That question is the seam into fw-09.

3.5 Deep dive: navigation is a state machine with cancellation

Almost every hard router bug is a concurrency bug. Model navigation explicitly:

idle ──navigate──► loading ──resolved──► committing ──► idle
                     │  │
                     │  └──superseded──► discarded
                     └──error──────────► error route

The transitions that produce bugs:

  • superseded — a second navigation starts while the first is loading. The first must be cancelled and must not commit.
  • back during loadpopstate arrives mid-navigation. Which wins, and what is the URL now?
  • redirect during load — a loader returns a redirect; the original navigation must not commit.
  • error during load — which boundary catches it, and does the URL change?

The invariant to name: only the most recent navigation may commit. Every stale-data bug in a router is a violation of it, and stating it in those terms turns four separate bugs into one.

The three fixes, ranked

FixMechanismStops the work?Correct with server side effects?
Render-time guardcompare response's URL to currentnono — the effect happened
Sequence numberignore responses with a stale idnono — same
AbortControllersignal cancellation to the requestyes, best-effortclosest — the server may still have acted

The ranking is: AbortController plus a sequence-number guard. Cancellation is best-effort — the request may already have reached the server — so you still need to ignore late responses.

The question that separates a mid-level from a senior answer: what if the loader has a server side effect? Then no client-side fix is sufficient. You need idempotency keys, or the operation must not be in a loader at all. That is the seam into fw-09, and it is a genuinely architectural answer rather than a code fix.


3.6 Deep dive: the History API's sharp edges

RealityConsequence
popstate does not say directionyou cannot tell back from forward without tracking indices yourself
pushState does not fire popstateyour own navigations need explicit handling
History entries have a state object with a size limitdo not store your app state there
You cannot read the history stackno "can I go back?" without your own bookkeeping
You cannot cancel a back navigationwhich is why unsaved-changes guards are hard

That last row is a deliberate platform decision: allowing pages to trap the back button would be user-hostile. So beforeunload is limited to a browser-controlled dialog, and in-app blocking only works for navigations your router controls.

The Navigation API is the modern answer — it exposes an intercept-able navigation event with real cancellation — and knowing why it exists (the table above) is more valuable than knowing its method names.

Scroll restoration

history.scrollRestoration = 'manual' hands you the job. Doing it correctly requires restoring after the content that determines page height exists, which in an async-loading app means after data resolves — and possibly after images load. Restoring too early scrolls to a position that does not exist yet and silently clamps to the bottom.

This is the same anchoring problem as fw-10's virtualized list and the browser's own scroll anchoring (bi-08). Three encounters, one problem: you cannot restore a position in a document whose size you do not yet know.

4. Trade-offs

Commit-then-load vs load-then-commit. Showing the new route immediately with a spinner feels faster and can flash; waiting feels slower and is consistent. Frameworks disagree, and the right answer is product-specific.

Router-owned data vs component-owned. Loaders remove waterfalls and couple data to routes.

5. Principal Engineer Review

  1. Name the invariant violated by a stale loader overwrite, and defend your preferred fix including the side-effecting case.
  2. popstate does not distinguish back from forward. What does that force routers to build?
  3. Loaders eliminate request waterfalls but couple data to URL structure. When is that wrong?
  4. Design navigation blocking for unsaved changes that works with the back button. What can you not do, and why is that a platform decision?
  5. Compare commit-then-load with load-then-commit on perceived performance and on correctness.