Skip to content

Router: 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:

  1. pushStates the new URL.
  2. Fetches it with a phaze-router: 1 request header and reads the response as text.
  3. Parses the HTML, extracts the new #__phaze_root__ subtree and the window.__PHAZE_CF__ edge-signal payload, and updates document.title from the new head.
  4. 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 swap

What 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.

How the swap applies depends on whether you defined a Phaze App (src/app.tsx):

Phaze App present (recommended)No Phaze App (direct-mount)
MechanismcurrentRoute() signal updated via __setCurrentRoute(...)root.innerHTML = newRoot.innerHTML + re-startClient()
Re-hydrationNone — only the page subtree re-rendersFull — the whole scope tears down and re-hydrates
Chrome / module signals / focus across navPreserved (everything outside the page view)Lost
currentRoute()returns RouteStatereturns 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() 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).