From 488a95dc54e526b486d8c699fee1ad40b66e58b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 14:10:46 +0000 Subject: [PATCH] fix: avoid buffering RSC payloads via unused tee branches in renderHTML renderHTML unconditionally teed the incoming RSC stream, but the second branch was only consumed in dev when no clientRscStream was provided. In build mode and in dev non-SSR mode the unread tee branch caused the stream implementation to buffer the entire RSC payload in memory (per entry during builds). Now the stream is only teed when the inline injection branch will actually be consumed. Closes #146 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HMVhUo2qQedCq79R4Ks6zh --- packages/static/src/ssr/entry.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/static/src/ssr/entry.tsx b/packages/static/src/ssr/entry.tsx index fe9ba35..3d51d1e 100644 --- a/packages/static/src/ssr/entry.tsx +++ b/packages/static/src/ssr/entry.tsx @@ -21,13 +21,21 @@ export async function renderHTML( clientRscStream?: ReadableStream; }, ): Promise<{ stream: ReadableStream; status?: number }> { - const [rscStream1, rscStream2] = rscStream.tee(); + // Tee only when the second branch is actually consumed (dev without a + // separate client RSC stream). An unread tee branch buffers the entire + // RSC payload in memory, so skip teeing in build mode and in dev when + // `clientRscStream` is injected instead. + let ssrRscStream = rscStream; + let inlineRscStream = options.clientRscStream; + if (!options.build && inlineRscStream === undefined) { + [ssrRscStream, inlineRscStream] = rscStream.tee(); + } let payload: Promise | undefined; function SsrRoot() { // Tip: calling `createFromReadableStream` inside a component // makes `preinit`/`preload` work properly. - payload ??= createFromReadableStream(rscStream1); + payload ??= createFromReadableStream(ssrRscStream); if (options.build) { preload(rscPayloadPlaceholder, { crossOrigin: "anonymous", @@ -109,10 +117,9 @@ export async function renderHTML( // In dev: always inject (client reads from inline stream). // In build+SSR: skip (HTML already has full content, client hydrates directly). // In build+no-SSR: skip (client fetches RSC from separate file). - if (!options.build) { - const streamToInject = options.clientRscStream ?? rscStream2; + if (!options.build && inlineRscStream !== undefined) { responseStream = responseStream.pipeThrough( - injectRSCPayload(streamToInject, { + injectRSCPayload(inlineRscStream, { nonce: options?.nonce, }), );