SPA Navigation
Starting the runtime with pp.mount() makes a server-rendered site
navigate like a single-page app: it fetches the next page, swaps it in without
reloading, and keeps scroll positions and history in sync. Your server keeps
rendering whole HTML documents exactly as before. This page covers what the
browser does and the small amount a backend can do to take part.
Turning it on
SPA navigation comes from pp.mount(), not from
ComponentInit.bootstrap(). bootstrap() only mounts components,
which is right for a static page or a widget inside a page you don't control. When
you own the whole document, use mount():
<script type="module">
import "/js/pp-reactive-v2.min.js"; // defines the global pp
// mount() = bootstrap components + hydration cloak + SPA navigation.
// It is idempotent, so calling it twice is harmless.
if (document.readyState !== "loading") pp.mount();
else document.addEventListener("DOMContentLoaded", () => pp.mount(), { once: true });
</script>
On first load mount() also hides the page while components hydrate. It sets
opacity: 0, pointer-events: none, user-select: none,
aria-busy and inert on <body>, disables CSS transitions
and animations, then shows the page one frame after hydration and restores your
original <body> style. If you ship the same cloak inline on
<body> to avoid a flash before the script loads, it removes that too.
See Plain HTML for how this interacts with bootstrap().
Which clicks are intercepted
| Link / action | What happens |
|---|---|
| Same-origin <a href> click | Intercepted: fetched and swapped in place. |
| Different origin | Left to the browser. |
| target="_blank" or a download attribute | Left to the browser. |
| pp-spa="false" on the link | Left to the browser (full page load). |
| Ctrl, Cmd, Shift or Alt held | Left to the browser (new tab / window). |
| Same path and query, with a #hash | No fetch: pushState, then smooth-scroll to the target. |
| Back / forward | Fetched and swapped, and the saved scroll positions are restored. |
Navigating from code
pp.redirect(url) returns a promise. Same-origin URLs go through SPA
navigation once the runtime is mounted, and fall back to a full load otherwise.
Other origins always get a full load.
const save = async (data) => {
const order = await pp.rpc("createOrder", data);
await pp.redirect(`/orders/${order.id}`); // SPA when mounted, full load otherwise
};
The server contract
Nothing below is required: a server that returns ordinary HTML pages already works.
The headers only matter if your server wants to answer navigations differently.
Each navigation is a GET to the target URL with:
| Request header | Meaning |
|---|---|
X-PP-Navigation: true |
This GET is an SPA navigation. You may return the same full document as always. |
X-PulsePoint-Wire: true |
Wire-format marker, shared with RPC. |
X-Requested-With: XMLHttpRequest |
Standard AJAX marker. |
Accept: text/html |
A full HTML document is expected back. |
How the client handles your response:
| Response | Client behavior |
|---|---|
| 200 + HTML document | Title, managed head tags, body attributes and body content are swapped; components are bootstrapped. |
| Non-2xx (404, 500, …) | Treated as a failure: pp:navigation:error fires, then a full page load of the same URL, so your normal error page renders. |
| No response within 15 s | Same as a failure: full page load. |
| X-PP-Root-Layout differs from the current page's | Full page load. Use it when two sections have different <head> assets. |
| fetch followed a redirect (3xx) | Navigates to the final URL. A #hash on the original URL is carried into a ?next= parameter if one exists. Cross-origin → full page load. |
| X-PP-Redirect: /target header | Navigates to /target instead of rendering. Cross-origin targets are ignored. |
What gets swapped
document.title, from the new page's<title>.- Managed head tags: every element in
<head>markeddata-pp-metais removed, and the new page'sdata-pp-metaelements are appended. Mark description, canonical, robots, Open Graph and Twitter tags this way, or they keep the first page's values for the rest of the visit. - Every
<body>attribute exceptstyle(soclass,data-*andpp-reset-scrollfollow the page). <body>content: all current components are destroyed (effects cleaned up), the new body is inserted, and components are bootstrapped in the same task, so the browser never paints a half-hydrated page.- Everything else in
<head>is left as it is. New stylesheets or scripts in the next page's head are not loaded. Plain (non-component)<script>tags in the new body don't run either, because they are inserted withinnerHTML. Put page behavior in component scripts, or useX-PP-Root-Layoutto force a full load between sections with different assets.
<head>
<title>Orders — Acme</title>
<!-- Replaced on every SPA navigation because they carry data-pp-meta -->
<meta name="description" content="Your recent orders" data-pp-meta />
<meta property="og:title" content="Orders — Acme" data-pp-meta />
<link rel="canonical" href="https://acme.test/orders" data-pp-meta />
<!-- Left alone: stylesheets, scripts, charset, viewport, anything unmarked -->
<link rel="stylesheet" href="/css/app.css" />
<!-- Section identity; must match the X-PP-Root-Layout response header -->
<meta name="pp-root-layout" content="shop" />
</head>
Root layouts
Give each group of pages that share a <head> (marketing site, app,
admin) an id. Send it as the X-PP-Root-Layout response header and render it
as <meta name="pp-root-layout" content="…">. When the header of the
next page differs from the meta tag of the current page, the client does a full load
instead of an SPA swap. Both must be present for the check to run.
Scroll restoration
The runtime switches history.scrollRestoration to manual and saves
the window's scroll position, plus that of every scrolled element, in the history entry.
A new navigation starts at the top of the window. Back and forward restore the
saved positions.
<body>
<aside class="sidebar" pp-scroll-key="docs-sidebar">…</aside> <!-- keeps its scroll -->
<main pp-reset-scroll="true" pp-loading-content="true">…</main> <!-- back to top -->
</body>
<!-- Or reset everything, window included, on every navigation into this page: -->
<body pp-reset-scroll="true">…</body>
pp-scroll-keygives a scroll container a stable identity across pages. Without it the key falls back toid, then the fullclassstring, then the tag name. A class change between pages would therefore lose the position, which is why the explicit key exists.pp-reset-scroll="true"on a container resets it to the top on every navigation into that page, back and forward included. Put it on the content pane of a shell layout, and leave the sidebar unmarked so it keeps its position.pp-reset-scroll="true"on<body>resets the window and every tracked container.- The correction runs before the new page's first paint, then again on the next two frames, so layout settling cannot put an old offset back.
- A
#hashin the target URL is scrolled into view after the swap.
Loading UI
While the next page is being fetched, the runtime can show route-specific loading
markup. Render a hidden container with the fixed id loading-file-1B87E
holding one <div pp-loading-url> per route prefix:
<!-- Anywhere in the page (usually the root layout), hidden -->
<div id="loading-file-1B87E" hidden>
<div pp-loading-url="/">
<p class="spinner">Loading…</p>
</div>
<div pp-loading-url="/dashboard">
<div pp-loading-transition='{"fadeIn": "150ms", "fadeOut": "100ms"}'></div>
<div class="skeleton-table"></div>
</div>
</div>
<!-- The region the loading markup is swapped into -->
<main pp-loading-content="true">…</main>
- The lookup uses the path of the page you are leaving, walking up one segment at a time (
/dashboard/orders→/dashboard→/), and takes the first match. - The match's inner HTML replaces the content of
[pp-loading-content="true"], or the whole<body>if no element has that attribute. pp-loading-transitionon an element inside the match holds JSON withfadeIn/fadeOutinms,sorm(default 250 ms each). They set how long the swap waits. The runtime sets a CSS transition but does not change opacity itself, so style the region if you want a visible fade.- With no loading markup, the runtime still waits 250 ms before the swap in browsers without the View Transitions API.
Navigation events
Each navigation dispatches CustomEvents on document:
pp:navigation:start, then pp:navigation:complete or
pp:navigation:error. event.detail is { url }, plus
error on failure. A progress bar that lives in a component:
<div pp-component="top_progress">
<div class="progress-bar" hidden="{!busy}"></div>
<script>
const [busy, setBusy] = pp.state(false);
pp.effect(() => {
const start = () => setBusy(true);
const stop = () => setBusy(false);
document.addEventListener("pp:navigation:start", start);
document.addEventListener("pp:navigation:complete", stop);
document.addEventListener("pp:navigation:error", stop);
return () => {
document.removeEventListener("pp:navigation:start", start);
document.removeEventListener("pp:navigation:complete", stop);
document.removeEventListener("pp:navigation:error", stop);
};
}, []);
</script>
</div>
Or outside any component, for analytics:
document.addEventListener("pp:navigation:complete", (event) => {
analytics.page(event.detail.url); // detail = { url }
});
document.addEventListener("pp:navigation:error", (event) => {
console.warn("Navigation failed", event.detail.url, event.detail.error);
});
A component that listens must live outside the swapped region, or be re-created by the swap. Every component is destroyed and bootstrapped again on each navigation, so the cleanup above is what stops listeners from piling up.
Rules
- State does not survive navigation. Anything that must outlive a page belongs on the server, in the URL, or in browser storage.
- Mark every per-page head tag with
data-pp-meta. - Opt a link out with
pp-spa="false"when it must reach a server route that does not return an HTML page (downloads, OAuth redirects, raw files). - Send the root-layout header and meta tag whenever two sections load different head assets.