From 9c671c5992ef7e11e75f8103e0c23e7491911853 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 7 Apr 2026 20:56:56 +0200 Subject: [PATCH 1/6] Node.js streams: Add forkpoint for continueDynamicHTMLResumeNode --- .../next/src/server/app-render/app-render.tsx | 11 +++-- .../src/server/app-render/stream-ops.node.ts | 47 ++++++++++++++++++- .../next/src/server/app-render/stream-ops.ts | 3 +- .../src/server/app-render/stream-ops.web.ts | 9 +++- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 21dfa9ab20a0..bd83874e71d3 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -42,7 +42,8 @@ import { continueFizzStream, continueDynamicPrerender, continueStaticPrerender, - continueDynamicHTMLResume, + continueDynamicHTMLResumeNode, + continueDynamicHTMLResumeWeb, continueStaticFallbackPrerender, streamToBuffer, streamToString, @@ -3783,7 +3784,7 @@ async function renderToStream( // We have a complete HTML Document in the prerender but we need to // still include the new server component render because it was not included // in the static prelude. - const inlinedDataStream = createWebInlinedDataStream( + const inlinedDataStream = createNodeInlinedDataStream( reactServerResult.tee(), nonce, formState @@ -3834,10 +3835,10 @@ async function renderToStream( if (renderSpan.isRecording()) renderSpan.end() }) - return await continueDynamicHTMLResume(htmlStream, { + return await continueDynamicHTMLResumeNode(htmlStream, { delayDataUntilFirstHtmlChunk: preludeState === DynamicHTMLPreludeState.Empty, - inlinedDataStream: createWebInlinedDataStream( + inlinedDataStream: createNodeInlinedDataStream( reactServerResult.consume(), nonce, formState @@ -3973,7 +3974,7 @@ async function renderToStream( if (renderSpan.isRecording()) renderSpan.end() }) - return await continueDynamicHTMLResume(htmlStream, { + return await continueDynamicHTMLResumeWeb(htmlStream, { delayDataUntilFirstHtmlChunk: preludeState === DynamicHTMLPreludeState.Empty, inlinedDataStream: createWebInlinedDataStream( diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index c4bbd483d846..2e8412e74396 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -812,7 +812,52 @@ export async function continueStaticFallbackPrerender( return webToReadable(webResult) } -export async function continueDynamicHTMLResume( +export async function continueDynamicHTMLResumeNode( + renderStream: AnyStream, + { + delayDataUntilFirstHtmlChunk, + inlinedDataStream, + getServerInsertedHTML, + getServerInsertedMetadata, + deploymentId, + }: import('./stream-ops.web').ContinueDynamicHTMLResumeOptions +): Promise { + await waitAtLeastOneReactRenderTask() + + const buffered = createBufferedTransformStream() + webToReadable(renderStream).pipe(buffered) + + let source: Readable = buffered + + if (deploymentId) { + const dplId = createHtmlDataDplIdTransform(deploymentId) + source.pipe(dplId) + source = dplId + } + + const headInsertion = createHeadInsertionTransform(getServerInsertedHTML) + source.pipe(headInsertion) + source = headInsertion + + const metadata = createMetadataTransform(getServerInsertedMetadata) + source.pipe(metadata) + source = metadata + + const flightInjection = createFlightDataInjectionTransform( + webToReadable(inlinedDataStream), + delayDataUntilFirstHtmlChunk + ) + source.pipe(flightInjection) + source = flightInjection + + const moveSuffix = createMoveSuffixTransform() + source.pipe(moveSuffix) + source = moveSuffix + + return source +} + +export async function continueDynamicHTMLResumeWeb( renderStream: AnyStream, opts: import('./stream-ops.web').ContinueDynamicHTMLResumeOptions ): Promise { diff --git a/packages/next/src/server/app-render/stream-ops.ts b/packages/next/src/server/app-render/stream-ops.ts index 015a035bf1e5..f28b6dc2187e 100644 --- a/packages/next/src/server/app-render/stream-ops.ts +++ b/packages/next/src/server/app-render/stream-ops.ts @@ -35,7 +35,8 @@ export const continueStaticPrerender = _m.continueStaticPrerender export const continueDynamicPrerender = _m.continueDynamicPrerender export const continueStaticFallbackPrerender = _m.continueStaticFallbackPrerender -export const continueDynamicHTMLResume = _m.continueDynamicHTMLResume +export const continueDynamicHTMLResumeNode = _m.continueDynamicHTMLResumeNode +export const continueDynamicHTMLResumeWeb = _m.continueDynamicHTMLResumeWeb export const streamToBuffer = _m.streamToBuffer export const chainStreams = _m.chainStreams export const createDocumentClosingStream = _m.createDocumentClosingStream diff --git a/packages/next/src/server/app-render/stream-ops.web.ts b/packages/next/src/server/app-render/stream-ops.web.ts index cf3b47d702f6..cb755e92c03f 100644 --- a/packages/next/src/server/app-render/stream-ops.web.ts +++ b/packages/next/src/server/app-render/stream-ops.web.ts @@ -141,7 +141,7 @@ export async function continueStaticFallbackPrerender( ) } -export async function continueDynamicHTMLResume( +export async function continueDynamicHTMLResumeWeb( renderStream: AnyStream, opts: ContinueDynamicHTMLResumeOptions ): Promise { @@ -154,6 +154,13 @@ export async function continueDynamicHTMLResume( ) } +export function continueDynamicHTMLResumeNode( + _renderStream: AnyStream, + _opts: ContinueDynamicHTMLResumeOptions +): Promise { + throw new Error('not implemented') +} + export async function streamToBuffer(stream: AnyStream): Promise { return webStreamToBuffer(stream as ReadableStream) } From bfcd34c43c54adc4d132a7c294f80d7571241298 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 7 Apr 2026 21:53:19 +0200 Subject: [PATCH 2/6] Node.js streams: generateDynamicFlightRenderResult forkpoint --- .../next/src/server/app-render/app-render.tsx | 95 +++++++++++++------ 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index bd83874e71d3..a3174a47eed0 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -846,42 +846,77 @@ async function generateDynamicFlightRenderResult( onFlightDataRenderError ) - const debugChannel = setReactDebugChannel && createWebDebugChannel() + if (process.env.__NEXT_USE_NODE_STREAMS) { + const debugChannel = setReactDebugChannel && createNodeDebugChannel() - if (debugChannel) { - setReactDebugChannel(debugChannel.clientSide, htmlRequestId, requestId) - } + if (debugChannel) { + setReactDebugChannel(debugChannel.clientSide, htmlRequestId, requestId) + } - const { clientModules } = getClientReferenceManifest() + const { clientModules } = getClientReferenceManifest() - // For app dir, use the bundled version of Flight server renderer (renderToReadableStream) - // which contains the subset React. - const rscPayload = await workUnitAsyncStorage.run( - requestStore, - generateDynamicRSCPayload, - ctx, - options - ) + const rscPayload = await workUnitAsyncStorage.run( + requestStore, + generateDynamicRSCPayload, + ctx, + options + ) - const flightStream = workUnitAsyncStorage.run( - requestStore, - renderToWebFlightStream, - ctx.componentMod, - rscPayload, - clientModules, - { - onError, - temporaryReferences: options?.temporaryReferences, - filterStackFrame, - debugChannel: debugChannel?.serverSide, + const flightStream = workUnitAsyncStorage.run( + requestStore, + renderToNodeFlightStream, + ctx.componentMod, + rscPayload, + clientModules, + { + onError, + temporaryReferences: options?.temporaryReferences, + filterStackFrame, + debugChannel: debugChannel?.serverSide, + } + ) + + return new FlightRenderResult( + flightStream, + { fetchMetrics: workStore.fetchMetrics }, + options?.waitUntil + ) + } else { + const debugChannel = setReactDebugChannel && createWebDebugChannel() + + if (debugChannel) { + setReactDebugChannel(debugChannel.clientSide, htmlRequestId, requestId) } - ) - return new FlightRenderResult( - flightStream, - { fetchMetrics: workStore.fetchMetrics }, - options?.waitUntil - ) + const { clientModules } = getClientReferenceManifest() + + const rscPayload = await workUnitAsyncStorage.run( + requestStore, + generateDynamicRSCPayload, + ctx, + options + ) + + const flightStream = workUnitAsyncStorage.run( + requestStore, + renderToWebFlightStream, + ctx.componentMod, + rscPayload, + clientModules, + { + onError, + temporaryReferences: options?.temporaryReferences, + filterStackFrame, + debugChannel: debugChannel?.serverSide, + } + ) + + return new FlightRenderResult( + flightStream, + { fetchMetrics: workStore.fetchMetrics }, + options?.waitUntil + ) + } } /** From 9ec9544e51d2d9399d4f798a7f74fb3b65f3e4a0 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 8 Apr 2026 11:13:44 +0200 Subject: [PATCH 3/6] Node.js streams: Add forkpoint for generateStagedDynamicFlightRenderResult --- .../next/src/server/app-render/app-render.tsx | 183 +++++++++++++++++- 1 file changed, 181 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index a3174a47eed0..6544429124b5 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -924,7 +924,7 @@ async function generateDynamicFlightRenderResult( * staged rendering to separate static (RDC-backed) from runtime/dynamic * content. */ -async function generateStagedDynamicFlightRenderResult( +async function generateStagedDynamicFlightRenderResultWeb( req: BaseNextRequest, ctx: AppRenderContext, requestStore: RequestStore @@ -1078,6 +1078,168 @@ async function generateStagedDynamicFlightRenderResult( }) } +/** + * Production-only staged dynamic flight render for cache components (Node.js + * streams). Uses staged rendering to separate static (RDC-backed) from + * runtime/dynamic content. + */ +async function generateStagedDynamicFlightRenderResultNode( + req: BaseNextRequest, + ctx: AppRenderContext, + requestStore: RequestStore +): Promise { + const { componentMod, workStore, renderOpts } = ctx + const { routeModule } = componentMod + const { loaderTree } = routeModule.userland + const { onInstrumentationRequestError, experimental } = renderOpts + + function onFlightDataRenderError(err: DigestedError, silenceLog: boolean) { + return onInstrumentationRequestError?.( + err, + req, + createErrorContext(ctx, 'react-server-components-payload'), + silenceLog + ) + } + + const onError = createReactServerErrorHandler( + false, + false, + workStore.reactServerErrorsByDigest, + onFlightDataRenderError + ) + + const selectStaleTime = createSelectStaleTime(experimental) + const staleTimeIterable = new StaleTimeIterable() + + // TODO(cached-navs): this assumes that we checked during build that there's no sync IO. + // but it can happen e.g. after a revalidation or conditionally for a param that wasn't prerendered. + // we should change this to track sync IO, log an error and advance to dynamic. + const shouldTrackSyncIO = false + const stageController = new StagedRenderingController( + null, // no aborting + null, // no abandoning + shouldTrackSyncIO + ) + + // Initialize stale time tracking on the request store. + requestStore.stale = INFINITE_CACHE + requestStore.stagedRendering = stageController + requestStore.varyParamsAccumulator = createResponseVaryParamsAccumulator() + requestStore.asyncApiPromises = createAsyncApiPromises( + stageController, + requestStore.cookies, + requestStore.mutableCookies, + requestStore.headers + ) + + trackStaleTime( + requestStore as { stale: number }, + staleTimeIterable, + selectStaleTime + ) + + // Deferred promise for the static stage byte length. Flight serializes the + // resolved value into the stream so the client knows where the static + // prefix ends. + let resolveStaticStageByteLength: (count: number) => void + const staticStageByteLengthPromise = new Promise((resolve) => { + resolveStaticStageByteLength = resolve + }) + + // Check if this route has opted into runtime prefetching via + // unstable_instant. If so, we piggyback on the dynamic render to fill caches + // and then spawn a final runtime prerender whose result stream is embedded in + // the RSC payload. This is gated on the explicit opt-in because it adds extra + // server processing, increases the response payload size, and the runtime + // prefetch output should have been validated first. + const hasRuntimePrefetch = + await anySegmentHasRuntimePrefetchEnabled(loaderTree) + + let runtimePrefetchStream: ReadableStream | undefined + + if (hasRuntimePrefetch) { + // Create a mutable cache that gets filled during the dynamic render. + const prerenderResumeDataCache = createPrerenderResumeDataCache() + requestStore.prerenderResumeDataCache = prerenderResumeDataCache + + const cacheSignal = new CacheSignal() + trackPendingModules(cacheSignal) + requestStore.cacheSignal = cacheSignal + + // Create a deferred stream for the runtime prefetch result. Its readable + // side goes into the RSC payload (Flight serializes it lazily). The + // writable side receives the runtime prerender result once the dynamic + // render has filled all caches. + const runtimePrefetchTransform = new TransformStream() + runtimePrefetchStream = runtimePrefetchTransform.readable + + // Wait for the dynamic render to fill caches, then run the final runtime + // prerender (fire-and-forget — does not block the response). + void cacheSignal + .cacheReady() + .then(() => + spawnRuntimePrefetchWithFilledCaches( + runtimePrefetchTransform.writable, + ctx, + prerenderResumeDataCache, + requestStore, + onError + ) + ) + } + + const rscPayload = await workUnitAsyncStorage.run( + requestStore, + generateDynamicRSCPayload, + ctx, + { staleTimeIterable, staticStageByteLengthPromise, runtimePrefetchStream } + ) + + const { clientModules } = getClientReferenceManifest() + + const flightStream = await runInSequentialTasks( + () => { + stageController.advanceStage(RenderStage.Static) + + const sourceStream = workUnitAsyncStorage.run( + requestStore, + renderToNodeFlightStream, + ctx.componentMod, + rscPayload, + clientModules, + { onError, filterStackFrame } + ) as Readable + + const replayable = new ReplayableNodeStream(sourceStream) + const dynamicStream = replayable.createReplayStream() + const staticStream = replayable.createReplayStream() + + countStaticStageBytesNode(staticStream, stageController).then( + resolveStaticStageByteLength + ) + + return dynamicStream + }, + () => { + // This is a separate task that doesn't advance a stage. It forces + // draining the microtask queue so that the stale time iterable and vary + // params accumulators are closed before we advance to the dynamic stage. + void finishStaleTimeTracking(staleTimeIterable) + if (requestStore.varyParamsAccumulator) { + void finishAccumulatingVaryParams(requestStore.varyParamsAccumulator) + } + }, + () => { + stageController.advanceStage(RenderStage.Dynamic) + } + ) + + return new FlightRenderResult(flightStream, { + fetchMetrics: workStore.fetchMetrics, + }) +} + /** * Runs a final runtime prerender using the provided (already filled) cache and * pipes its output into the provided writable stream. The caller is responsible @@ -2754,8 +2916,10 @@ async function renderToHTMLOrFlightImpl( }) } + // MARK: RSC request if (isRSCRequest) { if (isRuntimePrefetchRequest) { + // MARK: RSC runtimePrefetch return generateRuntimePrefetchResult(req, ctx, requestStore) } else { if ( @@ -2763,6 +2927,7 @@ async function renderToHTMLOrFlightImpl( process.env.NEXT_RUNTIME !== 'edge' && cacheComponents ) { + // MARK: RSC devCacheComponents return generateDynamicFlightRenderResultWithStagesInDev( req, ctx, @@ -2771,8 +2936,22 @@ async function renderToHTMLOrFlightImpl( fallbackParams ) } else if (cacheComponents && cachedNavigations) { - return generateStagedDynamicFlightRenderResult(req, ctx, requestStore) + // MARK: RSC cacheComponents + if (process.env.__NEXT_USE_NODE_STREAMS) { + return generateStagedDynamicFlightRenderResultNode( + req, + ctx, + requestStore + ) + } else { + return generateStagedDynamicFlightRenderResultWeb( + req, + ctx, + requestStore + ) + } } else { + // MARK: RSC dynamic return generateDynamicFlightRenderResult(req, ctx, requestStore) } } From dfc9f65bccf3737a8ac70f4afe33021238816d6e Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 8 Apr 2026 11:34:22 +0200 Subject: [PATCH 4/6] Node.js streams: Add forkpoint for logMessagesAndSendErrorsToBrowser --- .../next/src/server/app-render/app-render.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 6544429124b5..15edea0daa2c 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -5458,12 +5458,22 @@ async function logMessagesAndSendErrorsToBrowser( const { clientModules } = getClientReferenceManifest() - const errorsFlightStream = renderToWebFlightStream( - ctx.componentMod, - { errors, errorCodes }, - clientModules, - { filterStackFrame } - ) + let errorsFlightStream: AnyStream + if (process.env.__NEXT_USE_NODE_STREAMS) { + errorsFlightStream = renderToNodeFlightStream( + ctx.componentMod, + { errors, errorCodes }, + clientModules, + { filterStackFrame } + ) + } else { + errorsFlightStream = renderToWebFlightStream( + ctx.componentMod, + { errors, errorCodes }, + clientModules, + { filterStackFrame } + ) + } sendErrorsToBrowser(errorsFlightStream, htmlRequestId) } From c039dc48f5e1ccb24313b2262c2681d62492e1c3 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 8 Apr 2026 12:04:56 +0200 Subject: [PATCH 5/6] Node.js streams: Add forkpoint for createCombinedPayloadStream --- .../next/src/server/app-render/app-render.tsx | 17 +++++++++++++---- .../instant-validation/instant-validation.tsx | 12 +++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 15edea0daa2c..1174f61fc5d8 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -6046,13 +6046,20 @@ async function validateInstantConfigs( const clientReferenceManifest = getClientReferenceManifest() + const renderFlightStream = process.env.__NEXT_USE_NODE_STREAMS + ? renderToNodeFlightStream + : renderToWebFlightStream + const createDebugChannel = process.env.__NEXT_USE_NODE_STREAMS + ? createNodeDebugChannel + : createWebDebugChannel + const { cache, payload: initialRscPayload, stageEndTimes, } = await collectStagedSegmentData( ctx.componentMod, - renderToWebFlightStream, + renderFlightStream, { [RenderStage.Static]: accumulatedChunks.staticChunks, [RenderStage.Runtime]: accumulatedChunks.runtimeChunks, @@ -6061,7 +6068,8 @@ async function validateInstantConfigs( debugChunks, startTime, hasRuntimePrefetch, - clientReferenceManifest + clientReferenceManifest, + createDebugChannel ) const { implicitTags, nonce, workStore } = ctx @@ -6129,13 +6137,14 @@ async function validateInstantConfigs( const { stream: serverStream, debugStream } = await createCombinedPayloadStream( ctx.componentMod, - renderToWebFlightStream, + renderFlightStream, payloadResult.payload, extraChunksController, reactController.signal, clientReferenceManifest, startTime, - isDebugChannelEnabled + isDebugChannelEnabled, + createDebugChannel ) const instantValidationState = createInstantValidationState( diff --git a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx index b8cf5474a75e..5bdf2f3336b3 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx +++ b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx @@ -41,7 +41,7 @@ import { createNodeStreamWithLateRelease, createNodeStreamFromChunks, } from './stream-utils' -import { createWebDebugChannel } from '../debug-channel-server' +import type { DebugChannelPair } from '../debug-channel-server' import type { FlightComponentMod } from '../stream-ops' // eslint-disable-next-line import/no-extraneous-dependencies @@ -217,7 +217,8 @@ export async function collectStagedSegmentData( fullPageDebugChunks: Uint8Array[] | null, startTime: number, hasRuntimePrefetch: boolean, - clientReferenceManifest: ClientReferenceManifest + clientReferenceManifest: ClientReferenceManifest, + createDebugChannel: () => DebugChannelPair | undefined ) { const debugChannelAbortController = new AbortController() const debugStream = fullPageDebugChunks @@ -297,7 +298,7 @@ export async function collectStagedSegmentData( cacheEntry: SegmentCacheItem ): Promise => { const segmentDebugChannel = cacheEntry.debugChunks - ? createWebDebugChannel() + ? createDebugChannel() : undefined const itemStream = renderFlightStream( @@ -527,7 +528,8 @@ export async function createCombinedPayloadStream( renderSignal: AbortSignal, clientReferenceManifest: ClientReferenceManifest, startTime: number, - isDebugChannelEnabled: boolean + isDebugChannelEnabled: boolean, + createDebugChannel: () => DebugChannelPair | undefined ) { // Collect all the chunks so that we're not dependent on timing of the render. @@ -536,7 +538,7 @@ export async function createCombinedPayloadStream( const allChunks: Uint8Array[] = [] const debugChunks: Uint8Array[] | null = isDebugChannelEnabled ? [] : null - const debugChannel = isDebugChannelEnabled ? createWebDebugChannel() : null + const debugChannel = isDebugChannelEnabled ? createDebugChannel() : null let streamFinished: Promise From eda1ad8720990f17ccee3a82cf63e2ead2372686 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 8 Apr 2026 12:11:25 +0200 Subject: [PATCH 6/6] Node.js streams: Add forkpoint for renderWithRestartOnCacheMissInValidation --- packages/next/src/server/app-render/app-render.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 1174f61fc5d8..75da32dcac78 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -6388,6 +6388,9 @@ async function renderWithRestartOnCacheMissInValidation( }> { const { componentMod: ComponentMod } = ctx const { clientModules } = getClientReferenceManifest() + const renderFlightStream = process.env.__NEXT_USE_NODE_STREAMS + ? renderToNodeFlightStream + : renderToWebFlightStream let startTime = -Infinity let requestStore: RequestStore = initialRequestStore @@ -6452,7 +6455,7 @@ async function renderWithRestartOnCacheMissInValidation( const stream = workUnitAsyncStorage.run( requestStore, - renderToWebFlightStream, + renderFlightStream, ComponentMod, initialRscPayload, clientModules, @@ -6553,7 +6556,7 @@ async function renderWithRestartOnCacheMissInValidation( const stream = workUnitAsyncStorage.run( requestStore, - renderToWebFlightStream, + renderFlightStream, ComponentMod, finalRscPayload, clientModules,