From 4aaa5dd534720393728dc523e79dad1ad68f6f55 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 1 Jun 2026 18:36:26 +0200 Subject: [PATCH 1/4] Enable Node streams by default --- .github/workflows/build_and_test.yml | 132 ------------------ packages/next/src/server/config-shared.ts | 1 + packages/next/src/server/config.ts | 24 ++-- .../ppr-root-param-fallback.test.ts | 20 ++- .../use-server-inserted-html.test.ts | 43 +++--- test/use-node-streams-tests-manifest.json | 13 -- 6 files changed, 58 insertions(+), 175 deletions(-) delete mode 100644 test/use-node-streams-tests-manifest.json diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 42c54b6c6bb8..9fef923b17a4 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -1013,134 +1013,6 @@ jobs: stepName: 'test-cache-components-prod-${{ matrix.group }}' secrets: inherit - test-node-streams-cache-components-dev: - name: test node streams cache components dev - needs: - [ - 'optimize-ci', - 'changes', - 'build-native', - 'build-next', - 'fetch-test-timings', - ] - if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }} - - strategy: - fail-fast: false - matrix: - group: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6] - uses: ./.github/workflows/build_reusable.yml - with: - afterBuild: | - export __NEXT_USE_NODE_STREAMS=true - export __NEXT_CACHE_COMPONENTS=true - export __NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS=true - export __NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=true - export NEXT_EXTERNAL_TESTS_FILTERS="test/cache-components-tests-manifest.json" - export NEXT_TEST_MODE=dev - export IS_TURBOPACK_TEST=1 - export TURBOPACK_DEV=1 - - node run-tests.js \ - --timings \ - --require-timings \ - -g ${{ matrix.group }} \ - --type development - testTimingsArtifact: 'test-timings' - stepName: 'test-node-streams-cache-components-dev-${{ matrix.group }}' - secrets: inherit - - test-node-streams-cache-components-prod: - name: test node streams cache components prod - needs: - [ - 'optimize-ci', - 'changes', - 'build-native', - 'build-next', - 'fetch-test-timings', - ] - if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }} - - strategy: - fail-fast: false - matrix: - group: [1/7, 2/7, 3/7, 4/7, 5/7, 6/7, 7/7] - uses: ./.github/workflows/build_reusable.yml - with: - afterBuild: | - export __NEXT_USE_NODE_STREAMS=true - export __NEXT_CACHE_COMPONENTS=true - export __NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS=true - export __NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=true - export NEXT_EXTERNAL_TESTS_FILTERS="test/cache-components-tests-manifest.json" - export NEXT_TEST_MODE=start - export IS_TURBOPACK_TEST=1 - export TURBOPACK_BUILD=1 - - node run-tests.js \ - --timings \ - --require-timings \ - -g ${{ matrix.group }} \ - --type production - testTimingsArtifact: 'test-timings' - stepName: 'test-node-streams-cache-components-prod-${{ matrix.group }}' - secrets: inherit - - test-node-streams-dev: - name: test node streams dev - needs: ['optimize-ci', 'changes', 'build-native', 'build-next'] - if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }} - - strategy: - fail-fast: false - matrix: - group: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6] - uses: ./.github/workflows/build_reusable.yml - with: - afterBuild: | - export __NEXT_USE_NODE_STREAMS=true - export NEXT_EXTERNAL_TESTS_FILTERS="test/use-node-streams-tests-manifest.json" - export NEXT_TEST_MODE=dev - export IS_TURBOPACK_TEST=1 - export TURBOPACK_DEV=1 - export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true - export RUST_BACKTRACE=1 - - node run-tests.js \ - --timings \ - -g ${{ matrix.group }} \ - --type development - stepName: 'test-node-streams-dev-${{ matrix.group }}' - secrets: inherit - - test-node-streams-prod: - name: test node streams prod - needs: ['optimize-ci', 'changes', 'build-native', 'build-next'] - if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }} - - strategy: - fail-fast: false - matrix: - group: [1/7, 2/7, 3/7, 4/7, 5/7, 6/7, 7/7] - uses: ./.github/workflows/build_reusable.yml - with: - afterBuild: | - export __NEXT_USE_NODE_STREAMS=true - export NEXT_EXTERNAL_TESTS_FILTERS="test/use-node-streams-tests-manifest.json" - export NEXT_TEST_MODE=start - export IS_TURBOPACK_TEST=1 - export TURBOPACK_BUILD=1 - export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true - export RUST_BACKTRACE=1 - - node run-tests.js \ - --timings \ - -g ${{ matrix.group }} \ - --type production - stepName: 'test-node-streams-prod-${{ matrix.group }}' - secrets: inherit - tests-pass: needs: [ @@ -1157,10 +1029,6 @@ jobs: 'test-firefox-safari', 'test-cache-components-dev', 'test-cache-components-prod', - 'test-node-streams-cache-components-dev', - 'test-node-streams-cache-components-prod', - 'test-node-streams-dev', - 'test-node-streams-prod', 'test-cargo-unit', 'rust-check', 'rustdoc-check', diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 8da5f855611e..fe771f1b5ff3 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -2040,6 +2040,7 @@ export const defaultConfig = Object.freeze({ gestureTransition: false, inlineCss: false, useCache: undefined, + useNodeStreams: true, slowModuleDetection: undefined, globalNotFound: false, browserDebugInfoInTerminal: 'warn', diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index c61f17984c65..bf62f63637f3 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -1620,9 +1620,20 @@ function finalizeConfig(config: NextConfigComplete): NextConfigComplete { validationLevel: config.experimental.instantInsights?.validationLevel ?? 'manual-warning', } + syncUseNodeStreamsEnv(config) return config } +function syncUseNodeStreamsEnv(config: NextConfig): void { + // This must use resolved config: user configs are inspected before defaults + // are merged, while runtime bundles must select the default implementation. + if (config.experimental?.useNodeStreams) { + process.env.__NEXT_USE_NODE_STREAMS = 'true' + } else { + delete process.env.__NEXT_USE_NODE_STREAMS + } +} + async function applyModifyConfig( config: NextConfigComplete, phase: PHASE_TYPE, @@ -1750,6 +1761,7 @@ export default async function loadConfig( return cachedResult.rawConfig } + syncUseNodeStreamsEnv(cachedResult.config) return cachedResult.config } else { // Reset next.config errors before loading config @@ -1777,6 +1789,8 @@ export default async function loadConfig( process.env.__NEXT_PRIVATE_STANDALONE_CONFIG ) + syncUseNodeStreamsEnv(standaloneConfig) + // Cache the standalone config configCache.set(cacheKey, { config: standaloneConfig, @@ -2229,16 +2243,6 @@ function enforceExperimentalFeatures( config.experimental.useNodeStreams = true } - // Keep runtime bundle selection env in sync with the resolved config. - // Explicit user config (e.g. useNodeStreams: false) should win over an - // inherited shell env var to avoid selecting nodestream runtime bundles - // while define-env compiled user bundles with node streams disabled. - if (config.experimental.useNodeStreams) { - process.env.__NEXT_USE_NODE_STREAMS = 'true' - } else { - delete process.env.__NEXT_USE_NODE_STREAMS - } - // TODO: Remove this once strictRouteTypes is the default. if ( process.env.__NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES === 'true' && diff --git a/test/e2e/app-dir/ppr-root-param-fallback/ppr-root-param-fallback.test.ts b/test/e2e/app-dir/ppr-root-param-fallback/ppr-root-param-fallback.test.ts index e29b7fd25747..7a5c5857a0a7 100644 --- a/test/e2e/app-dir/ppr-root-param-fallback/ppr-root-param-fallback.test.ts +++ b/test/e2e/app-dir/ppr-root-param-fallback/ppr-root-param-fallback.test.ts @@ -14,17 +14,29 @@ describe('ppr-root-param-fallback', () => { // filled in for all pregenerated locales. for (const locale of ['en', 'fr']) { - // next.render$ doesn't stream, so we get just the shell content const $ = await next.render$(`/${locale}/blog/new-post`) + const html = $.html() // The shell should have the locale-header with cached content, - // NOT the locale-loading Suspense fallback + // even if Node streams flush the Suspense fallback before the resolved + // content that replaces it. expect($('#locale-header').length).toBe(1) expect($('#locale-header').text()).toContain(`Locale: ${locale}`) expect($('#translations').text()).toContain(`Home (${locale})`) - // The Suspense fallback should NOT be in the shell - expect($('#locale-loading').length).toBe(0) + const localeLoading = $('#locale-loading') + if (localeLoading.length > 0) { + const fallbackTemplateId = localeLoading.prev('template').attr('id') + const resolvedContentId = $('#locale-header') + .parent('div[hidden]') + .attr('id') + + expect(fallbackTemplateId).toBeTruthy() + expect(resolvedContentId).toBeTruthy() + expect(html).toContain( + `$RC("${fallbackTemplateId}","${resolvedContentId}")` + ) + } } }) }) diff --git a/test/e2e/app-dir/use-server-inserted-html/use-server-inserted-html.test.ts b/test/e2e/app-dir/use-server-inserted-html/use-server-inserted-html.test.ts index 8dc604279a67..5509187f5684 100644 --- a/test/e2e/app-dir/use-server-inserted-html/use-server-inserted-html.test.ts +++ b/test/e2e/app-dir/use-server-inserted-html/use-server-inserted-html.test.ts @@ -49,22 +49,33 @@ describe('use-server-inserted-html', () => { it('should render css-in-js suspense boundary correctly', async () => { await next.fetch('/css-in-js/suspense').then(async (response) => { - const results = [] - - await resolveStreamResponse(response, (chunk: string) => { - const isSuspenseyDataResolved = - /]*>(\s)*.+{padding:2px;(\s)*color:orange;}/.test(chunk) - if (isSuspenseyDataResolved) results.push('data') - - // check if rsc refresh script for suspense show up, the test content could change with react version - const hasRCScript = /\$RC=function/.test(chunk) - if (hasRCScript) results.push('refresh-script') - - const isFallbackResolved = chunk.includes('$test-fallback-sentinel') - if (isFallbackResolved) results.push('fallback') - }) - - expect(results).toEqual(['fallback', 'data', 'refresh-script']) + let fallbackIndex = -1 + let dataIndex = -1 + let refreshScriptIndex = -1 + + await resolveStreamResponse( + response, + (_chunk: string, result: string) => { + if (dataIndex === -1) { + dataIndex = result.search( + /]*>(\s)*.+{padding:2px;(\s)*color:orange;}/ + ) + } + + // check if rsc refresh script for suspense show up, the test content could change with react version + if (refreshScriptIndex === -1) { + refreshScriptIndex = result.search(/\$RC=function|\$RC\(/) + } + + if (fallbackIndex === -1) { + fallbackIndex = result.indexOf('$test-fallback-sentinel') + } + } + ) + + expect(fallbackIndex).toBeGreaterThanOrEqual(0) + expect(dataIndex).toBeGreaterThan(fallbackIndex) + expect(refreshScriptIndex).toBeGreaterThanOrEqual(0) }) // // TODO-APP: fix streaming/suspense within browser for test suite // const browser = await next.browser( '/css-in-js', { waitHydration: false }) diff --git a/test/use-node-streams-tests-manifest.json b/test/use-node-streams-tests-manifest.json deleted file mode 100644 index 46c1c103b37e..000000000000 --- a/test/use-node-streams-tests-manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 2, - "suites": {}, - "rules": { - "include": [ - "test/e2e/**/*.test.{t,j}s{,x}", - "test/production/app-*/**/*.test.{t,j}s{,x}", - "test/development/app-*/**/*.test.{t,j}s{,x}", - "test/development/acceptance-app/**/*.test.{t,j}s{,x}" - ], - "exclude": [] - } -} From cc4b061de5e7019e492bd71a6d3504fafd8020bb Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 1 Jun 2026 20:30:59 +0200 Subject: [PATCH 2/4] Preserve Node streams selection across env reloads --- packages/next/src/server/config.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index bf62f63637f3..937c481487eb 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -1627,11 +1627,19 @@ function finalizeConfig(config: NextConfigComplete): NextConfigComplete { function syncUseNodeStreamsEnv(config: NextConfig): void { // This must use resolved config: user configs are inspected before defaults // are merged, while runtime bundles must select the default implementation. - if (config.experimental?.useNodeStreams) { - process.env.__NEXT_USE_NODE_STREAMS = 'true' + const useNodeStreams = config.experimental?.useNodeStreams + ? 'true' + : undefined + + if (useNodeStreams) { + process.env.__NEXT_USE_NODE_STREAMS = useNodeStreams } else { delete process.env.__NEXT_USE_NODE_STREAMS } + + // Dev env reloads restore process.env from this snapshot. Preserve the + // resolved runtime selection so a reload cannot mix stream implementations. + updateInitialEnv({ __NEXT_USE_NODE_STREAMS: useNodeStreams }) } async function applyModifyConfig( From a7060543dc82cf987b1c0a39eb441b8f6e84f057 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 2 Jun 2026 09:18:52 +0200 Subject: [PATCH 3/4] Preserve unset values during env restoration --- packages/next-env/index.ts | 12 +++++++++++- test/unit/preserve-process-env.test.ts | 22 +++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/next-env/index.ts b/packages/next-env/index.ts index 392670c7817e..5b34e68be666 100644 --- a/packages/next-env/index.ts +++ b/packages/next-env/index.ts @@ -18,7 +18,17 @@ let cachedLoadedEnvFiles: LoadedEnvFiles = [] let previousLoadedEnvFiles: LoadedEnvFiles = [] export function updateInitialEnv(newEnv: Env) { - Object.assign(initialEnv || {}, newEnv) + if (!initialEnv) { + return + } + + for (const [key, value] of Object.entries(newEnv)) { + if (value === undefined) { + delete initialEnv[key] + } else { + initialEnv[key] = value + } + } } type Log = { diff --git a/test/unit/preserve-process-env.test.ts b/test/unit/preserve-process-env.test.ts index cbddc233040e..174ea9f764d2 100644 --- a/test/unit/preserve-process-env.test.ts +++ b/test/unit/preserve-process-env.test.ts @@ -1,4 +1,8 @@ -import { loadEnvConfig } from '../../packages/next-env/' +import { + loadEnvConfig, + resetEnv, + updateInitialEnv, +} from '../../packages/next-env/' describe('preserve process env', () => { it('should not reassign `process.env`', () => { @@ -6,4 +10,20 @@ describe('preserve process env', () => { loadEnvConfig('.') expect(Object.is(originalProcessEnv, process.env)).toBeTrue() }) + + it('should remove values unset in the initial env snapshot', () => { + const key = '__NEXT_TEST_UNSET_INITIAL_ENV' + + try { + loadEnvConfig('.') + process.env[key] = 'changed' + updateInitialEnv({ [key]: undefined }) + + resetEnv() + + expect(process.env[key]).toBeUndefined() + } finally { + delete process.env[key] + } + }) }) From 097ed49a15b01c78298b3279121630e29a44ca88 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 2 Jun 2026 15:38:25 +0200 Subject: [PATCH 4/4] Move Node stream assertion updates to separate PR --- .../ppr-root-param-fallback.test.ts | 20 ++------- .../use-server-inserted-html.test.ts | 43 +++++++------------ 2 files changed, 20 insertions(+), 43 deletions(-) diff --git a/test/e2e/app-dir/ppr-root-param-fallback/ppr-root-param-fallback.test.ts b/test/e2e/app-dir/ppr-root-param-fallback/ppr-root-param-fallback.test.ts index 7a5c5857a0a7..e29b7fd25747 100644 --- a/test/e2e/app-dir/ppr-root-param-fallback/ppr-root-param-fallback.test.ts +++ b/test/e2e/app-dir/ppr-root-param-fallback/ppr-root-param-fallback.test.ts @@ -14,29 +14,17 @@ describe('ppr-root-param-fallback', () => { // filled in for all pregenerated locales. for (const locale of ['en', 'fr']) { + // next.render$ doesn't stream, so we get just the shell content const $ = await next.render$(`/${locale}/blog/new-post`) - const html = $.html() // The shell should have the locale-header with cached content, - // even if Node streams flush the Suspense fallback before the resolved - // content that replaces it. + // NOT the locale-loading Suspense fallback expect($('#locale-header').length).toBe(1) expect($('#locale-header').text()).toContain(`Locale: ${locale}`) expect($('#translations').text()).toContain(`Home (${locale})`) - const localeLoading = $('#locale-loading') - if (localeLoading.length > 0) { - const fallbackTemplateId = localeLoading.prev('template').attr('id') - const resolvedContentId = $('#locale-header') - .parent('div[hidden]') - .attr('id') - - expect(fallbackTemplateId).toBeTruthy() - expect(resolvedContentId).toBeTruthy() - expect(html).toContain( - `$RC("${fallbackTemplateId}","${resolvedContentId}")` - ) - } + // The Suspense fallback should NOT be in the shell + expect($('#locale-loading').length).toBe(0) } }) }) diff --git a/test/e2e/app-dir/use-server-inserted-html/use-server-inserted-html.test.ts b/test/e2e/app-dir/use-server-inserted-html/use-server-inserted-html.test.ts index 5509187f5684..8dc604279a67 100644 --- a/test/e2e/app-dir/use-server-inserted-html/use-server-inserted-html.test.ts +++ b/test/e2e/app-dir/use-server-inserted-html/use-server-inserted-html.test.ts @@ -49,33 +49,22 @@ describe('use-server-inserted-html', () => { it('should render css-in-js suspense boundary correctly', async () => { await next.fetch('/css-in-js/suspense').then(async (response) => { - let fallbackIndex = -1 - let dataIndex = -1 - let refreshScriptIndex = -1 - - await resolveStreamResponse( - response, - (_chunk: string, result: string) => { - if (dataIndex === -1) { - dataIndex = result.search( - /]*>(\s)*.+{padding:2px;(\s)*color:orange;}/ - ) - } - - // check if rsc refresh script for suspense show up, the test content could change with react version - if (refreshScriptIndex === -1) { - refreshScriptIndex = result.search(/\$RC=function|\$RC\(/) - } - - if (fallbackIndex === -1) { - fallbackIndex = result.indexOf('$test-fallback-sentinel') - } - } - ) - - expect(fallbackIndex).toBeGreaterThanOrEqual(0) - expect(dataIndex).toBeGreaterThan(fallbackIndex) - expect(refreshScriptIndex).toBeGreaterThanOrEqual(0) + const results = [] + + await resolveStreamResponse(response, (chunk: string) => { + const isSuspenseyDataResolved = + /]*>(\s)*.+{padding:2px;(\s)*color:orange;}/.test(chunk) + if (isSuspenseyDataResolved) results.push('data') + + // check if rsc refresh script for suspense show up, the test content could change with react version + const hasRCScript = /\$RC=function/.test(chunk) + if (hasRCScript) results.push('refresh-script') + + const isFallbackResolved = chunk.includes('$test-fallback-sentinel') + if (isFallbackResolved) results.push('fallback') + }) + + expect(results).toEqual(['fallback', 'data', 'refresh-script']) }) // // TODO-APP: fix streaming/suspense within browser for test suite // const browser = await next.browser( '/css-in-js', { waitHydration: false })