Documentation
How it works
The path a request takes, and what hydration does to the DOM.
Stoneware is small enough to hold in your head. This page is the whole of it: one request pipeline, one render pass, and one hydration step.
The request pipeline
Every response leaves through a single function, which is what makes the security headers structural rather than something each route remembers.
Request
│
├─ /_stoneware/* ─────────────────► built island chunk (Bun.file)
│
├─ matches public/ ──────────► static asset (Bun.file)
│
├─ router.match(pathname)
│ └─ no match ──────────► 404
│
├─ CSRF verify ◄─────── every non-GET request, before any
│ └─ invalid ───────────► 403 handler can observe it
│
├─ action route ─────────────► POST/PUT/DELETE handler ──┐
│ │
└─ page route │
│ │
├─ component(props) ──► VNode tree │
├─ renderToString ──► HTML string (escaping) │
├─ buildDocument ──► + payload + scripts │
│ │
└───────────────┬───────────────────────────────────┘
▼
security headers applied ◄── single exit point
│
▼
ResponseThe render pass
A template is called once. It returns an inert { type, props } record, which the renderer walks depth-first, appending to a string. There is no previous tree, no diff, and nothing retained afterwards.
<Page />
│
│ Bun transpiles TSX ──► jsx("div", { children: [...] })
▼
VNode { type, props } inert data, no methods, no instance
│
│ renderToString walks it once
▼
┌──────────────────────────────────────────────┐
│ string ──► escaped via Bun.escapeHTML │
│ number ──► escaped │
│ signal ──► .value, then escaped │
│ raw("...") ──► emitted verbatim ◄── the only way through
│ function type ──► called, result walked │
│ island ──► marked + props collected │
└──────────────────────────────────────────────┘
│
▼
HTML stringWhat the server sends
An island is server-rendered with its real initial state, so there is no flash of empty content. Three things go into the response: the markup, a props payload, and one module script per distinct island.
<button class="counter"
data-stoneware-island="LiveCounter" <-- which island
data-stoneware-id="stoneware-1"> <-- which instance
fired <b>0</b> times
</button>
<script type="application/json" id="stoneware-islands">
[{"name":"LiveCounter","id":"stoneware-1","props":{}}]
</script>
<script type="module" src="/_stoneware/LiveCounter-6dhtkfqt.js"></script>The markers sit on the island's own root element rather than a wrapper, so the served HTML has no extra node and no layout impact. That is why an island must render exactly one element at its root.
The payload is type="application/json", which browsers never execute, and its <, >, & and U+2028/9 are escaped. Nothing user-controlled is ever concatenated into executable script source.
What hydration does to the DOM
BEFORE AFTER
────── ─────
<button data-stoneware-id="stoneware-1"> <button data-stoneware-id="stoneware-1">
"fired " "fired " ◄─ static text
<b> <b>
"0" "0" ◄────────── Text node, now
</b> </b> bound by effect()
" times" " times"
</button> </button>
▲
inert markup └─ click listener attached
count.value++
│
└─► effect fires ─► node.data = "1" one text node written
no re-render, no diffThe client runtime builds the tree once, replaces the marked element, and attaches a subscription to the exact text node or attribute that depends on each signal. Updating a signal does not call the component again.
island bundle loads
│
├─ read #stoneware-islands ──► parsed as data, never evaluated
│
├─ for each { name, id, props } matching this island:
│ │
│ ├─ component(props) ──► VNode tree
│ ├─ mountTree(vnode) ──► real DOM nodes
│ │ │
│ │ ├─ signal child ──► Text node + effect()
│ │ ├─ signal attribute ──► effect() -> setAttribute
│ │ ├─ style object ──► CSSOM (CSP-safe)
│ │ └─ onClick ──► addEventListener
│ │
│ └─ querySelector([data-stoneware-id]).replaceWith(tree)
│
└─ done. No further work until a signal changes.Why there is no reconciler
A virtual DOM earns its cost when you re-render a whole tree and need to find what changed. Stoneware never re-renders a tree, so there is nothing to compare. The dependency graph that would justify a reconciler is already provided by signals, which is why reusing them rather than writing one is the project's firmest scope boundary.
Something wrong in the framework itself rather than the page? Open an issue on GitHub.