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.

<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 entry
go.replace('/dashboard') // navigate WITHOUT one
go('/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.

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 signal
listen(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.

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