Skip to content

fix: eliminate SSR streaming race between onShellReady and onAllReady#323

Open
Uttkarsh-Srivastava wants to merge 1 commit into
mainfrom
fix/ssr-streaming-race-webpack
Open

fix: eliminate SSR streaming race between onShellReady and onAllReady#323
Uttkarsh-Srivastava wants to merge 1 commit into
mainfrom
fix/ssr-streaming-race-webpack

Conversation

@Uttkarsh-Srivastava

@Uttkarsh-Srivastava Uttkarsh-Srivastava commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • renderToPipeableStream's pipe(res) in onShellReady and the handler's own res.write(firstFoldCss/firstFoldJS)/res.end() in onAllReady (packages/catalyst-core/src/server/renderer/handler.js) are two independent completion paths racing to close the same response. Whichever loses is silently dropped (no error listener on res), truncating every SSR response and permanently sticking async <Suspense> boundaries on their fallback in some documents.
  • Fixes this by routing React's output through an intermediate Transform ("tail"). React auto-ends tail exactly like it auto-ended res before, but tail's flush() hook is guaranteed to run exactly once — appending firstFoldCss/firstFoldJS — before a plain tail.pipe(res) is allowed to end res. Exactly one caller of res.end() now.
  • Progressive streaming is unchanged: every chunk still flows to res immediately as React writes it; only the trailing asset tags were ever deferred to the end, same as before.

Fixes #320

Visual walkthrough of the race and the fix: https://claude.ai/code/artifact/79a44698-079d-47f0-b72e-911a352f05e4

Test plan

  • Build a fixture app against this branch and confirm </html> is present and Suspense content resolves
  • Verify firstFoldCss/firstFoldJS still injected correctly for both bot and non-bot requests

@deputydev-agent

Copy link
Copy Markdown

DeputyDev will no longer review pull requests automatically.To request a review, simply comment #review on your pull request—this will trigger an on-demand review whenever you need it.

renderToPipeableStream's pipe(res) in onShellReady and the handler's
own res.write()/res.end() in onAllReady were two independent
completion paths racing to close the same response. Whichever lost
was silently dropped (no error listener on res), truncating every
response: missing closing tags on react-dom 19.x, or lost deferred
asset scripts on 18.x, with Suspense fallbacks stuck permanently on
some documents.

Route React's output through a Transform ("tail") instead. React
auto-ends tail exactly like it auto-ended res before, but tail's
flush() hook is guaranteed to run once, appending the deferred asset
tags, before a plain tail.pipe(res) is allowed to end res. There is
now exactly one caller of res.end().

Rebased onto main post the Vite Release merge (#284), which replaced
the old webpack packages/catalyst-core/src/server/renderer/handler.js
with this handler.jsx (same defect, new location) and added safe-area
handling (cleanupSafeArea) that this fix preserves.

Fixes #320

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Uttkarsh-Srivastava
Uttkarsh-Srivastava force-pushed the fix/ssr-streaming-race-webpack branch from 61d7ff9 to 20639e0 Compare July 22, 2026 10:07
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.

SSR streaming race: pipe(res) in onShellReady competes with res.write()/res.end() in onAllReady, truncating every SSR response

1 participant