Skip to content

start.renderMode: 'stream' | 'async' (+ per-request form) for no-JS/crawler-complete documents #344

Description

@ryansolid

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions