DocsBuilding pages
PulsePoint runtime
The browser half of Rahti: it mounts the boundaries the server named, evaluates their scripts, tracks hook state, applies bindings, and handles SPA navigation and RPC calls. It ships prebuilt — there is no JavaScript build step.
A file, not a build step
The runtime is a prebuilt asset. An application ships public/js/pp-reactive-v2.min.js and mounts it from public/js/main.js. cargo rahti new writes the same bundle into every project it creates, and cargo rahti upgrade replaces it when the framework ships a new build.
The authoring model
PulsePoint scripts are plain JavaScript written inside the reactive root they belong to. The root and the script are one component scope.
html! {
<section>
<p>{count}</p>
<button onclick={setCount(value => value + 1)}>"Increment"</button>
<script>
const [count, setCount] = pp.state(0);
</script>
</section>
}Hooks must run in a stable order during every render, as in React. Do not call them conditionally, or after the component is disposed.
Component boundaries
The runtime reads the served document back and mounts one scope per boundary. A boundary arrives in one of three shapes, all written by html! and never by hand:
- An element carrying
pp-component— the ordinary case: a page's root, a component's root, a single-rooted child fragment. The element anchors the scope. - A template — around a root layout's
<slot />, and around server-deferred content generally. The runtime materialises it into live DOM before mounting. - A comment pair — around a run of siblings from a
<>…</>fragment. At mount the runtime raises the pair into a live, layout-invisible boundary element.
Two things are deliberately not boundaries. A block whose root is a component tag contributes no markup of its own, so it adds no scope. And several instances of one component are separate scopes even though they share a name — the runtime derives a unique identity per instance, which is why two copies of the same counter count independently.
The binding surface
| Syntax | Purpose |
|---|---|
| {expression} in text | Escaped reactive text or value. |
| {expression} in a quoted attribute | A reactive attribute or property. |
| any native on* attribute | An event handler expression. |
| pp-for="item in items" | A loop on a <template>. |
| pp-for="(item, index) in items" | The same, with an index. |
| key="{expression}" | Repeated-row identity. |
| pp-ref={ref} | Binds a native element to a ref. |
| pp-style="{cssText}" | Dynamic inline style. |
| pp-spread="{object}" | Spreads dynamic attributes. |
| <token.provider value="{value}"> | A context provider. |
| pp-spa="false" on an anchor | Opts out of SPA interception. |
| pp-reset-scroll="true" | Resets a scroll container on navigation. |
| pp-scroll-key="name" | A stable scroll-restoration identity. |
| pp-loading-content="true" | The region a loading.rs replaces. |
| pp-loading-transition | Fade timing for that swap, as JSON. |
Value rendering
A binding does not print its value with String(...). PulsePoint serializes it the way React serializes a JSX child. Read this before binding a value that is not a plain string or number — most surprises reported as "my binding is blank" are one of these rules working as designed.
In text position
| Value | Rendered |
|---|---|
| "text", 42, 12n | The value, HTML-escaped. |
| true / false | Nothing. Both booleans, not only false. |
| null / undefined | Nothing. |
| "" | Nothing — it is the empty string, not a blank. |
| 0 | 0. Falsy, but printed. |
| an array | Each item by these same rules, concatenated with no separator. |
| an object, function or symbol | Nothing, plus a [PP-WARN] console line. React throws here; PulsePoint omits and warns. |
The boolean row is the one that costs time. A value seeded from Rust as false, or a None seeded as null, arrives in the browser correctly and then renders as an empty span.
// admin === false renders nothing at all — both booleans vanish.
<p>"Admin: "<span>{admin}</span></p>
// Spell the display out in the expression instead.
<p>"Admin: "<span>{admin ? "yes" : "no"}</span></p>
<p>"Role: "<span>{role ? role : "none"}</span></p>
// The React `&&` idiom leaks a zero: `0` prints, only booleans and nullish
// values vanish.
<p>{items.length > 0 ? "Some" : "None"}</p>In attribute position
| Attribute kind | Value | Result |
|---|---|---|
| HTML boolean (disabled, hidden, checked…) | true | Attribute present. |
false, nullish, or a falsy primitive | Attribute absent. | |
| a truthy primitive | Present, carrying that value. | |
| anything else (class, title, aria-*, data-*) | true / false | The literal strings "true" / "false". |
null / undefined | Attribute present, with an empty value. | |
0, NaN | "0", "NaN". |
Two of those rows differ from React. A boolean on a non-boolean attribute is serialized rather than dropped, because aria-pressed and data- flags are string-valued in the DOM. And a nullish value leaves the attribute present and empty — harmless on class or title, not harmless on a URL, where src="" re-requests the current document.
// A nullish value leaves a non-boolean attribute present and empty, and
// src="" re-requests the current document. Guard URL attributes.
<img src="{avatar ? avatar : placeholder}" />A pp-for collection that is not iterable renders no rows and logs a warning; null and undefined render no rows silently. Binding an object rather than an array is a blank list, not an error.
Events
onclick={save()}
oninput={setName(target.value)}
// Inside an event expression the runtime exposes `event`, `e`, `$event`,
// `target`, `currentTarget` and `el`. Use native lowercase names — `onclick`,
// never `onClick`.Handlers on a component root
A native handler written on a component's root runs in that component instance's script scope. Each instance resolves its own handler, even when several instances of the same component appear on a page. A caller's handler forwarded through bindings runs in the caller's scope.
use rahti::{Html, component, html};
#[component]
pub fn SubmitForm() -> Html {
html! {
<form onsubmit={handleSubmit(event)}>
<button type="submit">"Save"</button>
<script>
function handleSubmit(e) {
e.preventDefault();
}
</script>
</form>
}
}One element has one event owner. A root cannot combine its own native handlers with a caller's: an on…=@{…} value beside an owned handler is a compile error, and an attribute spread that brings a native event handler onto such a root triggers a debug assertion and is dropped in release builds. Put the caller's handler on an inner element, or call it from the component's own handler.
Rahti writes pp-event-owner automatically; do not author it. Run cargo rahti upgrade with the current CLI to update both the Rust dependencies and the shipped PulsePoint bundle.
Bindings on a component root
Handlers are the only client expressions a component writes on its own root. The runtime evaluates every other root attribute where the tag was written, so a {…} the component writes there could never read its own state or pp.props. In a #[component] that is a compile error; bind an element inside the root instead. Server @{…} values, pp-* attributes and JSON data on the root compile as usual, and page and layout roots are not checked.
The other side of the same rule is what makes component tags work like React props. A {…} the caller writes on a component tag is handed to the component as source, the component writes it on its root through bindings, and it reads the state of the page that wrote the tag — attributes and handlers alike. That holds between another component's tags too: children carrying such a prop ship owner-scoped, so the inner component's root reads the page rather than the component it sits in.
// A `{…}` a component writes on its own root is evaluated in the caller's
// scope, so it could never read the component's state: in a #[component]
// it is a compile error. Bind an element inside the root instead.
<section hidden={!open}> // compile error
<section><div hidden={!open}>"…"</div> // fine
// A caller's `{…}` prop is the caller's expression. The component writes it
// on its root through `bindings`, and it reads the page that wrote the tag —
// here and between another component's tags alike.
<Button disabled={count === 0} onclick={setCount(count + 1)}>"Add"</Button>
<Card><Input value={query} oninput={setQuery(target.value)} /></Card>A component with no script compiles in its caller's scope — that is how a script-less component takes expressions from the page. When its own markup below the root binds a client value, html! adds an empty <script></script> before the root closes, so <p>{pp.props.label}</p> reads that component's props. A spread below the root keeps the caller's scope, because the spread may carry the caller's bindings.
Forms
value={state} and checked={state} are controlled bindings; default values are uncontrolled. Do not switch a control between the two after mount. PulsePoint preserves selection and focus, and handles input, textarea, select, checkbox, radio and form-reset behaviour.
// Controlled: the binding owns the value.
<input value={name} oninput={setName(target.value)} />
<input type="checkbox" checked={agreed} onchange={setAgreed(target.checked)} />
// A file input is imperative — reach it with a ref.
<input type="file" pp-ref={file} />
<script>
const file = pp.ref(null);
</script>A controlled value works the same when the control is a component's own root: <Select value={fruit}> keeps the page's fruit selected through every re-render, whether its options are plain <option>s or components of their own.
Lists
For browser-owned collections, loop over a template. The collection must be iterable, and stable keys preserve row identity and local state. For a server-owned list rendered once in Rust, use Html::concat instead.
<ul>
<template pp-for="(item, index) in items">
<li key="{item.id}">{index}: {item.label}</li>
</template>
</ul>Hooks
| Hook | What it gives you |
|---|---|
| pp.state(initial) | [value, setter]. The initial value may be lazy; the setter takes a replacement or (previous) => next. Equal values use Object.is and do not rerender. |
| pp.effect(fn, deps?) | Runs after commit; may return a synchronous cleanup. Start async work inside — do not return a Promise. [] runs once per mount. |
| pp.layoutEffect(fn, deps?) | Runs synchronously after DOM mutation, before normal effects. For measurement. |
| pp.ref(initial?) | A stable { current } object, for pp-ref, portals and imperative handles. |
| pp.memo(factory, deps?) | Memoises a computed value. |
| pp.callback(fn, deps?) | Memoises a function identity, for stable subscriptions. |
| pp.reducer(reducer, initial, init?) | [state, dispatch], with an optional initialiser. |
| pp.createContext(default) / pp.context(token) | Logical ancestor context. Read it in the render phase, not inside a later callback. |
| pp.portal(ref, target?) | Moves content elsewhere in the DOM while preserving logical ancestry, context and lifecycle. |
| pp.id() | A stable component-local identifier for id, for and ARIA pairs. |
| pp.errorBoundary() | [error, reset] catches this component's and descendants' render/effect failures. The error stays until reset; after five failures without reset, the boundary stops catching. Event-handler errors are logged separately. Distinct from server error.rs. |
| pp.syncExternalStore(subscribe, getSnapshot) | Subscribes to browser or external state. |
| pp.imperativeHandle(ref, create, deps?) | Publishes a controlled imperative API. |
| pp.transition() | [isPending, startTransition]. Pending until the scope or returned Promise settles. |
| pp.deferredValue(value, initial?) | A value that updates after the current commit. |
| pp.optimistic(passthrough, reducer?) | [optimisticValue, addOptimistic]. Pending actions replay over the confirmed value. |
| pp.props | The props of the block that wrote the expression, read from its root attributes with kebab-case names in camelCase. A server value the component writes on its root arrives as a string; a caller's {…} prop it writes there keeps its real type. A Rust argument is here only when the component writes it on its root. |
pp.effect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id); // synchronous cleanup only
}, []);const [pending, startTransition] = pp.transition();
async function save() {
await startTransition(async () => {
const saved = await pp.rpc("save_note", { text });
setNote(saved);
});
}// A provider is the lowercase token-derived `.provider` tag.
<theme.provider value="{scheme}">
<slot />
</theme.provider>
<script>
const theme = pp.createContext("light");
const scheme = pp.context(theme);
</script>Runtime utilities
| Call | What it does |
|---|---|
| pp.mount() | Mounts the document. public/js/main.js calls it. |
| pp.redirect(url) | A PulsePoint-aware navigation. |
| pp.rpc(name, data?, optionsOrAbort?) | Calls a Rahti #[rpc]. See RPC & uploads. |
| pp.socket(name, args?, handlers?) | Opens a WebSocket to a #[socket] function, with heartbeats and reconnect after unexpected closes. See WebSockets. |
| pp.enablePerf() / pp.disablePerf() | Toggle runtime performance sampling. |
| pp.getPerfStats() / pp.resetPerfStats() | Per-component render-phase aggregates. |
twMerge is published as a global by public/js/main.js, for when a reactive class string can contain conflicting Tailwind utilities.
// `twMerge` is published as a global by public/js/main.js.
<div class={twMerge("p-2 px-4")}></div>Native web APIs
A component's <script> is plain browser JavaScript, evaluated as a function body in the page's own global scope. It is not a sandbox and no build step rewrites it: window, document and navigator are the objects every other script on the page sees, and DevTools debug the script as written. Every web API the browser ships is therefore available to a component directly.
| API | For |
|---|---|
WebGPU (navigator.gpu) | GPU rendering and compute: large charts, simulations, image and video processing, in-browser ML inference. |
| Canvas 2D / WebGL | Drawing, charts, image processing. |
| Web Workers / OffscreenCanvas | Parsing, number crunching or rendering off the main thread. |
| WebAssembly | Native-speed modules compiled from Rust, C or Go. |
| Web Audio, media capture, WebRTC | Synthesis, camera, microphone, screen share, calls. |
| IndexedDB / Cache Storage, observers | Client storage; Intersection, Resize and Mutation observers. |
| Web Serial / WebUSB / WebHID / Web Bluetooth | Hardware, plus Clipboard, Notifications, File System Access and Geolocation. |
Who does what
| Job | Use |
|---|---|
| Data from the server | #[rpc] + pp.rpc, RpcStream, #[socket] + pp.socket, or @{Json(&value)} for the first render. |
| Values the markup shows | pp.state — a change re-renders only the nodes that differ. |
| Handles to browser objects | pp.ref — devices, buffers, contexts, workers, audio graphs, streams. A ref change never re-renders. |
| Acquire, feed, release | pp.effect — create on mount, push new state in when it changes, dispose in the cleanup. |
| The heavy work | The browser API itself. Shaders, threads, codecs and hardware run at native speed, outside PulsePoint. |
A WebGPU chart fed by an RPC. The server renders the first series into the page, and each click asks for a new one. One effect acquires the device and releases it on unmount; a second feeds it every new series.
use crate::rahti::{Html, Json, html, rpc};
fn series(points: u32, phase: f32) -> Vec<f32> {
(0..points.clamp(8, 512))
.map(|i| 0.5 + 0.4 * (phase + i as f32 / 6.0).sin())
.collect()
}
#[rpc]
pub async fn gpu_series(points: u32, phase: f32) -> Vec<f32> {
series(points, phase)
}
pub async fn page() -> Html {
let initial = series(48, 0.0);
html! {
<section>
<canvas pp-ref={canvas} width="640" height="240"></canvas>
<button onclick={load()}>"Refresh"</button>
<p hidden={gpuSupported}>"WebGPU is unavailable; drawing with Canvas 2D."</p>
<script>
const canvas = pp.ref(null);
const gpu = pp.ref(null); // device, pipeline, buffers: never state
const [series, setSeries] = pp.state(@{Json(&initial)});
const [ready, setReady] = pp.state(false);
const gpuSupported = !!navigator.gpu;
// Acquire the GPU once; release it on unmount.
pp.effect(() => {
if (!navigator.gpu) { setReady(true); return; }
let cancelled = false;
initGpu(canvas.current).then((g) => {
if (cancelled) return g?.device.destroy();
gpu.current = g;
setReady(true);
});
return () => {
cancelled = true;
gpu.current?.device.destroy();
gpu.current = null;
};
}, []);
// Every new series: write it to a GPU buffer and draw.
pp.effect(() => {
if (!ready) return;
gpu.current ? drawGpu(gpu.current, series) : draw2d(canvas.current, series);
}, [series, ready]);
async function load() {
const values = await pp.rpc("gpu_series", { points: 64, phase: Math.random() * 6.28 });
setSeries(values);
}
// initGpu, drawGpu, and draw2d are plain WebGPU and Canvas code:
// navigator.gpu.requestAdapter(), adapter.requestDevice(),
// canvas.getContext("webgpu"), a render pipeline,
// device.queue.writeBuffer(...), a render pass.
</script>
</section>
}
}A continuous animation belongs to the browser's frame loop, not to the render cycle. State only starts and stops it; the per-frame value lives in a ref.
const frame = pp.ref(0);
const [running, setRunning] = pp.state(true);
pp.effect(() => {
if (!running) return;
let id = requestAnimationFrame(function tick(t) {
frame.current += 1; // a ref: no render per frame
renderFrame(gpu.current, t); // the GPU does the per-frame work
id = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(id);
}, [running]);A worker takes data from the server and hands its answer back to state. A WebAssembly module is loaded the same way: started in an effect, guarded against an unmount that lands first, and kept in a ref.
const worker = pp.ref(null);
const [result, setResult] = pp.state(null);
pp.effect(() => {
const w = new Worker("/js/parse-worker.js", { type: "module" });
w.onmessage = (event) => setResult(event.data);
worker.current = w;
return () => w.terminate();
}, []);
async function analyze() {
worker.current.postMessage(await pp.rpc("export_rows"));
}// A module in public/wasm/, compiled once per mount. WebAssembly
// compilation needs 'unsafe-eval', which Rahti's default CSP grants.
const wasm = pp.ref(null);
const [ready, setReady] = pp.state(false);
pp.effect(() => {
let cancelled = false;
WebAssembly.instantiateStreaming(fetch("/wasm/filters.wasm")).then(({ instance }) => {
if (cancelled) return;
wasm.current = instance.exports;
setReady(true);
});
return () => { cancelled = true; wasm.current = null; };
}, []);Rules
- Browser objects — GPU devices and buffers, contexts, workers, audio contexts, media streams, peer connections, observers, database handles — live in
pp.ref, never inpp.state. - Acquire in an effect with
[]dependencies and release in its cleanup:device.destroy(),worker.terminate(),audioContext.close(),track.stop(),observer.disconnect(). SPA navigation unmounts the component, so a missing cleanup leaks a GPU device or a running worker into the next page. - Cleanups are synchronous. Start async setup —
requestAdapter(),getUserMedia(),WebAssembly.instantiateStreaming()— inside the effect and guard it with acancelledflag. - Feed the API from a second effect whose dependencies are the state it reads. That effect is the bridge from server data to the GPU, worker or audio graph.
- Per-frame values live in refs and per-frame work runs on
requestAnimationFrame. Never call a state setter every frame. - Feature-detect (
if (!navigator.gpu)) and keep a fallback. WebGPU and most device APIs need a secure context: HTTPS, orlocalhostin development. - APIs that need a user gesture — the hardware
request*()pickers, clipboard writes, fullscreen, the firstAudioContext.resume()— run inside theon…handler that received it, not in an effect. - A component script has no static
import/exportand no top-levelawait. Load a library withimport()inside an effect, or with its own<script type="module">in a root layout. - Worker scripts,
.wasmmodules, shaders and model files go inpublic/, which serves them at the URL root. - Results that must stay authoritative or secret — prices, permissions, anything persisted — are computed in Rust behind
#[rpc]. The browser API is for rendering, interaction and client-side acceleration.
SPA navigation
After mount, PulsePoint intercepts eligible same-origin links, fetches the next document, morphs owned DOM, preserves component identity where it can, and manages focus, scroll, history, redirects and loading regions.
// Opt one link out of SPA interception — an external site, a download, a
// route that must reload the document.
<a href="/report.pdf" pp-spa="false">"Download"</a>Crossing root layouts
A body swap only makes sense while the document stays the same. When an application has more than one root layout, the two shells have different heads — different stylesheets, different scripts — and swapping one body into the other would leave the page running the wrong shell.
So PulsePoint compares the X-PP-Root-Layout header of the navigation response with the <meta name="pp-root-layout"> of the current document. When both are present and they differ, it abandons the SPA path and performs an ordinary full load. Equal values navigate in place as usual, and Rahti sends both halves for every page a root layout wraps.
Loading regions
While a navigation is in flight the runtime replaces the first element marked pp-loading-content="true" — or the whole <body> when nothing is marked — with the nearest loading region, then restores it from the response. Ownership, the literal-path walk and the transition JSON are all in Routing.
APIs that are not there
Do not invent React compatibility. PulsePoint v2 has no direct equivalent for forwardRef, component-wrapper memo, lazy, Suspense, useInsertionEffect, useActionState, useFormStatus, or a free-standing startTransition.
The full reference
Everything above is PulsePoint as a Rahti page meets it — the boundaries html! writes, the bindings it accepts, and the rules that keep the two dialects apart. The runtime itself is a separate project and is documented separately: pulsepoint.tsnc.tech has the deeper account of each hook, the scheduler, the reconciler, and the behaviour this page summarises in a table.
Two things to carry across when you read it. Its examples are written in plain HTML, because it is backend-agnostic; in Rahti the same markup is authored inside html!, so quoted text is a Rust string and a server value arrives through @{…}. And the version that matters here is the one in public/js/pp-reactive-v2.min.js, which cargo rahti upgrade replaces — a runtime feature documented upstream but not yet in the bundle your project ships is not one you have.
