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
- URL parsing and route matching (static, params, wildcards); ranking by specificity.
- History API:
pushState,replaceState,popstate. Note whatpopstatedoes not tell you. - Nested routes and a matched-route chain.
- Redirects, including loops — detect and fail loudly.
- Loaders: async data per route, resolved before the transition commits.
- Cancellation: a navigation superseded by another must abort its loaders.
- Error routes and error boundaries per level.
- Back/forward, including during an in-flight navigation.
- Scroll restoration.
- Blocking navigation on unsaved changes.
3. Failure Lab
- 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.
- Back during load. Press back while a loader is pending. Where does the state end up?
- Redirect loop. A→B→A. What does the user see, and what should they see?
- Scroll restoration vs async content. Restore scroll before the content that made the page tall has loaded.
- 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 load —
popstatearrives 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
| Fix | Mechanism | Stops the work? | Correct with server side effects? |
|---|---|---|---|
| Render-time guard | compare response's URL to current | no | no — the effect happened |
| Sequence number | ignore responses with a stale id | no | no — same |
AbortController | signal cancellation to the request | yes, best-effort | closest — 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
| Reality | Consequence |
|---|---|
popstate does not say direction | you cannot tell back from forward without tracking indices yourself |
pushState does not fire popstate | your own navigations need explicit handling |
| History entries have a state object with a size limit | do not store your app state there |
| You cannot read the history stack | no "can I go back?" without your own bookkeeping |
| You cannot cancel a back navigation | which 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
- Name the invariant violated by a stale loader overwrite, and defend your preferred fix including the side-effecting case.
popstatedoes not distinguish back from forward. What does that force routers to build?- Loaders eliminate request waterfalls but couple data to URL structure. When is that wrong?
- Design navigation blocking for unsaved changes that works with the back button. What can you not do, and why is that a platform decision?
- Compare commit-then-load with load-then-commit on perceived performance and on correctness.