Streaming
renderToStream(code, { renderId?, nonce?, timeoutMs?, signal?, onShellReady?
renderToStream(code, { renderId?, nonce?, timeoutMs?, signal?, onShellReady? }) returns a
ReadableStream<Uint8Array>. It sends the shell immediately, with each pending <Suspense> showing its
fallback, and then one chunk per boundary as its data arrives, in any order. Finally it sends the JSON data
block for hydrate. The page works without JavaScript in the sense that the chunks are plain HTML; a tiny
inline script moves each chunk into place.
import { useResource } from "@shadow-js/core";
import { Suspense } from "@shadow-js/web";
export function Name(props: { load: () => Promise<string> }) {
const name = useResource(() => props.load(), { key: "name" });
return <strong>{name()}</strong>;
}
export function App(props: { load: () => Promise<string> }) {
return (
<main>
<h1>Profile</h1>
<Suspense fallback={<em>loading</em>}>
<Name load={props.load} />
</Suspense>
</main>
);
}
import { renderToStream } from "@shadow-js/web/server";
import { App } from "./src/stream-app";
const stream = renderToStream(() => <App load={() => Promise.resolve("Ada")} />, {
nonce: "n0nce",
});
const reader = stream.getReader();
const decoder = new TextDecoder();
let first = true;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
// The swap runtime is a constant script; elide it here.
const shown = text.replace(/window\.\$S=function[\s\S]*?\};<\/script>/, "window.$S=function(i,f){…}</script>");
console.log(`${first ? "shell" : "chunk"}: ${shown}`);
first = false;
}
shell: <main data-hk=What each piece is:
- Shell. The page markup with
<!--[b:ID-->fallback<!--]-->around each pending boundary, followed by a constant script that defineswindow.$S. It carries the CSPnonceyou pass. - Boundary chunk.
<template id="s:ID">with the resolved content, then$S("ID"), which replaces the boundary's fallback with the template content and removes the template. Chunks for different boundaries are independent and can arrive in any order. - Data block.
<script type="application/json" data-sh="<renderId>">with theserialize()d resource values. It is only sent when there are values.
If a boundary's resources fail, it is not swapped: the client keeps the fallback and fetches the data itself.
timeoutMs (default 30 000) errors the stream; signal aborts it and abandons pending fetches; a stream the
consumer cancels disposes the render.
Holding the first flush with deferStream#
A resource created with deferStream: true holds the first flush, as in Solid. While the shell renders, the stream
waits for that resource, renders the shell again with its value and sends it in the first chunk, so the value is in
the initial HTML and not behind a fallback. Other boundaries still stream as their data arrives.
- Only resources created while the shell renders hold it. A resource created by a boundary that streams later is not held: that boundary streams when its data arrives.
- A resource that rejects does not hold the stream. Its fallback stays, and the client fetches the data itself.
- Each shell render uses the values the one before it resolved. A chain of held resources is bounded: after ten shell
renders the stream errors with
SSR_BOUNDARY_WATERFALL. - A fetch the first shell render started is joined by the render that sends the shell, not started again.
renderToStringandrenderToStringAsyncalready wait for every resource, so the option has no effect there.
Route loaders need no flag: a route's data resolves before its page renders, so it is in the shell already.
A hydration after streaming is the same call as for a string: the swap has already put the real markup in
place, and the <!--[b:...--> markers are treated like any other region marker and removed.