Router: Navigation lifecycle
The navigation lifecycle
Section titled “The navigation lifecycle”When router: true, the client entry calls startRouter(routes), which registers one delegated click listener on document plus a popstate listener. On a qualifying click it:
pushStates the new URL.- Fetches it with a
phaze-router: 1request header and reads the response as text. - Parses the HTML, extracts the new
#__phaze_root__subtree and thewindow.__PHAZE_CF__edge-signal payload, and updatesdocument.titlefrom the new head. - Swaps the page — inside
document.startViewTransition()where supported (Chrome 111+), or a plain swap otherwise.
click <a href="/about"> └─ pushState('/about') └─ fetch('/about', { headers: { 'phaze-router': '1' } }) └─ parse → #__phaze_root__ + __PHAZE_CF__ + <title> └─ startViewTransition(swap) // or plain swapWhat gets intercepted. Only a plain left-click (button === 0, no Ctrl/Cmd/Shift/Alt) on a same-origin http(s) <a>. Modified clicks, middle-clicks, cross-origin links, and non-http protocols (mailto:, tel:) are left to the browser. Back/forward (popstate) navigates the same way.
The phaze-router header. The client sends phaze-router: 1 so a server can distinguish a soft navigation from a hard load (for logging, cache-keying, or future partial responses). Today the server returns the same full SSR’d HTML a hard navigation would — the router simply extracts the page root and payload from it.
Failure is always safe. A non-OK response, a fetch error, or a missing root/payload falls back to a full location.href navigation — a broken SPA hop never strands the user on a blank page.
Two swap modes
Section titled “Two swap modes”How the swap applies depends on whether you defined a Phaze App (src/app.tsx):
| Phaze App present (recommended) | No Phaze App (direct-mount) | |
|---|---|---|
| Mechanism | currentRoute() signal updated via __setCurrentRoute(...) | root.innerHTML = newRoot.innerHTML + re-startClient() |
| Re-hydration | None — only the page subtree re-renders | Full — the whole scope tears down and re-hydrates |
| Chrome / module signals / focus across nav | Preserved (everything outside the page view) | Lost |
currentRoute() | returns RouteState | returns null |
The Phaze App path is the reason to define src/app.tsx: the Phaze App hydrated once at first load, and navigations only re-run the currentRoute()-driven page view. Persistent chrome (header, nav, theme provider), a focused search input in the header, an in-flight sidebar animation, a playing <video> in a sticky player — all survive, because nothing outside the page view re-mounts.
currentRoute() and RouteState
Section titled “currentRoute() and RouteState”currentRoute() is exported from @madenowhere/phaze-cloudflare. It returns the active route, or null in direct-mount mode (no Phaze App to drive):
interface RouteState { pathname: string params: Record<string, string> // dynamic segments, e.g. /users/[id] → { id } data: unknown // the page loader's return value module: PageModule // the resolved page module (`.default` is the component)}It’s a signal: the router sets it on every navigation, so only the subtree that reads it re-runs. The canonical Phaze App reads it in the children ?? (...) fallback arrow (above).