Server rendering
The two halves of Solid's rendering API live in different packages: render and hydrate in @shadow-js/web, the string and stream renderers in…
The two halves of Solid's rendering API live in different packages: render and hydrate in @shadow-js/web, the
string and stream renderers in @shadow-js/web/server.
| Solid | ShadowJS | What differs |
|---|---|---|
renderToString(fn) |
renderToString(fn, { renderId, head }) from @shadow-js/web/server |
Synchronous. A pending Suspense renders its fallback. Effects never run. No nonce option (no inline script is emitted). |
renderToStringAsync(fn) |
renderToStringAsync(fn, { renderId, head, timeoutMs, maxPasses }) |
Resolves to { html, data }, not a string. data holds the resolved resources for the client. Renders in passes until nothing is pending (default 10 passes, 10 s). |
renderToStream(fn) with pipe and pipeTo |
renderToStream(fn, { renderId, head, nonce, timeoutMs, signal, onShellReady }) |
Returns a ReadableStream<Uint8Array>; there is no pipe/pipeTo. The shell is sent first, then one chunk per resolved Suspense boundary, out of order. nonce goes on the swap script. |
hydrate(fn, el, { renderId, owner }) |
hydrate(fn, el, { renderId, data, owner }) |
renderId must match the server's; data is the data of renderToStringAsync; owner creates the hydrated root under that owner so its providers are visible. |
<HydrationScript />, generateHydrationScript |
HydrationScript, generateHydrationScript (server entry) |
Records early click and input events; hydrate() replays them. |
getRequestEvent, isServer |
isServer from @shadow-js/core or @shadow-js/web |
There is no request event. |
src/ssr-page.tsxtsx
import { useResource } from "@shadow-js/core";
import { Suspense } from "@shadow-js/web";
import {
createComponent,
renderToStream,
renderToString,
renderToStringAsync,
} from "@shadow-js/web/server";
function Page() {
const post = useResource(async () => "Hello from the server");
return (
<main>
<Suspense fallback={<p>loading</p>}>
<h1>{post()}</h1>
</Suspense>
</main>
);
}
const app = () => createComponent(Page, {});
const sync = renderToString(app);
console.log(`fallback in sync output: ${sync.includes("loading")}`);
const result = await renderToStringAsync(app);
console.log(`async html has data: ${result.html.includes("Hello from the server")}`);
console.log(`async data keys: ${Object.keys(result.data).length}`);
const decoder = new TextDecoder();
const chunks: string[] = [];
const reader = renderToStream(app).getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(decoder.decode(value));
}
console.log(`stream chunks: ${chunks.length}`);
console.log(`shell shows the fallback: ${(chunks[0] ?? "").includes("loading")}`);
console.log(`last chunk carries data: ${(chunks.at(-1) ?? "").includes("Hello from the server")}`);
fallback in sync output: true
async html has data: true
async data keys: 1
stream chunks: 3
shell shows the fallback: true
last chunk carries data: trueHead tags are collected explicitly. Create a ServerHead, pass it as the head option, and write head.html() into
the document <head>. This replaces MetaProvider plus getAssets(); without the option nothing is registered.
textContent={expression} is escaped and rendered.
src/ssr-head.tsxtsx
import { Title } from "@shadow-js/web";
import { createServerHead, renderToString } from "@shadow-js/web/server";
const head = createServerHead();
const body = renderToString(() => <Title>Page</Title>, { head });
console.log(`title in the head collector: ${head.html().includes("<title")}`);
console.log(`title in the body markup: ${body.includes("<title")}`);
const text = "<b>";
console.log(`textContent expression: ${renderToString(() => <p textContent={text} />)}`);
title in the head collector: true
title in the body markup: false
textContent expression: <p data-hk="0-0"><b></p>