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.
go() — navigating from code
Section titled “go() — navigating from code”<a href> is the primary way to navigate; the router’s click handler does the rest. For the cases with no anchor — after a sign-in ceremony, after an action, on a timeout — go() is the same navigation from code:
import { go } from '@madenowhere/phaze-cloudflare'
go('/dashboard') // push a history entrygo.replace('/dashboard') // navigate WITHOUT onego('/articles', { scroll: 'top' }) // per-navigation options…go('/dashboard', { transition: 'auth-close' })The options are the same ones nav: puts on a link, and they travel the same way — written into history.state, so they’re per-entry and survive the gap between issuing a navigation and the swap landing.
go.replace is for navigations that shouldn’t be somewhere Back can return to — the classic case being a re-gate after sign-in, where the door is no longer a place that exists — and for re-gating the same URL, where a push would stack a duplicate entry.
Blocking a navigation — phaze:navigate
Section titled “Blocking a navigation — phaze:navigate”A cancelable event fires on document before a link navigation proceeds. Calling
preventDefault() stops it — the unsaved-changes guard:
import { listen } from '@madenowhere/phaze'
const unsaved = s(false) // an ordinary module signallisten(document, 'phaze:navigate', (e) => { if (unsaved()) e.preventDefault() })listen cleans up with its scope, so there’s nothing to unregister. And because the
router is a signal rather than an object you reach through a hook, the “is it dirty” fact
is just a module-scope signal both ends read — the form writes it, the guard reads it,
and nothing in between needs to exist. React’s equivalent needs a Context, a provider in
the root layout, and a custom <Link> wrapper, because the fact has no way to reach the
link except through the tree.
The event carries detail: { to }, and is emitted only when something listens for it —
the plugin scans for the name and threads the dispatch into the generated entry, so an app
without a guard pays nothing.
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).