From 533493166213a2570f70f4fe9929d0985cd3de8e Mon Sep 17 00:00:00 2001 From: Razin Shafayet Date: Thu, 4 Jun 2026 13:48:24 +0600 Subject: [PATCH 1/4] docs: fix typos in `testing.md` (#94446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed two typos in `contributing/core/testing.md`: - `sett` → `setting` (`Consider also setting NEXT_E2E_TEST_TIMEOUT=0`) - `again` → `against` (`run a test against both Turbopack and Webpack`) --- contributing/core/testing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contributing/core/testing.md b/contributing/core/testing.md index d31a0626dd50..3db9581ff87b 100644 --- a/contributing/core/testing.md +++ b/contributing/core/testing.md @@ -114,7 +114,7 @@ const { next } = nextTestSetup({ }) ``` -Consider also sett `NEXT_E2E_TEST_TIMEOUT=0` +Consider also setting `NEXT_E2E_TEST_TIMEOUT=0` To debug the test process itself you need to pass the `inspect` flag to the node process running jest. e.g. `IS_TURBOPACK_TEST=1 TURBOPACK_DEV=1 NEXT_TEST_MODE=dev node --inspect node_modules/jest/bin/jest.js ...` @@ -130,7 +130,7 @@ To run the test suite using Turbopack, you can use the `-turbo` version of the n pnpm test-dev-turbo test/e2e/app-dir/app/ ``` -If you want to run a test again both Turbopack and Webpack, use Jest's `--projects` flag: +If you want to run a test against both Turbopack and Webpack, use Jest's `--projects` flag: ```sh pnpm test-dev test/e2e/app-dir/app/ --projects jest.config.* From 8c809215bfbd287d3406ccb552c02e210e7cb853 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 4 Jun 2026 11:29:52 +0200 Subject: [PATCH 2/4] Keep the dev React debug channel on Node streams end to end (#94433) With Node streams enabled (now the default), the dev-only React debug channel was still being round-tripped through web streams repeatedly. The Node variant of `teeStream` wrapped the readable with `Readable.toWeb`, teed it, then unwrapped with `Readable.fromWeb`, applied up to twice as the channel was split for validation and then for SSR and the browser, and `connectReactDebugChannel` additionally converted the readable to a web `ReadableStream` and ran every chunk through a web `TransformStream` batcher before forwarding it over HMR. Each conversion allocates web-stream wrappers and adds per-chunk microtask scheduling that contends for CPU with the render, which is wasteful because the debug payload is non-trivial in dev (async debug info, console replay, large debug strings) and the whole point of the Node streams flag is to avoid that overhead. This change keeps the channel Node-native whenever Node streams are in use. The Node variant of `teeStream` now fans out through `ReplayableNodeStream` instead of the web round-trip, which fixes every Node-mode caller at once. The Cache Components Node render path, which needs three independent consumers (validation, SSR, and the browser), fans out through a single `ReplayableNodeStream` directly rather than nesting binary tees. `connectReactDebugChannel` now branches on the stream type and, for Node input, batches with a Node `Transform` (`createNodeBufferedTransformStream`, now exported with a byte-length cap to match the web batcher) consumed via `data`/`end`/`error` events, with no `toWeb` conversion. A `NodeDebugChannelPair` type narrows the client-side readable to a Node `Readable` so Node call sites can consume it without casting `AnyStream` down. The web path is left untouched and remains conversion-free. --- .../next/src/server/app-render/app-render.tsx | 28 +++--- .../app-render/debug-channel-server.node.ts | 27 ++++-- .../server/app-render/debug-channel-server.ts | 6 +- .../app-render/debug-channel-server.web.ts | 2 +- .../src/server/app-render/stream-ops.node.ts | 30 +++++-- packages/next/src/server/dev/debug-channel.ts | 88 +++++++++++++------ 6 files changed, 120 insertions(+), 61 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index b327aac3e312..17a220b27702 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -3593,7 +3593,7 @@ async function renderToStream( ) { if (process.env.__NEXT_USE_NODE_STREAMS) { // MARK: nodeStreams dev CacheComponents RSC - let debugChannel: DebugChannelPair | undefined + let debugChannelClientStream: ReplayableNodeStream | undefined // eslint-disable-next-line @typescript-eslint/no-shadow const getPayload = async (requestStore: RequestStore) => { @@ -3652,11 +3652,11 @@ async function renderToStream( let validationDebugChannelClient: AnyStream | undefined = undefined if (returnedDebugChannel) { - const [t1, t2] = teeStream( + debugChannelClientStream = new ReplayableNodeStream( returnedDebugChannel.clientSide.readable ) - returnedDebugChannel.clientSide.readable = t1 - validationDebugChannelClient = t2 + validationDebugChannelClient = + debugChannelClientStream.createReplayStream() } consoleAsyncStorage.run( @@ -3676,14 +3676,14 @@ async function renderToStream( reactServerResult = new ReactServerResult(serverStream) requestStore = finalRequestStore - debugChannel = returnedDebugChannel } else { logValidationSkipped(ctx) // We're either bypassing caches or we can't restart the render. // Do a dynamic render, but with (basic) environment labels. - debugChannel = setReactDebugChannel && createNodeDebugChannel() + const debugChannel = + setReactDebugChannel && createNodeDebugChannel() const serverStream = await stagedRenderWithoutCachesInDevNode( ctx, @@ -3696,17 +3696,19 @@ async function renderToStream( } ) reactServerResult = new ReactServerResult(serverStream) - } - if (debugChannel && setReactDebugChannel) { - const [readableSsr, readableBrowser] = teeStream( - debugChannel.clientSide.readable - ) + if (debugChannel) { + debugChannelClientStream = new ReplayableNodeStream( + debugChannel.clientSide.readable + ) + } + } - reactDebugStream = readableSsr + if (debugChannelClientStream && setReactDebugChannel) { + reactDebugStream = debugChannelClientStream.createReplayStream() setReactDebugChannel( - { readable: readableBrowser }, + { readable: debugChannelClientStream.createReplayStream() }, htmlRequestId, requestId ) diff --git a/packages/next/src/server/app-render/debug-channel-server.node.ts b/packages/next/src/server/app-render/debug-channel-server.node.ts index f4aaa4f084be..c3571aa06c67 100644 --- a/packages/next/src/server/app-render/debug-channel-server.node.ts +++ b/packages/next/src/server/app-render/debug-channel-server.node.ts @@ -3,17 +3,27 @@ * Loaded by debug-channel-server.ts when __NEXT_USE_NODE_STREAMS is enabled. */ -import { PassThrough, Writable } from 'node:stream' -import type { DebugChannelPair } from './debug-channel-server.web' +import { PassThrough, Writable, type Readable } from 'node:stream' +import type { DebugChannelServer } from './debug-channel-server.web' export { createWebDebugChannel } from './debug-channel-server.web' +/** + * Node variant: identical to `DebugChannelPair` except the client-side readable + * is narrowed to a Node `Readable`, so node call sites can consume it (e.g. + * `new ReplayableNodeStream(...)`) without casting `AnyStream` down. + */ +export type NodeDebugChannelPair = { + serverSide: DebugChannelServer + clientSide: { readable: Readable } +} + /** * Creates a debug channel using Node.js streams. * Use with renderToNodeFlightStream (React's renderToPipeableStream), * which expects debugChannel to be a Node.js stream with a .write() method. */ -export function createNodeDebugChannel(): DebugChannelPair { +export function createNodeDebugChannel(): NodeDebugChannelPair { // The readable side is a PassThrough that the client reads from. The // server-side write target is a separate, write-only Writable that forwards // into it rather than the PassThrough itself: React's renderToPipeableStream @@ -23,12 +33,11 @@ export function createNodeDebugChannel(): DebugChannelPair { // The forwarding must use `passthrough.write()` / `passthrough.end()`, not // `passthrough.push()` / `passthrough.push(null)`. A PassThrough is a Duplex; // pushing `null` ends only its readable half and leaves the writable half - // open (`writableEnded` stays false). The readable is later consumed via - // `Readable.toWeb()` (in `connectReactDebugChannel`, and inside `teeStream`), - // and `Readable.toWeb()` never closes the resulting web stream while the - // PassThrough's writable half is still open — so the debug channel never - // closes on the client. Ending it via `passthrough.end()` closes both halves - // and the close propagates. + // open (`writableEnded` stays false). If the readable is consumed via + // `Readable.toWeb()`, that web stream never closes while the PassThrough's + // writable half is still open — so the debug channel would never close on the + // client. Ending it via `passthrough.end()` closes both halves and the close + // propagates. const passthrough = new PassThrough() const writable = new Writable({ diff --git a/packages/next/src/server/app-render/debug-channel-server.ts b/packages/next/src/server/app-render/debug-channel-server.ts index fb0595230d9f..911d757ee511 100644 --- a/packages/next/src/server/app-render/debug-channel-server.ts +++ b/packages/next/src/server/app-render/debug-channel-server.ts @@ -8,11 +8,13 @@ export type { DebugChannelPair, DebugChannelServer, } from './debug-channel-server.web' +export type { NodeDebugChannelPair } from './debug-channel-server.node' import type { DebugChannelPair } from './debug-channel-server.web' +import type { NodeDebugChannelPair } from './debug-channel-server.node' type DebugChannelMod = { createWebDebugChannel: typeof import('./debug-channel-server.web').createWebDebugChannel - createNodeDebugChannel: typeof import('./debug-channel-server.web').createNodeDebugChannel + createNodeDebugChannel: typeof import('./debug-channel-server.node').createNodeDebugChannel } let _m: DebugChannelMod @@ -32,7 +34,7 @@ export function createWebDebugChannel(): DebugChannelPair | undefined { return _m.createWebDebugChannel() } -export function createNodeDebugChannel(): DebugChannelPair | undefined { +export function createNodeDebugChannel(): NodeDebugChannelPair | undefined { if (process.env.NODE_ENV === 'production') { return undefined } diff --git a/packages/next/src/server/app-render/debug-channel-server.web.ts b/packages/next/src/server/app-render/debug-channel-server.web.ts index c30480d7a337..f04bc2b8d291 100644 --- a/packages/next/src/server/app-render/debug-channel-server.web.ts +++ b/packages/next/src/server/app-render/debug-channel-server.web.ts @@ -53,6 +53,6 @@ export function createWebDebugChannel(): DebugChannelPair { * Use with renderToNodeFlightStream (React's renderToPipeableStream), * which expects debugChannel to be a Node.js stream with a .write() method. */ -export function createNodeDebugChannel(): DebugChannelPair { +export function createNodeDebugChannel(): never { throw new Error('not implemented') } 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 90dc34d9aadb..0ad970b95e49 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -36,7 +36,10 @@ import { htmlEscapeJsonString, } from '../../shared/lib/htmlescape' import { createInlinedDataReadableStream } from './use-flight-response' -import type { AnyStream as AnyStreamType } from './app-render-prerender-utils' +import { + ReplayableNodeStream, + type AnyStream as AnyStreamType, +} from './app-render-prerender-utils' import { DetachedPromise } from '../../lib/detached-promise' import { getTracer } from '../lib/trace/tracer' import { AppRenderSpan } from '../lib/trace/constants' @@ -118,10 +121,14 @@ function webToReadable( // --------------------------------------------------------------------------- // Buffered transform – Node.js Transform that coalesces chunks written in the -// same microtask into a single Uint8Array before pushing downstream. +// same microtask into a single Uint8Array before pushing downstream, flushing +// synchronously once the buffer reaches `maxBufferByteLength` (default: never, +// i.e. microtask-only). // --------------------------------------------------------------------------- -function createBufferedTransformStream(): Transform { +export function createNodeBufferedTransformStream( + maxBufferByteLength: number = Infinity +): Transform { let bufferedChunks: Array = [] let bufferByteLength = 0 let flushScheduled = false @@ -146,7 +153,9 @@ function createBufferedTransformStream(): Transform { bufferedChunks.push(chunk) bufferByteLength += chunk.byteLength - if (!flushScheduled) { + if (bufferByteLength >= maxBufferByteLength) { + flushBuffered(this) + } else if (!flushScheduled) { flushScheduled = true queueMicrotask(() => { flushScheduled = false @@ -725,7 +734,7 @@ export async function continueFizzStream( // 1. Buffer – coalesces chunks written in the same microtask into one Uint8Array // 2. Flight data injection – interleaves RSC data chunks with the HTML stream // 3. Head insertion – inserts server-generated HTML before - const buffered = createBufferedTransformStream() + const buffered = createNodeBufferedTransformStream() webToReadable(renderStream).pipe(buffered) let source: Readable = buffered @@ -836,7 +845,7 @@ export async function continueDynamicHTMLResumeNode( ): Promise { await waitAtLeastOneReactRenderTask() - const buffered = createBufferedTransformStream() + const buffered = createNodeBufferedTransformStream() webToReadable(renderStream).pipe(buffered) let source: Readable = buffered @@ -1098,7 +1107,12 @@ export function getServerPrerender(ComponentMod: { export const getClientPrerender: typeof import('react-dom/static').prerender = prerender +// Node counterpart of the web `teeStream`. Like the web version it assumes the +// stream type matching its build — here a Node `Readable` — and fans out +// through `ReplayableNodeStream`. Need three or more consumers from one source? +// Use `ReplayableNodeStream` directly (N `createReplayStream()` calls) to avoid +// nesting tees. export function teeStream(stream: AnyStream): [AnyStream, AnyStream] { - const [s1, s2] = nodeReadableToWebReadableStream(stream).tee() - return [webToReadable(s1), webToReadable(s2)] + const replayable = new ReplayableNodeStream(stream as Readable) + return [replayable.createReplayStream(), replayable.createReplayStream()] } diff --git a/packages/next/src/server/dev/debug-channel.ts b/packages/next/src/server/dev/debug-channel.ts index 61510afb2970..8ba53e60467f 100644 --- a/packages/next/src/server/dev/debug-channel.ts +++ b/packages/next/src/server/dev/debug-channel.ts @@ -6,14 +6,9 @@ import { } from './hot-reloader-types' import type { AnyStream } from '../app-render/stream-ops' -function toWebReadableStream(stream: AnyStream): ReadableStream { - if (stream instanceof ReadableStream) { - return stream - } - const { Readable: ReadableClass } = - require('node:stream') as typeof import('node:stream') - return ReadableClass.toWeb(stream as Readable) as ReadableStream -} +// Chunks are sent to the browser in batches to reduce overhead, flushing +// synchronously once this many bytes have accumulated. +const MAX_DEBUG_CHANNEL_BATCH_BYTES = 128 * 1024 export interface ReactDebugChannelForBrowser { readonly readable: AnyStream @@ -24,19 +19,23 @@ const reactDebugChannelsByHtmlRequestId = new Map< ReactDebugChannelForBrowser >() +/** + * Reads the React debug channel and forwards its chunks to the browser through + * the websocket. Branches on the stream type so that Node streams stay + * node-native — batched with a Node `Transform` and consumed via events. + */ export function connectReactDebugChannel( requestId: string, debugChannel: ReactDebugChannelForBrowser, sendToClient: (message: HmrMessageSentToBrowser) => void ) { - const reader = toWebReadableStream(debugChannel.readable) - .pipeThrough( - // We're sending the chunks in batches to reduce overhead in the browser. - createBufferedTransformStream({ maxBufferByteLength: 128 * 1024 }) - ) - .getReader() + let finished = false const stop = () => { + if (finished) { + return + } + finished = true sendToClient({ type: HMR_MESSAGE_SENT_TO_BROWSER.REACT_DEBUG_CHUNK, requestId, @@ -45,25 +44,58 @@ export function connectReactDebugChannel( } const onError = (err: unknown) => { - console.error(new Error('React debug channel stream error', { cause: err })) + if (!finished) { + console.error( + new Error('React debug channel stream error', { cause: err }) + ) + } stop() } - const progress = (entry: ReadableStreamReadResult) => { - if (entry.done) { - stop() - } else { - sendToClient({ - type: HMR_MESSAGE_SENT_TO_BROWSER.REACT_DEBUG_CHUNK, - requestId, - chunk: entry.value, - }) - - reader.read().then(progress, onError) - } + const sendChunk = (chunk: Uint8Array) => { + sendToClient({ + type: HMR_MESSAGE_SENT_TO_BROWSER.REACT_DEBUG_CHUNK, + requestId, + chunk, + }) } - reader.read().then(progress, onError) + const { readable } = debugChannel + + if (readable instanceof ReadableStream) { + const reader = readable + .pipeThrough( + createBufferedTransformStream({ + maxBufferByteLength: MAX_DEBUG_CHANNEL_BATCH_BYTES, + }) + ) + .getReader() + + const progress = (entry: ReadableStreamReadResult) => { + if (entry.done) { + stop() + } else { + sendChunk(entry.value) + reader.read().then(progress, onError) + } + } + + reader.read().then(progress, onError) + } else { + const { createNodeBufferedTransformStream } = + require('../app-render/stream-ops.node') as typeof import('../app-render/stream-ops.node') + + const source = readable as Readable + // `pipe` does not forward source errors to the destination, so handle them + // on the source directly. + source.on('error', onError) + const batched = source.pipe( + createNodeBufferedTransformStream(MAX_DEBUG_CHANNEL_BATCH_BYTES) + ) + batched.on('data', sendChunk) + batched.on('end', stop) + batched.on('error', onError) + } } export function connectReactDebugChannelForHtmlRequest( From dfa4d3974eec7dc8829b1b513f809528d544f956 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 4 Jun 2026 13:14:34 +0200 Subject: [PATCH 3/4] test: pin material-ui link fixture dependencies (#94354) ### What? Pin the dependency versions used by the Material UI new-link-behavior fixture. ### Why? The fixture currently installs `latest`, which now resolves `@mui/material` to 9.0.1. That version causes an SWC source-map panic while compiling the fixture, so an unrelated link behavior test fails before reaching its assertion. ### How? Replace the floating dependency versions with the known-good Material UI 5.11.16, Emotion 11, and prop-types versions previously used by the fixture. ### Verification - `pnpm build-all` - `pnpm prettier --with-node-modules --ignore-path .prettierignore --write test/e2e/new-link-behavior/material-ui.test.ts` - `npx eslint --config eslint.config.mjs --fix test/e2e/new-link-behavior/material-ui.test.ts` - `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-dev-webpack test/e2e/new-link-behavior/material-ui.test.ts` --- test/e2e/new-link-behavior/material-ui.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/e2e/new-link-behavior/material-ui.test.ts b/test/e2e/new-link-behavior/material-ui.test.ts index acb9f241f954..c024acf58862 100644 --- a/test/e2e/new-link-behavior/material-ui.test.ts +++ b/test/e2e/new-link-behavior/material-ui.test.ts @@ -11,13 +11,13 @@ describe('New Link Behavior with material-ui', () => { 'next.config.js': new FileRef(path.join(appDir, 'next.config.js')), }, dependencies: { - '@emotion/cache': 'latest', - '@emotion/react': 'latest', - '@emotion/server': 'latest', - '@emotion/styled': 'latest', - '@mui/icons-material': 'latest', - '@mui/material': 'latest', - 'prop-types': 'latest', + '@emotion/cache': '11.10.5', + '@emotion/react': '11.10.6', + '@emotion/server': '11.10.0', + '@emotion/styled': '11.10.6', + '@mui/icons-material': '5.11.16', + '@mui/material': '5.11.16', + 'prop-types': '15.8.1', }, }) From d6d439c6871068b69e5aa46b153582bc7f7e518e Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 4 Jun 2026 14:51:42 +0200 Subject: [PATCH 4/4] Revert "Keep the dev React debug channel on Node streams end to end" (#94459) --- .../next/src/server/app-render/app-render.tsx | 28 +++--- .../app-render/debug-channel-server.node.ts | 27 ++---- .../server/app-render/debug-channel-server.ts | 6 +- .../app-render/debug-channel-server.web.ts | 2 +- .../src/server/app-render/stream-ops.node.ts | 30 ++----- packages/next/src/server/dev/debug-channel.ts | 88 ++++++------------- 6 files changed, 61 insertions(+), 120 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 17a220b27702..b327aac3e312 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -3593,7 +3593,7 @@ async function renderToStream( ) { if (process.env.__NEXT_USE_NODE_STREAMS) { // MARK: nodeStreams dev CacheComponents RSC - let debugChannelClientStream: ReplayableNodeStream | undefined + let debugChannel: DebugChannelPair | undefined // eslint-disable-next-line @typescript-eslint/no-shadow const getPayload = async (requestStore: RequestStore) => { @@ -3652,11 +3652,11 @@ async function renderToStream( let validationDebugChannelClient: AnyStream | undefined = undefined if (returnedDebugChannel) { - debugChannelClientStream = new ReplayableNodeStream( + const [t1, t2] = teeStream( returnedDebugChannel.clientSide.readable ) - validationDebugChannelClient = - debugChannelClientStream.createReplayStream() + returnedDebugChannel.clientSide.readable = t1 + validationDebugChannelClient = t2 } consoleAsyncStorage.run( @@ -3676,14 +3676,14 @@ async function renderToStream( reactServerResult = new ReactServerResult(serverStream) requestStore = finalRequestStore + debugChannel = returnedDebugChannel } else { logValidationSkipped(ctx) // We're either bypassing caches or we can't restart the render. // Do a dynamic render, but with (basic) environment labels. - const debugChannel = - setReactDebugChannel && createNodeDebugChannel() + debugChannel = setReactDebugChannel && createNodeDebugChannel() const serverStream = await stagedRenderWithoutCachesInDevNode( ctx, @@ -3696,19 +3696,17 @@ async function renderToStream( } ) reactServerResult = new ReactServerResult(serverStream) - - if (debugChannel) { - debugChannelClientStream = new ReplayableNodeStream( - debugChannel.clientSide.readable - ) - } } - if (debugChannelClientStream && setReactDebugChannel) { - reactDebugStream = debugChannelClientStream.createReplayStream() + if (debugChannel && setReactDebugChannel) { + const [readableSsr, readableBrowser] = teeStream( + debugChannel.clientSide.readable + ) + + reactDebugStream = readableSsr setReactDebugChannel( - { readable: debugChannelClientStream.createReplayStream() }, + { readable: readableBrowser }, htmlRequestId, requestId ) diff --git a/packages/next/src/server/app-render/debug-channel-server.node.ts b/packages/next/src/server/app-render/debug-channel-server.node.ts index c3571aa06c67..f4aaa4f084be 100644 --- a/packages/next/src/server/app-render/debug-channel-server.node.ts +++ b/packages/next/src/server/app-render/debug-channel-server.node.ts @@ -3,27 +3,17 @@ * Loaded by debug-channel-server.ts when __NEXT_USE_NODE_STREAMS is enabled. */ -import { PassThrough, Writable, type Readable } from 'node:stream' -import type { DebugChannelServer } from './debug-channel-server.web' +import { PassThrough, Writable } from 'node:stream' +import type { DebugChannelPair } from './debug-channel-server.web' export { createWebDebugChannel } from './debug-channel-server.web' -/** - * Node variant: identical to `DebugChannelPair` except the client-side readable - * is narrowed to a Node `Readable`, so node call sites can consume it (e.g. - * `new ReplayableNodeStream(...)`) without casting `AnyStream` down. - */ -export type NodeDebugChannelPair = { - serverSide: DebugChannelServer - clientSide: { readable: Readable } -} - /** * Creates a debug channel using Node.js streams. * Use with renderToNodeFlightStream (React's renderToPipeableStream), * which expects debugChannel to be a Node.js stream with a .write() method. */ -export function createNodeDebugChannel(): NodeDebugChannelPair { +export function createNodeDebugChannel(): DebugChannelPair { // The readable side is a PassThrough that the client reads from. The // server-side write target is a separate, write-only Writable that forwards // into it rather than the PassThrough itself: React's renderToPipeableStream @@ -33,11 +23,12 @@ export function createNodeDebugChannel(): NodeDebugChannelPair { // The forwarding must use `passthrough.write()` / `passthrough.end()`, not // `passthrough.push()` / `passthrough.push(null)`. A PassThrough is a Duplex; // pushing `null` ends only its readable half and leaves the writable half - // open (`writableEnded` stays false). If the readable is consumed via - // `Readable.toWeb()`, that web stream never closes while the PassThrough's - // writable half is still open — so the debug channel would never close on the - // client. Ending it via `passthrough.end()` closes both halves and the close - // propagates. + // open (`writableEnded` stays false). The readable is later consumed via + // `Readable.toWeb()` (in `connectReactDebugChannel`, and inside `teeStream`), + // and `Readable.toWeb()` never closes the resulting web stream while the + // PassThrough's writable half is still open — so the debug channel never + // closes on the client. Ending it via `passthrough.end()` closes both halves + // and the close propagates. const passthrough = new PassThrough() const writable = new Writable({ diff --git a/packages/next/src/server/app-render/debug-channel-server.ts b/packages/next/src/server/app-render/debug-channel-server.ts index 911d757ee511..fb0595230d9f 100644 --- a/packages/next/src/server/app-render/debug-channel-server.ts +++ b/packages/next/src/server/app-render/debug-channel-server.ts @@ -8,13 +8,11 @@ export type { DebugChannelPair, DebugChannelServer, } from './debug-channel-server.web' -export type { NodeDebugChannelPair } from './debug-channel-server.node' import type { DebugChannelPair } from './debug-channel-server.web' -import type { NodeDebugChannelPair } from './debug-channel-server.node' type DebugChannelMod = { createWebDebugChannel: typeof import('./debug-channel-server.web').createWebDebugChannel - createNodeDebugChannel: typeof import('./debug-channel-server.node').createNodeDebugChannel + createNodeDebugChannel: typeof import('./debug-channel-server.web').createNodeDebugChannel } let _m: DebugChannelMod @@ -34,7 +32,7 @@ export function createWebDebugChannel(): DebugChannelPair | undefined { return _m.createWebDebugChannel() } -export function createNodeDebugChannel(): NodeDebugChannelPair | undefined { +export function createNodeDebugChannel(): DebugChannelPair | undefined { if (process.env.NODE_ENV === 'production') { return undefined } diff --git a/packages/next/src/server/app-render/debug-channel-server.web.ts b/packages/next/src/server/app-render/debug-channel-server.web.ts index f04bc2b8d291..c30480d7a337 100644 --- a/packages/next/src/server/app-render/debug-channel-server.web.ts +++ b/packages/next/src/server/app-render/debug-channel-server.web.ts @@ -53,6 +53,6 @@ export function createWebDebugChannel(): DebugChannelPair { * Use with renderToNodeFlightStream (React's renderToPipeableStream), * which expects debugChannel to be a Node.js stream with a .write() method. */ -export function createNodeDebugChannel(): never { +export function createNodeDebugChannel(): DebugChannelPair { throw new Error('not implemented') } 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 0ad970b95e49..90dc34d9aadb 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -36,10 +36,7 @@ import { htmlEscapeJsonString, } from '../../shared/lib/htmlescape' import { createInlinedDataReadableStream } from './use-flight-response' -import { - ReplayableNodeStream, - type AnyStream as AnyStreamType, -} from './app-render-prerender-utils' +import type { AnyStream as AnyStreamType } from './app-render-prerender-utils' import { DetachedPromise } from '../../lib/detached-promise' import { getTracer } from '../lib/trace/tracer' import { AppRenderSpan } from '../lib/trace/constants' @@ -121,14 +118,10 @@ function webToReadable( // --------------------------------------------------------------------------- // Buffered transform – Node.js Transform that coalesces chunks written in the -// same microtask into a single Uint8Array before pushing downstream, flushing -// synchronously once the buffer reaches `maxBufferByteLength` (default: never, -// i.e. microtask-only). +// same microtask into a single Uint8Array before pushing downstream. // --------------------------------------------------------------------------- -export function createNodeBufferedTransformStream( - maxBufferByteLength: number = Infinity -): Transform { +function createBufferedTransformStream(): Transform { let bufferedChunks: Array = [] let bufferByteLength = 0 let flushScheduled = false @@ -153,9 +146,7 @@ export function createNodeBufferedTransformStream( bufferedChunks.push(chunk) bufferByteLength += chunk.byteLength - if (bufferByteLength >= maxBufferByteLength) { - flushBuffered(this) - } else if (!flushScheduled) { + if (!flushScheduled) { flushScheduled = true queueMicrotask(() => { flushScheduled = false @@ -734,7 +725,7 @@ export async function continueFizzStream( // 1. Buffer – coalesces chunks written in the same microtask into one Uint8Array // 2. Flight data injection – interleaves RSC data chunks with the HTML stream // 3. Head insertion – inserts server-generated HTML before - const buffered = createNodeBufferedTransformStream() + const buffered = createBufferedTransformStream() webToReadable(renderStream).pipe(buffered) let source: Readable = buffered @@ -845,7 +836,7 @@ export async function continueDynamicHTMLResumeNode( ): Promise { await waitAtLeastOneReactRenderTask() - const buffered = createNodeBufferedTransformStream() + const buffered = createBufferedTransformStream() webToReadable(renderStream).pipe(buffered) let source: Readable = buffered @@ -1107,12 +1098,7 @@ export function getServerPrerender(ComponentMod: { export const getClientPrerender: typeof import('react-dom/static').prerender = prerender -// Node counterpart of the web `teeStream`. Like the web version it assumes the -// stream type matching its build — here a Node `Readable` — and fans out -// through `ReplayableNodeStream`. Need three or more consumers from one source? -// Use `ReplayableNodeStream` directly (N `createReplayStream()` calls) to avoid -// nesting tees. export function teeStream(stream: AnyStream): [AnyStream, AnyStream] { - const replayable = new ReplayableNodeStream(stream as Readable) - return [replayable.createReplayStream(), replayable.createReplayStream()] + const [s1, s2] = nodeReadableToWebReadableStream(stream).tee() + return [webToReadable(s1), webToReadable(s2)] } diff --git a/packages/next/src/server/dev/debug-channel.ts b/packages/next/src/server/dev/debug-channel.ts index 8ba53e60467f..61510afb2970 100644 --- a/packages/next/src/server/dev/debug-channel.ts +++ b/packages/next/src/server/dev/debug-channel.ts @@ -6,9 +6,14 @@ import { } from './hot-reloader-types' import type { AnyStream } from '../app-render/stream-ops' -// Chunks are sent to the browser in batches to reduce overhead, flushing -// synchronously once this many bytes have accumulated. -const MAX_DEBUG_CHANNEL_BATCH_BYTES = 128 * 1024 +function toWebReadableStream(stream: AnyStream): ReadableStream { + if (stream instanceof ReadableStream) { + return stream + } + const { Readable: ReadableClass } = + require('node:stream') as typeof import('node:stream') + return ReadableClass.toWeb(stream as Readable) as ReadableStream +} export interface ReactDebugChannelForBrowser { readonly readable: AnyStream @@ -19,23 +24,19 @@ const reactDebugChannelsByHtmlRequestId = new Map< ReactDebugChannelForBrowser >() -/** - * Reads the React debug channel and forwards its chunks to the browser through - * the websocket. Branches on the stream type so that Node streams stay - * node-native — batched with a Node `Transform` and consumed via events. - */ export function connectReactDebugChannel( requestId: string, debugChannel: ReactDebugChannelForBrowser, sendToClient: (message: HmrMessageSentToBrowser) => void ) { - let finished = false + const reader = toWebReadableStream(debugChannel.readable) + .pipeThrough( + // We're sending the chunks in batches to reduce overhead in the browser. + createBufferedTransformStream({ maxBufferByteLength: 128 * 1024 }) + ) + .getReader() const stop = () => { - if (finished) { - return - } - finished = true sendToClient({ type: HMR_MESSAGE_SENT_TO_BROWSER.REACT_DEBUG_CHUNK, requestId, @@ -44,58 +45,25 @@ export function connectReactDebugChannel( } const onError = (err: unknown) => { - if (!finished) { - console.error( - new Error('React debug channel stream error', { cause: err }) - ) - } + console.error(new Error('React debug channel stream error', { cause: err })) stop() } - const sendChunk = (chunk: Uint8Array) => { - sendToClient({ - type: HMR_MESSAGE_SENT_TO_BROWSER.REACT_DEBUG_CHUNK, - requestId, - chunk, - }) - } - - const { readable } = debugChannel - - if (readable instanceof ReadableStream) { - const reader = readable - .pipeThrough( - createBufferedTransformStream({ - maxBufferByteLength: MAX_DEBUG_CHANNEL_BATCH_BYTES, - }) - ) - .getReader() - - const progress = (entry: ReadableStreamReadResult) => { - if (entry.done) { - stop() - } else { - sendChunk(entry.value) - reader.read().then(progress, onError) - } + const progress = (entry: ReadableStreamReadResult) => { + if (entry.done) { + stop() + } else { + sendToClient({ + type: HMR_MESSAGE_SENT_TO_BROWSER.REACT_DEBUG_CHUNK, + requestId, + chunk: entry.value, + }) + + reader.read().then(progress, onError) } - - reader.read().then(progress, onError) - } else { - const { createNodeBufferedTransformStream } = - require('../app-render/stream-ops.node') as typeof import('../app-render/stream-ops.node') - - const source = readable as Readable - // `pipe` does not forward source errors to the destination, so handle them - // on the source directly. - source.on('error', onError) - const batched = source.pipe( - createNodeBufferedTransformStream(MAX_DEBUG_CHANNEL_BATCH_BYTES) - ) - batched.on('data', sendChunk) - batched.on('end', stop) - batched.on('error', onError) } + + reader.read().then(progress, onError) } export function connectReactDebugChannelForHtmlRequest(