brust 2.0.0-alpha.50
Guide / Navigation

Navigation

A link click does not reload the page. brust fetches the real HTML of the target and swaps it in place, so the page is the one a full load would show. You write nothing for it.

Every full document carries a small nav script, also a page that is all static and loads no runtime, and the output of brust build --target static too. A page that cannot be swapped is loaded the normal way, so a link always works.

view-source
<script type="module" src="/_brust/client/nav-3f9c1a07b2.js"
  data-brust-nav data-brust-build="9a41c0e7" data-brust-root="root:5b1d0c33"></script>

The one rule

A navigation shows exactly what a full load of that URL would show. Only JavaScript state that lives outside the page survives: live stores (except the keys the new page's loaders write), module-level state, and elements you marked data-brust-persist. When you are unsure what a navigation does, ask what a full load would do.

What is swapped

  • The head becomes the new document's head children, in the new order: title, meta, canonical, stylesheets, inline styles, JSON-LD. An identical element is reused, so a stylesheet both pages share is not downloaded or flashed again. New stylesheets and every module script of the new page are loaded before the swap, so the page never paints unstyled and a feature the first page did not use is there when its component mounts. A head element that a live component added at runtime, such as a CSS-in-JS <style>, is kept.
  • The body: only the layout levels that changed are replaced. A nested layout whose own markup is the same in both pages stays live, with its DOM, component state, hydrated islands and inner scroll positions. When the root layout changes, the whole body is replaced, and the attributes of <html> and <body> follow the new page.
  • Scroll: the new page scrolls to its #hash target or to the top. Back and forward restore the position the entry had. A link to the URL already on screen loads it again, from the page cache while it is fresh, without a new history entry.

Which clicks navigate

A plain primary-button click on an <a href> to the same origin. These are left to the browser: a click with Ctrl, Meta, Shift or Alt, a link with a target other than _self or with download, a click that something already preventDefault()-ed, and a link that only changes the #hash of the current page. A <form> is not intercepted: it submits as before.

Opt-outs

AttributeEffect
data-brust-reloadOn a link, a region, <html> or <body>: the links inside get a normal full load and are not prefetched. On <html> it turns SPA navigation off for the whole app.
data-brust-prefetch="false"On a link or a region: the links inside are not prefetched. They still navigate.
data-brust-persist="<key>"If the old and the new page both have an element with this key, the live old element is moved into the new page and never disposed: a playing video, an open chat widget.
data-brust-onceOn a classic <script>: it runs on the first load of the tab only, as an analytics loader should.
data-brust-cache="<seconds>"On <html>: how long a fetched page stays fresh in the page cache (default 300). 0 turns the cache off.
data-brust-prefetch-gap="<seconds>"On <html>: the least time between two prefetches of the same URL (default 10). 0 prefetches on every hover.
data-brust-progress="off"On <html>: no progress bar.
data-brust-transition="off"On <html>: no page transition.
Layout.tsx
<audio data-brust-persist="player" src="/public/live.mp3" controls></audio>

There is no config key: the attribute is the opt-out. A classic <script> in the head that is new to the page runs. After a whole-body swap, every classic head script runs again in order, as on a full load, which keeps a theme script that sets <html class> correct. A script in a layout that was kept does not run again.

Prefetch and the page cache

brust fetches a link's page when you show intent: after 65 ms of hover, on focus, or at once on touchstart. Prefetch is off when the browser asks to save data or is on a 2g connection, and for the opt-outs above. A prefetch fires no events, and a link to the page you are on is not prefetched.

The prefetch gap. A page the cache does not keep (no-store, below) would be fetched again by every hover, and again by the click. So brust remembers when each URL was last fetched, by a prefetch or a navigation, kept or not, and a prefetch of a URL fetched less than 10 seconds ago does nothing. <html data-brust-prefetch-gap="30"> sets the gap in seconds; 0 prefetches on every hover. The gap holds back prefetches only: a click, navigate() and back/forward never wait for it, and a no-store page is still never shown from memory. A mutation through @brust/core/client and clearNavigationCache() clear the record with the cache. It holds the last 32 URLs.

Every fetched page, prefetched or navigated to, goes into a page cache in memory, one per tab:

  • Fresh for 5 minutes. While a page is fresh, hovering its link again fetches nothing, and a click or navigate() shows it with zero requests. After that it is fetched again.
  • Back and forward show the cached page at any age, as the browser's back-forward cache does. A click during a prefetch waits for that same request.
  • Never cached: a response with Cache-Control: no-store, any status of 400 or more, and a response that is not a brust page of this build. A page that must never be stale sends no-store.
  • Only no-store keeps a page out. private, no-cache, max-age=0 and must-revalidate do not: many hosts send public, max-age=0, must-revalidate on every page, and such a page is cached for its 5 minutes. A page that must never be shown from memory, such as an authenticated or sensitive one, sends Cache-Control: no-store: a loader sets it with res.headers, and on a layout it covers every page under it (see Response headers). A logout through @brust/core/client already clears the cache; any other logout calls clearNavigationCache().
  • It holds at most 32 pages, the least recently used going first, keyed by the URL without its #hash. <html data-brust-cache="60"> sets the freshness in seconds, and 0 turns it off.

Mutations clear it. A call through @brust/core/client with any method other than GET or HEAD clears the whole cache once it gets a response, so a page read after a mutation is never one from before it. If you change data another way, clear it yourself:

cart.ts
import { clearNavigationCache } from '@brust/core'

await fetch('/api/cart', { method: 'POST', body })
clearNavigationCache()

A navigation served from the cache fires the same events. Without a page transition it ends within the same task, so data-brust-loading is never painted. With one, the old page is painted once more while it is set, for up to about 100 ms, so an indicator of your own should wait about 150 ms before it shows, as the built-in bar does.

Events

Every event is a CustomEvent on document that bubbles. A native component, a React island and a plain <script> all listen the same way.

EventWhendetailCancelable
brust:nav:clickA qualifying link click, before anything else.{ id, url, from, link }Yes: preventDefault() leaves the click to the browser.
brust:nav:loadThe navigation starts: a link click, navigate() or back/forward.{ id, url, from, trigger }No
brust:nav:successThe new page is in place: swapped, history updated, scrolled and mounting.{ id, url, from, trigger, status }No
brust:nav:errorThe navigation did not succeed.{ id, url, from, trigger, status, error, swapped }Yes
  • trigger is 'link', 'navigate' or 'popstate'. from is the URL that was on screen, url the final URL after redirects, and id a per-tab counter that pairs the end of a navigation with its start.
  • The order is click (link clicks only), load, then exactly one of success or error. A newer navigation supersedes the one in flight, and the superseded one fires nothing more. Its request still finishes and goes into the page cache.
  • success fires when the new page is in the DOM, not when a page transition finishes animating.
  • error has two cases. If the server answered with a brust page and a status of 400 or more, such as a notFound() 404, that page is swapped in as a full load would show it: swapped is true and there is no default action. If nothing could be swapped, such as a network failure, a response that is not a brust page of this build or a failed module import, swapped is false, error holds the cause and the default action is a full load of url. preventDefault() cancels that full load, so you can show your own message.
  • Only a response from the same build is swapped. After a deploy, the first navigation does a full load and picks up the new runtime.

While a navigation loads, <html> carries data-brust-loading: set at load, removed at success or error.

The progress bar

A 2 px line along the top of the viewport while the next page loads. It appears only when a navigation is still loading 150 ms after load, so a cached page or a fast server never shows it. It moves on its own toward about 80%, slowing as it goes, runs to the end at success or error, and fades out. With prefers-reduced-motion: reduce it shows at 80% after the same 150 ms, without the movement, and goes at once at the end. Set its colour with --brust-progress-color and its height with --brust-progress-height:

app.css
:root {
  --brust-progress-color: var(--accent);
  --brust-progress-height: 3px;
}

To restyle it further, write a rule for div[data-brust-bar] in your CSS. The built-in rules are one attribute selector each and go first in the head, so yours wins. data-brust-progress="off" on <html> turns it off.

To build your own indicator, set data-brust-progress="off" on <html> and read the navigation store in a component, with no addEventListener. This site's loading line is made this way:

LoadingLine.tsx
import { navigation } from '@brust/core/navigation'
import { useStore } from '@brust/core/store/react'

export default function LoadingLine() {
  const nav = useStore(navigation)
  return <div className="line" data-state={nav.state} />
}
app.css
.line { transform: scaleX(0); transform-origin: left; }
.line[data-state="loading"] {
  transform: scaleX(.8);
  transition: transform 8s cubic-bezier(.1, .8, .2, 1) .15s;
}

Put it in the layout. It reads state, which is idle in every page's server markup, so the layout renders the same on each page and is kept across navigations. The .15s delay keeps the line still on a navigation that is quick, such as one served from the cache.

The store is fed by the four events above. With no component to put a store in, a plain script can listen to them directly:

bar.ts
document.addEventListener('brust:nav:load', () => bar.start())
document.addEventListener('brust:nav:success', () => bar.done())
document.addEventListener('brust:nav:error', (e) => {
  bar.done()
  if (!e.detail.swapped) report(e.detail.error)
})

Or CSS alone, with no JavaScript and no component:

app.css
.bar { transform: scaleX(0); transform-origin: left; }
html[data-brust-loading] .bar {
  transform: scaleX(.8);
  transition: transform 8s cubic-bezier(.1, .8, .2, 1) .15s;
}

Page transitions

When the browser has the View Transitions API, the swap runs inside a view transition: the browser's own cross-fade of the whole page, opacity only, about 250 ms. Pixels that are the same in both pages, such as a header or sidebar that stays, do not visibly change, so only the content that changed fades. Nothing slides or scales.

  • Everything slow happens first: the fetch, the new page's scripts and stylesheets. The old page is frozen for about one frame, never for a network wait.
  • The eager images the navigation swaps in (a kept layout's are on screen already) get up to 100 ms to decode before the fade starts, so a hero image does not pop in after it. Mark images below the fold loading="lazy": those are not waited for.
  • Back and forward fade the same way. A navigation started during a transition skips it and lands on the newer page.
  • With prefers-reduced-motion: reduce, without the API, or with data-brust-transition="off" on <html>, the page swaps at once.

Customise it with the standard ::view-transition-* CSS, or give your own elements a view-transition-name:

app.css
::view-transition-old(root),
::view-transition-new(root) { animation-duration: 150ms; }
Search.tsx
import { navigate } from '@brust/core'

navigate('/docs/store')                                // push an entry
await navigate('/search?q=brust', { replace: true })   // replace the current one

navigate(url, { replace }) runs the same flow with trigger: 'navigate' and resolves after success or error. Call it from an event handler, an effect or an island. In server code, or while rendering on the server, it throws navigate is browser-only. When the page has no nav script, it does location.assign(url), or location.replace(url) with replace: true.

@brust/core also exports clearNavigationCache() and the types NavigateOptions, NavTrigger, NavLoadDetail, NavSuccessDetail and NavErrorDetail.

The events are the low-level contract. For a component, brust ships one built-in store that mirrors them, so you read navigation state the way you read any other store:

NavSpinner.tsx
import { navigation } from '@brust/core/navigation'
import { useStore } from '@brust/core/store/react'

export default function NavSpinner() {
  const nav = useStore(navigation)
  return <div hidden={nav.state !== 'loading'}>Loading {nav.to}…</div>
}

It works in a native component and in a React island, and the first paint matches: the server renders state: 'idle' and url as the request's path and query, so hydration finds the markup it expects.

KeyTypeMeaning
state'idle' | 'loading''loading' from brust:nav:load until success or error
urlstringPath and query of the page on screen. Updated at success, and at error when the error page was swapped in
tostring | nullPath and query being loaded, else null
trigger'link' | 'navigate' | 'popstate' | nullOf the current or last navigation
statusnumber | nullHTTP status of the last navigation
errorstring | nullThe message of the last failed navigation, cleared at the next load. A swapped 404 reads HTTP 404
idnumberThe last navigation id (0 before any)
  • It is a normal store: navigation.state.subscribe(...) works in a plain script and navigation.snapshot() gives plain values. Do not write it; the next event overwrites a write.
  • navigation is a reserved store name. A defineStore('navigation', …) of your own that a component reads is a build error (store-name-reserved).
  • A component that reads url depends on the URL, so a layout that reads it renders different markup on every page and is replaced on every navigation instead of kept. Read to and state in a layout, and url where a page-level component is fine.
  • The module listens for the events once, when the first chunk that imports it loads. A navigation that is already loading at that moment shows idle until its success.

Stores and islands

A store that is already live keeps its client state across a navigation, except for the keys the new page's loaders wrote for that request: those take the server's value, as the page's props do. A page whose loader sets item.id shows the new id in every component and island, while a count that only the client writes keeps the tab's value.

  • A key counts as written when a loader of the new page (its layouts' loaders included) calls .set() on it, even with the value the store already holds. store.hydrate() is a seed, not a write. A loader that writes nothing leaves the live store as it is.
  • A store the tab has not used yet seeds from the new page's snapshot, as on a full load.
  • The order is: the old page's effect cleanups run and its islands unmount, then the written keys are set, then the new page mounts and its islands hydrate, then the new page's effects run. A cleanup that reads the store still sees the old page's values, an effect of the old page never sees the new ones, and the new page never runs an effect with the old ones. Components that stay (a kept layout, a persisted element) update like on any store write.
  • A page from the cache (a prefetch, back and forward) applies the values written when that document was fetched, the same as its props.
Exception

Keys the loader did not write keep their client value: it is the one place a navigation differs from a full load, and it matches React apps. See Store.

When a page leaves the document, its islands are unmounted and their effect cleanups run, as on a React route change. The islands of the new page hydrate when idle, as on a full load.