Hydration
Hydration in Pulse is automatic. Specs with mutations, actions, or persist are hydrated automatically — Pulse binds events to the server-rendered HTML without re-rendering it. Purely server-rendered specs get zero JavaScript sent to the browser.
What "hydration" means in Pulse
Pulse uses the word in its general sense: making server-rendered HTML interactive. It works differently from hydration in React-based frameworks such as Next.js:
| Pulse | React / Next.js | |
|---|---|---|
| On load | Binds event listeners to the HTML the server sent. Nothing is rebuilt. | Rebuilds the component tree in the browser and reconciles it with the server HTML. |
| Mismatches | None possible at load: the initial render is skipped, so the server HTML is never compared with anything. | The client render is compared with the server HTML; differences produce warnings and can force a re-render. |
| What updates the page | After a state change, view() runs and the result is morphed into the existing DOM. There is no virtual DOM. | Re-rendering a virtual tree and reconciling it. |
| What ships | Per page: a spec with mutations, actions, persist or store gets one bundle; every other page ships no JavaScript. | Per client component; server components ship none. |
| Granularity | The whole page hydrates, with one set of delegated listeners on the root. | Each client component hydrates, and can be deferred (Suspense, lazy loading). |
One case differs: if a page restores persisted state on load, Pulse renders once straight away so the restored values appear, instead of waiting for the first mutation.
view() runs in the browser for later updates. What stays small is the work done on load: nothing is rebuilt, and pages without client behaviour ship no JavaScript.How hydration works
Pass spec files as URL objects to createServer. Pulse detects whether each spec needs client-side interactivity and, if so, derives the browser-importable path automatically — no hydrate field needed in the spec:
// server.js
await createServer(
[
new URL('./src/pages/counter.js', import.meta.url), // has mutations → auto-hydrated
new URL('./src/pages/about.js', import.meta.url), // no mutations → zero JS
],
{ port: 3000, root: new URL('.', import.meta.url) }
)
// src/pages/counter.js — no hydrate field needed
export default {
route: '/counter',
state: { count: 0 },
mutations: {
increment: (state) => ({ count: state.count + 1 }),
decrement: (state) => ({ count: state.count - 1 }),
},
view: (state) => `
<div>
<button data-event="decrement">-</button>
<span>${state.count}</span>
<button data-event="increment">+</button>
</div>
`,
}
Development bootstrap
In development Pulse emits an inline bootstrap script that imports the spec and runtime source files directly — no build step required:
<script type="module">
import spec from '/src/pages/counter.js'
import { mount } from '/src/runtime/index.js'
import { initNavigation } from '/src/runtime/navigate.js'
const instance = mount(spec, root, window.__PULSE_SERVER__ || {}, { ssr: true })
initNavigation(root, mount).setMount(instance) // so the first navigation destroys this page's mount
</script>
This imports the spec and runtime source files directly — no build step required for development.
Production bundles
Run npm run build to generate production bundles. This creates content-hashed files in public/dist/ and a manifest.json mapping spec hydrate paths to bundle paths.
# Generated by npm run build
public/dist/
chunk-abc123.js # shared code (runtime + anything pages share), brotli
counter.boot-def456.js # per-page spec bundle, brotli
manifest.json # { '/src/pages/counter.js': '/dist/counter.boot-def456.js' }
When Pulse detects a manifest (via staticDir auto-detection or explicit manifest option), it resolves the auto-derived hydrate path to the bundle path and emits a single <script src> tag instead of the inline bootstrap:
<script type="module" src="/dist/counter.boot-def456.js"></script>
The { ssr: true } option
The bootstrap script calls mount(spec, root, serverState, { ssr: true }). This tells the runtime to skip the initial re-render and bind event listeners to the existing DOM only.
This is what keeps LCP fast. The server-painted HTML is the LCP element. The JavaScript binds events without touching the DOM — no flash, no layout shift, no JS-rendered replacement.
{ ssr: false } on a server-rendered page. It re-renders the entire DOM on mount, replacing the server-painted HTML — causing a visible flash and pushing LCP to 400–600ms.mount()
The mount function attaches the Pulse runtime to a DOM element:
import { mount } from '@invisibleloop/pulse/runtime'
mount(
spec, // the page spec
rootEl, // the DOM element to mount into
serverState, // window.__PULSE_SERVER__ (server data from SSR)
{ ssr: true } // skip re-render on first mount
)
After mount, all data-event and data-action attributes in the DOM are wired to the spec's mutations and actions. State updates trigger a full view re-render via innerHTML replacement.
Pages without hydration
Specs with no mutations, actions, or persist automatically get zero JavaScript — no runtime overhead, no hydration cost. This is the correct default for:
- Documentation pages
- Marketing/landing pages
- Blog posts and articles
- Any page with no client-side interactivity
Passing server state to the client
Server data fetched via server.data() is serialised into the page HTML as window.__PULSE_SERVER__. The client runtime reads this on mount, making server data available to the view during client re-renders without an additional network request.
// Emitted in the page HTML
<script id="__PULSE_SERVER__" type="application/json">
{"product":{"id":1,"name":"Widget","price":9.99}}
</script>