Client Hydration
Pre-rendered markup needs a client takeover the reader never sees. This topic covers hydrate's settle-and-swap boot — how it differs from init, what gates the swap, and the semantics around it.
Settle-and-swap Copy link
renderToString → hydrate is the pre-rendering story: the server (or build step) serializes the page (see Server Rendering), & hydrate boots the client on top of it without ever showing a flash. Where init (see Bootstrapping) mounts the app shell immediately — replacing the root's children by default — then churns again as lazy routes & data land, hydrate leaves the pre-rendered DOM untouched while the app renders detached, & performs a single atomic swap once the app has settled — lazy route content & async activity work included. Because server & client run the same render path, the swapped-in DOM matches the served markup & the takeover is invisible.
hydrate Copy link
hydrate(props): Promise<void> - init's contract minus placement (the swap is always a full replace); resolves after the swap & onAppMounted.
app,root,globalConfig,onAppMounted- As ininit.ready?: Promise<unknown>- Optional caller-owned gate: the swap awaits it alongside settlement. Use it for async work the framework cannot track (see the tracking boundary below).maxWait?: number- Upper bound in ms (default4000) on how long the swap waits. On expiry the swap runs with whatever has rendered & a framework console warning names the still-pending count.Infinitydisables the bound.replayEvents?: boolean | string[]- Opt-in event replay for the settle window:trueenables the default set (click&submit); an array names event types explicitly. Recorded interactions re-dispatch after the swap — see Pre-swap inertness below. Off by default.
Inclusion import { hydrate } from '@loom-js/core';
Quick Example
import { hydrate } from '@loom-js/core';
import { App } from '@app/app';
// The root already carries the server-rendered markup. hydrate({
app: App(),
root: document.querySelector('#page-content')
});The example leans on the app having settled — and you can import that signal directly.
settled Copy link
settled(): Promise<void> - The signal hydrate gates on, importable directly: resolves once no framework-mediated async work is pending for the current DOM window, confirmed by one macrotask of continued quiet (so chained lazy work is awaited to quiescence). Useful as a test await point or anywhere "the app is done booting" matters. What counts as tracked — and what doesn't — is The settlement signal's subject, under Activities.
In a test, that makes the await itself the synchronization - because the transform's promise is tracked, there's no polling loop and no arbitrary sleep:
import { expect } from '@esm-bundle/chai';
import { activity, component, init, settled } from '@loom-js/core';
const greeting = activity('', async ({ input: name, update }) => {
update(await fetchGreeting(name));
});
const Page = component(
(html) => html`
<main>${greeting.effect(({ value }) => value)}</main>
` );
it('renders the fetched greeting', async () => {
init({ app: Page(), root: document.body });
greeting.update('loom');
// The transform's fetch is still in flight here… await settled();
// …and here the DOM is final. expect(document.querySelector('main')?.textContent).to.contain(
'Hello, loom' ); });The swap Copy link
The mechanism behind the invisibility is worth naming precisely. While the served markup sits untouched in the root, hydrate renders the app into a detached tree — real, live DOM nodes that just aren't attached to the document yet; effects & activity updates land in that tree as they resolve. When the gate opens (settlement, plus ready if you passed one), hydrate replaces the root's children with the detached tree in a single synchronous DOM call. That's the swap. Three things follow from it:
The served markup is discarded, not adopted. The app you end up interacting with is entirely client-rendered; the server's markup never receives listeners or lifecycle — it exists to be seen (& indexed) before the takeover, nothing more.
The swap is the client tree's attachment to the document. Which is why
onMountedfires at that moment, &onAppMountedright after. WithreplayEventson, interactions recorded pre-swap re-dispatch between the two — after theonMountedsweep, so handlers observe a mounted tree, & beforeonAppMounted, so the app-mounted callback sees post-replay state.The invisibility isn't diffing or patching. Server & client run the same render path against the same data, so the two trees match — replacing one with the other changes nothing on screen.
Semantics worth knowing Copy link
The tracking boundary: settlement counts only work that passes through an activity transform — the full rule lives under The settlement signal. Anything outside it (a raw
fetchinside awatchcallback, asetTimeout) is invisible to the swap's gate — hand it tohydrateviaready.Pre-swap inertness: event listeners attach only to the app, rendering detached — the served DOM never gets any. Anchors still work regardless, because they're real
<a href>elements: clicking one before the swap triggers an ordinary browser navigation (a full page load) to the target, so a user who clicks early still gets where they were going — just without client-side routing. Anything that needs a framework handler — a$clickbutton, an intercepted form — does nothing until the swap; that gap lasts until settlement or at mostmaxWait(4s by default).replayEventscloses that gap: opt in & interactions of the chosen types (defaultclick&submit) landing during the window are recorded — their native action cancelled — & re-dispatched in order to the structurally corresponding client nodes after the swap, so an early click or submit is honored instead of lost. Anchor clicks are exempt: native navigation stays untouched even with replay on. A recorded event whose target no longer resolves in the hydrated tree (e.g. after amaxWait-expired swap) is dropped with a console warning rather than mis-delivered. One caveat: replays are real constructed events dispatched withisTrusted: false— analytics or dedupe logic that keys on trusted events should expect them.Lifecycle timing matches real attachment:
onCreated&onRenderedfire during the detached render exactly as underinit;onMountedfires at the swap — the client tree's attachment (see The swap above);onAppMountedfollows it.An empty root mounts immediately (e.g. a dev server without pre-rendered markup): with nothing to preserve there's no reason to gate — the app renders progressively exactly as under
init, so loading states paint while async work is pending.Non-hydrating apps pay nothing:
hydratetree-shakes out of aninit-only bundle entirely.Skip the refetch: by default the hydrating client re-runs the fetches the server already ran. Funnel them through the resource cache & they don't have to — see Dehydrated State.