From 6e894d05112e0bb255bc765121f4799d3c698bd0 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Wed, 20 May 2026 21:29:22 +0200 Subject: [PATCH 1/9] Turbopack: respect the module-sync export condition (#93970) I set it to `::Unknown` because we the runtime may or may not support module-sync. So let's just trace both for now There is one problem though: https://nodejs.org/docs/latest-v26.x/api/packages.html#conditional-exports > "module-sync" - matches no matter the package is loaded via import, import() or require(). The format is expected to be ES modules that does not contain top-level await in its module graph - if it does, ERR_REQUIRE_ASYNC_MODULE will be thrown when the module is require()-ed. So we have multiple options: 1. We definitely want to respect module-sync for tracing. We don't really have to throw ERR_REQUIRE_ASYNC_MODULE here, Node.js will do it at runtime anyway. 2. ~~What about bundling? Either~~ - ~~Respect module-sync always, but don't throw ERR_REQUIRE_ASYNC_MODULE.~~ - ~~Respect module-sync always, and do throw a build error the module requires TLA. This would require a separate validation pass though.~~ - I'm not enabling it for bundling for now --- .../crates/turbopack-core/src/resolve/options.rs | 5 ++++- turbopack/crates/turbopack-nft/src/nft.rs | 2 ++ .../crates/turbopack-resolve/src/ecmascript.rs | 6 ++++++ .../crates/turbopack-resolve/src/resolve.rs | 3 +++ .../src/resolve_options_context.rs | 4 +++- .../turbopack-tracing/tests/node-file-trace.rs | 2 ++ .../test/unit/imports-module-sync/output.js | 3 ++- .../module-sync-condition-es-nested/output.js | 3 ++- .../test/unit/module-sync-condition-es/output.js | 3 ++- .../unit/self-reference-module-sync/output.js | 3 ++- turbopack/crates/turbopack-tracing/tests/unit.rs | 16 ++++++++++------ turbopack/crates/turbopack/src/lib.rs | 8 ++++++-- 12 files changed, 44 insertions(+), 14 deletions(-) diff --git a/turbopack/crates/turbopack-core/src/resolve/options.rs b/turbopack/crates/turbopack-core/src/resolve/options.rs index a22e5bae8960..1309e5a8a295 100644 --- a/turbopack/crates/turbopack-core/src/resolve/options.rs +++ b/turbopack/crates/turbopack-core/src/resolve/options.rs @@ -39,9 +39,12 @@ pub enum ResolveModules { }, } -#[derive(TraceRawVcs, Hash, PartialEq, Eq, Clone, Copy, Debug, NonLocalValue, Encode, Decode)] +#[derive( + TraceRawVcs, Hash, PartialEq, Eq, Clone, Copy, Debug, NonLocalValue, Encode, Decode, Default, +)] pub enum ConditionValue { Set, + #[default] Unset, Unknown, } diff --git a/turbopack/crates/turbopack-nft/src/nft.rs b/turbopack/crates/turbopack-nft/src/nft.rs index 56ff4740bc8a..09710e89d483 100644 --- a/turbopack/crates/turbopack-nft/src/nft.rs +++ b/turbopack/crates/turbopack-nft/src/nft.rs @@ -22,6 +22,7 @@ use turbopack_core::{ output::{OutputAsset, OutputAssetsReference}, reference::all_assets_from_entries, reference_type::ReferenceType, + resolve::options::ConditionValue, traced_asset::TracedAsset, }; use turbopack_ecmascript::AnalyzeMode; @@ -109,6 +110,7 @@ async fn node_file_trace_operation( enable_node_native_modules: true, enable_node_modules: Some(input_dir), custom_conditions: vec![rcstr!("node")], + module_sync: ConditionValue::Unknown, enable_node_externals: true, loose_errors: true, collect_affecting_sources: true, diff --git a/turbopack/crates/turbopack-resolve/src/ecmascript.rs b/turbopack/crates/turbopack-resolve/src/ecmascript.rs index 9e5c4fc720c8..7da64126bcfc 100644 --- a/turbopack/crates/turbopack-resolve/src/ecmascript.rs +++ b/turbopack/crates/turbopack-resolve/src/ecmascript.rs @@ -66,6 +66,9 @@ async fn apply_esm_specific_options_internal( for conditions in get_condition_maps(&mut options) { conditions.insert(rcstr!("import"), ConditionValue::Set); conditions.insert(rcstr!("require"), ConditionValue::Unset); + // Don't set "module-sync" to ConditionValue::Set here. When tracing, the Node.js runtime + // version might not support it yet, so we still want the "import"/"require"/"default" + // result anyway. } if clear_extensions { @@ -83,6 +86,9 @@ pub async fn apply_cjs_specific_options(options: Vc) -> Result, pub custom_extensions: Option>, /// An additional import map to use when resolving modules. diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace.rs b/turbopack/crates/turbopack-tracing/tests/node-file-trace.rs index 9a5ec0125585..8823d7e25591 100644 --- a/turbopack/crates/turbopack-tracing/tests/node-file-trace.rs +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace.rs @@ -51,6 +51,7 @@ use turbopack_core::{ rebase::RebasedAsset, reference::all_assets_from_entry, reference_type::ReferenceType, + resolve::options::ConditionValue, }; use turbopack_ecmascript::AnalyzeMode; use turbopack_resolve::resolve_options_context::ResolveOptionsContext; @@ -414,6 +415,7 @@ async fn node_file_trace_operation( enable_node_native_modules: true, enable_node_modules: Some(input_dir.clone()), custom_conditions: vec![rcstr!("node")], + module_sync: ConditionValue::Unknown, collect_affecting_sources: true, ..Default::default() } diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/imports-module-sync/output.js b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/imports-module-sync/output.js index a19128185eee..2991cbcddbea 100644 --- a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/imports-module-sync/output.js +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/imports-module-sync/output.js @@ -1,6 +1,7 @@ ;[ 'test/unit/imports-module-sync/input.js', - 'test/unit/imports-module-sync/internal-default.js', + // @vercel/nft outputs internal-default.js here instead, but I think that's wrong? + 'test/unit/imports-module-sync/internal-import.js', 'test/unit/imports-module-sync/internal-sync.js', 'test/unit/imports-module-sync/package.json', ] diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-es-nested/output.js b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-es-nested/output.js index 56dafdbc009b..0f847c2004b0 100644 --- a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-es-nested/output.js +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-es-nested/output.js @@ -1,5 +1,6 @@ ;[ - 'test/unit/module-sync-condition-es-nested/fallback.js', + // @vercel/nft outputs fallback.js here instead, but I think that's wrong? + 'test/unit/module-sync-condition-es-nested/import.js', 'test/unit/module-sync-condition-es-nested/input.js', 'test/unit/module-sync-condition-es-nested/module-sync.js', 'test/unit/module-sync-condition-es-nested/package.json', diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-es/output.js b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-es/output.js index 6b86a3380efc..a778d0c36077 100644 --- a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-es/output.js +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-es/output.js @@ -1,5 +1,6 @@ ;[ - 'test/unit/module-sync-condition-es/fallback.js', + // @vercel/nft outputs fallback.js here instead, but I think that's wrong? + 'test/unit/module-sync-condition-es/import.js', 'test/unit/module-sync-condition-es/input.js', 'test/unit/module-sync-condition-es/module-sync.js', 'test/unit/module-sync-condition-es/package.json', diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/self-reference-module-sync/output.js b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/self-reference-module-sync/output.js index 63c2e06f85be..0c571c8a3d7e 100644 --- a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/self-reference-module-sync/output.js +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/self-reference-module-sync/output.js @@ -1,5 +1,6 @@ ;[ - 'test/unit/self-reference-module-sync/default.js', + // @vercel/nft outputs default.js here instead, but I think that's wrong? + 'test/unit/self-reference-module-sync/import.js', 'test/unit/self-reference-module-sync/input.js', 'test/unit/self-reference-module-sync/module-sync.js', 'test/unit/self-reference-module-sync/package.json', diff --git a/turbopack/crates/turbopack-tracing/tests/unit.rs b/turbopack/crates/turbopack-tracing/tests/unit.rs index 9ffe936e6344..0d9424e6ae09 100644 --- a/turbopack/crates/turbopack-tracing/tests/unit.rs +++ b/turbopack/crates/turbopack-tracing/tests/unit.rs @@ -30,6 +30,7 @@ use turbopack_core::{ output::OutputAsset, reference::all_assets_from_entries, reference_type::ReferenceType, + resolve::options::ConditionValue, traced_asset::TracedAsset, }; use turbopack_ecmascript::AnalyzeMode; @@ -108,8 +109,8 @@ static ALLOC: turbo_tasks_malloc::TurboMalloc = turbo_tasks_malloc::TurboMalloc; // #[case::import_meta_tpl_cnd("import-meta-tpl-cnd")] #[case::import_meta_url("import-meta-url")] // #[case::imports("imports")] -// #[case::imports_module_sync("imports-module-sync")] -// #[case::imports_module_sync_cjs("imports-module-sync-cjs")] +#[case::imports_module_sync("imports-module-sync")] +#[case::imports_module_sync_cjs("imports-module-sync-cjs")] // #[case::jsonc_parser_wrapper("jsonc-parser-wrapper")] // #[case::jsx_input("jsx-input")] // #[case::microtime_node_gyp("microtime-node-gyp")] @@ -123,10 +124,12 @@ static ALLOC: turbo_tasks_malloc::TurboMalloc = turbo_tasks_malloc::TurboMalloc; #[case::module_create_require_no_mixed("module-create-require-no-mixed")] // #[case::module_register("module-register")] // #[case::module_require("module-require")] -// #[case::module_sync_condition_cjs("module-sync-condition-cjs")] +#[case::module_sync_condition_cjs("module-sync-condition-cjs")] +// Turbopack always includes the module-sync version, regardless of the current Node version // #[case::module_sync_condition_cjs_node20("module-sync-condition-cjs-node20")] -// #[case::module_sync_condition_es("module-sync-condition-es")] -// #[case::module_sync_condition_es_nested("module-sync-condition-es-nested")] +#[case::module_sync_condition_es("module-sync-condition-es")] +#[case::module_sync_condition_es_nested("module-sync-condition-es-nested")] +// Turbopack always includes the module-sync version, regardless of the current Node version // #[case::module_sync_condition_es_node20("module-sync-condition-es-node20")] // #[case::mongoose("mongoose")] // #[case::multi_input("multi-input")] @@ -165,7 +168,7 @@ static ALLOC: turbo_tasks_malloc::TurboMalloc = turbo_tasks_malloc::TurboMalloc; // #[case::resolve_from("resolve-from")] // #[case::resolve_hook("resolve-hook")] // #[case::return_emission("return-emission")] -// #[case::self_reference_module_sync("self-reference-module-sync")] +#[case::self_reference_module_sync("self-reference-module-sync")] // #[case::shiki("shiki")] // #[case::string_concat("string-concat")] #[case::syntax_err("syntax-err")] @@ -250,6 +253,7 @@ async fn node_file_trace_operation(package_root: RcStr, input: RcStr) -> Result< enable_node_native_modules: true, enable_node_modules: Some(input_dir.clone()), custom_conditions: vec![rcstr!("node")], + module_sync: ConditionValue::Unknown, ..Default::default() } .cell(), diff --git a/turbopack/crates/turbopack/src/lib.rs b/turbopack/crates/turbopack/src/lib.rs index b7849383000c..451b4c847ba7 100644 --- a/turbopack/crates/turbopack/src/lib.rs +++ b/turbopack/crates/turbopack/src/lib.rs @@ -36,8 +36,11 @@ use turbopack_core::{ }, resolve::{ ExternalTraced, ExternalType, ModulePart, ModuleResolveResult, ModuleResolveResultItem, - ResolveResult, ResolveResultItem, options::ResolveOptions, origin::PlainResolveOrigin, - parse::Request, resolve, + ResolveResult, ResolveResultItem, + options::{ConditionValue, ResolveOptions}, + origin::PlainResolveOrigin, + parse::Request, + resolve, }, source::Source, source_transform::SourceTransforms, @@ -926,6 +929,7 @@ pub async fn externals_tracing_module_context( loose_errors: true, collect_affecting_sources: true, custom_conditions: vec![rcstr!("node")], + module_sync: ConditionValue::Unknown, ..Default::default() }; From 108e9652d866e362f45b834b285cf62fbebd036f Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Wed, 20 May 2026 14:13:46 -0700 Subject: [PATCH 2/9] use the action cache for passing tests (#93954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Memoize passing tests across attempts of the same workflow run, so a re-attempt after a timeout or cancellation can skip what already passed. ## What changed - `run-tests.js` appends each passing test's filename to `$NEXT_TEST_PASSED_FILE` as it finishes (sync `write` + `fsync`, fd kept open across calls). On startup it reads the same file and skips any entries that match. - `build_reusable.yml` wraps the test step with `actions/cache/restore` (before) and `actions/cache/save` (after, with `if: always()`). The save step runs on success, failure, and cancel — which is the whole point: a cancelled attempt still gets to preserve its progress for retry. - Cache key is scoped to `${input_step_key}-${run_id}-attempt${run_attempt}`, with `restore-keys` falling back to any earlier attempt of the same run. The previous implementation wrote a single Turbo-remote-cache blob *after* `Promise.allSettled` resolved — so a cancellation (like [run 26127371192](https://github.com/vercel/next.js/actions/runs/26127371192/job/76845288867)) lost every passed test from that attempt. This also removes one dependency on the turbo-remote cache (there are still two more uses of `scripts/turbo-cache.mjs`) --- .github/workflows/build_reusable.yml | 22 +++++ run-tests.js | 130 +++++++++++++-------------- 2 files changed, 84 insertions(+), 68 deletions(-) diff --git a/.github/workflows/build_reusable.yml b/.github/workflows/build_reusable.yml index c4a1cf15b136..83b583f0ed78 100644 --- a/.github/workflows/build_reusable.yml +++ b/.github/workflows/build_reusable.yml @@ -128,6 +128,8 @@ env: KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }} KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }} NEXT_TEST_JOB: 1 + # Per-job memoization of passed test files. + NEXT_TEST_PASSED_FILE: .next-test-passed.txt VERCEL_TEST_TOKEN: ${{ secrets.VERCEL_TEST_TOKEN }} VERCEL_TEST_TEAM: vtest314-next-e2e-tests VERCEL_ADAPTER_TEST_TOKEN: ${{ secrets.VERCEL_ADAPTER_TEST_TOKEN }} @@ -371,6 +373,17 @@ jobs: if: ${{ inputs.testTimingsArtifact == '' }} run: pnpm dlx turbo@${TURBO_VERSION} run get-test-timings -- --build ${{ github.sha }} + # Restore the list of tests that already passed in an earlier + # attempt of this same workflow run. + - name: Restore passed-tests cache + if: ${{ inputs.afterBuild && runner.environment == 'github-hosted' }} + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ env.NEXT_TEST_PASSED_FILE }} + key: next-test-passed-${{ steps.var.outputs.input_step_key }}-${{ github.run_id }}-attempt${{ github.run_attempt }} + restore-keys: | + next-test-passed-${{ steps.var.outputs.input_step_key }}-${{ github.run_id }}- + - run: ${{ inputs.afterBuild }} # defaults.run.shell sets a stronger options (`-leo pipefail`) # Set this back to github action's weaker defaults: @@ -380,6 +393,15 @@ jobs: shell: bash -le {0} timeout-minutes: ${{ inputs.timeout_minutes }} + # Save the passed-tests file so the next attempt (if any) can skip + # them. `always()` makes this run on success, failure, or cancellation. + - name: Save passed-tests cache + if: ${{ always() && inputs.afterBuild && runner.environment == 'github-hosted' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ env.NEXT_TEST_PASSED_FILE }} + key: next-test-passed-${{ steps.var.outputs.input_step_key }}-${{ github.run_id }}-attempt${{ github.run_attempt }} + - name: Upload test result artifacts if: ${{ inputs.testReportsArtifactPrefix != '' && inputs.afterBuild && always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/run-tests.js b/run-tests.js index bee42baf2a65..b8dd2ead2957 100644 --- a/run-tests.js +++ b/run-tests.js @@ -2,11 +2,10 @@ const path = require('path') const _glob = require('glob') -const { existsSync } = require('fs') +const fs = require('fs') const fsp = require('fs/promises') const { createClient } = require('@vercel/kv') const { promisify } = require('util') -const { createHash } = require('crypto') const { Sema } = require('async-sema') const { spawn, exec: execOrig } = require('child_process') const { createNextInstall } = require('./test/lib/create-next-install') @@ -16,8 +15,9 @@ const core = require('@actions/core') const { getTestFilter } = require('./test/get-test-filter') const { checkBuildFreshness } = require('./test/lib/check-build-freshness') -// --- Test profile and result caching via turbo remote cache --- +// --- Test profile and result caching via actions cache --- // On CI retry attempts, skip tests that already passed on this commit. +// The file contains null-byte delimited filenames class TestProfile { // Env vars that always affect test behavior (non-NEXT prefixed). @@ -39,20 +39,21 @@ class TestProfile { 'NEXT_CI_RUNNER', 'NEXT_E2E_TEST_TIMEOUT', 'NEXT_TURBOPACK_IO_CONCURRENCY', + 'NEXT_TEST_PASSED_FILE', ]) - // All key=value pairs that form the cache identity, sorted by key. - // Computed once at construction from a snapshot of process.env. + // All key=value pairs identifying this test profile, sorted by key. + // Used for the diagnostic `log()` output. Computed once at construction + // from a snapshot of process.env. The actual cache key for the workflow + // is `input_step_key` (see `.github/workflows/build_reusable.yml`). entries // NEXT_*/__NEXT* vars that matched the pattern but were ignored. ignoredVars cachingEnabled - #cacheKey constructor({ group = '', type = '', testPattern = '' } = {}) { this.cachingEnabled = !!( process.env.CI && - process.env.TURBO_TOKEN && process.env.GITHUB_SHA && !process.env.NEXT_FLAKE_DETECTION && !process.env.NEXT_TEST_SKIP_RESULT_CACHE @@ -102,18 +103,6 @@ class TestProfile { this.entries = [...map.entries()] } - get cacheKey() { - if (!this.#cacheKey) { - const hash = createHash('sha256') - hash.update('test-result-v2\0') - for (const [k, v] of this.entries) { - hash.update(`${k}=${v}\0`) - } - this.#cacheKey = hash.digest('hex') - } - return this.#cacheKey - } - get description() { return this.entries .map(([k, v]) => (k === 'sha' ? `sha=${v?.slice(0, 10)}` : `${k}=${v}`)) @@ -132,44 +121,28 @@ class TestProfile { console.log('') } - // --- Cache operations --- - - #turboCache = null - async #getTurboCache() { - if (this.#turboCache) return this.#turboCache - if (!this.cachingEnabled) return null - try { - this.#turboCache = await import('./scripts/turbo-cache.mjs') - return this.#turboCache - } catch { - return null - } - } - - async loadPassedTests() { + // Read the passed-tests file (restored from cache by the workflow + // before this script runs). Returns a Set of test filenames. Empty + // Set on miss / parse error — best-effort by design. + loadPassedTests() { + if (!this.cachingEnabled) return new Set() + const file = process.env.NEXT_TEST_PASSED_FILE + if (!file) return new Set() try { - const cache = await this.#getTurboCache() - if (!cache) return new Set() - const data = await cache.get(this.cacheKey) - if (!data) return new Set() - const parsed = JSON.parse(data.toString()) - return new Set(parsed.passed || []) - } catch { + const data = fs.readFileSync(file, 'utf8') + // Tolerate a partial trailing line from a hard kill mid-append by + // requiring an explicit '\0' terminator. Lines without it are + // dropped. + const lines = data.split('\0') + return new Set(data.endsWith('\0') ? lines : lines.slice(0, -1)) + } catch (err) { + // ENOENT is the normal "no prior attempt" path — silent. + if (err && err.code !== 'ENOENT') { + console.log(`Test result cache: failed to load (${err.message})`) + } return new Set() } } - - async savePassedTests(passedFiles) { - try { - const cache = await this.#getTurboCache() - if (!cache) return false - const payload = JSON.stringify({ passed: [...passedFiles].sort() }) - await cache.put(this.cacheKey, Buffer.from(payload)) - return true - } catch { - return false - } - } } // Do not rename or format. sync-react script relies on this line. @@ -849,21 +822,46 @@ ${ENDGROUP}`) const isRetryAttempt = process.env.CI && parseInt(process.env.GITHUB_RUN_ATTEMPT || '1', 10) > 1 - const passedTestFiles = new Set() - // On retry, load previously-passed tests and filter them out + // Load tests that already passed (either on this attempt — restored + // from cache by the workflow — or after a timeout/cancel of an earlier + // attempt). The workflow's `actions/cache/restore` step lands the file + // at the path `loadPassedTests` reads. let cachedPassedTests = new Set() - if (isRetryAttempt) { + if (process.env.CI) { cachedPassedTests = await profile.loadPassedTests() if (cachedPassedTests.size > 0) { console.log( `Test result cache: loaded ${cachedPassedTests.size} passed test(s) (${profile.description})` ) - } else { + } else if (isRetryAttempt) { console.log(`Test result cache: miss (${profile.description})`) } } + // Stream passing test names to this file. + /** @type {number | null} */ + let passedTestsFd = null + if (profile.cachingEnabled) { + try { + passedTestsFd = fs.openSync(process.env.NEXT_TEST_PASSED_FILE, 'a') + } catch (err) { + console.log(`Test result cache: open failed (${err.message})`) + } + } + /** @param {string} file */ + const recordPassed = (file) => { + if (passedTestsFd === null) return + try { + // Ensure durability of our writes by syncing each one. + // This is a tiny bit of overhead but shouldn't really matter. + fs.writeSync(passedTestsFd, `${file}\0`) + fs.fdatasyncSync(passedTestsFd) + } catch (err) { + console.log(`Test result cache: append failed (${err.message})`) + } + } + const runTest = async (/** @type {TestFile} */ test) => { // On CI retry attempts, skip tests that already passed on this commit if (cachedPassedTests.has(test.file)) { @@ -909,7 +907,7 @@ ${ENDGROUP}`) } if (passed) { - passedTestFiles.add(test.file) + recordPassed(test.file) } if (!passed) { @@ -989,16 +987,12 @@ ${ENDGROUP}`) } } - // Save all passed tests (from this run + previously cached) as a single cache entry - if (process.env.CI && passedTestFiles.size > 0) { - const allPassed = new Set([...cachedPassedTests, ...passedTestFiles]) - const saved = await profile.savePassedTests(allPassed) - if (saved) { - console.log( - `Test result cache: saved ${allPassed.size} passed test(s) (${profile.description})` - ) - } + if (passedTestsFd !== null) { + try { + fs.closeSync(passedTestsFd) + } catch {} } + if (cachedPassedTests.size > 0) { const skipped = tests.filter((t) => cachedPassedTests.has(t.file)).length if (skipped > 0) { @@ -1047,7 +1041,7 @@ ${ENDGROUP}`) // Clean up stale timings for deleted tests for (const test of Object.keys(newTimings)) { - if (!existsSync(path.join(__dirname, test))) { + if (!fs.existsSync(path.join(__dirname, test))) { console.log('removing stale timing', test) delete newTimings[test] } From 56825e5d1200b09c65bd315902754637d6988d0c Mon Sep 17 00:00:00 2001 From: Will Binns-Smith Date: Wed, 20 May 2026 14:27:14 -0700 Subject: [PATCH 3/9] devlow-bench: percentile-based comparison and run retries (#93950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A few changes to devlow-bench so the comparison output is easier to read and so a couple of flaky page-load runs don't quietly poison the stats. **compare** - Show p50 / p90 / p99 instead of mean / p50 / p90, with Δ p50 and a single Mann–Whitney p-value (Welch's t-test and Δ mean are gone — the mean was dragging on bad runs). - Hoist the modal sample count into an `n = 7 per metric` banner so only the outlier rows carry `(n)`. **runner** - Each attempt's measurements are buffered locally and only merged on success. Failed runs are retried (capped at 2× warmup+n) until we have a clean n samples. - Per-variant retry line plus an end-of-run summary noting which variants recovered and which fell short. **browser** - `hardNavigation` / `reload` now throw when the navigation response is missing or non-2xx. Previously a `200`-committed-but-broken page was being recorded as a real sample. The trigger was a run where 2 of 7 cold-build attempts hit an error page, dragging the mean response size from 45 MB to 33 MB while the p50 was unchanged. --- .../packages/devlow-bench/src/browser.ts | 18 ++- turbopack/packages/devlow-bench/src/cli.ts | 2 +- .../packages/devlow-bench/src/compare.ts | 104 ++++++++++---- turbopack/packages/devlow-bench/src/runner.ts | 130 ++++++++++++++---- 4 files changed, 193 insertions(+), 61 deletions(-) diff --git a/turbopack/packages/devlow-bench/src/browser.ts b/turbopack/packages/devlow-bench/src/browser.ts index 74e4910e24af..6d2e6a1d3544 100644 --- a/turbopack/packages/devlow-bench/src/browser.ts +++ b/turbopack/packages/devlow-bench/src/browser.ts @@ -259,9 +259,17 @@ class BrowserSessionImpl implements BrowserSession { await withRequestMetrics(metricName, page, async () => { await measureTime(`${metricName}/start`) const idle = networkIdle(page, 3000) - await page.goto(url, { + const response = await page.goto(url, { waitUntil: 'commit', }) + if (!response) { + throw new Error(`Navigation to ${url} produced no response`) + } + if (!response.ok()) { + throw new Error( + `Navigation to ${url} returned HTTP ${response.status()}` + ) + } await measureTime(`${metricName}/html`, { relativeTo: `${metricName}/start`, }) @@ -318,9 +326,15 @@ class BrowserSessionImpl implements BrowserSession { await withRequestMetrics(metricName, page, async () => { await measureTime(`${metricName}/start`) const idle = networkIdle(page, 3000) - await page.reload({ + const response = await page.reload({ waitUntil: 'commit', }) + if (!response) { + throw new Error('Reload produced no response') + } + if (!response.ok()) { + throw new Error(`Reload returned HTTP ${response.status()}`) + } await measureTime(`${metricName}/html`, { relativeTo: `${metricName}/start`, }) diff --git a/turbopack/packages/devlow-bench/src/cli.ts b/turbopack/packages/devlow-bench/src/cli.ts index 53f1743df0f8..c19006b36633 100644 --- a/turbopack/packages/devlow-bench/src/cli.ts +++ b/turbopack/packages/devlow-bench/src/cli.ts @@ -35,7 +35,7 @@ async function runCompareSubcommand(argv: string[]): Promise { if (args.help || args._.length === 0) { console.log(`Usage: devlow-bench compare Reads both snapshot CSVs and prints a side-by-side comparison table - with mean/p50/p90 plus Welch’s t and Mann–Whitney U p-values per metric.`) + with p50/p90/p99 plus a Mann–Whitney U p-value per metric.`) if (args.help) return } if (args._.length !== 2) { diff --git a/turbopack/packages/devlow-bench/src/compare.ts b/turbopack/packages/devlow-bench/src/compare.ts index 8dd2f3e1048b..88c5bc9efb1d 100644 --- a/turbopack/packages/devlow-bench/src/compare.ts +++ b/turbopack/packages/devlow-bench/src/compare.ts @@ -1,6 +1,6 @@ import picocolors from 'picocolors' import { SnapshotRow } from './snapshot.js' -import { mannWhitneyU, summary, welchsTTest } from './statistics.js' +import { mannWhitneyU, quantile } from './statistics.js' import { formatUnit } from './units.js' const { bold, dim, green, red, underline } = picocolors @@ -72,7 +72,7 @@ export function printComparison( console.log(bold(underline(`Comparison vs baseline: ${baselineLabel}`))) } - const rows: FormattedRow[] = [] + const pairs: { base: SampleGroup; cur: SampleGroup }[] = [] const onlyInCurrent: string[] = [] const onlyInBaseline: string[] = [] const skippedMismatch: string[] = [] @@ -89,7 +89,7 @@ export function printComparison( ) continue } - rows.push(formatRow(base, cur)) + pairs.push({ base, cur }) } for (const [key, base] of baseline) { if (!current.has(key)) { @@ -97,17 +97,32 @@ export function printComparison( } } - if (rows.length > 0) { + // Hoist the most common sample count for each side into a banner above the + // table; per-row `(n)` is then shown only for rows that deviate from it, + // so the eye-catching exceptions aren't drowned in repeated `(7)`s. + const baseMode = modeN(pairs.map((p) => p.base.samples.length)) + const curMode = modeN(pairs.map((p) => p.cur.samples.length)) + + if (pairs.length > 0) { + if (baseMode != null && curMode != null) { + const banner = + baseMode === curMode + ? `n = ${baseMode} per metric` + : `baseline n = ${baseMode}, current n = ${curMode}` + console.log(bold(banner)) + } const header = [ 'Scenario · Variant', 'Metric', - 'Baseline μ / p50 / p90 (n)', - 'Current μ / p50 / p90 (n)', - 'Δ mean', + 'Baseline p50 / p90 / p99', + 'Current p50 / p90 / p99', + 'Δ p50', 'Δ%', - "Welch's p", - 'MWU p', + 'p', ] + const rows = pairs.map(({ base, cur }) => + formatRow(base, cur, baseMode, curMode) + ) printTable(header, rows) } else { console.log( @@ -129,54 +144,85 @@ function logList(label: string, items: string[], max: number = 5): void { console.log(dim(`${label} (${items.length}): ${shown}${suffix}`)) } -function formatRow(base: SampleGroup, cur: SampleGroup): FormattedRow { - const bs = summary(base.samples) - const cs = summary(cur.samples) - const delta = cs.mean - bs.mean - const deltaPct = bs.mean === 0 ? NaN : (delta / bs.mean) * 100 - const welchs = welchsTTest(base.samples, cur.samples) +// Picks the most common n in the list. Ties are broken in favor of the +// larger n (treat "the typical fully-collected sample count" as the default). +// Returns null for an empty list. +function modeN(ns: number[]): number | null { + if (ns.length === 0) return null + const counts = new Map() + for (const n of ns) counts.set(n, (counts.get(n) ?? 0) + 1) + let bestN = ns[0] + let bestCount = 0 + for (const [n, c] of counts) { + if (c > bestCount || (c === bestCount && n > bestN)) { + bestN = n + bestCount = c + } + } + return bestN +} + +function formatRow( + base: SampleGroup, + cur: SampleGroup, + baseMode: number | null, + curMode: number | null +): FormattedRow { + const bs = percentiles(base.samples) + const cs = percentiles(cur.samples) + const delta = cs.p50 - bs.p50 + const deltaPct = bs.p50 === 0 ? NaN : (delta / bs.p50) * 100 const mwu = mannWhitneyU(base.samples, cur.samples) - // Color verdict uses Welch's p as the primary signal. + // Color verdict uses Mann–Whitney U's p as the significance signal, with + // the sign of the p50 shift determining improvement vs regression. let verdict: RowVerdict - if ( - Number.isFinite(welchs.p) && - welchs.p < SIGNIFICANCE_THRESHOLD && - delta !== 0 - ) { + if (Number.isFinite(mwu.p) && mwu.p < SIGNIFICANCE_THRESHOLD && delta !== 0) { verdict = delta < 0 ? 'improved' : 'regressed' } return { cells: [ `${base.scenario} · ${base.variant}`, base.metric, - formatGroupCell(base, bs), - formatGroupCell(cur, cs), + formatGroupCell(base, bs, base.samples.length !== baseMode), + formatGroupCell(cur, cs, cur.samples.length !== curMode), formatDelta(delta, base.unit), Number.isFinite(deltaPct) ? `${deltaPct.toFixed(1)}%` : 'n/a', - formatP(welchs.p), formatP(mwu.p), ], verdict, } } +function percentiles(samples: number[]): { + p50: number + p90: number + p99: number +} { + return { + p50: quantile(samples, 0.5), + p90: quantile(samples, 0.9), + p99: quantile(samples, 0.99), + } +} + function formatGroupCell( group: SampleGroup, - s: { mean: number; p50: number; p90: number } + s: { p50: number; p90: number; p99: number }, + showN: boolean ): string { - const parts = [s.mean, s.p50, s.p90].map((v) => + const parts = [s.p50, s.p90, s.p99].map((v) => splitFormattedUnit(v, group.unit) ) - const n = group.samples.length + const nSuffix = showN ? ` (${group.samples.length})` : '' // If all three values render with the same unit suffix (e.g. all "requests"), // only print the suffix on the last value to avoid "7 req / 7 req / 7 req". if ( parts[0].suffix === parts[1].suffix && parts[1].suffix === parts[2].suffix ) { - return `${parts[0].num} / ${parts[1].num} / ${parts[2].num}${parts[2].suffix} (${n})` + return `${parts[0].num} / ${parts[1].num} / ${parts[2].num}${parts[2].suffix}${nSuffix}` } - return `${parts[0].num}${parts[0].suffix} / ${parts[1].num}${parts[1].suffix} / ${parts[2].num}${parts[2].suffix} (${n})` + return `${parts[0].num}${parts[0].suffix} / ${parts[1].num}${parts[1].suffix} / ${parts[2].num}${parts[2].suffix}${nSuffix}` } function splitFormattedUnit( diff --git a/turbopack/packages/devlow-bench/src/runner.ts b/turbopack/packages/devlow-bench/src/runner.ts index f68833080589..3ae5a0b4164f 100644 --- a/turbopack/packages/devlow-bench/src/runner.ts +++ b/turbopack/packages/devlow-bench/src/runner.ts @@ -7,6 +7,7 @@ import { intoFullInterface, } from './index.js' import { summary } from './statistics.js' +import { formatVariant } from './utils.js' interface SampleSet { samples: number[] @@ -14,6 +15,17 @@ interface SampleSet { relativeTo?: string } +interface BufferedSample { + value: number + unit: string + relativeTo?: string +} + +// Total attempts per variant are capped at this multiple of the requested +// `warmup + n`. Allows retrying flaky runs without burning unbounded time when +// every attempt fails. +const MAX_ATTEMPT_MULTIPLIER = 2 + export async function runScenarios( scenarios: Scenario[], iface: Interface, @@ -26,6 +38,9 @@ export async function runScenarios( scenarios = scenarios.filter((scenario) => scenario.only) } scenarios = await fullIface.filterScenarios(scenarios) + let totalFailedAttempts = 0 + let variantsWithRetries = 0 + let variantsShortOfTarget = 0 let variants = [] for (const scenario of scenarios) { let props = [{}] @@ -55,25 +70,23 @@ export async function runScenarios( for (const variant of variants) { const samplesByMetric = new Map() - // Wrap the interface for this variant so that per-sample `measurement` - // calls (a) flow through to all underlying reporters (preserving Datadog / - // Snowflake / etc. behavior) and (b) get collected into samplesByMetric - // for aggregation. Only counted on non-warmup runs. + // Each attempt buffers its measurements here. On success we merge into + // `samplesByMetric`; on failure we discard, so a partial run can't + // pollute the aggregated stats. Downstream per-sample reporters (Datadog, + // console, …) still receive every measurement inline — only the + // aggregated per-variant samples are gated on success. let collecting = false + let perRun = new Map() const wrappedIface: FullInterface = { ...fullIface, measurement: async (scenario, props, name, value, unit, relativeTo) => { if (collecting) { - const existing = samplesByMetric.get(name) - if (existing) { - existing.samples.push(value) - } else { - samplesByMetric.set(name, { - samples: [value], - unit, - relativeTo, - }) + let list = perRun.get(name) + if (!list) { + list = [] + perRun.set(name, list) } + list.push({ value, unit, relativeTo }) } await fullIface.measurement( scenario, @@ -86,18 +99,25 @@ export async function runScenarios( }, } - const totalRuns = warmup + n - let aborted = false - for (let run = 0; run < totalRuns; run++) { - collecting = run >= warmup - // Only report run progress when the user is actually doing multiple - // runs. Single-sample runs don't need a "[1/1]" tag. - const runInfo = - totalRuns > 1 - ? run < warmup - ? { run: run + 1, total: warmup, warmup: true } - : { run: run - warmup + 1, total: n, warmup: false } - : undefined + const maxAttempts = (warmup + n) * MAX_ATTEMPT_MULTIPLIER + const showRunInfo = warmup + n > 1 + let warmupDone = 0 + let collectedRuns = 0 + let totalAttempts = 0 + + while ( + (warmupDone < warmup || collectedRuns < n) && + totalAttempts < maxAttempts + ) { + totalAttempts++ + const isWarmup = warmupDone < warmup + collecting = !isWarmup + perRun = new Map() + const runInfo = showRunInfo + ? isWarmup + ? { run: warmupDone + 1, total: warmup, warmup: true } + : { run: collectedRuns + 1, total: n, warmup: false } + : undefined try { const measurements = new Map() await withCurrent( @@ -120,15 +140,49 @@ export async function runScenarios( await wrappedIface.end(variant.scenario.name, variant.props) } ) + if (collecting) { + // Commit the buffered samples for this run into the aggregate. + for (const [name, entries] of perRun) { + let set = samplesByMetric.get(name) + if (!set) { + const first = entries[0] + set = { + samples: [], + unit: first.unit, + relativeTo: first.relativeTo, + } + samplesByMetric.set(name, set) + } + for (const e of entries) { + set.samples.push(e.value) + } + } + collectedRuns++ + } else { + warmupDone++ + } } catch (e) { await wrappedIface.error(variant.scenario.name, variant.props, e) - process.exitCode = 1 - aborted = true - break + // Loop continues; the buffered samples for this attempt are dropped + // when `perRun` is replaced on the next iteration. } } - if (!aborted && samplesByMetric.size > 0) { + const failedAttempts = totalAttempts - warmupDone - collectedRuns + if (failedAttempts > 0) { + const label = formatVariant(variant.scenario.name, variant.props) + console.log( + `${label}: collected ${collectedRuns}/${n} samples after ${totalAttempts} attempts (${failedAttempts} failed)` + ) + totalFailedAttempts += failedAttempts + variantsWithRetries++ + } + if (collectedRuns < n) { + process.exitCode = 1 + variantsShortOfTarget++ + } + + if (samplesByMetric.size > 0) { const stats: Record = {} for (const [name, set] of samplesByMetric) { stats[name] = { @@ -146,5 +200,23 @@ export async function runScenarios( } } + if (totalFailedAttempts > 0) { + const recovered = variantsWithRetries - variantsShortOfTarget + const parts: string[] = [] + if (recovered > 0) { + parts.push( + `${recovered} variant${recovered === 1 ? '' : 's'} hit n=${n} after retries` + ) + } + if (variantsShortOfTarget > 0) { + parts.push( + `${variantsShortOfTarget} variant${variantsShortOfTarget === 1 ? '' : 's'} fell short of n=${n}` + ) + } + console.log( + `\n${totalFailedAttempts} failed attempt${totalFailedAttempts === 1 ? '' : 's'} across ${variantsWithRetries} variant${variantsWithRetries === 1 ? '' : 's'}: ${parts.join('; ')}.` + ) + } + await fullIface.finish() } From 3e97d89d0f37ee54c7389fc4cd99b191ddd5a0df Mon Sep 17 00:00:00 2001 From: Will Binns-Smith Date: Wed, 20 May 2026 14:27:15 -0700 Subject: [PATCH 4/9] =?UTF-8?q?@vercel/devlow-bench:=200.3.5=20=E2=86=92?= =?UTF-8?q?=200.4.0=20(#93951)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version bump so we can publish the changes from #93950 (and the previously-unpublished 0.3.5 work) to npm. Stacked on top of #93950. --- turbopack/packages/devlow-bench/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/turbopack/packages/devlow-bench/package.json b/turbopack/packages/devlow-bench/package.json index 5be176caba24..058152deb4c3 100644 --- a/turbopack/packages/devlow-bench/package.json +++ b/turbopack/packages/devlow-bench/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/devlow-bench", - "version": "0.3.5", + "version": "0.4.0", "description": "Benchmarking tool for the developer workflow", "repository": { "type": "git", From fda6986b264e1f01fad493fc9c174e7b2ac9919c Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 21 May 2026 00:33:55 +0200 Subject: [PATCH 5/9] [test] Prerendering HTTP access fallback pages with Cache Components (#93987) --- ...ors.http-access-fallback-prerender.test.ts | 384 ++++++++++++++++++ .../app/forbidden/[slug]/forbidden.tsx | 9 + .../app/forbidden/[slug]/page.tsx | 19 + .../app/layout.tsx | 7 + .../app/not-found/[slug]/not-found.tsx | 9 + .../app/not-found/[slug]/page.tsx | 19 + .../app/unauthorized/[slug]/page.tsx | 19 + .../app/unauthorized/[slug]/unauthorized.tsx | 9 + .../next.config.js | 11 + 9 files changed, 486 insertions(+) create mode 100644 test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts create mode 100644 test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/forbidden/[slug]/forbidden.tsx create mode 100644 test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/forbidden/[slug]/page.tsx create mode 100644 test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/layout.tsx create mode 100644 test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found/[slug]/not-found.tsx create mode 100644 test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found/[slug]/page.tsx create mode 100644 test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/unauthorized/[slug]/page.tsx create mode 100644 test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/unauthorized/[slug]/unauthorized.tsx create mode 100644 test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/next.config.js diff --git a/test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts b/test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts new file mode 100644 index 000000000000..afc7c71b0f17 --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts @@ -0,0 +1,384 @@ +import { isNextDev, nextTestSetup } from 'e2e-utils' +import { getPrerenderOutput } from './utils' + +describe('Cache Components HTTP Access Fallback Prerender', () => { + const { next, isTurbopack, isNextStart, skipped } = nextTestSetup({ + files: __dirname + '/fixtures/http-access-fallback-prerender', + skipStart: !isNextDev, + skipDeployment: true, + }) + + if (skipped) { + return + } + + let cliOutputLength: number + + beforeEach(() => { + cliOutputLength = next.cliOutput.length + }) + + afterEach(async () => { + if (isNextStart) { + await next.stop() + } + }) + + const testCases: { isDebugPrerender: boolean; name: string }[] = [] + + if (isNextDev) { + testCases.push({ isDebugPrerender: false, name: 'Dev' }) + } else { + const prerenderMode = process.env.NEXT_TEST_DEBUG_PRERENDER + if (!prerenderMode || prerenderMode === 'true') { + testCases.push({ + isDebugPrerender: true, + name: 'Build With --debug-prerender', + }) + } + if (!prerenderMode || prerenderMode === 'false') { + testCases.push({ + isDebugPrerender: false, + name: 'Build Without --debug-prerender', + }) + } + } + + describe.each(testCases)('$name', ({ isDebugPrerender }) => { + beforeAll(async () => { + if (isNextStart) { + const args = ['--experimental-build-mode', 'compile'] + + if (isDebugPrerender) { + args.push('--debug-prerender') + } + + await next.build({ args }) + } + }) + + const prerender = async (pathname: string) => { + const args = [ + '--experimental-build-mode', + 'generate', + '--debug-build-paths', + `app${pathname}/page.tsx`, + ] + + if (isDebugPrerender) { + args.push('--debug-prerender') + } + + await next.build({ args }) + } + + describe('notFound()', () => { + const pagePath = '/not-found/[slug]' + const visitUrl = '/not-found/not-found' + + if (isNextDev) { + it('should show a collapsed redbox when not-found.tsx uses useSearchParams without Suspense', async () => { + const browser = await next.browser(visitUrl) + + await expect(browser).toDisplayCollapsedRedbox( + `"Redbox did not open."` + ) + }) + } else { + it('should error the build with a blocking-route error', async () => { + try { + await prerender(pagePath) + } catch { + // we expect the build to fail + } + + const output = getPrerenderOutput( + next.cliOutput.slice(cliOutputLength), + { isMinified: !isDebugPrerender } + ) + + if (isTurbopack) { + if (isDebugPrerender) { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at NotFound (app/not-found/[slug]/not-found.tsx:6:39) + 4 | + 5 | export default function NotFound() { + > 6 | const searchParams = useSearchParams() + | ^ + 7 | + 8 | return

not found {searchParams.get('foo')}

+ 9 | } { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/not-found/not-found". Read more: https://nextjs.org/docs/messages/prerender-error + + > Export encountered errors on 1 path: + /not-found/[slug]/page: /not-found/not-found" + `) + } else { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at (app/not-found/[slug]/not-found.tsx:6:24) + 4 | + 5 | export default function NotFound() { + > 6 | const searchParams = useSearchParams() + | ^ + 7 | + 8 | return

not found {searchParams.get('foo')}

+ 9 | } { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/not-found/not-found". Read more: https://nextjs.org/docs/messages/prerender-error + Export encountered an error on /not-found/[slug]/page: /not-found/not-found, exiting the build." + `) + } + } else { + if (isDebugPrerender) { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at useDynamicSearchParams (webpack:///) + at useSearchParams (webpack:///) + at NotFound (webpack:///app/not-found/[slug]/not-found.tsx:6:39) + 707 | return + 708 | } + > 709 | throw new BailoutToCSRError(expression) + | ^ + 710 | } + 711 | case 'prerender': + 712 | case 'prerender-runtime': { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/not-found/not-found". Read more: https://nextjs.org/docs/messages/prerender-error + + > Export encountered errors on 1 path: + /not-found/[slug]/page: /not-found/not-found" + `) + } else { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at a () + at b () + at c () { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/not-found/not-found". Read more: https://nextjs.org/docs/messages/prerender-error + Export encountered an error on /not-found/[slug]/page: /not-found/not-found, exiting the build." + `) + } + } + }) + } + }) + + describe('forbidden()', () => { + const pagePath = '/forbidden/[slug]' + const visitUrl = '/forbidden/forbidden' + + if (isNextDev) { + it('should show a collapsed redbox when forbidden.tsx uses useSearchParams without Suspense', async () => { + const browser = await next.browser(visitUrl) + + await expect(browser).toDisplayCollapsedRedbox( + `"Redbox did not open."` + ) + }) + } else { + it('should error the build with a blocking-route error', async () => { + try { + await prerender(pagePath) + } catch { + // we expect the build to fail + } + + const output = getPrerenderOutput( + next.cliOutput.slice(cliOutputLength), + { isMinified: !isDebugPrerender } + ) + + if (isTurbopack) { + if (isDebugPrerender) { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at Forbidden (app/forbidden/[slug]/forbidden.tsx:6:39) + 4 | + 5 | export default function Forbidden() { + > 6 | const searchParams = useSearchParams() + | ^ + 7 | + 8 | return

forbidden {searchParams.get('foo')}

+ 9 | } { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/forbidden/forbidden". Read more: https://nextjs.org/docs/messages/prerender-error + + > Export encountered errors on 1 path: + /forbidden/[slug]/page: /forbidden/forbidden" + `) + } else { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at (app/forbidden/[slug]/forbidden.tsx:6:24) + 4 | + 5 | export default function Forbidden() { + > 6 | const searchParams = useSearchParams() + | ^ + 7 | + 8 | return

forbidden {searchParams.get('foo')}

+ 9 | } { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/forbidden/forbidden". Read more: https://nextjs.org/docs/messages/prerender-error + Export encountered an error on /forbidden/[slug]/page: /forbidden/forbidden, exiting the build." + `) + } + } else { + if (isDebugPrerender) { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at useDynamicSearchParams (webpack:///) + at useSearchParams (webpack:///) + at Forbidden (webpack:///app/forbidden/[slug]/forbidden.tsx:6:39) + 707 | return + 708 | } + > 709 | throw new BailoutToCSRError(expression) + | ^ + 710 | } + 711 | case 'prerender': + 712 | case 'prerender-runtime': { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/forbidden/forbidden". Read more: https://nextjs.org/docs/messages/prerender-error + + > Export encountered errors on 1 path: + /forbidden/[slug]/page: /forbidden/forbidden" + `) + } else { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at a () + at b () + at c () { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/forbidden/forbidden". Read more: https://nextjs.org/docs/messages/prerender-error + Export encountered an error on /forbidden/[slug]/page: /forbidden/forbidden, exiting the build." + `) + } + } + }) + } + }) + + describe('unauthorized()', () => { + const pagePath = '/unauthorized/[slug]' + const visitUrl = '/unauthorized/unauthorized' + + if (isNextDev) { + it('should show a collapsed redbox when unauthorized.tsx uses useSearchParams without Suspense', async () => { + const browser = await next.browser(visitUrl) + + await expect(browser).toDisplayCollapsedRedbox( + `"Redbox did not open."` + ) + }) + } else { + it('should error the build with a blocking-route error', async () => { + try { + await prerender(pagePath) + } catch { + // we expect the build to fail + } + + const output = getPrerenderOutput( + next.cliOutput.slice(cliOutputLength), + { isMinified: !isDebugPrerender } + ) + + if (isTurbopack) { + if (isDebugPrerender) { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at Unauthorized (app/unauthorized/[slug]/unauthorized.tsx:6:39) + 4 | + 5 | export default function Unauthorized() { + > 6 | const searchParams = useSearchParams() + | ^ + 7 | + 8 | return

unauthorized {searchParams.get('foo')}

+ 9 | } { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/unauthorized/unauthorized". Read more: https://nextjs.org/docs/messages/prerender-error + + > Export encountered errors on 1 path: + /unauthorized/[slug]/page: /unauthorized/unauthorized" + `) + } else { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at (app/unauthorized/[slug]/unauthorized.tsx:6:24) + 4 | + 5 | export default function Unauthorized() { + > 6 | const searchParams = useSearchParams() + | ^ + 7 | + 8 | return

unauthorized {searchParams.get('foo')}

+ 9 | } { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/unauthorized/unauthorized". Read more: https://nextjs.org/docs/messages/prerender-error + Export encountered an error on /unauthorized/[slug]/page: /unauthorized/unauthorized, exiting the build." + `) + } + } else { + if (isDebugPrerender) { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at useDynamicSearchParams (webpack:///) + at useSearchParams (webpack:///) + at Unauthorized (webpack:///app/unauthorized/[slug]/unauthorized.tsx:6:39) + 707 | return + 708 | } + > 709 | throw new BailoutToCSRError(expression) + | ^ + 710 | } + 711 | case 'prerender': + 712 | case 'prerender-runtime': { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/unauthorized/unauthorized". Read more: https://nextjs.org/docs/messages/prerender-error + + > Export encountered errors on 1 path: + /unauthorized/[slug]/page: /unauthorized/unauthorized" + `) + } else { + expect(output).toMatchInlineSnapshot(` + "Error: Bail out to client-side rendering: useSearchParams() + at a () + at b () + at c () { + reason: 'useSearchParams()', + digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' + } + Error occurred prerendering page "/unauthorized/unauthorized". Read more: https://nextjs.org/docs/messages/prerender-error + Export encountered an error on /unauthorized/[slug]/page: /unauthorized/unauthorized, exiting the build." + `) + } + } + }) + } + }) + }) +}) diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/forbidden/[slug]/forbidden.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/forbidden/[slug]/forbidden.tsx new file mode 100644 index 000000000000..bbbd96172921 --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/forbidden/[slug]/forbidden.tsx @@ -0,0 +1,9 @@ +'use client' + +import { useSearchParams } from 'next/navigation' + +export default function Forbidden() { + const searchParams = useSearchParams() + + return

forbidden {searchParams.get('foo')}

+} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/forbidden/[slug]/page.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/forbidden/[slug]/page.tsx new file mode 100644 index 000000000000..00000a3c45a7 --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/forbidden/[slug]/page.tsx @@ -0,0 +1,19 @@ +import { forbidden } from 'next/navigation' + +export function generateStaticParams() { + return [{ slug: 'allowed' }, { slug: 'forbidden' }] +} + +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + + if (slug === 'allowed') { + return

hello world

+ } + + forbidden() +} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/layout.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/layout.tsx new file mode 100644 index 000000000000..e7077399c03c --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found/[slug]/not-found.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found/[slug]/not-found.tsx new file mode 100644 index 000000000000..4937fa2fc7dc --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found/[slug]/not-found.tsx @@ -0,0 +1,9 @@ +'use client' + +import { useSearchParams } from 'next/navigation' + +export default function NotFound() { + const searchParams = useSearchParams() + + return

not found {searchParams.get('foo')}

+} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found/[slug]/page.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found/[slug]/page.tsx new file mode 100644 index 000000000000..560ba513ae4f --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found/[slug]/page.tsx @@ -0,0 +1,19 @@ +import { notFound } from 'next/navigation' + +export function generateStaticParams() { + return [{ slug: 'found' }, { slug: 'not-found' }] +} + +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + + if (slug === 'found') { + return

hello world

+ } + + notFound() +} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/unauthorized/[slug]/page.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/unauthorized/[slug]/page.tsx new file mode 100644 index 000000000000..1775d7e658ea --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/unauthorized/[slug]/page.tsx @@ -0,0 +1,19 @@ +import { unauthorized } from 'next/navigation' + +export function generateStaticParams() { + return [{ slug: 'authorized' }, { slug: 'unauthorized' }] +} + +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + + if (slug === 'authorized') { + return

hello world

+ } + + unauthorized() +} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/unauthorized/[slug]/unauthorized.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/unauthorized/[slug]/unauthorized.tsx new file mode 100644 index 000000000000..d1dbf5c80048 --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/unauthorized/[slug]/unauthorized.tsx @@ -0,0 +1,9 @@ +'use client' + +import { useSearchParams } from 'next/navigation' + +export default function Unauthorized() { + const searchParams = useSearchParams() + + return

unauthorized {searchParams.get('foo')}

+} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/next.config.js b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/next.config.js new file mode 100644 index 000000000000..34aa2b5aed26 --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: true, + experimental: { + authInterrupts: true, + }, +} + +module.exports = nextConfig From 5b6747ad3fd34ebcc409e3743b705524b420a122 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 21 May 2026 01:01:16 +0200 Subject: [PATCH 6/9] Prerender HTTP access fallbacks with Cache Components semantics (#93988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The body of `if (cacheComponents)` in `prerenderToStream` is extracted into a local helper, `prerenderWithCacheComponents(getPayload)`. The catch-block error paths now route through that helper whenever Cache Components is enabled, which means the legacy `prerender-legacy` store is no longer reachable from a Cache Components prerender. As a result, `notFound()`, `forbidden()`, and `unauthorized()` recovery renders the matching fallback boundary under `prerender` and `prerender-client` semantics. Dynamic API access in those boundaries — for example `useSearchParams()` without a surrounding `` — now surfaces as a blocking-route error instead of the legacy `BailoutToCSRError`. The dev side will be handled in a follow-up. The prerender handling diverged a bit from the dev rendering in #92231, and we'll likely need to re-align the dev rendering first. > [!TIP] > Best reviewed with hidden whitespace changes. --- .../next/src/server/app-render/app-render.tsx | 1495 +++++++++-------- ...ors.http-access-fallback-prerender.test.ts | 372 +++- 2 files changed, 1042 insertions(+), 825 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index b9191a3de3d8..fbb95bc4fb41 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -5603,6 +5603,15 @@ async function spawnStaticShellValidationInDev( * While it doesn't return a stream we want it to have identical * prerender semantics to prerenderToStream and should update it * in conjunction with any changes to that function. + * + * TODO: HTTP access fallback recovery (`notFound()` / `forbidden()` / + * `unauthorized()`) has been moved to use Cache Components prerender + * semantics in `prerenderToStream` via `prerenderWithCacheComponents`. + * This dev validation path still only validates the page tree, not the + * fallback boundary, so dynamic API access in `not-found.tsx` etc. is + * not yet observed by the dev redbox. Re-validate against the fallback + * boundary tree when an `HTTPAccessFallbackError` surfaces from the + * upstream RSC render to match the build-time behavior. */ async function spawnStaticShellValidationInDevImpl( accumulatedChunksPromise: Promise, @@ -7313,107 +7322,228 @@ async function prerenderToStream( let prerenderStore: PrerenderStore | null = null - try { - if (cacheComponents) { - /** - * cacheComponents with PPR - * - * The general approach is to render the RSC stream first allowing any cache reads to resolve. - * Once we have settled all cache reads we restart the render and abort after a single Task. - * - * Unlike with the non PPR case we can't synchronously abort the render when a dynamic API is used - * during the initial render because we need to ensure all caches can be filled as part of the initial Task - * and a synchronous abort might prevent us from filling all caches. - * - * Once the render is complete we allow the SSR render to finish and use a combination of the postponed state - * and the reactServerIsDynamic value to determine how to treat the resulting render - */ + const prerenderWithCacheComponents = async ( + getPayload: () => Promise + ): Promise => { + /** + * cacheComponents with PPR + * + * The general approach is to render the RSC stream first allowing any cache reads to resolve. + * Once we have settled all cache reads we restart the render and abort after a single Task. + * + * Unlike with the non PPR case we can't synchronously abort the render when a dynamic API is used + * during the initial render because we need to ensure all caches can be filled as part of the initial Task + * and a synchronous abort might prevent us from filling all caches. + * + * Once the render is complete we allow the SSR render to finish and use a combination of the postponed state + * and the reactServerIsDynamic value to determine how to treat the resulting render + */ + + // The prerender controller represents the lifetime of the prerender. It + // will be aborted when a task is complete or a synchronously aborting API + // is called. Notably, during prospective prerenders, this does not + // actually terminate the prerender itself, which will continue until all + // caches are filled. + const initialServerPrerenderController = new AbortController() + + // This controller is used to abort the React prerender. + const initialServerReactController = new AbortController() + + // This controller represents the lifetime of the React prerender. Its + // signal can be used for any I/O operation to abort the I/O and/or to + // reject, when prerendering aborts. This includes our own hanging + // promises for accessing request data, and for fetch calls. It might be + // replaced in the future by React.cacheSignal(). It's aborted after the + // React controller, so that no pending I/O can register abort listeners + // that are called before React's abort listener is called. This ensures + // that pending I/O is not rejected too early when aborting the prerender. + // Notably, during the prospective prerender, it is different from the + // prerender controller because we don't want to end the React prerender + // until all caches are filled. + const initialServerRenderController = new AbortController() + + // The cacheSignal helps us track whether caches are still filling or we are ready + // to cut the render off. + const cacheSignal = new CacheSignal() - // The prerender controller represents the lifetime of the prerender. It - // will be aborted when a task is complete or a synchronously aborting API - // is called. Notably, during prospective prerenders, this does not - // actually terminate the prerender itself, which will continue until all - // caches are filled. - const initialServerPrerenderController = new AbortController() - - // This controller is used to abort the React prerender. - const initialServerReactController = new AbortController() - - // This controller represents the lifetime of the React prerender. Its - // signal can be used for any I/O operation to abort the I/O and/or to - // reject, when prerendering aborts. This includes our own hanging - // promises for accessing request data, and for fetch calls. It might be - // replaced in the future by React.cacheSignal(). It's aborted after the - // React controller, so that no pending I/O can register abort listeners - // that are called before React's abort listener is called. This ensures - // that pending I/O is not rejected too early when aborting the prerender. - // Notably, during the prospective prerender, it is different from the - // prerender controller because we don't want to end the React prerender - // until all caches are filled. - const initialServerRenderController = new AbortController() - - // The cacheSignal helps us track whether caches are still filling or we are ready - // to cut the render off. - const cacheSignal = new CacheSignal() - - // Always start with a fresh prerender RDC so warmup can fill misses, - // even when we have a prefilled render RDC to seed from. - const prerenderResumeDataCache = createPrerenderResumeDataCache() - let renderResumeDataCache: RenderResumeDataCache | null = - renderOpts.renderResumeDataCache ?? null + // Always start with a fresh prerender RDC so warmup can fill misses, + // even when we have a prefilled render RDC to seed from. + const prerenderResumeDataCache = createPrerenderResumeDataCache() + let renderResumeDataCache: RenderResumeDataCache | null = + renderOpts.renderResumeDataCache ?? null - const initialServerPayloadPrerenderStore: PrerenderStore = { - type: 'prerender', - phase: 'render', - rootParams, - fallbackRouteParams, - implicitTags, - // While this render signal isn't going to be used to abort a React render while getting the RSC payload - // various request data APIs bind to this controller to reject after completion. - renderSignal: initialServerRenderController.signal, - // When we generate the RSC payload we might abort this controller due to sync IO - // but we don't actually care about sync IO in this phase so we use a throw away controller - // that isn't connected to anything - controller: new AbortController(), - // During the initial prerender we need to track all cache reads to ensure - // we render long enough to fill every cache it is possible to visit during - // the final prerender. - cacheSignal, - dynamicTracking: null, - allowEmptyStaticShell, - revalidate: INFINITE_CACHE, - expire: INFINITE_CACHE, - stale: INFINITE_CACHE, - tags: [...implicitTags.tags], - prerenderResumeDataCache, - renderResumeDataCache, - hmrRefreshHash: undefined, - // We don't track vary params during initial prerender, only the final one - varyParamsAccumulator: null, - } + const initialServerPayloadPrerenderStore: PrerenderStore = { + type: 'prerender', + phase: 'render', + rootParams, + fallbackRouteParams, + implicitTags, + // While this render signal isn't going to be used to abort a React render while getting the RSC payload + // various request data APIs bind to this controller to reject after completion. + renderSignal: initialServerRenderController.signal, + // When we generate the RSC payload we might abort this controller due to sync IO + // but we don't actually care about sync IO in this phase so we use a throw away controller + // that isn't connected to anything + controller: new AbortController(), + // During the initial prerender we need to track all cache reads to ensure + // we render long enough to fill every cache it is possible to visit during + // the final prerender. + cacheSignal, + dynamicTracking: null, + allowEmptyStaticShell, + revalidate: INFINITE_CACHE, + expire: INFINITE_CACHE, + stale: INFINITE_CACHE, + tags: [...implicitTags.tags], + prerenderResumeDataCache, + renderResumeDataCache, + hmrRefreshHash: undefined, + // We don't track vary params during initial prerender, only the final one + varyParamsAccumulator: null, + } - // We're not going to use the result of this render because the only time it could be used - // is if it completes in a microtask and that's likely very rare for any non-trivial app - const initialServerPayload = await workUnitAsyncStorage.run( - initialServerPayloadPrerenderStore, - getRSCPayload, - tree, - ctx, - { is404: res.statusCode === 404 } + // We're not going to use the result of this render because the only time it could be used + // is if it completes in a microtask and that's likely very rare for any non-trivial app + const initialServerPayload = await workUnitAsyncStorage.run( + initialServerPayloadPrerenderStore, + getPayload + ) + + const initialServerPrerenderStore: PrerenderStore = (prerenderStore = { + type: 'prerender', + phase: 'render', + rootParams, + fallbackRouteParams, + implicitTags, + renderSignal: initialServerRenderController.signal, + controller: initialServerPrerenderController, + // During the initial prerender we need to track all cache reads to ensure + // we render long enough to fill every cache it is possible to visit during + // the final prerender. + cacheSignal, + dynamicTracking: null, + allowEmptyStaticShell, + revalidate: INFINITE_CACHE, + expire: INFINITE_CACHE, + stale: INFINITE_CACHE, + tags: [...implicitTags.tags], + prerenderResumeDataCache, + renderResumeDataCache, + hmrRefreshHash: undefined, + // We don't track vary params during initial prerender, only the final one + varyParamsAccumulator: null, + }) + + const initialPrerenderOptions = { + filterStackFrame, + onError: (err: unknown) => { + const digest = getDigestForWellKnownError(err) + + if (digest) { + return digest + } + + if (isReactLargeShellError(err)) { + // TODO: Aggregate + console.error(err) + return undefined + } + + if (initialServerPrerenderController.signal.aborted) { + // The render aborted before this error was handled which indicates + // the error is caused by unfinished components within the render + return + } else if ( + process.env.NEXT_DEBUG_BUILD || + process.env.__NEXT_VERBOSE_LOGGING + ) { + printDebugThrownValueForProspectiveRender( + err, + workStore.route, + Phase.ProspectiveRender + ) + } + }, + // We don't want to stop rendering until the cacheSignal is complete so we pass + // a different signal to this render call than is used by dynamic APIs to signify + // transitioning out of the prerender environment + signal: initialServerReactController.signal, + } + + const pendingInitialServerResult = workUnitAsyncStorage.run( + initialServerPrerenderStore, + getServerPrerender(ComponentMod), + initialServerPayload, + clientModules, + initialPrerenderOptions + ) + + // The listener to abort our own render controller must be added after + // React has added its listener, to ensure that pending I/O is not + // aborted/rejected too early. + initialServerReactController.signal.addEventListener( + 'abort', + () => { + initialServerRenderController.abort() + initialServerPrerenderController.abort() + }, + { once: true } + ) + + // Wait for all caches to be finished filling and for async imports to resolve + trackPendingModules(cacheSignal) + await cacheSignal.cacheReady() + + initialServerReactController.abort() + + // We don't need to continue the prerender process if we already + // detected invalid dynamic usage in the initial prerender phase. + if (workStore.invalidDynamicUsageError) { + logDisallowedDynamicError(workStore, workStore.invalidDynamicUsageError) + throw new StaticGenBailoutError() + } + + let initialServerResult + try { + initialServerResult = await createReactServerPrerenderResult( + pendingInitialServerResult ) + } catch (err) { + if ( + initialServerReactController.signal.aborted || + initialServerPrerenderController.signal.aborted + ) { + // These are expected errors that might error the prerender. we ignore them. + } else if ( + process.env.NEXT_DEBUG_BUILD || + process.env.__NEXT_VERBOSE_LOGGING + ) { + // We don't normally log these errors because we are going to retry anyway but + // it can be useful for debugging Next.js itself to get visibility here when needed + printDebugThrownValueForProspectiveRender( + err, + workStore.route, + Phase.ProspectiveRender + ) + } + } - const initialServerPrerenderStore: PrerenderStore = (prerenderStore = { - type: 'prerender', + if (initialServerResult) { + const initialClientPrerenderController = new AbortController() + const initialClientReactController = new AbortController() + const initialClientRenderController = new AbortController() + + const initialClientPrerenderStore: PrerenderStore = { + type: 'prerender-client', phase: 'render', rootParams, fallbackRouteParams, implicitTags, - renderSignal: initialServerRenderController.signal, - controller: initialServerPrerenderController, - // During the initial prerender we need to track all cache reads to ensure - // we render long enough to fill every cache it is possible to visit during - // the final prerender. - cacheSignal, + renderSignal: initialClientRenderController.signal, + controller: initialClientPrerenderController, + // For HTML Generation the only cache tracked activity + // is module loading, which has it's own cache signal + cacheSignal: null, dynamicTracking: null, allowEmptyStaticShell, revalidate: INFINITE_CACHE, @@ -7423,88 +7553,72 @@ async function prerenderToStream( prerenderResumeDataCache, renderResumeDataCache, hmrRefreshHash: undefined, - // We don't track vary params during initial prerender, only the final one + // Client prerenders don't track server param access varyParamsAccumulator: null, - }) - - const initialPrerenderOptions = { - filterStackFrame, - onError: (err: unknown) => { - const digest = getDigestForWellKnownError(err) + } - if (digest) { - return digest - } + const pendingInitialClientResult = workUnitAsyncStorage.run( + initialClientPrerenderStore, + getClientPrerender, + // eslint-disable-next-line @next/internal/no-ambiguous-jsx + , + { + signal: initialClientReactController.signal, + onError: (err: unknown) => { + const digest = getDigestForWellKnownError(err) - if (isReactLargeShellError(err)) { - // TODO: Aggregate - console.error(err) - return undefined - } + if (digest) { + return digest + } - if (initialServerPrerenderController.signal.aborted) { - // The render aborted before this error was handled which indicates - // the error is caused by unfinished components within the render - return - } else if ( - process.env.NEXT_DEBUG_BUILD || - process.env.__NEXT_VERBOSE_LOGGING - ) { - printDebugThrownValueForProspectiveRender( - err, - workStore.route, - Phase.ProspectiveRender - ) - } - }, - // We don't want to stop rendering until the cacheSignal is complete so we pass - // a different signal to this render call than is used by dynamic APIs to signify - // transitioning out of the prerender environment - signal: initialServerReactController.signal, - } + if (isReactLargeShellError(err)) { + // TODO: Aggregate + console.error(err) + return undefined + } - const pendingInitialServerResult = workUnitAsyncStorage.run( - initialServerPrerenderStore, - getServerPrerender(ComponentMod), - initialServerPayload, - clientModules, - initialPrerenderOptions + if (initialClientReactController.signal.aborted) { + // These are expected errors that might error the prerender. we ignore them. + } else if ( + process.env.NEXT_DEBUG_BUILD || + process.env.__NEXT_VERBOSE_LOGGING + ) { + // We don't normally log these errors because we are going to retry anyway but + // it can be useful for debugging Next.js itself to get visibility here when needed + printDebugThrownValueForProspectiveRender( + err, + workStore.route, + Phase.ProspectiveRender + ) + } + }, + bootstrapScripts: [bootstrapScript], + } ) // The listener to abort our own render controller must be added after // React has added its listener, to ensure that pending I/O is not // aborted/rejected too early. - initialServerReactController.signal.addEventListener( + initialClientReactController.signal.addEventListener( 'abort', () => { - initialServerRenderController.abort() - initialServerPrerenderController.abort() + initialClientRenderController.abort() }, { once: true } ) - // Wait for all caches to be finished filling and for async imports to resolve - trackPendingModules(cacheSignal) - await cacheSignal.cacheReady() - - initialServerReactController.abort() - - // We don't need to continue the prerender process if we already - // detected invalid dynamic usage in the initial prerender phase. - if (workStore.invalidDynamicUsageError) { - logDisallowedDynamicError(workStore, workStore.invalidDynamicUsageError) - throw new StaticGenBailoutError() - } - - let initialServerResult - try { - initialServerResult = await createReactServerPrerenderResult( - pendingInitialServerResult - ) - } catch (err) { + pendingInitialClientResult.catch((err: unknown) => { if ( - initialServerReactController.signal.aborted || - initialServerPrerenderController.signal.aborted + initialClientReactController.signal.aborted || + isPrerenderInterruptedError(err) ) { // These are expected errors that might error the prerender. we ignore them. } else if ( @@ -7519,579 +7633,474 @@ async function prerenderToStream( Phase.ProspectiveRender ) } - } - - if (initialServerResult) { - const initialClientPrerenderController = new AbortController() - const initialClientReactController = new AbortController() - const initialClientRenderController = new AbortController() - - const initialClientPrerenderStore: PrerenderStore = { - type: 'prerender-client', - phase: 'render', - rootParams, - fallbackRouteParams, - implicitTags, - renderSignal: initialClientRenderController.signal, - controller: initialClientPrerenderController, - // For HTML Generation the only cache tracked activity - // is module loading, which has it's own cache signal - cacheSignal: null, - dynamicTracking: null, - allowEmptyStaticShell, - revalidate: INFINITE_CACHE, - expire: INFINITE_CACHE, - stale: INFINITE_CACHE, - tags: [...implicitTags.tags], - prerenderResumeDataCache, - renderResumeDataCache, - hmrRefreshHash: undefined, - // Client prerenders don't track server param access - varyParamsAccumulator: null, - } + }) - const pendingInitialClientResult = workUnitAsyncStorage.run( - initialClientPrerenderStore, - getClientPrerender, - // eslint-disable-next-line @next/internal/no-ambiguous-jsx - , - { - signal: initialClientReactController.signal, - onError: (err: unknown) => { - const digest = getDigestForWellKnownError(err) + // This is mostly needed for dynamic `import()`s in client components. + // Promises passed to client were already awaited above (assuming that they came from cached functions) + trackPendingModules(cacheSignal) + await cacheSignal.cacheReady() + initialClientReactController.abort() + } - if (digest) { - return digest - } + if (renderOpts.renderResumeDataCache) { + // Swap to the warmed cache so the final render uses entries produced during warmup. + renderResumeDataCache = createRenderResumeDataCache( + prerenderResumeDataCache + ) + } - if (isReactLargeShellError(err)) { - // TODO: Aggregate - console.error(err) - return undefined - } + const finalServerReactController = new AbortController() + const finalServerRenderController = new AbortController() - if (initialClientReactController.signal.aborted) { - // These are expected errors that might error the prerender. we ignore them. - } else if ( - process.env.NEXT_DEBUG_BUILD || - process.env.__NEXT_VERBOSE_LOGGING - ) { - // We don't normally log these errors because we are going to retry anyway but - // it can be useful for debugging Next.js itself to get visibility here when needed - printDebugThrownValueForProspectiveRender( - err, - workStore.route, - Phase.ProspectiveRender - ) - } - }, - bootstrapScripts: [bootstrapScript], - } - ) + const varyParamsAccumulator = createResponseVaryParamsAccumulator() - // The listener to abort our own render controller must be added after - // React has added its listener, to ensure that pending I/O is not - // aborted/rejected too early. - initialClientReactController.signal.addEventListener( - 'abort', - () => { - initialClientRenderController.abort() - }, - { once: true } - ) + const finalServerPayloadPrerenderStore: PrerenderStore = { + type: 'prerender', + phase: 'render', + rootParams, + fallbackRouteParams, + implicitTags, + // While this render signal isn't going to be used to abort a React render while getting the RSC payload + // various request data APIs bind to this controller to reject after completion. + renderSignal: finalServerRenderController.signal, + // When we generate the RSC payload we might abort this controller due to sync IO + // but we don't actually care about sync IO in this phase so we use a throw away controller + // that isn't connected to anything + controller: new AbortController(), + // All caches we could read must already be filled so no tracking is necessary + cacheSignal: null, + dynamicTracking: null, + allowEmptyStaticShell, + revalidate: INFINITE_CACHE, + expire: INFINITE_CACHE, + stale: INFINITE_CACHE, + tags: [...implicitTags.tags], + prerenderResumeDataCache, + renderResumeDataCache, + hmrRefreshHash: undefined, + varyParamsAccumulator, + } - pendingInitialClientResult.catch((err: unknown) => { - if ( - initialClientReactController.signal.aborted || - isPrerenderInterruptedError(err) - ) { - // These are expected errors that might error the prerender. we ignore them. - } else if ( - process.env.NEXT_DEBUG_BUILD || - process.env.__NEXT_VERBOSE_LOGGING - ) { - // We don't normally log these errors because we are going to retry anyway but - // it can be useful for debugging Next.js itself to get visibility here when needed - printDebugThrownValueForProspectiveRender( - err, - workStore.route, - Phase.ProspectiveRender - ) - } - }) + const finalAttemptRSCPayload = await workUnitAsyncStorage.run( + finalServerPayloadPrerenderStore, + getPayload + ) - // This is mostly needed for dynamic `import()`s in client components. - // Promises passed to client were already awaited above (assuming that they came from cached functions) - trackPendingModules(cacheSignal) - await cacheSignal.cacheReady() - initialClientReactController.abort() - } + let staleTimeIterable: StaleTimeIterable | undefined + if (cachedNavigations) { + staleTimeIterable = new StaleTimeIterable() + finalAttemptRSCPayload.s = staleTimeIterable + } - if (renderOpts.renderResumeDataCache) { - // Swap to the warmed cache so the final render uses entries produced during warmup. - renderResumeDataCache = createRenderResumeDataCache( - prerenderResumeDataCache - ) - } + const serverDynamicTracking = createDynamicTrackingState( + isDebugDynamicAccesses + ) + let serverIsDynamic = false - const finalServerReactController = new AbortController() - const finalServerRenderController = new AbortController() + const finalServerPrerenderStore: PrerenderStore = (prerenderStore = { + type: 'prerender', + phase: 'render', + rootParams, + fallbackRouteParams, + implicitTags, + renderSignal: finalServerRenderController.signal, + controller: finalServerReactController, + // All caches we could read must already be filled so no tracking is necessary + cacheSignal: null, + dynamicTracking: serverDynamicTracking, + allowEmptyStaticShell, + revalidate: INFINITE_CACHE, + expire: INFINITE_CACHE, + stale: INFINITE_CACHE, + tags: [...implicitTags.tags], + prerenderResumeDataCache, + renderResumeDataCache, + hmrRefreshHash: undefined, + varyParamsAccumulator, + }) - const varyParamsAccumulator = createResponseVaryParamsAccumulator() + if (staleTimeIterable !== undefined) { + trackStaleTime( + finalServerPrerenderStore, + staleTimeIterable, + selectStaleTime + ) + } - const finalServerPayloadPrerenderStore: PrerenderStore = { - type: 'prerender', - phase: 'render', - rootParams, - fallbackRouteParams, - implicitTags, - // While this render signal isn't going to be used to abort a React render while getting the RSC payload - // various request data APIs bind to this controller to reject after completion. - renderSignal: finalServerRenderController.signal, - // When we generate the RSC payload we might abort this controller due to sync IO - // but we don't actually care about sync IO in this phase so we use a throw away controller - // that isn't connected to anything - controller: new AbortController(), - // All caches we could read must already be filled so no tracking is necessary - cacheSignal: null, - dynamicTracking: null, - allowEmptyStaticShell, - revalidate: INFINITE_CACHE, - expire: INFINITE_CACHE, - stale: INFINITE_CACHE, - tags: [...implicitTags.tags], - prerenderResumeDataCache, - renderResumeDataCache, - hmrRefreshHash: undefined, - varyParamsAccumulator, + let prerenderIsPending = true + const finalRSCPrerenderOptions = { + filterStackFrame, + onError: (err: unknown) => { + return serverComponentsErrorHandler(err) + }, + signal: finalServerReactController.signal, + } + const finalRSCAbortCallback = async () => { + // Now that the prerendering is complete, we know the final stale + // time and vary params. Close the stale time iterable and resolve + // the vary params thenable so Flight can serialize their values + // into the stream. The timing here is important: both were + // included in the Flight payload, but they can only be serialized + // at the very end, after all the components have finished. + // + // We resolve these directly here instead of reading from the work + // unit store because this callback runs in a separate task (via + // setTimeout) and may not have access to the async storage context. + const pendingFinishes: Promise[] = [ + finishAccumulatingVaryParams(varyParamsAccumulator), + ] + if (staleTimeIterable !== undefined) { + pendingFinishes.push(finishStaleTimeTracking(staleTimeIterable)) } + await Promise.all(pendingFinishes) - const finalAttemptRSCPayload = await workUnitAsyncStorage.run( - finalServerPayloadPrerenderStore, - getRSCPayload, - tree, - ctx, - { is404: res.statusCode === 404 } - ) + if (finalServerReactController.signal.aborted) { + // If the server controller is already aborted we must have called something + // that required aborting the prerender synchronously such as with new Date() + serverIsDynamic = true + return + } - let staleTimeIterable: StaleTimeIterable | undefined - if (cachedNavigations) { - staleTimeIterable = new StaleTimeIterable() - finalAttemptRSCPayload.s = staleTimeIterable + if (prerenderIsPending) { + // If prerenderIsPending then we have blocked for longer than a Task and we assume + // there is something unfinished. + serverIsDynamic = true } - const serverDynamicTracking = createDynamicTrackingState( - isDebugDynamicAccesses + finalServerReactController.abort() + } + const finalRSCPrerenderFn = async () => { + const pendingPrerenderResult = workUnitAsyncStorage.run( + // The store to scope + finalServerPrerenderStore, + // The function to run + getServerPrerender(ComponentMod), + // ... the arguments for the function to run + finalAttemptRSCPayload, + clientModules, + finalRSCPrerenderOptions ) - let serverIsDynamic = false - const finalServerPrerenderStore: PrerenderStore = (prerenderStore = { - type: 'prerender', - phase: 'render', - rootParams, - fallbackRouteParams, - implicitTags, - renderSignal: finalServerRenderController.signal, - controller: finalServerReactController, - // All caches we could read must already be filled so no tracking is necessary - cacheSignal: null, - dynamicTracking: serverDynamicTracking, - allowEmptyStaticShell, - revalidate: INFINITE_CACHE, - expire: INFINITE_CACHE, - stale: INFINITE_CACHE, - tags: [...implicitTags.tags], - prerenderResumeDataCache, - renderResumeDataCache, - hmrRefreshHash: undefined, - varyParamsAccumulator, - }) + // The listener to abort our own render controller must be added + // after React has added its listener, to ensure that pending I/O + // is not aborted/rejected too early. + finalServerReactController.signal.addEventListener( + 'abort', + () => { + finalServerRenderController.abort() + }, + { once: true } + ) - if (staleTimeIterable !== undefined) { - trackStaleTime( - finalServerPrerenderStore, - staleTimeIterable, - selectStaleTime - ) - } + const prerenderResult = await pendingPrerenderResult + prerenderIsPending = false - let prerenderIsPending = true - const finalRSCPrerenderOptions = { - filterStackFrame, - onError: (err: unknown) => { - return serverComponentsErrorHandler(err) - }, - signal: finalServerReactController.signal, - } - const finalRSCAbortCallback = async () => { - // Now that the prerendering is complete, we know the final stale - // time and vary params. Close the stale time iterable and resolve - // the vary params thenable so Flight can serialize their values - // into the stream. The timing here is important: both were - // included in the Flight payload, but they can only be serialized - // at the very end, after all the components have finished. - // - // We resolve these directly here instead of reading from the work - // unit store because this callback runs in a separate task (via - // setTimeout) and may not have access to the async storage context. - const pendingFinishes: Promise[] = [ - finishAccumulatingVaryParams(varyParamsAccumulator), - ] - if (staleTimeIterable !== undefined) { - pendingFinishes.push(finishStaleTimeTracking(staleTimeIterable)) - } - await Promise.all(pendingFinishes) + return prerenderResult + } + const reactServerResult = (reactServerPrerenderResult = + await createReactServerPrerenderResult( + runInSequentialTasks(finalRSCPrerenderFn, finalRSCAbortCallback) + )) - if (finalServerReactController.signal.aborted) { - // If the server controller is already aborted we must have called something - // that required aborting the prerender synchronously such as with new Date() - serverIsDynamic = true - return - } + const clientDynamicTracking = createDynamicTrackingState( + isDebugDynamicAccesses + ) - if (prerenderIsPending) { - // If prerenderIsPending then we have blocked for longer than a Task and we assume - // there is something unfinished. - serverIsDynamic = true - } + const finalClientReactController = new AbortController() + const finalClientRenderController = new AbortController() - finalServerReactController.abort() - } - const finalRSCPrerenderFn = async () => { - const pendingPrerenderResult = workUnitAsyncStorage.run( - // The store to scope - finalServerPrerenderStore, - // The function to run - getServerPrerender(ComponentMod), - // ... the arguments for the function to run - finalAttemptRSCPayload, - clientModules, - finalRSCPrerenderOptions + const finalClientPrerenderStore: PrerenderStore = { + type: 'prerender-client', + phase: 'render', + rootParams, + fallbackRouteParams, + implicitTags, + renderSignal: finalClientRenderController.signal, + controller: finalClientReactController, + // No APIs require a cacheSignal through the workUnitStore during the HTML prerender + cacheSignal: null, + dynamicTracking: clientDynamicTracking, + allowEmptyStaticShell, + revalidate: INFINITE_CACHE, + expire: INFINITE_CACHE, + stale: INFINITE_CACHE, + tags: [...implicitTags.tags], + prerenderResumeDataCache, + renderResumeDataCache, + hmrRefreshHash: undefined, + // Client prerenders don't track server param access + varyParamsAccumulator: null, + } + + let dynamicValidation = createDynamicValidationState() + + const finalClientOnHeaders = createOnHeadersCallback(appendHeader) + + let { prelude: unprocessedPrelude, postponed } = await runInSequentialTasks( + () => { + const pendingFinalClientResult = workUnitAsyncStorage.run( + finalClientPrerenderStore, + getClientPrerender, + // eslint-disable-next-line @next/internal/no-ambiguous-jsx + , + { + signal: finalClientReactController.signal, + onError: (err: unknown, errorInfo: ErrorInfo) => { + if ( + isPrerenderInterruptedError(err) || + finalClientReactController.signal.aborted + ) { + const componentStack: string | undefined = (errorInfo as any) + .componentStack + if (typeof componentStack === 'string') { + trackAllowedDynamicAccess( + workStore, + componentStack, + dynamicValidation, + clientDynamicTracking + ) + } + return + } + + return htmlRendererErrorHandler(err, errorInfo) + }, + onHeaders: finalClientOnHeaders, + maxHeadersLength: reactMaxHeadersLength, + bootstrapScripts: [bootstrapScript], + } ) // The listener to abort our own render controller must be added - // after React has added its listener, to ensure that pending I/O - // is not aborted/rejected too early. - finalServerReactController.signal.addEventListener( + // after React has added its listener, to ensure that pending I/O is + // not aborted/rejected too early. + finalClientReactController.signal.addEventListener( 'abort', () => { - finalServerRenderController.abort() + finalClientRenderController.abort() }, { once: true } ) - const prerenderResult = await pendingPrerenderResult - prerenderIsPending = false - - return prerenderResult + return pendingFinalClientResult + }, + () => { + finalClientReactController.abort() } - const reactServerResult = (reactServerPrerenderResult = - await createReactServerPrerenderResult( - runInSequentialTasks(finalRSCPrerenderFn, finalRSCAbortCallback) - )) - - const clientDynamicTracking = createDynamicTrackingState( - isDebugDynamicAccesses - ) + ) - const finalClientReactController = new AbortController() - const finalClientRenderController = new AbortController() + const { prelude, preludeIsEmpty } = + await processPreludeOp(unprocessedPrelude) - const finalClientPrerenderStore: PrerenderStore = { - type: 'prerender-client', - phase: 'render', - rootParams, - fallbackRouteParams, - implicitTags, - renderSignal: finalClientRenderController.signal, - controller: finalClientReactController, - // No APIs require a cacheSignal through the workUnitStore during the HTML prerender - cacheSignal: null, - dynamicTracking: clientDynamicTracking, - allowEmptyStaticShell, - revalidate: INFINITE_CACHE, - expire: INFINITE_CACHE, - stale: INFINITE_CACHE, - tags: [...implicitTags.tags], - prerenderResumeDataCache, - renderResumeDataCache, - hmrRefreshHash: undefined, - // Client prerenders don't track server param access - varyParamsAccumulator: null, - } + throwIfDisallowedDynamic( + workStore, + preludeIsEmpty ? PreludeState.Empty : PreludeState.Full, + dynamicValidation, + serverDynamicTracking, + allowEmptyStaticShell + ) - let dynamicValidation = createDynamicValidationState() + const getServerInsertedHTML = makeGetServerInsertedHTML({ + polyfills, + renderServerInsertedHTML, + serverCapturedErrors: allCapturedErrors, + basePath, + tracingMetadata: tracingMetadata, + }) - const finalClientOnHeaders = createOnHeadersCallback(appendHeader) + metadata.flightData = await streamToBuffer( + cachedNavigations + ? prependIsPartialByte(reactServerResult.asStream(), serverIsDynamic) + : reactServerResult.asStream() + ) - let { prelude: unprocessedPrelude, postponed } = - await runInSequentialTasks( - () => { - const pendingFinalClientResult = workUnitAsyncStorage.run( - finalClientPrerenderStore, - getClientPrerender, - // eslint-disable-next-line @next/internal/no-ambiguous-jsx - , - { - signal: finalClientReactController.signal, - onError: (err: unknown, errorInfo: ErrorInfo) => { - if ( - isPrerenderInterruptedError(err) || - finalClientReactController.signal.aborted - ) { - const componentStack: string | undefined = ( - errorInfo as any - ).componentStack - if (typeof componentStack === 'string') { - trackAllowedDynamicAccess( - workStore, - componentStack, - dynamicValidation, - clientDynamicTracking - ) - } - return - } + // collectSegmentData needs the raw flight data without the marker byte. + const flightData = cachedNavigations + ? metadata.flightData.subarray(1) + : metadata.flightData - return htmlRendererErrorHandler(err, errorInfo) - }, - onHeaders: finalClientOnHeaders, - maxHeadersLength: reactMaxHeadersLength, - bootstrapScripts: [bootstrapScript], - } - ) + await collectSegmentData( + flightData, + finalServerPrerenderStore, + ComponentMod, + renderOpts, + ctx.pagePath, + metadata + ) - // The listener to abort our own render controller must be added - // after React has added its listener, to ensure that pending I/O is - // not aborted/rejected too early. - finalClientReactController.signal.addEventListener( - 'abort', - () => { - finalClientRenderController.abort() - }, - { once: true } - ) + if (serverIsDynamic) { + // Dynamic case + // We will always need to perform a "resume" render of some kind when this route is accessed + // because the RSC data itself is dynamic. We determine if there are any HTML holes or not + // but generally this is a "partial" prerender in that there will be a per-request compute + // concatenated to the static shell. + if (postponed != null) { + // Dynamic HTML case + metadata.postponed = await getDynamicHTMLPostponedState( + postponed, + preludeIsEmpty + ? DynamicHTMLPreludeState.Empty + : DynamicHTMLPreludeState.Full, + fallbackRouteParams, + prerenderResumeDataCache, + cacheComponents + ) + } else { + // Dynamic Data case + metadata.postponed = await getDynamicDataPostponedState( + prerenderResumeDataCache, + cacheComponents + ) + } + reactServerResult.consume() + const continueDynamicPrerenderOpts = { + getServerInsertedHTML, + getServerInsertedMetadata, + deploymentId: ctx.sharedContext.deploymentId, + } + return { + digestErrorsMap: reactServerErrorsByDigest, + ssrErrors: allCapturedErrors, + stream: await continueDynamicPrerender( + prelude, + continueDynamicPrerenderOpts + ), + dynamicAccess: consumeDynamicAccess( + serverDynamicTracking, + clientDynamicTracking + ), + // TODO: Should this include the SSR pass? + collectedRevalidate: finalServerPrerenderStore.revalidate, + collectedExpire: finalServerPrerenderStore.expire, + collectedStale: selectStaleTime(finalServerPrerenderStore.stale), + collectedTags: finalServerPrerenderStore.tags, + renderResumeDataCache: createRenderResumeDataCache( + prerenderResumeDataCache + ), + } + } else { + // Static case + // We will not perform resumption per request. The result can be served statically to the requestor + // and if there was anything dynamic it will only be rendered in the browser. + if (workStore.forceDynamic) { + throw new StaticGenBailoutError( + 'Invariant: a Page with `dynamic = "force-dynamic"` did not trigger the dynamic pathway. This is a bug in Next.js' + ) + } - return pendingFinalClientResult - }, - () => { - finalClientReactController.abort() + let htmlStream: AnyStream = prelude + if (postponed != null) { + // We postponed but nothing dynamic was used. We resume the render now and immediately abort it + // so we can set all the postponed boundaries to client render mode before we store the HTML response + const foreverStream = createPendingStream() + const resumePrelude = await workUnitAsyncStorage.run( + finalServerPrerenderStore, + resumeAndAbort, + // eslint-disable-next-line @next/internal/no-ambiguous-jsx + {}} + ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + nonce={nonce} + images={ctx.renderOpts.images} + />, + JSON.parse(JSON.stringify(postponed)), + { + signal: createRenderInBrowserAbortSignal(), + onError: htmlRendererErrorHandler, + nonce, } ) + // First we write everything from the prerender, then we write everything from the aborted resume render + htmlStream = chainStreams(prelude, resumePrelude) + } - const { prelude, preludeIsEmpty } = - await processPreludeOp(unprocessedPrelude) - - throwIfDisallowedDynamic( - workStore, - preludeIsEmpty ? PreludeState.Empty : PreludeState.Full, - dynamicValidation, - serverDynamicTracking, - allowEmptyStaticShell - ) - - const getServerInsertedHTML = makeGetServerInsertedHTML({ - polyfills, - renderServerInsertedHTML, - serverCapturedErrors: allCapturedErrors, - basePath, - tracingMetadata: tracingMetadata, - }) - - metadata.flightData = await streamToBuffer( - cachedNavigations - ? prependIsPartialByte(reactServerResult.asStream(), serverIsDynamic) - : reactServerResult.asStream() - ) - - // collectSegmentData needs the raw flight data without the marker byte. - const flightData = cachedNavigations - ? metadata.flightData.subarray(1) - : metadata.flightData - - await collectSegmentData( - flightData, - finalServerPrerenderStore, - ComponentMod, - renderOpts, - ctx.pagePath, - metadata - ) - - if (serverIsDynamic) { - // Dynamic case - // We will always need to perform a "resume" render of some kind when this route is accessed - // because the RSC data itself is dynamic. We determine if there are any HTML holes or not - // but generally this is a "partial" prerender in that there will be a per-request compute - // concatenated to the static shell. - if (postponed != null) { - // Dynamic HTML case - metadata.postponed = await getDynamicHTMLPostponedState( - postponed, - preludeIsEmpty - ? DynamicHTMLPreludeState.Empty - : DynamicHTMLPreludeState.Full, - fallbackRouteParams, - prerenderResumeDataCache, - cacheComponents - ) - } else { - // Dynamic Data case - metadata.postponed = await getDynamicDataPostponedState( - prerenderResumeDataCache, - cacheComponents + let finalStream + const hasFallbackRouteParams = + fallbackRouteParams && fallbackRouteParams.size > 0 + if (hasFallbackRouteParams) { + // This is a "static fallback" prerender: although the page didn't + // access any runtime params in a Server Component, it may have + // accessed a runtime param in a client segment. + // + // TODO: If there were no client segments, we can use the fully static + // path instead. + // + // Rather than use a dynamic server resume to fill in the params, + // we can rely on the client to parse the params from the URL and use + // that to hydrate the page. + // + // Send an empty InitialRSCPayload to the server component renderer + // The data will be fetched by the client instead. + // TODO: In the future, rather than defer the entire hydration payload + // to be fetched by the client, we should only defer the client + // segments, since those are the only ones whose data is not complete. + const emptyReactServerResult = + await createReactServerPrerenderResultFromRender( + renderFlightStream(ComponentMod, [], clientModules, { + filterStackFrame, + onError: serverComponentsErrorHandler, + }) ) - } - reactServerResult.consume() - const continueDynamicPrerenderOpts = { + finalStream = await continueStaticFallbackPrerender(htmlStream, { + inlinedDataStream: createInlinedDataStream( + emptyReactServerResult.consumeAsStream(), + nonce, + formState + ), getServerInsertedHTML, getServerInsertedMetadata, deploymentId: ctx.sharedContext.deploymentId, - } - return { - digestErrorsMap: reactServerErrorsByDigest, - ssrErrors: allCapturedErrors, - stream: await continueDynamicPrerender( - prelude, - continueDynamicPrerenderOpts - ), - dynamicAccess: consumeDynamicAccess( - serverDynamicTracking, - clientDynamicTracking - ), - // TODO: Should this include the SSR pass? - collectedRevalidate: finalServerPrerenderStore.revalidate, - collectedExpire: finalServerPrerenderStore.expire, - collectedStale: selectStaleTime(finalServerPrerenderStore.stale), - collectedTags: finalServerPrerenderStore.tags, - renderResumeDataCache: createRenderResumeDataCache( - prerenderResumeDataCache - ), - } + }) } else { - // Static case - // We will not perform resumption per request. The result can be served statically to the requestor - // and if there was anything dynamic it will only be rendered in the browser. - if (workStore.forceDynamic) { - throw new StaticGenBailoutError( - 'Invariant: a Page with `dynamic = "force-dynamic"` did not trigger the dynamic pathway. This is a bug in Next.js' - ) - } - - let htmlStream: AnyStream = prelude - if (postponed != null) { - // We postponed but nothing dynamic was used. We resume the render now and immediately abort it - // so we can set all the postponed boundaries to client render mode before we store the HTML response - const foreverStream = createPendingStream() - const resumePrelude = await workUnitAsyncStorage.run( - finalServerPrerenderStore, - resumeAndAbort, - // eslint-disable-next-line @next/internal/no-ambiguous-jsx - {}} - ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} - nonce={nonce} - images={ctx.renderOpts.images} - />, - JSON.parse(JSON.stringify(postponed)), - { - signal: createRenderInBrowserAbortSignal(), - onError: htmlRendererErrorHandler, - nonce, - } - ) - // First we write everything from the prerender, then we write everything from the aborted resume render - htmlStream = chainStreams(prelude, resumePrelude) - } - - let finalStream - const hasFallbackRouteParams = - fallbackRouteParams && fallbackRouteParams.size > 0 - if (hasFallbackRouteParams) { - // This is a "static fallback" prerender: although the page didn't - // access any runtime params in a Server Component, it may have - // accessed a runtime param in a client segment. - // - // TODO: If there were no client segments, we can use the fully static - // path instead. - // - // Rather than use a dynamic server resume to fill in the params, - // we can rely on the client to parse the params from the URL and use - // that to hydrate the page. - // - // Send an empty InitialRSCPayload to the server component renderer - // The data will be fetched by the client instead. - // TODO: In the future, rather than defer the entire hydration payload - // to be fetched by the client, we should only defer the client - // segments, since those are the only ones whose data is not complete. - const emptyReactServerResult = - await createReactServerPrerenderResultFromRender( - renderFlightStream(ComponentMod, [], clientModules, { - filterStackFrame, - onError: serverComponentsErrorHandler, - }) - ) - finalStream = await continueStaticFallbackPrerender(htmlStream, { - inlinedDataStream: createInlinedDataStream( - emptyReactServerResult.consumeAsStream(), - nonce, - formState - ), - getServerInsertedHTML, - getServerInsertedMetadata, - deploymentId: ctx.sharedContext.deploymentId, - }) - } else { - // Normal static prerender case, no fallback param handling needed - finalStream = await continueStaticPrerender(htmlStream, { - inlinedDataStream: createInlinedDataStream( - reactServerResult.consumeAsStream(), - nonce, - formState - ), - getServerInsertedHTML, - getServerInsertedMetadata, - deploymentId: ctx.sharedContext.deploymentId, - }) - } - - return { - digestErrorsMap: reactServerErrorsByDigest, - ssrErrors: allCapturedErrors, - stream: finalStream, - dynamicAccess: consumeDynamicAccess( - serverDynamicTracking, - clientDynamicTracking - ), - // TODO: Should this include the SSR pass? - collectedRevalidate: finalServerPrerenderStore.revalidate, - collectedExpire: finalServerPrerenderStore.expire, - collectedStale: selectStaleTime(finalServerPrerenderStore.stale), - collectedTags: finalServerPrerenderStore.tags, - renderResumeDataCache: createRenderResumeDataCache( - prerenderResumeDataCache + // Normal static prerender case, no fallback param handling needed + finalStream = await continueStaticPrerender(htmlStream, { + inlinedDataStream: createInlinedDataStream( + reactServerResult.consumeAsStream(), + nonce, + formState ), - } + getServerInsertedHTML, + getServerInsertedMetadata, + deploymentId: ctx.sharedContext.deploymentId, + }) } + + return { + digestErrorsMap: reactServerErrorsByDigest, + ssrErrors: allCapturedErrors, + stream: finalStream, + dynamicAccess: consumeDynamicAccess( + serverDynamicTracking, + clientDynamicTracking + ), + // TODO: Should this include the SSR pass? + collectedRevalidate: finalServerPrerenderStore.revalidate, + collectedExpire: finalServerPrerenderStore.expire, + collectedStale: selectStaleTime(finalServerPrerenderStore.stale), + collectedTags: finalServerPrerenderStore.tags, + renderResumeDataCache: createRenderResumeDataCache( + prerenderResumeDataCache + ), + } + } + } + + try { + if (cacheComponents) { + return await prerenderWithCacheComponents(() => + getRSCPayload(tree, ctx, { is404: res.statusCode === 404 }) + ) } else if (experimental.isRoutePPREnabled) { // We're statically generating with PPR and need to do dynamic tracking let dynamicTracking = createDynamicTrackingState(isDebugDynamicAccesses) @@ -8491,6 +8500,59 @@ async function prerenderToStream( metadata.statusCode = res.statusCode } + if (cacheComponents) { + // Route the recovery render through the Cache Components prerender helper + // to prerender with CC semantics. This ensures for example that dynamic + // API access in the HTTP fallback boundary (e.g. `useSearchParams()` in + // `not-found.tsx`) is observed by the helper's dynamic validation and + // surfaces as a blocking-route error rather than the legacy + // `BailoutToCSRError`. For recoveries that render the synthetic error + // shell from `getErrorRSCPayload` (generic `Error`, `RedirectError`, + // `HTTPAccessFallbackError` with no matching boundary), the helper still + // runs but no user code participates in the SSR — `global-error.tsx` is + // plumbed into the payload but its `RootErrorBoundary` is never triggered + // because the synthetic shell can't throw. We still route synthetic-shell + // recoveries through the helper for consistency so legacy prerendering is + // never used when Cache Components is on. + + let prerenderHTTPError: PrerenderHTTPErrorState | undefined + if (isHTTPAccessFallbackError(err)) { + const triggeredStatus = getAccessFallbackHTTPStatus( + err + ) as HTTPAccessErrorStatusCode + const boundaryTree = findPrerenderHTTPErrorBoundaryTree( + tree, + triggeredStatus, + ctx.renderOpts.experimental.authInterrupts + ) + + if (boundaryTree) { + prerenderHTTPError = { + boundaryTree, + triggeredStatus, + } + } + } + + if (prerenderHTTPError) { + return await prerenderWithCacheComponents(() => + getRSCPayload(tree, ctx, { + is404: errorType === 'not-found', + prerenderHTTPError, + }) + ) + } + + return await prerenderWithCacheComponents(() => + getErrorRSCPayload( + tree, + ctx, + reactServerErrorsByDigest.has((err as any).digest) ? undefined : err, + errorType + ) + ) + } + const [errorPreinitScripts, errorBootstrapScript] = getRequiredScripts( buildManifest, assetPrefix, @@ -8520,46 +8582,17 @@ async function prerenderToStream( : INFINITE_CACHE, tags: [...(prerenderStore?.tags || implicitTags.tags)], }) - let prerenderHTTPError: PrerenderHTTPErrorState | undefined - if (cacheComponents && isHTTPAccessFallbackError(err)) { - const triggeredStatus = getAccessFallbackHTTPStatus( - err - ) as HTTPAccessErrorStatusCode - const boundaryTree = findPrerenderHTTPErrorBoundaryTree( - tree, - triggeredStatus, - ctx.renderOpts.experimental.authInterrupts - ) - if (boundaryTree) { - prerenderHTTPError = { - boundaryTree, - triggeredStatus, - } - } - } - - const errorRSCPayload = prerenderHTTPError - ? await workUnitAsyncStorage.run( - prerenderLegacyStore, - getRSCPayload, - tree, - ctx, - { - is404: errorType === 'not-found', - prerenderHTTPError, - } - ) - : await workUnitAsyncStorage.run( - prerenderLegacyStore, - getErrorRSCPayload, - tree, - ctx, - reactServerErrorsByDigest.has((err as any).digest) ? undefined : err, - errorType - ) + const errorRSCPayload = await workUnitAsyncStorage.run( + prerenderLegacyStore, + getErrorRSCPayload, + tree, + ctx, + reactServerErrorsByDigest.has((err as any).digest) ? undefined : err, + errorType + ) - const errorServerStreamRaw = workUnitAsyncStorage.run( + const errorServerStream = workUnitAsyncStorage.run( prerenderLegacyStore, renderFlightStream, ComponentMod, @@ -8571,19 +8604,6 @@ async function prerenderToStream( } ) - let errorServerStream = errorServerStreamRaw - const errorFlightResultPromise = prerenderHTTPError - ? (() => { - // Fizz still needs to read the Flight stream to render ErrorApp, but - // the prerender path also needs a buffered Flight result for the HTML - // prelude and segment data collectors. Tee the stream so each consumer - // gets its own copy. - const [appStream, flightStream] = teeStream(errorServerStreamRaw) - errorServerStream = appStream - return createReactServerPrerenderResultFromRender(flightStream) - })() - : null - try { const { stream: errorHtmlStream } = await workUnitAsyncStorage.run( prerenderLegacyStore, @@ -8604,15 +8624,10 @@ async function prerenderToStream( { waitForAllReady: true } ) - const resolvedFlightResult = errorFlightResultPromise - ? await errorFlightResultPromise - : reactServerPrerenderResult - if (errorFlightResultPromise) { - reactServerPrerenderResult.consume() - } - if (shouldGenerateStaticFlightData(workStore)) { - const flightData = await streamToBuffer(resolvedFlightResult.asStream()) + const flightData = await streamToBuffer( + reactServerPrerenderResult.asStream() + ) metadata.flightData = flightData await collectSegmentData( flightData, @@ -8624,14 +8639,12 @@ async function prerenderToStream( ) } - const flightStream = resolvedFlightResult.consumeAsStream() - return { digestErrorsMap: reactServerErrorsByDigest, ssrErrors: allCapturedErrors, stream: await continueFizzStream(errorHtmlStream, { inlinedDataStream: createInlinedDataStream( - flightStream, + reactServerPrerenderResult.consumeAsStream(), nonce, formState ), diff --git a/test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts b/test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts index afc7c71b0f17..85983331877e 100644 --- a/test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts +++ b/test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts @@ -100,7 +100,17 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { if (isTurbopack) { if (isDebugPrerender) { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() + "Error: Route "/not-found/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route at NotFound (app/not-found/[slug]/not-found.tsx:6:39) 4 | 5 | export default function NotFound() { @@ -108,10 +118,8 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { | ^ 7 | 8 | return

not found {searchParams.get('foo')}

- 9 | } { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + 9 | } + To debug the issue, start the app in development mode by running \`next dev\`, then open "/not-found/[slug]" in your browser to investigate the error. Error occurred prerendering page "/not-found/not-found". Read more: https://nextjs.org/docs/messages/prerender-error > Export encountered errors on 1 path: @@ -119,18 +127,30 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { `) } else { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() + "Error: Route "/not-found/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route at (app/not-found/[slug]/not-found.tsx:6:24) + at body () + at html () 4 | 5 | export default function NotFound() { > 6 | const searchParams = useSearchParams() | ^ 7 | 8 | return

not found {searchParams.get('foo')}

- 9 | } { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + 9 | } + To get a more detailed stack trace and pinpoint the issue, try one of the following: + - Start the app in development mode by running \`next dev\`, then open "/not-found/[slug]" in your browser to investigate the error. + - Rerun the production build with \`next build --debug-prerender\` to generate better stack traces. Error occurred prerendering page "/not-found/not-found". Read more: https://nextjs.org/docs/messages/prerender-error Export encountered an error on /not-found/[slug]/page: /not-found/not-found, exiting the build." `) @@ -138,20 +158,26 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { } else { if (isDebugPrerender) { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() - at useDynamicSearchParams (webpack:///) - at useSearchParams (webpack:///) + "Error: Route "/not-found/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route at NotFound (webpack:///app/not-found/[slug]/not-found.tsx:6:39) - 707 | return - 708 | } - > 709 | throw new BailoutToCSRError(expression) - | ^ - 710 | } - 711 | case 'prerender': - 712 | case 'prerender-runtime': { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + 4 | + 5 | export default function NotFound() { + > 6 | const searchParams = useSearchParams() + | ^ + 7 | + 8 | return

not found {searchParams.get('foo')}

+ 9 | } + To debug the issue, start the app in development mode by running \`next dev\`, then open "/not-found/[slug]" in your browser to investigate the error. Error occurred prerendering page "/not-found/not-found". Read more: https://nextjs.org/docs/messages/prerender-error > Export encountered errors on 1 path: @@ -159,13 +185,55 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { `) } else { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() + "Error: Route "/not-found/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route + at a () + at b () + at c () + at d () + at e () + at f () + at g () + at h () + at i () + at j () + at k () + at l () + at m () + at n () + at o () + at p () + at q () + at r () + at s () + at t () + at u () + at v () + at w () + at x () + at y () + at z () at a () at b () - at c () { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + at c () + at d () + at e () + at f () + at g () + at body () + at html () + To get a more detailed stack trace and pinpoint the issue, try one of the following: + - Start the app in development mode by running \`next dev\`, then open "/not-found/[slug]" in your browser to investigate the error. + - Rerun the production build with \`next build --debug-prerender\` to generate better stack traces. Error occurred prerendering page "/not-found/not-found". Read more: https://nextjs.org/docs/messages/prerender-error Export encountered an error on /not-found/[slug]/page: /not-found/not-found, exiting the build." `) @@ -203,7 +271,17 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { if (isTurbopack) { if (isDebugPrerender) { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() + "Error: Route "/forbidden/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route at Forbidden (app/forbidden/[slug]/forbidden.tsx:6:39) 4 | 5 | export default function Forbidden() { @@ -211,10 +289,8 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { | ^ 7 | 8 | return

forbidden {searchParams.get('foo')}

- 9 | } { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + 9 | } + To debug the issue, start the app in development mode by running \`next dev\`, then open "/forbidden/[slug]" in your browser to investigate the error. Error occurred prerendering page "/forbidden/forbidden". Read more: https://nextjs.org/docs/messages/prerender-error > Export encountered errors on 1 path: @@ -222,18 +298,30 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { `) } else { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() + "Error: Route "/forbidden/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route at (app/forbidden/[slug]/forbidden.tsx:6:24) + at body () + at html () 4 | 5 | export default function Forbidden() { > 6 | const searchParams = useSearchParams() | ^ 7 | 8 | return

forbidden {searchParams.get('foo')}

- 9 | } { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + 9 | } + To get a more detailed stack trace and pinpoint the issue, try one of the following: + - Start the app in development mode by running \`next dev\`, then open "/forbidden/[slug]" in your browser to investigate the error. + - Rerun the production build with \`next build --debug-prerender\` to generate better stack traces. Error occurred prerendering page "/forbidden/forbidden". Read more: https://nextjs.org/docs/messages/prerender-error Export encountered an error on /forbidden/[slug]/page: /forbidden/forbidden, exiting the build." `) @@ -241,20 +329,26 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { } else { if (isDebugPrerender) { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() - at useDynamicSearchParams (webpack:///) - at useSearchParams (webpack:///) + "Error: Route "/forbidden/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route at Forbidden (webpack:///app/forbidden/[slug]/forbidden.tsx:6:39) - 707 | return - 708 | } - > 709 | throw new BailoutToCSRError(expression) - | ^ - 710 | } - 711 | case 'prerender': - 712 | case 'prerender-runtime': { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + 4 | + 5 | export default function Forbidden() { + > 6 | const searchParams = useSearchParams() + | ^ + 7 | + 8 | return

forbidden {searchParams.get('foo')}

+ 9 | } + To debug the issue, start the app in development mode by running \`next dev\`, then open "/forbidden/[slug]" in your browser to investigate the error. Error occurred prerendering page "/forbidden/forbidden". Read more: https://nextjs.org/docs/messages/prerender-error > Export encountered errors on 1 path: @@ -262,13 +356,55 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { `) } else { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() + "Error: Route "/forbidden/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route at a () at b () - at c () { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + at c () + at d () + at e () + at f () + at g () + at h () + at i () + at j () + at k () + at l () + at m () + at n () + at o () + at p () + at q () + at r () + at s () + at t () + at u () + at v () + at w () + at x () + at y () + at z () + at a () + at b () + at c () + at d () + at e () + at f () + at g () + at body () + at html () + To get a more detailed stack trace and pinpoint the issue, try one of the following: + - Start the app in development mode by running \`next dev\`, then open "/forbidden/[slug]" in your browser to investigate the error. + - Rerun the production build with \`next build --debug-prerender\` to generate better stack traces. Error occurred prerendering page "/forbidden/forbidden". Read more: https://nextjs.org/docs/messages/prerender-error Export encountered an error on /forbidden/[slug]/page: /forbidden/forbidden, exiting the build." `) @@ -306,7 +442,17 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { if (isTurbopack) { if (isDebugPrerender) { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() + "Error: Route "/unauthorized/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route at Unauthorized (app/unauthorized/[slug]/unauthorized.tsx:6:39) 4 | 5 | export default function Unauthorized() { @@ -314,10 +460,8 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { | ^ 7 | 8 | return

unauthorized {searchParams.get('foo')}

- 9 | } { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + 9 | } + To debug the issue, start the app in development mode by running \`next dev\`, then open "/unauthorized/[slug]" in your browser to investigate the error. Error occurred prerendering page "/unauthorized/unauthorized". Read more: https://nextjs.org/docs/messages/prerender-error > Export encountered errors on 1 path: @@ -325,18 +469,30 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { `) } else { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() + "Error: Route "/unauthorized/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route at (app/unauthorized/[slug]/unauthorized.tsx:6:24) + at body () + at html () 4 | 5 | export default function Unauthorized() { > 6 | const searchParams = useSearchParams() | ^ 7 | 8 | return

unauthorized {searchParams.get('foo')}

- 9 | } { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + 9 | } + To get a more detailed stack trace and pinpoint the issue, try one of the following: + - Start the app in development mode by running \`next dev\`, then open "/unauthorized/[slug]" in your browser to investigate the error. + - Rerun the production build with \`next build --debug-prerender\` to generate better stack traces. Error occurred prerendering page "/unauthorized/unauthorized". Read more: https://nextjs.org/docs/messages/prerender-error Export encountered an error on /unauthorized/[slug]/page: /unauthorized/unauthorized, exiting the build." `) @@ -344,20 +500,26 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { } else { if (isDebugPrerender) { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() - at useDynamicSearchParams (webpack:///) - at useSearchParams (webpack:///) + "Error: Route "/unauthorized/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route at Unauthorized (webpack:///app/unauthorized/[slug]/unauthorized.tsx:6:39) - 707 | return - 708 | } - > 709 | throw new BailoutToCSRError(expression) - | ^ - 710 | } - 711 | case 'prerender': - 712 | case 'prerender-runtime': { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + 4 | + 5 | export default function Unauthorized() { + > 6 | const searchParams = useSearchParams() + | ^ + 7 | + 8 | return

unauthorized {searchParams.get('foo')}

+ 9 | } + To debug the issue, start the app in development mode by running \`next dev\`, then open "/unauthorized/[slug]" in your browser to investigate the error. Error occurred prerendering page "/unauthorized/unauthorized". Read more: https://nextjs.org/docs/messages/prerender-error > Export encountered errors on 1 path: @@ -365,13 +527,55 @@ describe('Cache Components HTTP Access Fallback Prerender', () => { `) } else { expect(output).toMatchInlineSnapshot(` - "Error: Bail out to client-side rendering: useSearchParams() + "Error: Route "/unauthorized/[slug]": Next.js encountered uncached or runtime data during prerendering. + + \`fetch(...)\`, \`cookies()\`, \`headers()\`, \`params\`, \`searchParams\`, or \`connection()\` accessed outside of \`\` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. + + Ways to fix this: + - Cache the data access with \`"use cache"\` + - Provide a placeholder with \`\` around the data access + - If the runtime data is \`params\` and they're known, prerender them with \`generateStaticParams\` + - Set \`export const instant = false\` to allow a blocking route + + Learn more: https://nextjs.org/docs/messages/blocking-route + at a () + at b () + at c () + at d () + at e () + at f () + at g () + at h () + at i () + at j () + at k () + at l () + at m () + at n () + at o () + at p () + at q () + at r () + at s () + at t () + at u () + at v () + at w () + at x () + at y () + at z () at a () at b () - at c () { - reason: 'useSearchParams()', - digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING' - } + at c () + at d () + at e () + at f () + at g () + at body () + at html () + To get a more detailed stack trace and pinpoint the issue, try one of the following: + - Start the app in development mode by running \`next dev\`, then open "/unauthorized/[slug]" in your browser to investigate the error. + - Rerun the production build with \`next build --debug-prerender\` to generate better stack traces. Error occurred prerendering page "/unauthorized/unauthorized". Read more: https://nextjs.org/docs/messages/prerender-error Export encountered an error on /unauthorized/[slug]/page: /unauthorized/unauthorized, exiting the build." `) From 2ca99e1e81dae6d9b8f5f5531d47171c9a8a1660 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 20 May 2026 19:37:48 -0400 Subject: [PATCH 7/9] Add experimental.appShells feature flag (#93997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plumbing for upcoming App Shell prefetching support: sets up the config field, the zod entry, and the build-time `process.env.__NEXT_APP_SHELLS` define. No behavior change in this PR — nothing reads the flag yet. Subsequent PRs in this sequence wire the server- and client-side work behind it. Enabling `experimental.appShells` requires several adjacent flags to also be enabled: `cacheComponents`, `experimental.prefetchInlining`, `experimental.varyParams`, `experimental.optimisticRouting`, and `experimental.cachedNavigations`. All of these are on track to become defaults soon, so there's no value in supporting App Shells against arbitrary subsets — the validation enforces that App Shells is tested in the configuration it will ship with. Each requirement drops out as the corresponding flag becomes a default. --- packages/next/errors.json | 3 ++- packages/next/src/build/define-env.ts | 1 + packages/next/src/server/config-schema.ts | 1 + packages/next/src/server/config-shared.ts | 10 ++++++++ packages/next/src/server/config.ts | 29 +++++++++++++++++++++++ 5 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/next/errors.json b/packages/next/errors.json index cfc81bdc0063..35c6a675304d 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1276,5 +1276,6 @@ "1275": "\\`experimental.cssChunking: false\\` is only supported with webpack. Please remove the option or run Next.js with webpack in %s.", "1276": "Compilation failed but no issues were recorded", "1277": "no route matched for path \"%s\"", - "1278": "compileRoute: either routeSpecifier or path is required" + "1278": "compileRoute: either routeSpecifier or path is required", + "1279": "\\`experimental.appShells\\` requires the following to also be enabled: %s. Please update your %s accordingly." } diff --git a/packages/next/src/build/define-env.ts b/packages/next/src/build/define-env.ts index bf2e24c75d27..67fcbd73d19e 100644 --- a/packages/next/src/build/define-env.ts +++ b/packages/next/src/build/define-env.ts @@ -380,6 +380,7 @@ export function getDefineEnv({ config.experimental.gestureTransition ?? false, 'process.env.__NEXT_OPTIMISTIC_ROUTING': config.experimental.optimisticRouting ?? false, + 'process.env.__NEXT_APP_SHELLS': config.experimental.appShells ?? false, 'process.env.__NEXT_VARY_PARAMS': config.experimental.varyParams ?? false, 'process.env.__NEXT_EXPOSE_TESTING_API': dev || config.experimental.exposeTestingApiInProductionBuild === true, diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index dcda307a385c..b47f0110f0af 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -224,6 +224,7 @@ export const experimentalSchema = { dynamicOnHover: z.boolean().optional(), useOffline: z.boolean().optional(), optimisticRouting: z.boolean().optional(), + appShells: z.boolean().optional(), varyParams: z.boolean().optional(), prefetchInlining: z .union([ diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index a56de95104e2..3b2ed37ad143 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -470,6 +470,14 @@ export interface ExperimentalConfig { dynamicOnHover?: boolean useOffline?: boolean optimisticRouting?: boolean + /** + * Enables App Shell prefetching: a route's reusable, param-free loading + * state is prefetched once per session and served instantly for any + * concrete navigation. Routes marked as fully static (no per-request + * server work) are unaffected; the App Shell phase only runs for + * runtime-prefetchable routes. + */ + appShells?: boolean varyParams?: boolean prefetchInlining?: | boolean @@ -2117,6 +2125,7 @@ export interface NextConfigRuntime { | 'dynamicOnHover' | 'useOffline' | 'optimisticRouting' + | 'appShells' | 'inlineCss' | 'prefetchInlining' | 'authInterrupts' @@ -2184,6 +2193,7 @@ export function getNextConfigRuntime( dynamicOnHover: ex.dynamicOnHover, useOffline: ex.useOffline, optimisticRouting: ex.optimisticRouting, + appShells: ex.appShells, inlineCss: ex.inlineCss, prefetchInlining: ex.prefetchInlining, authInterrupts: ex.authInterrupts, diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 4125151a83d5..4832d1f1826b 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -502,6 +502,35 @@ function assignDefaultsAndValidate( ) } + if (result.experimental.appShells) { + // App Shells is tested in combination with the experimental flags it + // expects to ship alongside. All of these are on track to become + // defaults, so we don't support enabling App Shells against arbitrary + // subsets of them — the validation goes away once each becomes a + // default. + const missing: string[] = [] + if (!result.cacheComponents) { + missing.push('`cacheComponents`') + } + if (!result.experimental.prefetchInlining) { + missing.push('`experimental.prefetchInlining`') + } + if (!result.experimental.varyParams) { + missing.push('`experimental.varyParams`') + } + if (!result.experimental.optimisticRouting) { + missing.push('`experimental.optimisticRouting`') + } + if (!result.experimental.cachedNavigations) { + missing.push('`experimental.cachedNavigations`') + } + if (missing.length > 0) { + throw new Error( + `\`experimental.appShells\` requires the following to also be enabled: ${missing.join(', ')}. Please update your ${configFileName} accordingly.` + ) + } + } + if (result.experimental.ppr) { throw new HardDeprecatedConfigError( `\`experimental.ppr\` has been merged into \`cacheComponents\`. The Partial Prerendering feature is still available, but is now enabled via \`cacheComponents\`. Please update your ${configFileName} accordingly.` From 7c4b0525eeb2c072941a53f19aa89b71ed6704b4 Mon Sep 17 00:00:00 2001 From: "next-js-bot[bot]" <279046576+next-js-bot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 23:44:50 +0000 Subject: [PATCH 8/9] v16.3.0-canary.25 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/eslint-config-next/package.json | 4 ++-- packages/eslint-plugin-internal/package.json | 2 +- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-playwright/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-routing/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 +++++++------- packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 ++-- pnpm-lock.yaml | 16 ++++++++-------- 21 files changed, 36 insertions(+), 36 deletions(-) diff --git a/lerna.json b/lerna.json index ca86b8a0aca4..9b05981d4e2e 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.3.0-canary.24" + "version": "16.3.0-canary.25" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index f1299f37c586..253724040b3e 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 9eff5a3ccd05..80b65ad98b70 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.3.0-canary.24", + "@next/eslint-plugin-next": "16.3.0-canary.25", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 7f66a2ab6320..7e7e8061a20a 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 2443d6725559..1af57eda3f40 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index 9c9b771b5b0a..ab4e19c66ec6 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 3d0be1330434..ed05742821f4 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 2e44b5e51fa6..4e7ca0400a62 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index de4637c2a0a6..601090d9137d 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 6c46c5a1da16..a12928c05a47 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index dcae17df4936..bf5d7453ef8e 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 0666b1088583..7fb52c444e47 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 4041112bd048..f9fc9173991c 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 6c5fdf7189da..9b09bc31cf67 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index 2236563ae7d5..1b6a9e4bcfcc 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 8328bd4e2437..11641497cb3f 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index c0e505e1ab57..e17688ff94e4 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index fb3cbfcb2caf..fec968c491b5 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -101,7 +101,7 @@ ] }, "dependencies": { - "@next/env": "16.3.0-canary.24", + "@next/env": "16.3.0-canary.25", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -165,11 +165,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.3.0-canary.24", - "@next/polyfill-module": "16.3.0-canary.24", - "@next/polyfill-nomodule": "16.3.0-canary.24", - "@next/react-refresh-utils": "16.3.0-canary.24", - "@next/swc": "16.3.0-canary.24", + "@next/font": "16.3.0-canary.25", + "@next/polyfill-module": "16.3.0-canary.25", + "@next/polyfill-nomodule": "16.3.0-canary.25", + "@next/react-refresh-utils": "16.3.0-canary.25", + "@next/swc": "16.3.0-canary.25", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.58.2", "@rspack/core": "1.6.7", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index a013c0c31a0d..389418b121b6 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 67b8bcb7226a..2b97e71752bc 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.3.0-canary.24", + "version": "16.3.0-canary.25", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -27,7 +27,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.3.0-canary.24", + "next": "16.3.0-canary.25", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "6.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4b73758a95f..fb7cea10506a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -986,7 +986,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.3.0-canary.24 + specifier: 16.3.0-canary.25 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1063,7 +1063,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.3.0-canary.24 + specifier: 16.3.0-canary.25 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1184,19 +1184,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.3.0-canary.24 + specifier: 16.3.0-canary.25 version: link:../font '@next/polyfill-module': - specifier: 16.3.0-canary.24 + specifier: 16.3.0-canary.25 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.3.0-canary.24 + specifier: 16.3.0-canary.25 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.3.0-canary.24 + specifier: 16.3.0-canary.25 version: link:../react-refresh-utils '@next/swc': - specifier: 16.3.0-canary.24 + specifier: 16.3.0-canary.25 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1930,7 +1930,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.3.0-canary.24 + specifier: 16.3.0-canary.25 version: link:../next outdent: specifier: 0.8.0 From 80cb3447c172c9a406a0b2ffa27a7dc0e4e8fac3 Mon Sep 17 00:00:00 2001 From: Benjamin Woodruff Date: Wed, 20 May 2026 17:14:48 -0700 Subject: [PATCH 9/9] [ci] Add an `TURBO_TASKS_AVAILABLE_PARALLELISM` environment variable that overrides `std::thread::available_parallelism` callsites (#93995) We want to use this to potentially improve efficiency in e2e tests where we're running many instances of Turbopack at once. By default, turbo-tasks will try to use every available CPU core. **This environment variable does not strictly limit Turbopack to using 4 cores, it just restricts the number of main tokio worker threads and changes how we shard dashmaps.** This option will not make sense for the vast majority of users, it only makes sense when you are running *many* instances of Turbopack at once, and the exact semantics are potentially confusing, as it is not a hard limit. ## Test Plan Build vercel-site with this set to 1 and see a little over 100% CPU usage. Set it to 8 and see around 800% CPU usage. ``` TURBO_TASKS_AVAILABLE_PARALLELISM=1 pnpm next build --experimental-build-mode=compile ``` --- crates/next-api/src/project.rs | 7 +++-- crates/next-napi-bindings/src/lib.rs | 3 +- run-tests.js | 1 + .../src/database/turbo/mod.rs | 2 +- .../src/utils/shard_amount.rs | 2 +- turbopack/crates/turbo-tasks/src/parallel.rs | 30 +++++++++++++++++++ turbopack/crates/turbo-tasks/src/scope.rs | 6 ++-- turbopack/crates/turbo-tasks/src/util.rs | 2 +- turbopack/crates/turbopack-cli/src/main.rs | 3 +- .../crates/turbopack-node/src/evaluate.rs | 4 +-- 10 files changed, 47 insertions(+), 13 deletions(-) diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index 08e920bb5ec7..56cded9abe4b 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -588,9 +588,10 @@ impl ProjectContainer { node_version = options.current_node_js_version.as_str(), os = std::env::consts::OS, arch = std::env::consts::ARCH, - cpu_cores = std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(0), + turbo_tasks_available_parallelism = + turbo_tasks::parallel::available_parallelism().map(|n| n.get()).unwrap_or(0), + std_thread_available_parallelism = + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0), dev = options.dev, env_diff = Empty ); diff --git a/crates/next-napi-bindings/src/lib.rs b/crates/next-napi-bindings/src/lib.rs index 5107b983c65a..6282eb54750f 100644 --- a/crates/next-napi-bindings/src/lib.rs +++ b/crates/next-napi-bindings/src/lib.rs @@ -74,7 +74,6 @@ fn init() { use std::{ cell::RefCell, panic::{set_hook, take_hook}, - thread::available_parallelism, time::{Duration, Instant}, }; @@ -83,7 +82,7 @@ fn init() { } use tokio::runtime::Builder; - use turbo_tasks::panic_hooks::handle_panic; + use turbo_tasks::{panic_hooks::handle_panic, parallel::available_parallelism}; use turbo_tasks_malloc::TurboMalloc; let prev_hook = take_hook(); diff --git a/run-tests.js b/run-tests.js index b8dd2ead2957..e8a4d28114b4 100644 --- a/run-tests.js +++ b/run-tests.js @@ -40,6 +40,7 @@ class TestProfile { 'NEXT_E2E_TEST_TIMEOUT', 'NEXT_TURBOPACK_IO_CONCURRENCY', 'NEXT_TEST_PASSED_FILE', + 'TURBO_TASKS_AVAILABLE_PARALLELISM', ]) // All key=value pairs identifying this test profile, sorted by key. diff --git a/turbopack/crates/turbo-tasks-backend/src/database/turbo/mod.rs b/turbopack/crates/turbo-tasks-backend/src/database/turbo/mod.rs index a643613921e9..9b620db8ae0d 100644 --- a/turbopack/crates/turbo-tasks-backend/src/database/turbo/mod.rs +++ b/turbopack/crates/turbo-tasks-backend/src/database/turbo/mod.rs @@ -2,7 +2,6 @@ use std::{ cmp::max, path::PathBuf, sync::Arc, - thread::available_parallelism, time::{Duration, Instant, SystemTime}, }; @@ -13,6 +12,7 @@ use turbo_persistence::{ }; use turbo_tasks::{ message_queue::{TimingEvent, TraceEvent}, + parallel::available_parallelism, turbo_tasks, }; diff --git a/turbopack/crates/turbo-tasks-backend/src/utils/shard_amount.rs b/turbopack/crates/turbo-tasks-backend/src/utils/shard_amount.rs index 25e6e65e3c0c..0a0a44ce091f 100644 --- a/turbopack/crates/turbo-tasks-backend/src/utils/shard_amount.rs +++ b/turbopack/crates/turbo-tasks-backend/src/utils/shard_amount.rs @@ -1,4 +1,4 @@ -use std::thread::available_parallelism; +use turbo_tasks::parallel::available_parallelism; /// Compute a good number of shards to use for sharded data structures. /// The number of shards is computed based on the number of worker threads diff --git a/turbopack/crates/turbo-tasks/src/parallel.rs b/turbopack/crates/turbo-tasks/src/parallel.rs index 4837bf3d3f63..047612bdf456 100644 --- a/turbopack/crates/turbo-tasks/src/parallel.rs +++ b/turbopack/crates/turbo-tasks/src/parallel.rs @@ -5,11 +5,41 @@ //! //! See also: +use std::{ + env, io, + num::NonZeroUsize, + sync::{Arc, OnceLock}, + thread, +}; + use crate::{ scope::scope_and_block, util::{Chunk, good_chunk_size, into_chunks}, }; +/// Returns the recommended amount of parallelism for the current process. Typically the number of +/// available CPU cores. +/// +/// This wraps [`std::thread::available_parallelism`] with a couple extras: +/// +/// - If the `TURBO_TASKS_AVAILABLE_PARALLELISM` env var is set, overrides the value. Panics if this +/// env var fails to parse. +/// - The resolved value is cached in a [`OnceLock`] +pub fn available_parallelism() -> Result> { + static CACHED: OnceLock>> = OnceLock::new(); + CACHED + .get_or_init(|| { + if let Ok(raw) = env::var("TURBO_TASKS_AVAILABLE_PARALLELISM") { + Ok(raw.parse::().unwrap_or_else(|err| { + panic!("Invalid TURBO_TASKS_AVAILABLE_PARALLELISM={raw:?}: {err}") + })) + } else { + thread::available_parallelism().map_err(Arc::new) + } + }) + .clone() +} + struct Chunked { chunk_size: usize, chunk_count: usize, diff --git a/turbopack/crates/turbo-tasks/src/scope.rs b/turbopack/crates/turbo-tasks/src/scope.rs index 6fe7efc46120..fab2a1f6b552 100644 --- a/turbopack/crates/turbo-tasks/src/scope.rs +++ b/turbopack/crates/turbo-tasks/src/scope.rs @@ -9,7 +9,7 @@ use std::{ Arc, LazyLock, atomic::{AtomicUsize, Ordering}, }, - thread::{self, Thread, available_parallelism}, + thread::{self, Thread}, time::{Duration, Instant}, }; @@ -17,7 +17,9 @@ use parking_lot::{Condvar, Mutex}; use tokio::{runtime::Handle, task::block_in_place}; use tracing::{Span, info_span}; -use crate::{TurboTasksApi, manager::try_turbo_tasks, turbo_tasks_scope}; +use crate::{ + TurboTasksApi, manager::try_turbo_tasks, parallel::available_parallelism, turbo_tasks_scope, +}; /// Number of worker tasks to spawn that process jobs. It's 1 less than the number of cpus as we /// also use the current task as worker. diff --git a/turbopack/crates/turbo-tasks/src/util.rs b/turbopack/crates/turbo-tasks/src/util.rs index 86550addf48c..59879a444b56 100644 --- a/turbopack/crates/turbo-tasks/src/util.rs +++ b/turbopack/crates/turbo-tasks/src/util.rs @@ -9,7 +9,6 @@ use std::{ pin::Pin, sync::{Arc, LazyLock}, task::{Context, Poll}, - thread::available_parallelism, time::Duration, }; @@ -22,6 +21,7 @@ use bincode::{ }; use pin_project_lite::pin_project; +use crate::parallel::available_parallelism; pub use crate::{ id_factory::{IdFactory, IdFactoryWithReuse}, once_map::*, diff --git a/turbopack/crates/turbopack-cli/src/main.rs b/turbopack/crates/turbopack-cli/src/main.rs index a540e5b0223c..97867b8c47bc 100644 --- a/turbopack/crates/turbopack-cli/src/main.rs +++ b/turbopack/crates/turbopack-cli/src/main.rs @@ -1,8 +1,9 @@ -use std::{cell::RefCell, path::Path, thread::available_parallelism, time::Instant}; +use std::{cell::RefCell, path::Path, time::Instant}; use anyhow::{Context, Result}; use clap::Parser; use tracing_subscriber::{Registry, layer::SubscriberExt, util::SubscriberInitExt}; +use turbo_tasks::parallel::available_parallelism; use turbo_tasks_malloc::TurboMalloc; use turbopack_cli::arguments::Arguments; use turbopack_trace_utils::{ diff --git a/turbopack/crates/turbopack-node/src/evaluate.rs b/turbopack/crates/turbopack-node/src/evaluate.rs index 2c81f6105d16..111b35e8ef44 100644 --- a/turbopack/crates/turbopack-node/src/evaluate.rs +++ b/turbopack/crates/turbopack-node/src/evaluate.rs @@ -1,4 +1,4 @@ -use std::{iter, process::ExitStatus, sync::Arc, thread::available_parallelism, time::Duration}; +use std::{iter, process::ExitStatus, sync::Arc, time::Duration}; use anyhow::{Result, bail}; use async_trait::async_trait; @@ -11,7 +11,7 @@ use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{ Completion, FxIndexMap, NonLocalValue, OperationVc, PrettyPrintError, ResolvedVc, TaskInput, TryJoinIterExt, ValueToString, Vc, duration_span, fxindexmap, mark_top_level_task, - take_effects, trace::TraceRawVcs, + parallel::available_parallelism, take_effects, trace::TraceRawVcs, }; use turbo_tasks_env::{EnvMap, ProcessEnv}; use turbo_tasks_fs::{File, FileContent, FileSystemPath, to_sys_path};