Index · Previous: Building and deploying
A page that arrives empty and fills itself in is slower to read, worse to index and worse to share. Server rendering sends the content with the page; hydration is what stops the browser from throwing it away and building it again.
Both are the same application. Nothing in this chapter asks you to write a component differently.
npm install @firsthandjs/server
Three things change, and nothing else does.
A server renders to a string:
import { renderToString } from '@firsthandjs/server';
const html = renderToString(() => <App />);
A browser adopts instead of building:
import { hydrate } from '@firsthandjs/dom/hydrate';
hydrate(() => <App />, document.getElementById('app')!);
The build says it can hydrate:
// vite.config.ts
import { firsthand } from '@firsthandjs/compiler/vite';
export default defineConfig({
plugins: [firsthand({ hydratable: true })],
});
hydratable is the only thing the project declares. Which of the two builds is
running is something Vite already knows, and the plugin asks it: the server
build compiles against @firsthandjs/server/internal, the browser build
against @firsthandjs/dom/internal, from the same source file.
A complete project is in examples/ssr — an
application, two entry points, and a server in eighty lines of node:http.
The same components, the same signals, the same context, the same compiler. What a server does not have is time and a document, so:
| Browser | Server | |
|---|---|---|
setup |
runs once per instance | runs once per instance |
| markup | a cloned <template> |
a string |
| parts | subscribe and update | written once |
effect |
runs | does not run |
onCleanup |
runs on disposal | runs when the render is disposed |
ref |
gets the node | not emitted |
onClick |
attached | not emitted, attached by hydrate |
useResource |
runs inside an effect | runs once, awaited by renderToStringAsync |
An effect is a side effect over time, and a render that produces one string has
none: there is no later for a second run to happen in, no DOM to touch and
nobody to click. Anything that has to happen before the markup exists is
data, and data has useResource.
A view that loads something needs the loading to finish before the markup is
worth sending. renderToStringAsync renders, waits, and renders again:
import { renderToStringAsync } from '@firsthandjs/server';
import { createData, createMemoryStorage, serialize } from '@firsthandjs/data';
export async function render() {
const storage = createMemoryStorage();
const data = createData({ storage });
const html = await renderToStringAsync(() => <App data={data} />, {
settle: () => data.settle(),
// A loader that never answers must not hold the response open.
timeout: 2_000,
});
return { html, state: serialize(storage.dump()) };
}
settle is what waiting means, and the application says so: the server package
knows nothing about the data layer, and an application using a different one
passes its own.
A resource belongs to its call site, so it has no key to be serialised under — that is ADR-0022, and it is on purpose. A named resource does have one:
const notes = useResource(({ signal }) => listNotes(signal), { persist: 'notes' });
persist is a name you chose. The server fills a storage under it; the page
carries it; the browser starts from it:
<script>
window.__FIRSTHAND_DATA__ = { "notes": [...] };
</script>
const data = createData({ storage: createMemoryStorage(window.__FIRSTHAND_DATA__ ?? {}) });
hydrate(() => <App data={data} />, root);
The value is there during the resource’s first run in the browser, not a microtask later, which is what makes the first paint the markup rather than a spinner replacing it.
A resource without a name still renders on the server — the markup has its content. What it does not do is arrive answered, so the browser loads it again. That is a decision you make in one word and can see at the call site.
serialize is JSON.stringify with the characters that would end a <script>
element escaped. Use it, or your data ends your script tag.
hydrate runs exactly the same compiled code as render. What changes is
where the nodes come from: a template hands back a node the server sent rather
than a clone, and a dynamic child finds its content in place rather than
creating it.
You will see comments in the markup:
<p>Hello, <!--[-->Ada<!---->!</p>
<!--[--> says where a dynamic child begins — its content has a length the
template does not, so the markup has to say. The browser removes every one of
them once it has adopted the tree. A child that is the whole of its element’s
content gets no marker at all, because where the element’s children start is
where the region starts:
<td class="col-md-1">1</td>
The markers are in the server’s markup only. An application built without
hydratable emits neither them nor the navigation that steps over them, and
pays nothing for either.
The view has to be the view the server rendered — same props, same state, same answer. Where it is not:
Static markup that disagrees is kept as the server wrote it, because static markup is the markup nothing ever writes again. Development says so by name:
[firsthand] Hydration found <div class="wide"> where this render describes
"narrow". The markup is kept as the server sent it, because static markup is
never written again.
A view that renders two different things from the same props is a bug no
framework can repair. The usual causes are Date.now(), Math.random() and
reading window in a setup.
A spread is the one place where the framework itself could disagree, because
its names and shapes come from a runtime object rather than from the compiler.
It is written to mean the same thing on both sides — a class record toggles on
truthiness, a style record is serialized, prop:/attr: do what they say, and
a key spelled onmouseover is refused rather than written. The two tables are
in the DOM and
server references, and asserted against each
other in spread-parity.test.ts.
Markup assigned to a local variable is built rather than adopted:
return () => {
const badge = <span>{label}</span>; // built by the browser, not adopted
return <div>{badge}</div>;
};
Locals are evaluated before the element they go into, and markup is adopted
outside-in — so by the time the div is adopted, the span has already been
made. The page is correct and one element is rebuilt. Writing the markup where
it is used, which is how JSX is usually written, adopts it like everything
else.
A component with a tag is written as that element, with its content inside:
const Card = component(() => <div class="card">…</div>, { tag: 'my-card' });
<my-card><div class="card">…</div></my-card>
A shadow: true component is written as a declarative shadow root, which is
the only way markup can express one. The browser’s own defineElement then
takes it over.
Measured against the frameworks that render the same way, with the same rules the browser benchmark keeps — the output compared before anything is timed, the frameworks interleaved in one process, production builds on every side:
| 1 000 rows | Firsthand | Solid | Vue | React |
|---|---|---|---|---|
| render to markup | 0.171 ms | 0.205 ms | 15.02 ms | 199.67 ms |
| markup size | 222 802 B | 238 694 B | 222 802 B | 222 802 B |
| hydrate | 4.12 ms | 4.62 ms | 10.48 ms | — |
Reproduce them with:
node benchmarks/ssr/run.mjs
node benchmarks/ssr/hydrate.mjs
React’s hydrateRoot schedules its work rather than doing it, so a number
taken the same way would be the time to start hydrating. It is left out
rather than reported as something it is not.
Nothing. Hydration is its own entry point and nothing in the render path
imports it, so a bundle that never mentions @firsthandjs/dom/hydrate does not
contain it. The runtime budget is the same 7.00 kB gzip it was before server
rendering existed.
firsthand({ hydratable: true }) in the Vite config.renderToStringAsync with a settle and a timeout if anything loads.serialize whatever goes into a <script>.hydrate, not render, in the browser entry point.Index · Previous: Building and deploying