Skip to content

Add start.renderMode ('stream' | 'async') with per-request module and handleRequest override - #349

Merged
ryansolid merged 1 commit into
nextfrom
feat-344-render-mode
Sep 5, 2026
Merged

Add start.renderMode ('stream' | 'async') with per-request module and handleRequest override#349
ryansolid merged 1 commit into
nextfrom
feat-344-render-mode

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #344 (motivation: solidjs/solid#3280 — streaming SSR leaves <Loading> fallbacks unresolved for clients that never run the swap scripts).

What

  • start.renderMode?: 'stream' | 'async' (default 'stream'), baked into the generated SSR handler. Under 'async' the handler deliberately adopts the renderToStream thenable (if (mode === 'async' && result && typeof result.then === 'function') result = await result) so one settled document goes out: boundary content spliced in place pre-flush, no fallback markup, no swap templates/scripts, hydration data still serialized. The string takes createSSRResponse's existing string path (stub commit, doctype + client entry injection, mid-render Location → real 3xx). The pipe-before-then check is kept for stream mode.
  • start.renderMode: './src/render-mode.ts' — module path default-exporting (event) => 'stream' | 'async' | Promise<...>, following the middleware/setup convention; resolved per request inside the request scope after the middleware chain.
  • handleRequest(request, { renderMode }) runtime override. Precedence: runtime option > module fn result > static config.
  • Invalid values rejected at config time (unknown literal, missing module path) and at dispatch (bad module result / bad runtime option) with the source named.
  • Works identically for authored entries (render() returning a renderToStream result is awaited the same way). Client mode ignores the option.
  • Types for StartOptions and handleRequest options; README section covering what async does, tradeoffs (TTFB waits for full settle, whole page buffered), deferStream moot under async, crawler-UA / ?nojs per-request recipe, redirects becoming real 3xx.
  • No @solidjs/web changes.

Runtime note (deviation from the issue's design text)

On the awaited path @solidjs/web disposes the render owner (running onCleanup for httpStatus/httpHeader) before createSSRResponse commits the response stub, so under 'async' a bare httpStatus(404) would come back as 200. Generated entries now pass onCompleteAll to renderToStream, committing the response stub at render completion so status/headers survive. Authored entries that want the same guarantee should do likewise (documented in the README). Redirects were unaffected either way.

Tests

New render-mode mode in examples/start-ssr/test/run.mjs:

  • config validation (unknown literal, missing module path)
  • direct dispatch: runtime override beats static config and module fn; invalid runtime value rejected
  • dev + prod static 'async': complete document (doctype… in one piece, no Loading markup, no swap templates/scripts, boundary content in place, hydration data present), then the existing hydration/RPC/browser checks against the async-rendered page; httpStatus(404) / httpHeader / pre-flush and mid-render Location → real 3xx with no body
  • dev + prod module form: default request streams, x-render-mode: async / crawler UA / ?nojs settle, and the same server streams again for the next plain request
  • authored entry (dev + prod): static async, module form, mid-render redirect, hydration
  • prod builds: mode baked into handler chunk, render-mode module bundled server-side and absent from client assets

Full gate (start-ssr incl. http-bridge, start-client, start-env, ssr, css-matrix, vitest/cypress on Vite 8) run locally — results in the PR conversation.

Made with Cursor

…d handleRequest override

Streaming SSR leaves <Loading> fallbacks unresolved for clients that never
run the swap scripts (solidjs/solid#3280). `start.renderMode: 'async'` makes
the generated handler adopt the renderToStream thenable deliberately, so one
settled document goes out: boundaries spliced in place pre-flush, no fallback
markup or swap scripts, hydration data intact, and the string takes
createSSRResponse's string path (stub commit, doctype/client-entry injection,
mid-render Location as a real 3xx). A module path default-exporting
(event) => 'stream' | 'async' decides per request (middleware/setup
convention), and handleRequest(request, { renderMode }) overrides both;
invalid values are rejected at config time and at dispatch with the source
named. Generated entries commit the response head at render completion
(onCompleteAll) so httpStatus/httpHeader survive the runtime's
dispose-before-resolve on the awaited path.

Fixes #344

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Sep 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0985ce8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@solidjs/vite-plugin Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Sep 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@solidjs/vite-plugin@349

commit: 0985ce8

@ryansolid
ryansolid merged commit 17ec1f1 into next Sep 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant