Motivation
solidjs/solid#3280: streaming SSR serves <Loading> fallbacks that never resolve for no-JS clients (the content swap is script-driven). By design for streaming — the fix is a render mode option, the 1.x createHandler(..., { mode }) story.
Design (validated against the runtime and the handler)
Add start.renderMode?: 'stream' | 'async' (default 'stream'), plus a per-request form.
The runtime already supports async mode end to end:
await renderToStream(...) is the documented renderToStringAsync replacement: it resolves with the full HTML once every boundary settles. Because nothing has flushed, every boundary resolution goes through the pre-flush context.replace() splice — resolved content lands in place of its placeholder. No fallbacks, no swap templates, no swap scripts; hydration data still serializes so JS clients hydrate normally.
createSSRResponse has a first-class string path: stub commit, transformChunk (doctype + client entry injection), and a mid-render Location becomes a real 3xx instead of the post-flush script fallback — a strict improvement for no-JS clients.
- The generated handler's
dispatchRequest already has the exact seam. It currently checks pipe before then specifically to avoid adopting the buffer-everything thenable (the STREAM_BOX protocol exists for the same reason). Async mode = adopt the thenable deliberately:
let result = entry.render(request, { clientEntry, ...options.context });
if (mode === 'async' && result && typeof result.then === 'function') {
result = await result; // full HTML string, boundaries settled in place
}
Strings then flow through the existing createSSRResponse string path. Works identically for authored entries (same stream shape).
Config surface
start.renderMode: 'stream' | 'async' — static, baked into handler codegen.
start.renderMode: './src/render-mode.ts' — module path default-exporting (event) => 'stream' | 'async' | Promise<...>, following the middleware/setup convention (vite config cannot serialize closures into generated code). Enables per-request policy, e.g. 'async' for crawler user-agents or a ?nojs flag.
handleRequest(request, { renderMode }) — runtime override for hosts driving the handler directly.
Notes
- Tradeoffs are inherent to async mode: TTFB waits for full settle, whole page buffers in memory. Document both.
deferStream is moot under async mode (everything defers) — no interaction to handle.
- No core (
@solidjs/web) changes required.
Motivation
solidjs/solid#3280: streaming SSR serves
<Loading>fallbacks that never resolve for no-JS clients (the content swap is script-driven). By design for streaming — the fix is a render mode option, the 1.xcreateHandler(..., { mode })story.Design (validated against the runtime and the handler)
Add
start.renderMode?: 'stream' | 'async'(default'stream'), plus a per-request form.The runtime already supports async mode end to end:
await renderToStream(...)is the documentedrenderToStringAsyncreplacement: it resolves with the full HTML once every boundary settles. Because nothing has flushed, every boundary resolution goes through the pre-flushcontext.replace()splice — resolved content lands in place of its placeholder. No fallbacks, no swap templates, no swap scripts; hydration data still serializes so JS clients hydrate normally.createSSRResponsehas a first-class string path: stub commit,transformChunk(doctype + client entry injection), and a mid-renderLocationbecomes a real 3xx instead of the post-flush script fallback — a strict improvement for no-JS clients.dispatchRequestalready has the exact seam. It currently checkspipebeforethenspecifically to avoid adopting the buffer-everything thenable (theSTREAM_BOXprotocol exists for the same reason). Async mode = adopt the thenable deliberately:Strings then flow through the existing
createSSRResponsestring path. Works identically for authored entries (same stream shape).Config surface
start.renderMode: 'stream' | 'async'— static, baked into handler codegen.start.renderMode: './src/render-mode.ts'— module path default-exporting(event) => 'stream' | 'async' | Promise<...>, following themiddleware/setupconvention (vite config cannot serialize closures into generated code). Enables per-request policy, e.g.'async'for crawler user-agents or a?nojsflag.handleRequest(request, { renderMode })— runtime override for hosts driving the handler directly.Notes
deferStreamis moot under async mode (everything defers) — no interaction to handle.@solidjs/web) changes required.