You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
letresult=entry.render(request,{ clientEntry, ...options.context});if(mode==='async'&&result&&typeofresult.then==='function'){result=awaitresult;// 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.
start.renderMode: 'stream' | 'async' (default 'stream') baked into the generated handler; 'async' awaits the renderToStream thenable so one settled document goes out — boundary content in place, no <Loading> fallbacks or swap scripts, hydration data intact, mid-render Location → real 3xx.
start.renderMode: './src/render-mode.ts' per-request module ((event) => 'stream' | 'async' | Promise<...>), same convention as middleware/setup.
Works for authored entries; invalid values rejected at config time and at dispatch.
One deviation from the design text worth knowing: on the awaited path the runtime disposes the render owner before createSSRResponse commits the response stub, so httpStatus/httpHeader would revert under 'async'. Generated entries now commit the stub via onCompleteAll; authored entries wanting the same guarantee should do likewise (documented in the README). Redirects were unaffected.
Closing manually since auto-close doesn't fire off the non-default branch.
Activity
ryansolid commentedon Sep 5, 2026
Landed on
nextin #349 (merge 17ec1f1).start.renderMode: 'stream' | 'async'(default'stream') baked into the generated handler;'async'awaits therenderToStreamthenable so one settled document goes out — boundary content in place, no<Loading>fallbacks or swap scripts, hydration data intact, mid-renderLocation→ real 3xx.start.renderMode: './src/render-mode.ts'per-request module ((event) => 'stream' | 'async' | Promise<...>), same convention asmiddleware/setup.handleRequest(request, { renderMode })runtime override; precedence runtime > module fn > static.One deviation from the design text worth knowing: on the awaited path the runtime disposes the render owner before
createSSRResponsecommits the response stub, sohttpStatus/httpHeaderwould revert under'async'. Generated entries now commit the stub viaonCompleteAll; authored entries wanting the same guarantee should do likewise (documented in the README). Redirects were unaffected.Closing manually since auto-close doesn't fire off the non-default branch.