Skip to content

fix(web): createSSRResponse settles when the render ends before the shell - #3725

Merged
ryansolid merged 1 commit into
nextfrom
fix/ssr-response-pre-shell-settle-3719
Sep 30, 2026
Merged

ryansolid merged 1 commit into
nextfrom
fix/ssr-response-pre-shell-settle-3719

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #3719

Summary

createSSRResponse(renderToStream(...), event) never settled when the render ended before its shell flushed, so hosts that answer every page through it (including @solidjs/vite-plugin in its default stream mode) hung the request. It now always settles:

  • A render that fails before the shell (onError hears handling: "failed") resolves with a bodyless 500. If a Location is already on the response stub (set by middleware, for example), it resolves with the redirect instead, the same pre-flush rule as the shell path. The stub is committed.
  • An abort through signal before the shell resolves the same way: a bodyless 500 (or the redirect), stub committed. onError hears nothing, because a disconnect is not a render failure.
  • A render that succeeds with an empty document (() => null, () => "") also hung: its shell reaches the sink as no write at all. It now resolves with an empty 200, the same answer as createSSRResponse("").

The promise still never rejects.

Cause

createSSRResponse resolved only from its sink's first write. Its end() began with if (closed || !controller) return; (packages/web/src/server.ts L6815 at cce43eb44), and controller exists only after that first write. Since #3569, a pre-shell failure ends the sink with nothing written, so end() returned and the promise was never settled. An abort through signal never reaches the sink at all: abandon("signal") deliberately leaves it alone.

Fix

A bare end() cannot tell a failed render from a successful empty one; both arrive with nothing written. So the issue's suggested "no write means 500" would have answered empty successes with a 500. Instead:

  • pipe() checks the sink for a module-private method (a local Symbol(), not exported) that only createSSRResponse's own sink carries. A pre-shell failure calls it instead of end(), and so does a pre-shell abort, including a signal already aborted before pipe() is called. It commits the stub and resolves the 500 or the redirect, and it is guarded against settling twice.
  • A bare end() with nothing written now runs the normal first-write path, which is what gives the empty 200 (transformChunk still sees the empty first chunk, as the string path does).
  • Every other pipe() sink sees exactly what it did before: end() on failure, silence on abort.

No timers or listeners are added. The abort listener is still removed by the render's dispose, and onError still fires exactly once per failure.

Semantics and evidence

Resolve with a 500 on a pre-shell failure; never reject:

Abort before the shell resolves the same bodyless 500 (maintainer ruling). This matches createSSRResponse's resolve-only contract and pipeTo's own consumer-disconnect path, which resolves rather than rejects. The alternative considered was rejecting with signal.reason, as the server-function runtime's in-process calls do. It was not chosen: it would add the function's first rejection path, and host containment such as #383 would report it as a request failure anyway.

Public API Changes

No exports, types, options or signatures change. Documented behavior changes (JSDoc and documentation/solid-2.0/12-ssr-http.md updated):

  • createSSRResponse with a stream result: a render that fails, or is aborted through its signal, before the shell flushes now resolves with a bodyless 500 (or the redirect when a Location is already on the stub) and commits the stub. Before, the promise stayed pending forever.
  • createSSRResponse with a stream result that succeeds with an empty document now resolves with an empty 200 (default content-type). Before, it stayed pending forever.

Tests

The new tests sit beside the #3569 (b) tests in packages/web/test/server/ssr-async-rejection-3569.spec.tsx and reuse their failingPreShell() fixture, lifted to module scope. Each one races a deadline, so a hang fails with a name.

Case next this branch
failure via a non-converging <Loading> (the #3569 (b) fixture): 500, empty body, stub committed, one failed hangs passes
sync throw on a root hole's retry pass: 500, one failed hangs passes
async read rejecting with no boundary: 500, one failed hangs passes
Location + cookie set before the render: 302, Location and cookie kept hangs passes
abort mid-render: 500, onError silent, abort listener removed hangs passes
signal already aborted when the render starts: 500, listener removed hangs passes
empty successful render: 200, empty body, default content-type hangs passes
rejection after the shell (unchanged): 200, fallback streams, then the rejected fragment passes passes

A sync throw on the first render pass is unchanged: it still throws synchronously out of renderToStream, before createSSRResponse is called.

Suites run locally:

Suite Result
@solidjs/web server 1376 passed, 2 skipped
@solidjs/web client 1106 passed, 1 expected fail
@solidjs/web hydrate 270 passed
solid-js 817 passed
type tests (@solidjs/web, solid-js) passed

Size

0 B in every size scenario, including the server-entry floors from #3722: none of them imports renderToStream or createSSRResponse. The unminified dist/server.js grows by 811 B raw, 182 B brotli (the dist keeps comments).

Related

Known gap (follow-up)

An abort through signal after the shell still leaves the resolved Response's body open. abandon("signal") never touches the sink, so the stream is never closed or errored. With signal: request.signal the host usually cancels the body itself. When signal is a render deadline (AbortSignal.timeout(...)), the client keeps a partial body open. That case is out of scope here and deserves its own issue.

…hell

`createSSRResponse` resolved only from its sink's first `write`, and its
`end()` returned early while no stream controller existed. A render that
failed before the shell (the `pipe()` failure completion ends the sink with
nothing written) left the promise pending forever, and an abort through
`signal` never reached the sink at all. A successful empty render reached
the same early return.

`pipe()` now hands a pre-shell failure or abort to a module-private method
only `createSSRResponse`'s sink carries; it commits the stub and resolves a
bodyless 500, or the redirect when a `Location` is already on the stub. A
bare `end()` with nothing written runs the first-write path, so an empty
document resolves an empty 200. Every other `pipe()` sink sees exactly
what it did before. The promise still never rejects.

Fixes #3719

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

changeset-bot Bot commented Sep 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2656284

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

This PR includes changesets to release 12 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
todos-server-example Patch
@solidjs/compiler Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

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

@github-actions

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 9.51 KB 0 B 9.51 KB ✅
signals: + createStore 16.85 KB 0 B 16.85 KB ✅
signals: + isPending/latest 12.16 KB 0 B 12.16 KB ✅
app: render + one signal (the simple-app floor) 11.97 KB 0 B 12.05 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 19.68 KB 0 B 19.69 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.78 KB 0 B 30.79 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 14.90 KB 0 B 14.94 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 16.42 KB 0 B 16.48 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 30.65 KB 0 B 30.71 KB ✅ lazy-page.js 0.04 KB
frames: eager client consumer (frames client + transport, lazy codec) 12.70 KB 0 B 12.71 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 45.94 KB 0 B 45.95 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 50.07 KB 0 B 50.12 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.36 KB 0 B 20.36 KB ✅

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body).

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 36700026376

Coverage remained the same at 75.979%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1194
Covered Lines: 961
Line Coverage: 80.49%
Relevant Branches: 925
Covered Branches: 649
Branch Coverage: 70.16%
Branches in Coverage %: Yes
Coverage Strength: 27.52 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 30, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 185 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing fix/ssr-response-pre-shell-settle-3719 (2656284) with next (cce43eb)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@ryansolid
ryansolid merged commit 3dcc269 into next Sep 30, 2026
7 checks passed
@ryansolid
ryansolid deleted the fix/ssr-response-pre-shell-settle-3719 branch September 30, 2026 19:46
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.

2 participants