Client Navigation - developerz-ai/ultimate GitHub Wiki
Moving between pages of an opted-in surface without a full document load: the router fetches the next page the server renders anyway and swaps it into the same tab. The tab keeps its islands that did not change, its socket, its page store and its scroll history, so an app of server pages feels like an installed one.
Not a render mode. Every page is still rendered by its own route, in its own mode (ssr,
stream, static, isr), behind its own policy and cache headers. The router only decides what
the browser does with the answer. Without the script (scripting off, or a link marked
data-x-reload), the same markup is an ordinary multi-page app. Why this and not a client-rendered
mode: docs/history/render.md.
One rule over everything below: nothing the router sends is sent twice, and nothing it guesses runs a route that did not ask for it. The server enforces both, before any app code runs.
// app.config.ts
export const config = defineConfig({
name: 'notificado',
navigation: { client: ['app'] },
});navigation.client |
Effect |
|---|---|
[] (default) |
every navigation is a full load; no surface ships a router byte |
['app'] |
app/ pages navigate softly; site/ stays 0kb of JS |
['site', 'app'] |
both. Crossing from one surface to the other is still a full load |
api and shared render no documents and are refused (X_CONFIG_INVALID).
A page may then say how the router treats it, with the route's twelfth key:
export const config = defineRoute({ render: 'ssr', โฆ, navigation: 'prefetch' });navigation |
The router | The server, before load
|
|---|---|---|
| absent | swaps the page in on a click; never fetches it early | a prefetch gets an empty 204
|
'prefetch' |
also fetches it on hover/focus | a prefetch is answered like a visit |
'document' |
always a real document load; the page itself carries no router (As of 22.8.1): no script, no navigation metas, none of the router's bytes charged to its budget โ a 0b page stays 0b, and its own links and forms are plain browser navigations |
a soft visit gets 204 + x-ultimate-location; the browser's own load runs it once |
'modal' |
shown over the page the visitor is on, addressed by the hash (/runs#/runs/new) โ Modals. A full load of its own URL is still the whole page |
as absent: a prefetch gets an empty 204
|
Use 'document' for every GET that records something โ a recipient opening a link, a download
logged as evidence, a one-time token. Use 'prefetch' only for a GET that does nothing but
render. Declaring any of the three on a surface without client navigation refuses the boot
(X_ROUTE_NAVIGATION_INVALID).
An opted-in document (every page of the surface except a 'document' one) carries:
| Tag | Why |
|---|---|
<meta name="ultimate-navigation" content="<app>:<surface>"> |
which router may swap it in. The app's name is part of it, so two apps on one origin (web and admin) never swap each other's pages |
<meta name="x-ultimate-build" content="โฆ"> |
the skew check |
<script src="/_x/assets/navigation/<hash>.js" defer> |
the router: one classic script, content-addressed, immutable, 'self' under the default CSP. x dev, the container and x build --target static all serve or write it |
Budget: the router is 24,058 B minified (8,836 B gzip) As of 26.1.0 โ 19,966 B before
route-presented modals and navigating from code (+4,092 B). It is charged to
every route on the surface, like realtime's page boot, because it is interactivity the app opted
into. Raise a route's budget.js by the measured amount (bun run budget-raises).
Every router request carries x-ultimate-navigation: soft | prefetch, the document's
x-ultimate-surface: <app>:<surface>, its principal (x-ultimate-navigation-scope, when the
document has one) and, on a GET, its x-ultimate-build. @ultimat3/http answers before auth,
before any hook, before the handler:
| Request | Route | Answer |
|---|---|---|
| prefetch | anything but a page declaring 'prefetch' (actions, queries, assets, storage, app routes, pages) |
204, nothing ran |
| soft GET | anything but a page of THIS router's <app>:<surface> (a download, an evidence GET, a 'document' page, another surface or app) |
204 + x-ultimate-location: <same url>; the router loads it for real, and it runs once |
| soft GET | a page rendered for another principal than the router's document |
204 + x-ultimate-location, before load
|
| GET | a tab on another build |
409 from the existing build check; the router loads the page for real |
any router request answered with a 3xx (a form's 303, a sign-in wall, a load's setRedirect, an OAuth or payment hop) |
any |
204 + x-ultimate-location: <target>, cookies kept. The handler ran once; the router follows a same-origin target as its own next request, and hands another origin to the browser. A target that is not http:/https: (or will not parse) is never handed over: the header names the requested URL instead, as a path |
| POST | any | never gated: it is the form's submission |
A soft GET across a principal change runs auth and rate-limit twice โ once before the 204 of
the principal row above, once for the real load. Expected and harmless (auth and rate-limit only;
the handler and load run once), but a tight per-actor rate limit should budget one extra request
per sign-in or sign-out navigation.
The router never lets fetch follow a redirect (redirect: 'manual'), so a CORS-refused hop to
another origin, or a target on another surface, is never fetched and then asked for again.
Clicks and submits are read on window in the bubble phase, after every handler on the page:
a handler that calls preventDefault() keeps the event.
| Taken (soft) | Left to the browser (native) |
|---|---|
left click on a same-origin <a href> / <area href>
|
a modifier key or another button; target other than _self; download; rel="external"; data-x-reload; another origin or scheme |
| a link to another page, or another query on this page | a fragment on the page already shown, href="#" included: the browser scrolls, nothing is fetched. An href or form action no URL parses (http://): the browser decides |
<form method="get">: navigates to its query, like the browser |
method="dialog"; a target; another origin; data-x-reload on the form or its submitter |
<form method="post">, url-encoded or multipart |
a POST with a chosen file (the browser's own upload progress); enctype="text/plain"
|
The submitter's formaction, formmethod, formenctype, formtarget and its own name/value are
honoured. A GET query and a url-encoded body carry every line break as CRLF, as a native submit does
(As of 2026-10-02): a <textarea> sends the same bytes with the router as without it.
| Answer | Result |
|---|---|
| a page of this surface, build and principal | swapped in |
| a 4xx/5xx page to a GET, whatever rendered it (the framework's own error page carries no surface meta) | swapped in: a load that wrote and then threw is never run a second time. The next click is a real load |
204 + x-ultimate-location, same origin |
followed as the router's next request (at most 5 hops), or loaded when it names the URL just asked for |
204 + x-ultimate-location, another origin |
the browser goes there |
an empty 204
|
nothing: the browser stays, as it does for a 204 navigation |
| a 2xx that is not a page (a PDF, a zip, JSON) | handed to the browser from the bytes already received: saved under its Content-Disposition name when attachment, shown otherwise. Never asked for again |
a 4xx/5xx that is not a page (an offline service worker's empty 503, a JSON 404) |
a GET: a full load of the URL asked for โ the one answer the router asks for again, since there is nothing to show (As of 2026-10-05). A POST: ultimate:navigation-error, never re-sent |
| a POST answered in place with a page | swapped in: it cannot be repeated. When it was rendered for ANOTHER principal or build (a sign-in that renders instead of redirecting), every tab's cache is emptied and this tab stops swapping: every later link and form is a real document load |
| a page from another surface, build or principal where no framework server answered (a static host) | a full load |
| Failure | Result |
|---|---|
| a GET fails on the network | the browser loads it (its own offline page) |
| a POST fails on the network, or its answer cannot be read |
never re-sent. ultimate:navigation-error on document (cancelable, detail: { url, method, reason }); its default is a GET load of the page the visitor is on, which shows what the server actually has |
| the swap throws part-way, or the answer's body cannot be read | a real load of the page (GET), or ultimate:navigation-error (POST) โ never a half-replaced page |
A failed swap or body read loads a GET for real, so its route runs a second time. That is why a
page whose GET records anything must be navigation: 'document': the server then never runs it for
a soft request at all.
A failed POST's default is a GET reload of the current page. On a page where reloading has an
effect of its own (it is itself a 'document' page, or its GET records something), put
data-x-reload on its forms โ the browser then submits them natively โ or cancel
ultimate:navigation-error and show the failure in place.
A navigation started while another is in flight aborts the older one; the slower answer is never swapped in over the newer page.
In one order, so no frame is unstyled and no island runs twice:
- Stylesheets the next page links and this one has not loaded are loaded first.
-
Islands of the old body are disposed: each one's
mountreturn value, when it is a function, is called (Solid'srenderreturns one). -
Body replaced. Elements marked
data-x-persist="<id>"in both documents are carried across live (same node, same state, islands inside still mounted). What the element SAYS about the page follows the incoming one: its own attributes (the ones a server document set โ never one a script added), and every descendant'saria-currentwith theclassbeside it, matched byidor by position. A sidebar's active item moves. -
Head brought level by markup: server-rendered tags only this page had go (JSON-LD
included), tags only the next has arrive, identical ones stay. Tags a script added at run
time (a CSS-in-JS
<style>, analytics) are the tab's and are never removed. The previous page's own stylesheets it no longer links are retired after the swap.<html lang>anddirfollow. -
Scripts: the next page's head
<script src>and its body scripts run, in order. The inline hydration runtime runs again and boots the new islands; it visits each island root once per tab, so a carried island is not booted twice. Asrcthis tab already ran is not run again. An inline head script runs when its TEXT is new to this head (once per page that carries it, as a full load would), never when the current head already has it (the theme boot, on every page); one only the previous page had is removed. Every inline script, head or body, still needs its CSP hash โ the router re-inserts the same text, and the policy judges it as on a full load.
Inside document.startViewTransition when the browser has it and prefers-reduced-motion is not
reduce. Style the transition with the standard ::view-transition-* pseudo-elements. While it
animates the browser hit-tests every press to <html> (Chrome does whatever pointer-events the
overlay has), so a click that lands there skips the animation and is given to the element under the
pointer: the visitor's first click after a swap is never lost. A bare press โ a touch that scrolls,
a long press โ never skips it, so named elements finish their glide (As of 2026-10-05).
| Concern | Behaviour |
|---|---|
| history |
pushState per navigation, each entry naming the document it shows; replaceState for the same URL. Back/forward to an entry whose document is not the one on screen swaps it in; an entry an app pushed shows the document of its path |
| scroll | top on a new page; the fragment's element for #id; back AND forward restore the saved position (saved as the visitor scrolls), and so do a reload and a Back that is a full document load (As of 2026-10-02). Always instant, whatever scroll-behavior says |
| focus | moved to the new page's <main> (else its first <h1>), tabindex="-1" added if needed |
| announcement | the new document.title, in a polite aria-live region the router owns |
| progress |
data-x-navigating on <html> once a navigation runs past 150 ms. No bundled UI: style it, e.g. html[data-x-navigating] main { opacity: 0.6 } inside prefers-reduced-motion: no-preference
|
On intent: pointerover that rests 65 ms, focusin, touchstart. The server answers only pages
that declared navigation: 'prefetch'; everything else is an empty 204, remembered so a second
hover does not ask again. Answers wait in a per-tab memory cache (never storage), 30 s, at most 20
documents, keyed with the query sorted and without the fragment. It is the ONLY reuse layer: every
router fetch goes out cache: 'no-cache', so the browser's HTTP cache never answers a soft
navigation โ an anonymous page's stale-while-revalidate would otherwise show the document from
before a write, a sign-out or a deploy (navigation-fetch.ts; issue #693).
| A prefetched answer is never used when | |
|---|---|
| it is not a 2xx page, or it was a hand-over (a redirect, a login wall) | the click asks again |
the server said no-store and the click came more than 5 s later |
the click asks again |
| The cache is emptied by | |
|---|---|
| any POST the router sends โ when it is sent, and again when it settles, landed or failed: a prefetch sent while it was in flight may have rendered the old state | |
any write through the framework's client (rpc, a mutation, an upload โ @ultimat3/core's onClientWrite) |
|
a principal change: onRescope, a load for another principal, a POST answered for one, and every hand-over of the very URL asked for (the server's principal check answers that way, and the router cannot tell it from a 'document' page โ so it forgets either way) |
|
a BroadcastChannel('ultimate:navigation') message from another tab โ each of the above posts one |
|
the page's return from the back/forward cache: the channel is closed on pagehide (an open one keeps a page out of that cache), so every message sent meanwhile was missed |
Never prefetched at all: a link with data-x-no-prefetch, anything the click rules leave to the
browser, anything on Save-Data or a 2g/slow-2g connection, and the page a navigation is
already fetching. A press or click cancels a pending hover's prefetch, and the focus a press gives a
link is not an intent โ so a fast click sends exactly one request (As of 22.8.1).
As of 2026-09. A document that carries no router โ every page of a surface outside
navigation.client, and every navigation: 'document' page โ carries one
<script type="speculationrules"> instead: the browser fetches a link's document before the click,
and the full-page load that follows paints from memory. No JavaScript ships for it; a 0kb page
stays 0kb (budgets.ts reads the block as data). Prefetch only, never prerender โ a prerender
runs the next page's scripts for a page nobody opened.
navigation: { speculation: { prefetch: 'moderate', exclude: ['/blog/borrador-*'] } },speculation.prefetch |
The browser fetches |
|---|---|
'moderate' (default) |
on pointer rest (~200 ms) or pointer down |
'conservative' |
on pointer down only |
false |
nothing: no tag, no CSP source |
The rules are an allow-list from the route table โ a prefetch is a real GET with the visitor's cookies, so only a page the table says is a pure read is a candidate:
| A page is a candidate when | Why |
|---|---|
its surface has no router, and it is static or isr with no policy |
it was rendered at build time, for nobody |
its surface has the router, and it declared navigation: 'prefetch'
|
the app's own statement that its GET is a pure read |
Never a candidate: a navigation: 'document' page, an ssr page of a surface without the router
(it has no way to say its GET records nothing), an offline: 'network-only' page, an api/ route,
and anything that is not a page โ /_storage/*, /mcp*, an app's plain routes. Each candidate is
one URL pattern covering every routed locale ({/en}?/precios, {/en}?/blog/:slug);
speculation.exclude subtracts more, as URL patterns starting with /.
The body is the same string on every document of the app, sorted, and script-src admits it by
sha256 hashed from that string (page-speculation.ts), so the enforced policy needs no
'unsafe-inline'. Browsers without Speculation Rules ignore the block.
| Want | Write |
|---|---|
| a full load for this link or form | data-x-reload |
| soft on click, never fetched early | data-x-no-prefetch |
| a full load for every link to a page, from anywhere |
navigation: 'document' on its route |
| an element that survives navigations |
data-x-persist="<stable id>" on it, in both pages |
As of 26.1.0. A create form, an edit step, a confirmation: a page whose route says it is
presented over another.
// app/runs/new/page.tsx
import { defineRoute } from '@ultimat3/render';
export const config = defineRoute({
render: 'ssr',
offline: 'network-only',
meta: ({ t }) => ({ title: t('runs.new') }),
navigation: 'modal',
});// app/runs/page.tsx โ an ordinary link: without the router it is the whole page
<a href="/runs/new">New run</a>Two routers, one URL. The PATH is the page on screen, swapped as everywhere else on this page.
The HASH is the one modal over it, and the hash's path is itself a route of the same surface:
/runs#/runs/new, /projects/7#/projects/7/members/invite โ any depth, with a query
(#/runs/new?bank=ve). Every other fragment (#below) is still a fragment. Link the page's own
path; the router writes the hash.
| Visit | Result |
|---|---|
a click on a link to a 'modal' page |
its document fetched (one soft GET), its <main> shown in a native <dialog> over this page; the URL becomes <this page>#<its path>, pushed |
a reload, a pasted or bookmarked /runs#/runs/new, Forward onto it |
/runs renders; the router opens /runs/new over it |
| a hash typed over the page | the same |
a hash naming anything else โ a page that is not 'modal', a 404, a redirect, another principal's page, #//host
|
just the page: the hash is dropped in place. Never followed, loaded, or shown as an error |
a full load of /runs/new itself, scripting off, data-x-reload
|
the whole page, as before โ the server renders one document for both |
a link to the page on screen that is itself a 'modal' route |
that page, swapped in as usual |
Why the route, and not the link (axiom 1). Presentation is a property of the page: a create
form is a modal wherever it is reached from, and the one place that can say so for EVERY way of
reaching it โ a link, a redirect after a POST, a pasted hash โ is its route. A link attribute
would be a second declaration two links to one page could disagree on, and a redirect has no link
to carry it. The server names the page a modal in its own document
(<meta name="ultimate-presentation" content="modal">); the router never decides it.
Why the whole document, and not a fragment. The router asks for the page exactly as a full
load does โ same policy, same load, same cache headers, no request header that varies the
answer โ and shows its <main>. A second shape of one URL would need Vary, could not be a
static or isr file, and would put two answers under one key in every cache on the way.
| Concern | Behaviour |
|---|---|
| markup | one <dialog data-x-modal> opened with showModal(): top layer, ::backdrop, the page beneath inert, focus moved in. Named by its first h1/h2 (aria-labelledby), else by the page's title. The tab's title is the modal's while it is open |
| the page beneath | untouched: its islands stay mounted, its socket and page store live, its scroll where it was |
| links and forms | resolved against the MODAL's URL, as on its own page: href="?step=2" is /runs/new?step=2, a form with no action posts to /runs/new
|
| islands in the modal | booted by the page's hydration runtime; disposed when the modal closes or its content is replaced |
| styles | the modal page's stylesheets load first. @ultimat3/ui's global.scss styles dialog[data-x-modal] as its Dialog (surface-raised over scrim), under :where() so an app restyles it at zero specificity; its reset locks the body scroll behind any modal <dialog>
|
| one at a time | a link to another 'modal' page from inside replaces the content of the same dialog and pushes, so Back steps out one level (#/runs/new โ #/runs/new/advanced โ Back โ #/runs/new) |
| Gesture | Result |
|---|---|
Escape, a press outside it (the dialog carries closedby="any"; a browser without it gets the same from the router, for a press that begins and ends on the backdrop), a <form method="dialog">, any way the browser closes the dialog |
Back, when the entry is the one the router pushed; otherwise (a cold-loaded address โ Back would leave the app) the hash is dropped in place |
| the browser's Back | the entry beneath has no modal: closed |
| a link to the page beneath (the page's own Cancel, which is also its no-JS way back) | as Escape โ nothing is fetched |
| a link anywhere else | that page swaps in, in place of the modal's entry |
Focus returns to the link or button that opened it; the title returns to the page's.
The router renders no close button: it has no catalog to name one in, and a page that is also
a full page already has its way back. Give the modal page a Cancel link to the page it is opened
over (or a <form method="dialog"> button).
A form inside the modal submits through the router like any other: the POST is sent once, never again.
| The server answers | Result |
|---|---|
the modal page again (a 422 re-render with its errors) |
shown in the same dialog; the address stays (replace) |
a redirect to the page beneath (303 /runs) |
the modal closes and the page is fetched and swapped in โ its content refreshed โ through Back, so the spent form's entry is not left to reopen |
| a redirect anywhere else | the target swaps in, in place of the modal's entry |
a redirect to another 'modal' page (create, then confirm) |
shown in the same dialog, in place of the form's entry |
| nothing (the network failed) |
ultimate:navigation-error, never re-sent; its default reloads the tab โ the page and its modal both asked for again, since a hash alone is only scrolled to |
| anything else (a download, another principal) | as on any page โ What happens to the answer |
A 'modal' page's form on its own full page is an ordinary page form: a re-render there is a
page, never a modal.
As of 2026-10. From an island or an app script, never by clicking a hidden link โ imported from
@ultimat3/render/client, the package's browser entry:
import { closeModal, navigate, openModal, refresh } from '@ultimat3/render/client';
await navigate('/runs/7'); // as a click on a link to it
await navigate('/runs?status=failed', { replace: true }); // the current entry replaced
await refresh(); // the page on screen, rendered again, where it is scrolled
await openModal('/runs/new'); // the `navigation: 'modal'` route over this page: /runs#/runs/new
closeModal(); // as EscapeThe same entry exports disposeIslands(root): what the router calls on the body it swaps out, for an
island that drops markup holding other islands (As of 2026-10, Client data).
| Call | With the router | Without it (a site/ page, a 'document' page, the router's script blocked) |
|---|---|---|
navigate(url, { replace? }) |
the router's own navigation: the server's gate and the answer decide swap, modal or full load. Another origin: a document load |
location.assign / location.replace
|
refresh() |
the page fetched again (never from the prefetch cache) and swapped in with no history entry, at the visitor's scroll. An open modal is fetched again over it | location.reload() |
openModal(path) |
#<path> over the page, pushed, so Back closes it. A path that is not a modal of this tab opens nothing, as a stale hash |
location.assign(path): the modal route's whole page |
closeModal() |
as Escape: Back through the router's own entry, else the hash dropped in place | nothing โ no modal is open |
openModal takes the route's own path, as its link would ('/runs/new', '/runs/new?bank=ve'); a
full URL, //host or a relative path is a rejected promise, X_NAVIGATION_MODAL_PATH_INVALID โ
never a synchronous throw, so .catch sees it. On the server
every call does nothing. The functions import the router's TYPE only: an island that navigates
ships none of the router's bytes.
Import them from /client, never from @ultimat3/render: the barrel keeps render's whole error
table in any chunk that reaches it. An island whose body is refresh() is 216 B minified from
/client and was 9,323 B from the barrel (packages/render/src/client-bundle.test.ts, As of 2026-10-08). The barrel still exports them, for compatibility. The rejection is
NavigationModalPathInvalidError, one class from either entry, so instanceof holds whichever an
app imports; read .code when the two halves of an app may disagree on where they import from.
Dispatched on document:
| Event | When | detail |
|---|---|---|
ultimate:navigate |
before the fetch. Cancelable: preventDefault() keeps the tab where it is |
{ url, method } |
ultimate:navigated |
after the swap, the scroll and the new scripts โ and after a modal opens, its url the modal's |
{ url } |
ultimate:navigation-error |
a POST that failed or could not be shown, a swap that failed on a POST. Cancelable: the default is a GET of the current page | { url, method, reason } |
The three event names are on @ultimat3/render/client too โ what an island listens with. The event
names and attributes are exported from @ultimat3/render (NAVIGATE_EVENT,
NAVIGATED_EVENT, NAVIGATION_ERROR_EVENT, NAVIGATION_RELOAD_ATTRIBUTE,
NAVIGATION_NO_PREFETCH_ATTRIBUTE, NAVIGATION_PERSIST_ATTRIBUTE, NAVIGATING_ATTRIBUTE,
NAVIGATION_PRESENTATION_META, NAVIGATION_MODAL_ATTRIBUTE), with the pure rules the router runs
(linkVerdict, formVerdict, responseVerdict, reusable, mayPrefetch, modalAddress,
presentation, modalHistory, leaveModal). The headers are @ultimat3/core's, imported from there only
(CLIENT_NAVIGATION_HEADER, CLIENT_NAVIGATION_LOCATION_HEADER, CLIENT_NAVIGATION_SCOPE_HEADER,
CLIENT_NAVIGATION_SURFACE_HEADER) โ render's NAVIGATION_*_HEADER aliases were deleted in 25.0.0. The server half is
@ultimat3/http's navigationGate, redirectForRouter and relocate.
| What | Where |
|---|---|
| every client rule, as pure functions |
packages/render/src/navigation-rules.test.ts, navigation-cache.test.ts, navigation-dom.test.ts, route-navigation.test.ts, navigation-modal-rules.test.ts
|
a 'modal' page: never prefetched, one document for a soft and a full load, the meta it carries |
packages/cli/src/page-navigation.test.ts |
modals in a real Chrome: open over a live page, Escape/Back/Cancel, reload and pasted hash, stale hash, nesting, a refused and a redirected submit, a confirmation, openModal/closeModal/refresh from code, scripting off |
packages/cli/e2e/client-navigation-modal.e2e.test.ts |
navigating from code: through the router, the browser's fallbacks, the server, openModal's refusal |
packages/render/src/navigation-api.test.ts |
| the server gate and the redirect hand-over, counting handler runs | packages/http/src/navigation.test.ts |
| speculation rules: which pages are candidates, the tag on a router-less document only, the CSP hash of the served bytes |
packages/cli/src/page-speculation.test.ts, packages/render/src/speculation-rules.test.ts
|
real pages: prefetch and 'document' run no load, another principal or app is refused, a misplaced key refuses the boot |
packages/cli/src/page-navigation.test.ts |
| writes announced after they settle | packages/core/src/client-writes.test.ts |
| in a real Chrome, through the real pipeline under an enforced CSP: every rule above, counted in route executions | packages/cli/e2e/client-navigation-*.e2e.test.ts |
| the reference app, opted in: real pages, sign-out, a like after a round trip, the editor as a modal over the feed, scripting off | examples/dummy/apps/web/e2e/client-navigation.e2e.test.ts |