[]previous and next page

Streaming

renderToStream(code, { renderId?, nonce?, timeoutMs?, signal?, onShellReady?

For agentsUpdated

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.

src/stream-app.tsxtsx
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>
  );
}
stream-server.tsxtsx
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;
}
Expected output
shell: <main data-hk=

What each piece is:

  1. Shell. The page markup with <!--[b:ID--> fallback <!--]--> around each pending boundary, followed by a constant script that defines window.$S. It carries the CSP nonce you pass.
  2. 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.
  3. Data block. <script type="application/json" data-sh="<renderId>"> with the serialize()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.
  • renderToString and renderToStringAsync already 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.