diff --git a/Cargo.lock b/Cargo.lock
index 888b1001fc3e..d64935835bf1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3181,8 +3181,6 @@ version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
dependencies = [
- "allocator-api2",
- "equivalent",
"foldhash 0.1.5",
"serde",
]
@@ -4973,7 +4971,7 @@ dependencies = [
"allsorts",
"anyhow",
"async-trait",
- "base64 0.21.4",
+ "base64 0.22.1",
"bincode 2.0.1",
"either",
"futures",
@@ -4990,7 +4988,7 @@ dependencies = [
"qstring",
"react_remove_properties",
"regex",
- "regress 0.10.4",
+ "regress",
"remove_console",
"rustc-hash 2.1.1",
"serde",
@@ -5033,7 +5031,7 @@ name = "next-custom-transforms"
version = "0.0.0"
dependencies = [
"anyhow",
- "base64 0.21.4",
+ "base64 0.22.1",
"bytes-str",
"chrono",
"easy-error",
@@ -6596,16 +6594,6 @@ dependencies = [
"windows-sys 0.52.0",
]
-[[package]]
-name = "regress"
-version = "0.10.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "145bb27393fe455dd64d6cbc8d059adfa392590a45eadf079c01b11857e7b010"
-dependencies = [
- "hashbrown 0.15.4",
- "memchr",
-]
-
[[package]]
name = "regress"
version = "0.11.1"
@@ -7966,7 +7954,7 @@ dependencies = [
"indexmap 2.14.0",
"once_cell",
"regex",
- "regress 0.11.1",
+ "regress",
"rustc-hash 2.1.1",
"serde",
"serde_json",
@@ -9983,7 +9971,7 @@ dependencies = [
"anyhow",
"bincode 2.0.1",
"regex",
- "regress 0.10.4",
+ "regress",
"turbo-tasks",
]
@@ -10326,6 +10314,7 @@ dependencies = [
name = "turbo-tasks-malloc"
version = "0.1.0"
dependencies = [
+ "codspeed-criterion-compat",
"libc",
"libmimalloc-sys",
"mimalloc",
@@ -10771,7 +10760,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
- "base64 0.21.4",
+ "base64 0.22.1",
"bincode 2.0.1",
"image",
"mime",
@@ -10850,7 +10839,7 @@ dependencies = [
"anyhow",
"async-stream",
"async-trait",
- "base64 0.21.4",
+ "base64 0.22.1",
"bincode 2.0.1",
"bytes",
"const_format",
@@ -11088,7 +11077,7 @@ dependencies = [
"turbo-tasks-hash",
"turbopack-core",
"turbopack-ecmascript",
- "wasmparser 0.235.0",
+ "wasmparser 0.239.0",
"wat",
]
@@ -12066,10 +12055,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "161296c618fa2d63f6ed5fffd1112937e803cb9ec71b32b01a76321555660917"
dependencies = [
"bitflags 2.9.1",
- "hashbrown 0.15.4",
"indexmap 2.14.0",
- "semver",
- "serde",
]
[[package]]
@@ -12306,24 +12292,24 @@ dependencies = [
[[package]]
name = "wast"
-version = "235.0.0"
+version = "239.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1eda4293f626c99021bb3a6fbe4fbbe90c0e31a5ace89b5f620af8925de72e13"
+checksum = "9139176fe8a2590e0fb174cdcaf373b224cb93c3dde08e4297c1361d2ba1ea5d"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width 0.2.1",
- "wasm-encoder 0.235.0",
+ "wasm-encoder 0.239.0",
]
[[package]]
name = "wat"
-version = "1.235.0"
+version = "1.239.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e777e0327115793cb96ab220b98f85327ec3d11f34ec9e8d723264522ef206aa"
+checksum = "3e1c941927d34709f255558166f8901a2005f8ab4a9650432e9281b7cc6f3b75"
dependencies = [
- "wast 235.0.0",
+ "wast 239.0.0",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 094292699c0a..adf583452376 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -251,6 +251,7 @@ allsorts = { version = "0.14.0", default-features = false, features = [
] }
anyhow = "1.0.100"
async-trait = "0.1.64"
+base64 = "0.22.1"
bincode = { version = "2.0.1", features = ["serde"] }
bitfield = "0.19.4"
bumpalo = { version = "3.19.0", features = ["boxed", "collections", "allocator-api2"] }
@@ -322,7 +323,7 @@ quote = "1.0.45"
rand = "0.10.1"
rayon = "1.10.0"
regex = "1.12.3"
-regress = "0.10.4"
+regress = "0.11.1"
reqwest = { version = "0.13.2", default-features = false }
ringmap = "0.2.5"
roaring = "0.11.4"
diff --git a/crates/next-api/src/module_graph.rs b/crates/next-api/src/module_graph.rs
index 58568a60afff..1a2de21544b6 100644
--- a/crates/next-api/src/module_graph.rs
+++ b/crates/next-api/src/module_graph.rs
@@ -300,11 +300,10 @@ impl ServerActionsGraphs {
let result = self
.0
.iter()
- .map(async |graph| {
+ .map(|graph| {
graph
.get_server_actions_for_endpoint(entry, rsc_asset_context)
.owned()
- .await
})
.try_flat_join()
.await?;
diff --git a/crates/next-api/src/next_server_nft.rs b/crates/next-api/src/next_server_nft.rs
index 7abd4b326a1f..d171159ba056 100644
--- a/crates/next-api/src/next_server_nft.rs
+++ b/crates/next-api/src/next_server_nft.rs
@@ -208,7 +208,6 @@ impl Asset for ServerNftJsonAsset {
self.entries(),
Some(self.ignores()),
None,
- hash_salt,
)
.await?
.iter()
diff --git a/crates/next-api/src/nft.rs b/crates/next-api/src/nft.rs
index 97fa40868705..2233da11ae90 100644
--- a/crates/next-api/src/nft.rs
+++ b/crates/next-api/src/nft.rs
@@ -89,7 +89,6 @@ pub async fn trace_endpoint(
.await?
.map(|v| *v),
Some(next_config.config_file_path(project_path.clone())),
- hash_salt,
)
.await?;
@@ -284,12 +283,11 @@ pub async fn traced_modules_for_entries(
traced_entries: Vc,
exclude_glob: Option>,
forbidden_path: Option>,
- hash_salt: Vc,
) -> Result> {
let exclude_glob_and_module_idents = if let Some(exclude_glob) = exclude_glob {
let exclude_glob = exclude_glob.await?;
- let data = traced_module_data_for_graph(module_graph, traced_entries, hash_salt).await?;
- Some((exclude_glob, data.idents.await?))
+ let idents = traced_module_idents_for_graph(module_graph, traced_entries).await?;
+ Some((exclude_glob, idents))
} else {
None
};
@@ -388,13 +386,12 @@ pub struct TracedModuleData {
pub hashes: ResolvedVc,
}
-/// This caches the paths for all modules in the graph so that we don't have to do it once per page.
+/// This caches the paths for all modules in the graph without eagerly hashing their contents.
#[turbo_tasks::function]
-pub async fn traced_module_data_for_graph(
+async fn traced_module_idents_for_graph(
module_graph: Vc,
traced_entries: Vc,
- hash_salt: Vc,
-) -> Result> {
+) -> Result> {
// This function is very similar to traced_modules_for_entries, but doesn't apply the glob and
// is executed only once for the whole graph.
let module_graph = module_graph.await?;
@@ -423,30 +420,51 @@ pub async fn traced_module_data_for_graph(
true,
)?;
- let (idents, hashes): (FxHashMap<_, _>, FxHashMap<_, _>) = traced_modules
- .into_iter()
+ Ok(Vc::cell(
+ traced_modules
+ .into_iter()
+ .map(async |module| Ok((module, module.ident().await?)))
+ .try_join()
+ .await?
+ .into_iter()
+ .collect(),
+ ))
+}
+
+/// This caches the paths and content hashes for all modules in the graph so that we don't have to
+/// compute them once per page.
+#[turbo_tasks::function]
+pub async fn traced_module_data_for_graph(
+ module_graph: Vc,
+ traced_entries: Vc,
+ hash_salt: Vc,
+) -> Result> {
+ let idents = traced_module_idents_for_graph(module_graph, traced_entries)
+ .to_resolved()
+ .await?;
+ let hashes = idents
+ .await?
+ .keys()
+ .copied()
.map(async |module| {
Ok((
- (module, module.ident().await?),
- (
- module,
- module
- .source()
- .await?
- .context("NFT module has no content")?
- .content()
- .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
- .await?,
- ),
+ module,
+ module
+ .source()
+ .await?
+ .context("NFT module has no content")?
+ .content()
+ .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
+ .await?,
))
})
.try_join()
.await?
.into_iter()
- .unzip();
+ .collect();
Ok(TracedModuleData {
- idents: ResolvedVc::cell(idents),
+ idents,
hashes: ResolvedVc::cell(hashes),
}
.cell())
diff --git a/crates/next-api/src/versioned_content_map.rs b/crates/next-api/src/versioned_content_map.rs
index b6edcb316b39..863621d28830 100644
--- a/crates/next-api/src/versioned_content_map.rs
+++ b/crates/next-api/src/versioned_content_map.rs
@@ -295,12 +295,8 @@ impl VersionedContentMap {
};
let keys = keys
.into_iter()
- .map(|path| {
- let root = root.clone();
- async move { Ok(root.get_path_to(&path).map(RcStr::from)) }
- })
- .try_flat_join()
- .await?;
+ .filter_map(|path| root.get_path_to(&path).map(RcStr::from))
+ .collect();
Ok(Vc::cell(keys))
}
diff --git a/crates/next-core/Cargo.toml b/crates/next-core/Cargo.toml
index 4f4474694785..cc7d9cc60408 100644
--- a/crates/next-core/Cargo.toml
+++ b/crates/next-core/Cargo.toml
@@ -14,7 +14,7 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
-base64 = "0.21.0"
+base64 = { workspace = true }
allsorts = { workspace = true }
bincode = { workspace = true }
either = { workspace = true, features = ["serde"] }
diff --git a/crates/next-core/src/emit.rs b/crates/next-core/src/emit.rs
index bb894b4ff86f..4bdecc721881 100644
--- a/crates/next-core/src/emit.rs
+++ b/crates/next-core/src/emit.rs
@@ -105,11 +105,7 @@ pub async fn emit_assets(
let first = iter.next().unwrap();
let ext: RcStr = path.extension().unwrap_or_default().into();
let conflicts = iter
- .map(async |next| {
- assets_diff(*next, *first, ext.clone(), node_root.clone())
- .owned()
- .await
- })
+ .map(|next| assets_diff(*next, *first, ext.clone(), node_root.clone()).owned())
.try_flat_join()
.await?;
if let Some(detail) = conflicts.into_iter().next() {
diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs
index be73ae1f64b8..e60364275766 100644
--- a/crates/next-core/src/next_config.rs
+++ b/crates/next-core/src/next_config.rs
@@ -1883,12 +1883,11 @@ impl OutputFileTracingIncludesExcludes {
.iter()
.flat_map(|pattern| pattern.iter())
.filter_map(|pattern| pattern.as_str())
- .map(async |pattern_str| {
+ .map(|pattern_str| {
let (glob, root) = relativize_glob(pattern_str, &project_path)?;
Ok((RcStr::from(glob), root))
})
- .try_join()
- .await?;
+ .collect::>>()?;
Ok((route_pattern, file_patterns))
})
.try_join()
diff --git a/crates/next-custom-transforms/Cargo.toml b/crates/next-custom-transforms/Cargo.toml
index a8e97961511d..545c08684d74 100644
--- a/crates/next-custom-transforms/Cargo.toml
+++ b/crates/next-custom-transforms/Cargo.toml
@@ -8,7 +8,7 @@ publish = false
workspace = true
[dependencies]
-base64 = "0.21.0"
+base64 = { workspace = true }
bytes-str = { workspace = true }
chrono = "0.4"
easy-error = "1.0.0"
diff --git a/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx b/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx
index 1673c5297563..ff11a1617932 100644
--- a/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx
+++ b/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx
@@ -71,7 +71,11 @@ Note that `'use cache: private'` does not use cache handlers and cannot be custo
## API Reference
-A cache handler must implement the [`CacheHandler`](https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/cache-handlers/types.ts) interface with the following methods:
+A cache handler must implement the `CacheHandler` interface with the following methods. The `CacheHandler` and `CacheEntry` types are exported from `next/cache`:
+
+```ts
+import type { CacheHandler, CacheEntry } from 'next/cache'
+```
### `get()`
@@ -227,7 +231,7 @@ const cacheHandler = {
## CacheEntry Type
-The [`CacheEntry`](https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/cache-handlers/types.ts) object has the following structure:
+The `CacheEntry` object (exported from `next/cache`) has the following structure:
```ts
interface CacheEntry {
diff --git a/docs/01-app/03-api-reference/07-adapters/02-creating-an-adapter.mdx b/docs/01-app/03-api-reference/07-adapters/02-creating-an-adapter.mdx
index d3e51f2db4fd..b3fcc849e57a 100644
--- a/docs/01-app/03-api-reference/07-adapters/02-creating-an-adapter.mdx
+++ b/docs/01-app/03-api-reference/07-adapters/02-creating-an-adapter.mdx
@@ -48,6 +48,7 @@ export interface NextAdapter {
onBuildComplete?: (ctx: {
routing: {
beforeMiddleware: Array
+ middlewareMatchers: Array
beforeFiles: Array
afterFiles: Array
dynamicRoutes: Array
diff --git a/docs/01-app/03-api-reference/07-adapters/03-api-reference.mdx b/docs/01-app/03-api-reference/07-adapters/03-api-reference.mdx
index beac22e568e4..b1a4b5516fa7 100644
--- a/docs/01-app/03-api-reference/07-adapters/03-api-reference.mdx
+++ b/docs/01-app/03-api-reference/07-adapters/03-api-reference.mdx
@@ -24,6 +24,7 @@ Called after the build process completes with detailed information about routes
- `context.routing`: Object containing Next.js routing phases and metadata
- `routing.beforeMiddleware`: Routes executed before middleware (includes header and redirect handling)
+ - `routing.middlewareMatchers`: Middleware matcher definitions for this build, used to decide whether middleware should be invoked for a given request
- `routing.beforeFiles`: Rewrite routes checked before filesystem route matching
- `routing.afterFiles`: Rewrite routes checked after filesystem route matching
- `routing.dynamicRoutes`: Dynamic route matching table
diff --git a/docs/01-app/03-api-reference/07-adapters/09-output-types.mdx b/docs/01-app/03-api-reference/07-adapters/09-output-types.mdx
index d4e7a076ce1e..c2c01defc2c4 100644
--- a/docs/01-app/03-api-reference/07-adapters/09-output-types.mdx
+++ b/docs/01-app/03-api-reference/07-adapters/09-output-types.mdx
@@ -30,6 +30,7 @@ React pages from the `pages/` directory:
sourcePage: string // Original source file path in pages/ directory
runtime: 'nodejs' | 'edge'
assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path)
+ assetsHashes: Record // Content hashes of each `assets` entry (key: same as `assets`, value: content hash)
wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path)
edgeRuntime?: {
modulePath: string // Absolute path to the module registered in the edge runtime
@@ -57,6 +58,7 @@ API routes from `pages/api/`:
sourcePage: string // Original relative source file path
runtime: 'nodejs' | 'edge'
assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path)
+ assetsHashes: Record // Content hashes of each `assets` entry (key: same as `assets`, value: content hash)
wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path)
edgeRuntime?: {
modulePath: string // Absolute path to the module registered in the edge runtime
@@ -84,6 +86,7 @@ React pages from the `app/` directory:
sourcePage: string // Original relative source file path
runtime: 'nodejs' | 'edge' // Runtime the route is built for
assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path)
+ assetsHashes: Record // Content hashes of each `assets` entry (key: same as `assets`, value: content hash)
wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path)
edgeRuntime?: {
modulePath: string // Absolute path to the module registered in the edge runtime
@@ -111,6 +114,7 @@ API and metadata routes from the `app/` directory:
sourcePage: string // Original relative source file path
runtime: 'nodejs' | 'edge' // Runtime the route is built for
assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path)
+ assetsHashes: Record // Content hashes of each `assets` entry (key: same as `assets`, value: content hash)
wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path)
edgeRuntime?: {
modulePath: string // Absolute path to the module registered in the edge runtime
@@ -218,6 +222,7 @@ See [Supporting immutable static assets](/docs/app/api-reference/adapters/immuta
sourcePage: string // Always 'middleware'
runtime: 'nodejs' | 'edge' // Runtime the route is built for
assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path)
+ assetsHashes: Record // Content hashes of each `assets` entry (key: same as `assets`, value: content hash)
wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path)
edgeRuntime?: {
modulePath: string // Absolute path to the module registered in the edge runtime
diff --git a/docs/01-app/03-api-reference/07-adapters/10-routing-information.mdx b/docs/01-app/03-api-reference/07-adapters/10-routing-information.mdx
index e061dc153618..7dcd2b96f746 100644
--- a/docs/01-app/03-api-reference/07-adapters/10-routing-information.mdx
+++ b/docs/01-app/03-api-reference/07-adapters/10-routing-information.mdx
@@ -9,6 +9,10 @@ The `routing` object in `onBuildComplete` provides complete routing information
Routes applied before middleware execution. These include generated header and redirect behavior.
+## `routing.middlewareMatchers`
+
+Middleware matcher definitions emitted for this build. Use these to decide whether middleware should be invoked for a given request.
+
## `routing.beforeFiles`
Rewrite routes checked before filesystem route matching.
diff --git a/lerna.json b/lerna.json
index 7f24833a84a7..7779c377acf0 100644
--- a/lerna.json
+++ b/lerna.json
@@ -15,5 +15,5 @@
"registry": "https://registry.npmjs.org/"
}
},
- "version": "16.4.0-canary.3"
+ "version": "16.4.0-canary.4"
}
\ No newline at end of file
diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json
index 85f3d6a0abfe..56e2f95ad2dc 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.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"keywords": [
"react",
"next",
diff --git a/packages/devlow-bench/package.json b/packages/devlow-bench/package.json
index 5d61cac3a8eb..26959ddd7221 100644
--- a/packages/devlow-bench/package.json
+++ b/packages/devlow-bench/package.json
@@ -1,7 +1,7 @@
{
"name": "@vercel/devlow-bench",
"private": true,
- "version": "16.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"description": "Benchmarking tool for the developer workflow",
"repository": {
"type": "git",
diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json
index 5b438d220f55..f726a319c559 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.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"description": "ESLint configuration used by Next.js.",
"license": "MIT",
"repository": {
@@ -12,7 +12,7 @@
"dist"
],
"dependencies": {
- "@next/eslint-plugin-next": "16.4.0-canary.3",
+ "@next/eslint-plugin-next": "16.4.0-canary.4",
"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 95f60a0bbdb8..894c51f3c3b0 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.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"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 cc99d8426c19..ab065a37fd22 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.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"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 2f0277a80bf0..f79310dabd51 100644
--- a/packages/font/package.json
+++ b/packages/font/package.json
@@ -1,7 +1,7 @@
{
"name": "@next/font",
"private": true,
- "version": "16.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"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 c591f4a364e5..906625c56ac8 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.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"main": "index.js",
"types": "index.d.ts",
"license": "MIT",
diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json
index 0bf7b1cd2bd2..42812c1cab48 100644
--- a/packages/next-codemod/package.json
+++ b/packages/next-codemod/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/codemod",
- "version": "16.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"license": "MIT",
"repository": {
"type": "git",
diff --git a/packages/next-env/package.json b/packages/next-env/package.json
index bb944f2819e6..8437f4c45de9 100644
--- a/packages/next-env/package.json
+++ b/packages/next-env/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/env",
- "version": "16.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"keywords": [
"react",
"next",
diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json
index 4e84ace8547e..05c1392038b3 100644
--- a/packages/next-mdx/package.json
+++ b/packages/next-mdx/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/mdx",
- "version": "16.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"main": "index.js",
"license": "MIT",
"repository": {
diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json
index 3c8bd1aec5ee..fc2c5a9c7ff2 100644
--- a/packages/next-playwright/package.json
+++ b/packages/next-playwright/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/playwright",
- "version": "16.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"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 598cc9a1121e..992bac2aaad3 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.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"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 662bbf9959db..9dbe132b669d 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.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"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 7304cadd070a..1d1ce65154b0 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.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"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 2eb53bc7a75b..11a2e9f0153d 100644
--- a/packages/next-routing/package.json
+++ b/packages/next-routing/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/routing",
- "version": "16.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"keywords": [
"react",
"next",
diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json
index 610aaeacedba..8eec0f3e02c0 100644
--- a/packages/next-rspack/package.json
+++ b/packages/next-rspack/package.json
@@ -1,6 +1,6 @@
{
"name": "next-rspack",
- "version": "16.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"repository": {
"url": "vercel/next.js",
"directory": "packages/next-rspack"
diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json
index 4b4f869dbc21..7153de2bc113 100644
--- a/packages/next-swc/package.json
+++ b/packages/next-swc/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/swc",
- "version": "16.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"private": true,
"files": [
"native/"
diff --git a/packages/next/cache.d.ts b/packages/next/cache.d.ts
index 3a3750edfb8e..3c126e76dd62 100644
--- a/packages/next/cache.d.ts
+++ b/packages/next/cache.d.ts
@@ -1,5 +1,10 @@
export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache'
+export type {
+ CacheHandler,
+ CacheEntry,
+} from './dist/server/lib/cache-handlers/types'
+
export {
revalidatePath,
revalidateTag,
diff --git a/packages/next/package.json b/packages/next/package.json
index c60101857e3f..e73f7eeffa84 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -1,6 +1,6 @@
{
"name": "next",
- "version": "16.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"description": "The React Framework",
"main": "./dist/server/next.js",
"license": "MIT",
@@ -100,7 +100,7 @@
]
},
"dependencies": {
- "@next/env": "16.4.0-canary.3",
+ "@next/env": "16.4.0-canary.4",
"@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -164,11 +164,11 @@
"@modelcontextprotocol/sdk": "1.18.1",
"@mswjs/interceptors": "0.42.0",
"@napi-rs/triples": "1.2.0",
- "@next/font": "16.4.0-canary.3",
- "@next/polyfill-module": "16.4.0-canary.3",
- "@next/polyfill-nomodule": "16.4.0-canary.3",
- "@next/react-refresh-utils": "16.4.0-canary.3",
- "@next/swc": "16.4.0-canary.3",
+ "@next/font": "16.4.0-canary.4",
+ "@next/polyfill-module": "16.4.0-canary.4",
+ "@next/polyfill-nomodule": "16.4.0-canary.4",
+ "@next/react-refresh-utils": "16.4.0-canary.4",
+ "@next/swc": "16.4.0-canary.4",
"@opentelemetry/api": "1.6.0",
"@playwright/test": "1.61.0",
"@rspack/core": "1.6.7",
diff --git a/packages/next/src/build/adapter/build-complete.ts b/packages/next/src/build/adapter/build-complete.ts
index f9d295ce8331..2652682e0b37 100644
--- a/packages/next/src/build/adapter/build-complete.ts
+++ b/packages/next/src/build/adapter/build-complete.ts
@@ -56,6 +56,7 @@ import { escapeStringRegexp } from '../../shared/lib/escape-regexp'
import { sortSortableRoutes } from '../../shared/lib/router/utils/sortable-routes'
import { defaultOverrides } from '../../server/require-hook'
import { generateRoutesManifest } from '../generate-routes-manifest'
+import { collectFallbackShellRuns } from './fallback-shell-runs'
import { Bundler } from '../../lib/bundler'
import { resolveCacheHandlerPathToFilesystem } from '../../lib/format-dynamic-import-path'
import { InvariantError } from '../../shared/lib/invariant-error'
@@ -2100,7 +2101,24 @@ export async function handleBuildComplete({
},
]
+ // Without this collapse the loop below emits one entry per shell.
+ const fallbackShellRuns = config.experimental.collapseAdapterRoutes
+ ? collectFallbackShellRuns(
+ routesManifest.dynamicRoutes,
+ (page) => prerenderManifest.dynamicRoutes[page]?.fallback === false
+ )
+ : undefined
+
for (const route of routesManifest.dynamicRoutes) {
+ // An earlier entry in this loop serves this shell.
+ if (fallbackShellRuns?.replacedPages.has(route.page)) {
+ continue
+ }
+
+ const fallbackShellRun = fallbackShellRuns?.byRepresentativePage.get(
+ route.page
+ )
+
const shouldLocalize = Boolean(config.i18n)
const routeRegex = getNamedRouteRegex(route.page, {
@@ -2110,7 +2128,29 @@ export async function handleBuildComplete({
const isFallbackFalse =
prerenderManifest.dynamicRoutes[route.page]?.fallback === false
- const sourceRegex = routeRegex.namedRegex.replace(
+ // An entry for a whole run of shells matches every prefix in that run.
+ // The destination copies the prefix that matched.
+ //
+ // This replacement runs on the pattern for the page, and `sourceRegex`
+ // below prefixes the result with the base path and the locale group. That
+ // order is deliberate. The search text anchors at `^`, and here that
+ // anchor is the start of the page path. On `sourceRegex` the same anchor
+ // is the start of the base path. A replacement there would match a base
+ // path such as `/de/x`, and it would rewrite that base path instead of
+ // the page path.
+ const pagePattern = fallbackShellRun
+ ? routeRegex.namedRegex.replace(
+ `^/${escapeStringRegexp(fallbackShellRun.prefixes[0])}/`,
+ `^/(?${fallbackShellRun.prefixes
+ .map((prefix) => escapeStringRegexp(prefix))
+ .join('|')})/`
+ )
+ : routeRegex.namedRegex
+ const pagePath = fallbackShellRun
+ ? path.posix.join('/', '$shellPrefix', fallbackShellRun.tail)
+ : route.page
+
+ const sourceRegex = pagePattern.replace(
'^',
`^${config.basePath && config.basePath !== '/' ? path.posix.join('/', config.basePath || '') : ''}[/]?${shouldLocalize ? '(?[^/]{1,})' : ''}`
)
@@ -2119,51 +2159,104 @@ export async function handleBuildComplete({
'/',
config.basePath,
shouldLocalize ? '/$nextLocale' : '',
- route.page
+ pagePath
) + getDestinationQuery(route.routeKeys)
- if (appPageKeys && appPageKeys.length > 0) {
+ const hasAppPages = Boolean(appPageKeys && appPageKeys.length > 0)
+
+ const suffixedHas =
+ isFallbackFalse && !pageKeys.includes(route.page)
+ ? fallbackFalseHasCondition
+ : undefined
+ const plainHas = isFallbackFalse ? fallbackFalseHasCondition : undefined
+
+ // A single entry can serve both forms of the request only when both carry
+ // the same conditions. A pages router route with `fallback: false` is the
+ // one case where they differ: it requires the preview cookies on the
+ // plain form, and not on the suffixed form. An entry holds one set of
+ // conditions, so that case keeps a separate entry per form.
+ const canMergeSuffixedAndPlain =
+ config.experimental.collapseAdapterRoutes &&
+ hasAppPages &&
+ suffixedHas === plainHas
+
+ if (canMergeSuffixedAndPlain) {
+ // One entry serves every form of a request for this page:
+ //
+ // - The document at the page path.
+ // - The `.rsc` payload.
+ // - A per-segment prefetch.
+ //
+ // The suffix group ends with an empty alternative. The group therefore
+ // always matches, and it captures an empty string for a request that
+ // carries no suffix. The destination copies what the group captured.
+ //
+ // An optional group is unsafe here. An adapter, or the router that
+ // consumes its output, can resolve the placeholders in a destination
+ // from the match result rather than from the pattern. A group that does
+ // not match is then absent from that result, and the literal text
+ // `$rscSuffix` stays in the destination.
dynamicRoutes.push({
- source: route.page + '.rsc',
+ source: pagePath,
sourceRegex: sourceRegex.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
- '(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$'
+ '(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$'
),
destination: destination?.replace(/($|\?)/, '$rscSuffix$1'),
- has:
- isFallbackFalse && !pageKeys.includes(route.page)
- ? fallbackFalseHasCondition
- : undefined,
+ has: plainHas,
missing: undefined,
})
- }
-
- // needs basePath and locale handling if pages router
- dynamicRoutes.push({
- source: route.page,
- sourceRegex,
- destination,
- has: isFallbackFalse ? fallbackFalseHasCondition : undefined,
- missing: undefined,
- })
+ } else {
+ // This route serves two kinds of request for the page: a request for
+ // the `.rsc` payload, and a per-segment prefetch request. The suffix
+ // group accepts both forms, and the destination copies the matched
+ // suffix, so each request resolves to the artifact that it asks for.
+ if (hasAppPages) {
+ dynamicRoutes.push({
+ source: pagePath + '.rsc',
+ sourceRegex: sourceRegex.replace(
+ new RegExp(escapeStringRegexp('(?:/)?$')),
+ '(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$'
+ ),
+ destination: destination?.replace(/($|\?)/, '$rscSuffix$1'),
+ has: suffixedHas,
+ missing: undefined,
+ })
+ }
- for (const segmentRoute of route.prefetchSegmentDataRoutes || []) {
- dynamicSegmentRoutes.push({
- source: route.page,
- sourceRegex: segmentRoute.source.replace(
- '^',
- `^${config.basePath && config.basePath !== '/' ? path.posix.join('/', config.basePath || '') : ''}[/]?`
- ),
- destination: path.posix.join(
- '/',
- config.basePath,
- segmentRoute.destination +
- getDestinationQuery(segmentRoute.routeKeys)
- ),
- has: undefined,
+ // needs basePath and locale handling if pages router
+ dynamicRoutes.push({
+ source: pagePath,
+ sourceRegex,
+ destination,
+ has: plainHas,
missing: undefined,
})
}
+
+ // The entry above resolves a per-segment request on its own, because its
+ // suffix group accepts a segment path. A build that turns the collapse
+ // off emits a dedicated route for each segment, and the table lists those
+ // before that entry.
+ if (!config.experimental.collapseAdapterRoutes) {
+ for (const segmentRoute of route.prefetchSegmentDataRoutes || []) {
+ dynamicSegmentRoutes.push({
+ source: route.page,
+ sourceRegex: segmentRoute.source.replace(
+ '^',
+ `^${config.basePath && config.basePath !== '/' ? path.posix.join('/', config.basePath || '') : ''}[/]?`
+ ),
+ destination: path.posix.join(
+ '/',
+ config.basePath,
+ segmentRoute.destination +
+ getDestinationQuery(segmentRoute.routeKeys)
+ ),
+ has: undefined,
+ missing: undefined,
+ })
+ }
+ }
}
const needsMiddlewareResolveRoutes =
diff --git a/packages/next/src/build/adapter/fallback-shell-runs.test.ts b/packages/next/src/build/adapter/fallback-shell-runs.test.ts
new file mode 100644
index 000000000000..36d1f056a470
--- /dev/null
+++ b/packages/next/src/build/adapter/fallback-shell-runs.test.ts
@@ -0,0 +1,104 @@
+import { pageToRoute } from '../utils'
+import { collectFallbackShellRuns } from './fallback-shell-runs'
+import type { RoutesManifest } from '..'
+
+// The suites in `test/production/app-dir/adapter-dynamic-routes` cover the
+// shapes that a build produces, and they pin the entries that come out of them.
+// Most cases below cover something those suites cannot: inputs that make this
+// function decline. A build does not produce those inputs, and a guard that
+// stopped declining would collapse shells that are not safe to collapse, which
+// no snapshot would catch.
+//
+// The first case is a successful collapse, so that the file also shows the
+// shape of a result.
+
+/**
+ * A fallback shell, which the build derives from a source page.
+ */
+function shell(page: string, sourcePage: string) {
+ return pageToRoute(page, sourcePage)
+}
+
+/**
+ * Any other dynamic route, which carries no source page.
+ */
+function plain(page: string) {
+ return pageToRoute(page, undefined)
+}
+
+function collect(
+ routes: ReturnType[],
+ fallbackFalsePages: string[] = []
+) {
+ const result = collectFallbackShellRuns(
+ routes as RoutesManifest['dynamicRoutes'],
+ (page) => fallbackFalsePages.includes(page)
+ )
+
+ return {
+ runs: Object.fromEntries(result.byRepresentativePage),
+ replaced: [...result.replacedPages],
+ }
+}
+
+describe('collectFallbackShellRuns', () => {
+ it('collapses a run of shells that share a source page', () => {
+ expect(
+ collect([
+ shell('/de/posts/[id]', '/[lang]/posts/[id]'),
+ shell('/en/posts/[id]', '/[lang]/posts/[id]'),
+ plain('/[lang]/posts/[id]'),
+ ])
+ ).toEqual({
+ runs: {
+ '/de/posts/[id]': { prefixes: ['de', 'en'], tail: 'posts/[id]' },
+ },
+ replaced: ['/en/posts/[id]'],
+ })
+ })
+
+ it('keeps shells that another route separates', () => {
+ // The entry would take the position of the first shell, so it would move
+ // ahead of the route between the two.
+ expect(
+ collect([
+ shell('/de/posts/[id]', '/[lang]/posts/[id]'),
+ plain('/other/[id]'),
+ shell('/en/posts/[id]', '/[lang]/posts/[id]'),
+ ])
+ ).toEqual({ runs: {}, replaced: [] })
+ })
+
+ it('keeps shells that disagree on `fallback: false`', () => {
+ // One entry carries one set of conditions, so it cannot serve both.
+ expect(
+ collect(
+ [
+ shell('/de/posts/[id]', '/[lang]/posts/[id]'),
+ shell('/en/posts/[id]', '/[lang]/posts/[id]'),
+ ],
+ ['/en/posts/[id]']
+ )
+ ).toEqual({ runs: {}, replaced: [] })
+ })
+
+ it('keeps shells whose resolved segment is not the first one', () => {
+ // The prefix of a shell starts at the first segment, so `/docs` in front of
+ // the resolved segment puts these outside what the caller can rewrite.
+ expect(
+ collect([
+ shell('/docs/de/posts/[id]', '/docs/[lang]/posts/[id]'),
+ shell('/docs/en/posts/[id]', '/docs/[lang]/posts/[id]'),
+ ])
+ ).toEqual({ runs: {}, replaced: [] })
+ })
+
+ it('keeps shells that resolve every segment', () => {
+ // The pattern for `/de` ends after the prefix, so it has no slash for the
+ // caller to replace. A build cannot produce this shape, because it derives
+ // a fallback shell only from a prerender that leaves a param unresolved.
+ expect(collect([shell('/de', '/[lang]'), shell('/en', '/[lang]')])).toEqual(
+ { runs: {}, replaced: [] }
+ )
+ })
+})
diff --git a/packages/next/src/build/adapter/fallback-shell-runs.ts b/packages/next/src/build/adapter/fallback-shell-runs.ts
new file mode 100644
index 000000000000..89942220a6e5
--- /dev/null
+++ b/packages/next/src/build/adapter/fallback-shell-runs.ts
@@ -0,0 +1,263 @@
+/**
+ * One entry in the route table that a build passes to an adapter can serve
+ * several fallback shells.
+ *
+ * A fallback shell repeats the whole path of its source page, and it resolves
+ * the leading params of that page to concrete values. Those values form the
+ * prefix of the shell path, so the shells of one source page differ only in
+ * that prefix. A pattern can list several prefixes as alternatives, which lets
+ * one entry match them all.
+ *
+ * The shells that one entry serves are a run: a stretch of neighbours in the
+ * manifest. Adjacency is what makes a run safe to serve from one entry. That
+ * entry takes the position of the first shell of the run, which this file
+ * calls the representative, so every shell that the entry replaces keeps its
+ * place relative to the routes around it.
+ */
+
+import type { RoutesManifest } from '..'
+import { getNamedRouteRegex } from '../../shared/lib/router/utils/route-regex'
+import { escapeStringRegexp } from '../../shared/lib/escape-regexp'
+
+export type FallbackShellRun = {
+ /**
+ * The prefix of each shell of the run, in the order that the manifest lists
+ * them. The pattern of the entry holds these as alternatives, and the first
+ * one belongs to the representative.
+ */
+ prefixes: readonly string[]
+ /**
+ * The path that follows the prefix, without a leading slash. Every shell of
+ * the run shares it.
+ */
+ tail: string
+}
+
+export type FallbackShellRuns = {
+ /**
+ * The runs, keyed by the page of the representative of each.
+ */
+ byRepresentativePage: Map
+ /**
+ * The pages of the shells that a run serves, apart from the representative
+ * that keys it. The caller emits no entry of its own for these.
+ */
+ replacedPages: Set
+}
+
+/**
+ * A run while this file still collects it. The exported shape holds only what
+ * the caller needs in order to emit the entry.
+ */
+type PendingRun = {
+ /**
+ * The source page that every shell of the run repeats.
+ */
+ sourcePage: string
+ /**
+ * The path that every shell of the run has after its prefix.
+ */
+ tail: string
+ /**
+ * Whether the shells have `fallback: false`. Every shell of the run agrees on
+ * this value.
+ */
+ isFallbackFalse: boolean
+ /**
+ * The shells of the run, in the order that the manifest lists them.
+ */
+ shells: Array<{
+ /**
+ * The page of the shell.
+ */
+ page: string
+ /**
+ * The leading part of that page, which holds the values that the shell
+ * resolves for the params of the source page.
+ */
+ prefix: string
+ }>
+}
+
+/**
+ * Splits the page of a fallback shell into the prefix that holds its resolved
+ * param values, and the path that follows.
+ *
+ * Returns undefined when the page resolves nothing, or when the resolved
+ * segments are not consecutive from the first segment onwards.
+ */
+function splitShellPage(
+ page: string,
+ sourcePage: string
+): { prefix: string; tail: string } | undefined {
+ const pageSegments = page.split('/')
+ const sourceSegments = sourcePage.split('/')
+ if (pageSegments.length !== sourceSegments.length) {
+ return undefined
+ }
+
+ const resolved: number[] = []
+ for (let index = 0; index < pageSegments.length; index++) {
+ if (pageSegments[index] !== sourceSegments[index]) {
+ resolved.push(index)
+ }
+ }
+ if (resolved.length === 0) {
+ return undefined
+ }
+
+ // A shell resolves the leading params of its source page, so the resolved
+ // segments are consecutive and the first of them is the first segment of the
+ // path. `split` returns an empty string at index 0, so they start at index 1.
+ for (let position = 0; position < resolved.length; position++) {
+ if (resolved[position] !== position + 1) {
+ return undefined
+ }
+ }
+
+ // The source page declares a param at each resolved position, and the shell
+ // holds a value there. Anything else means the two pages differ for another
+ // reason, and the leading segments are not a prefix of resolved values.
+ for (const index of resolved) {
+ if (
+ !sourceSegments[index].startsWith('[') ||
+ pageSegments[index].startsWith('[')
+ ) {
+ return undefined
+ }
+ }
+
+ const tailStart = resolved.length + 1
+ return {
+ prefix: pageSegments.slice(1, tailStart).join('/'),
+ tail: pageSegments.slice(tailStart).join('/'),
+ }
+}
+
+/**
+ * Collects the runs of fallback shells that one entry can serve.
+ *
+ * The shells of a run are neighbours in the manifest that agree on:
+ *
+ * - The source page.
+ * - The path that follows the prefix.
+ * - The value of `fallback: false`.
+ *
+ * They have to be neighbours because the entry takes the position of the first
+ * shell of the run. Every shell that the entry replaces then keeps its place
+ * relative to the routes around it. Any other route between two shells ends the
+ * run, because an entry that reached across it would move ahead of a route that
+ * a request matches first.
+ *
+ * They have to agree on `fallback: false` because an entry carries one set of
+ * conditions.
+ *
+ * The caller builds one pattern for a run, and it lists the prefixes of the run
+ * as complete alternatives. For a source page `/[team]/[locale]/posts/[id]`
+ * with shells for `acme/en`, `acme/de` and `globex/en`, that pattern holds:
+ *
+ * ```
+ * (?acme/en|acme/de|globex/en)
+ * ```
+ *
+ * A pattern that offered a choice per param instead, such as
+ * `(acme|globex)/(en|de)`, would also match `globex/de`. The build never
+ * prerendered that pair, so a request for it would resolve to an output that
+ * does not exist, and it would then fall through to whichever route claims the
+ * rewritten path.
+ *
+ * A shell can belong to no run, and one source page can hold several runs. That
+ * happens when the build resolves a different number of params for neighbouring
+ * shells, because their prefixes then have different lengths and the paths that
+ * follow them differ.
+ *
+ * A run of only one shell has a single prefix, so an entry for it would match
+ * what the entry for that shell already matches. This function leaves such a
+ * shell out of the result.
+ */
+export function collectFallbackShellRuns(
+ dynamicRoutes: RoutesManifest['dynamicRoutes'],
+ hasFallbackFalse: (page: string) => boolean
+): FallbackShellRuns {
+ const runs: PendingRun[] = []
+ let current: PendingRun | undefined
+
+ for (const route of dynamicRoutes) {
+ // `pageToRoute` sets `sourcePage` only when the build passes it a source
+ // page, and the build does that for a fallback shell. The field therefore
+ // names the page whose path this shell repeats, and it is absent on every
+ // other route.
+ const { sourcePage } = route
+ const split =
+ sourcePage && sourcePage !== route.page
+ ? splitShellPage(route.page, sourcePage)
+ : undefined
+
+ // This route is not a shell that a run can hold, so it ends the run in
+ // progress.
+ if (!sourcePage || !split) {
+ current = undefined
+ continue
+ }
+
+ const isFallbackFalse = hasFallbackFalse(route.page)
+
+ if (
+ current &&
+ (current.sourcePage !== sourcePage ||
+ current.tail !== split.tail ||
+ current.isFallbackFalse !== isFallbackFalse)
+ ) {
+ current = undefined
+ }
+
+ if (!current) {
+ current = {
+ sourcePage,
+ tail: split.tail,
+ isFallbackFalse,
+ shells: [],
+ }
+ runs.push(current)
+ }
+
+ current.shells.push({ page: route.page, prefix: split.prefix })
+ }
+
+ const byRepresentativePage = new Map()
+ const replacedPages = new Set()
+
+ for (const run of runs) {
+ if (run.shells.length < 2) {
+ continue
+ }
+
+ // The caller replaces the escaped prefix at the start of the pattern for
+ // the representative, so this function keeps the run only when that pattern
+ // starts with the prefix.
+ //
+ // Two things make that true today. `getNamedRouteRegex` escapes each
+ // segment the same way, and a fallback shell keeps at least one param
+ // unresolved, so a slash always follows its prefix. This check holds the
+ // run back if either stops being true.
+ const [representative, ...replaced] = run.shells
+ const { namedRegex } = getNamedRouteRegex(representative.page, {
+ prefixRouteKeys: true,
+ })
+ if (
+ !namedRegex.startsWith(`^/${escapeStringRegexp(representative.prefix)}/`)
+ ) {
+ continue
+ }
+
+ byRepresentativePage.set(representative.page, {
+ prefixes: run.shells.map((shell) => shell.prefix),
+ tail: run.tail,
+ })
+ for (const shell of replaced) {
+ replacedPages.add(shell.page)
+ }
+ }
+
+ return { byRepresentativePage, replacedPages }
+}
diff --git a/packages/next/src/build/analysis/get-page-static-info.ts b/packages/next/src/build/analysis/get-page-static-info.ts
index 978e25084a36..12d65bf3ebf5 100644
--- a/packages/next/src/build/analysis/get-page-static-info.ts
+++ b/packages/next/src/build/analysis/get-page-static-info.ts
@@ -77,31 +77,26 @@ export type ProxyConfig = {
unstable_allowDynamic?: string[]
}
-export interface AppPageStaticInfo {
- type: PAGE_TYPES.APP
- ssg?: boolean
- ssr?: boolean
+export interface SharedPageStaticInfo {
rsc?: RSCModuleType
- generateStaticParams?: boolean
generateSitemaps?: boolean
generateImageMetadata?: boolean
middleware?: ProxyConfig
+ maxDuration: number | undefined
+ hadUnsupportedValue: boolean
+}
+
+export interface AppPageStaticInfo extends SharedPageStaticInfo {
+ type: PAGE_TYPES.APP
+ ssg?: boolean
+ ssr?: boolean
config: Omit | undefined
runtime: AppSegmentConfig['runtime'] | undefined
preferredRegion: AppSegmentConfig['preferredRegion'] | undefined
- maxDuration: number | undefined
- hadUnsupportedValue: boolean
}
-export interface PagesPageStaticInfo {
+export interface PagesPageStaticInfo extends SharedPageStaticInfo {
type: PAGE_TYPES.PAGES
- getStaticProps?: boolean
- getServerSideProps?: boolean
- rsc?: RSCModuleType
- generateStaticParams?: boolean
- generateSitemaps?: boolean
- generateImageMetadata?: boolean
- middleware?: ProxyConfig
config:
| (Omit & {
config?: Omit
@@ -109,8 +104,6 @@ export interface PagesPageStaticInfo {
| undefined
runtime: PagesSegmentConfig['runtime'] | undefined
preferredRegion: PagesSegmentConfigConfig['regions'] | undefined
- maxDuration: number | undefined
- hadUnsupportedValue: boolean
}
export type PageStaticInfo = AppPageStaticInfo | PagesPageStaticInfo
@@ -755,7 +748,6 @@ export async function getAppPageStaticInfo({
rsc,
generateImageMetadata,
generateSitemaps,
- generateStaticParams,
config,
middleware: parseMiddlewareConfig(page, exportedConfig.config, nextConfig),
runtime: config.runtime,
@@ -791,11 +783,7 @@ export async function getPagesPageStaticInfo({
isDev,
})
- const { getServerSideProps, getStaticProps, exports } = checkExports(
- ast,
- PagesSegmentConfigSchemaKeys,
- page
- )
+ const { exports } = checkExports(ast, PagesSegmentConfigSchemaKeys, page)
const { type: rsc } = getRSCModuleInformation(content, true)
@@ -870,8 +858,6 @@ export async function getPagesPageStaticInfo({
return {
type: PAGE_TYPES.PAGES,
- getStaticProps,
- getServerSideProps,
rsc,
config,
middleware: parseMiddlewareConfig(page, exportedConfig.config, nextConfig),
diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts
index a3734ea6df5e..74d9fe82c3da 100644
--- a/packages/next/src/build/entries.ts
+++ b/packages/next/src/build/entries.ts
@@ -4,11 +4,7 @@ import type { EdgeSSRLoaderQuery } from './webpack/loaders/next-edge-ssr-loader'
import type { EdgeAppRouteLoaderQuery } from './webpack/loaders/next-edge-app-route-loader'
import type { NextConfigComplete } from '../server/config-shared'
import type { webpack } from 'next/dist/compiled/webpack/webpack'
-import type {
- ProxyConfig,
- ProxyMatcher,
- PageStaticInfo,
-} from './analysis/get-page-static-info'
+import type { ProxyConfig, ProxyMatcher } from './analysis/get-page-static-info'
import type { LoadedEnvFiles } from '@next/env'
import type { AppLoaderOptions } from './webpack/loaders/next-app-loader'
@@ -478,7 +474,7 @@ export async function createEntrypoints(
(absolutePagePath.startsWith(APP_DIR_ALIAS) ||
absolutePagePath.startsWith(appDir))
- const staticInfo: PageStaticInfo = await getStaticInfoIncludingLayouts({
+ const staticInfo = await getStaticInfoIncludingLayouts({
isInsideAppDir,
pageExtensions,
pageFilePath,
diff --git a/packages/next/src/build/get-static-info-including-layouts.ts b/packages/next/src/build/get-static-info-including-layouts.ts
index 260bccb115ff..f5685624665c 100644
--- a/packages/next/src/build/get-static-info-including-layouts.ts
+++ b/packages/next/src/build/get-static-info-including-layouts.ts
@@ -31,7 +31,7 @@ export async function getStaticInfoIncludingLayouts({
config: NextConfigComplete
isDev: boolean
page: string
-}): Promise {
+}): Promise> {
// TODO: sync types for pages: PAGE_TYPES, ROUTER_TYPE, 'app' | 'pages', etc.
const pageType = isInsideAppDir ? PAGE_TYPES.APP : PAGE_TYPES.PAGES
@@ -90,7 +90,6 @@ export async function getStaticInfoIncludingLayouts({
return {
...pageStaticInfo,
- config,
runtime: config.runtime,
preferredRegion: config.preferredRegion,
maxDuration: config.maxDuration,
diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts
index 5c211ee47c9d..c453267be294 100644
--- a/packages/next/src/server/config-schema.ts
+++ b/packages/next/src/server/config-schema.ts
@@ -197,6 +197,7 @@ export const experimentalSchema = {
after: z.boolean().optional(),
appNavFailHandling: z.boolean().optional(),
coldCacheBadge: z.boolean().optional(),
+ collapseAdapterRoutes: z.boolean().optional(),
preloadEntriesOnStart: z.boolean().optional(),
allowedRevalidateHeaderKeys: z.array(z.string()).optional(),
staleTimes: z
diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts
index 0d3f6ddcb5d2..bcf2f2d1cef3 100644
--- a/packages/next/src/server/config-shared.ts
+++ b/packages/next/src/server/config-shared.ts
@@ -498,6 +498,20 @@ export interface ExperimentalConfig {
* regardless of this flag.
*/
coldCacheBadge?: boolean
+ /**
+ * Whether a build may serve several dynamic routes from one entry in the
+ * route table that it passes to an adapter. Several routes of an app can
+ * differ only in a part that a single pattern also matches, and one entry for
+ * them keeps the table smaller.
+ *
+ * A collapsed entry resolves each request to the same output as the entries
+ * that it replaces.
+ *
+ * The default is `false`, so a build keeps one entry per route.
+ *
+ * @default false
+ */
+ collapseAdapterRoutes?: boolean
useSkewCookie?: boolean
/** @deprecated use top-level `cacheHandlers` instead */
cacheHandlers?: NextConfig['cacheHandlers']
@@ -2229,6 +2243,7 @@ export const defaultConfig = Object.freeze({
adapterPath: process.env.NEXT_ADAPTER_PATH || undefined,
experimental: {
coldCacheBadge: false,
+ collapseAdapterRoutes: false,
devValidationWorker: true,
useSkewCookie: false,
cssChunking: true,
diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json
index afad4acac438..5860ab9072f9 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.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"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 962df405dd22..9fd31dcbdd76 100644
--- a/packages/third-parties/package.json
+++ b/packages/third-parties/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/third-parties",
- "version": "16.4.0-canary.3",
+ "version": "16.4.0-canary.4",
"repository": {
"url": "vercel/next.js",
"directory": "packages/third-parties"
@@ -26,7 +26,7 @@
"third-party-capital": "1.0.20"
},
"devDependencies": {
- "next": "16.4.0-canary.3",
+ "next": "16.4.0-canary.4",
"outdent": "0.8.0",
"prettier": "2.5.1",
"typescript": "6.0.2"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bba07d52d19f..0cd85d7e4f85 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1024,7 +1024,7 @@ importers:
packages/eslint-config-next:
dependencies:
'@next/eslint-plugin-next':
- specifier: 16.4.0-canary.3
+ specifier: 16.4.0-canary.4
version: link:../eslint-plugin-next
eslint:
specifier: '>=9.0.0'
@@ -1107,7 +1107,7 @@ importers:
packages/next:
dependencies:
'@next/env':
- specifier: 16.4.0-canary.3
+ specifier: 16.4.0-canary.4
version: link:../next-env
'@swc/helpers':
specifier: 0.5.23
@@ -1228,19 +1228,19 @@ importers:
specifier: 1.2.0
version: 1.2.0
'@next/font':
- specifier: 16.4.0-canary.3
+ specifier: 16.4.0-canary.4
version: link:../font
'@next/polyfill-module':
- specifier: 16.4.0-canary.3
+ specifier: 16.4.0-canary.4
version: link:../next-polyfill-module
'@next/polyfill-nomodule':
- specifier: 16.4.0-canary.3
+ specifier: 16.4.0-canary.4
version: link:../next-polyfill-nomodule
'@next/react-refresh-utils':
- specifier: 16.4.0-canary.3
+ specifier: 16.4.0-canary.4
version: link:../react-refresh-utils
'@next/swc':
- specifier: 16.4.0-canary.3
+ specifier: 16.4.0-canary.4
version: link:../next-swc
'@opentelemetry/api':
specifier: 1.6.0
@@ -1983,7 +1983,7 @@ importers:
version: 1.0.20
devDependencies:
next:
- specifier: 16.4.0-canary.3
+ specifier: 16.4.0-canary.4
version: link:../next
outdent:
specifier: 0.8.0
diff --git a/test/cache-components-tests-manifest.json b/test/cache-components-tests-manifest.json
index b4a73639e014..ea7094ef1f5d 100644
--- a/test/cache-components-tests-manifest.json
+++ b/test/cache-components-tests-manifest.json
@@ -349,6 +349,7 @@
"test/production/app-dir/actions-tree-shaking/reexport/reexport.test.ts",
"test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions-edge.test.ts",
"test/production/app-dir/actions-tree-shaking/use-effect-actions/use-effect-actions-edge.test.ts",
+ "test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts",
"test/production/app-dir/app-fetch-build-cache/app-fetch-build-cache.test.ts",
"test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts",
"test/production/app-dir/build-output/index.test.ts",
diff --git a/test/e2e/app-dir/use-cache-custom-handler/handler.js b/test/e2e/app-dir/use-cache-custom-handler/handler.js
index 4470fce06024..2d2c2a1ad3a6 100644
--- a/test/e2e/app-dir/use-cache-custom-handler/handler.js
+++ b/test/e2e/app-dir/use-cache-custom-handler/handler.js
@@ -6,7 +6,7 @@ const { AsyncLocalStorage } = require('node:async_hooks')
const snapshot = AsyncLocalStorage.snapshot()
/**
- * @type {import('next/dist/server/lib/cache-handlers/types').CacheHandler}
+ * @type {import('next/cache').CacheHandler}
*/
const cacheHandler = {
async get(cacheKey, softTags) {
diff --git a/test/e2e/basepath/basepath.test.ts b/test/e2e/basepath/basepath.test.ts
index 5f75fc02f26b..77873570923f 100644
--- a/test/e2e/basepath/basepath.test.ts
+++ b/test/e2e/basepath/basepath.test.ts
@@ -1,4 +1,5 @@
import assert from 'assert'
+import http from 'http'
import cheerio from 'cheerio'
import { nextTestSetup } from 'e2e-utils'
import {
@@ -7,7 +8,6 @@ import {
fetchViaHTTP,
getClientBuildManifestLoaderChunkUrlPath,
renderViaHTTP,
- waitFor,
} from 'next-test-utils'
describe('basePath', () => {
@@ -57,21 +57,62 @@ describe('basePath', () => {
},
})
+ let externalServer: http.Server | undefined
+ let externalUrl = 'https://example.vercel.sh'
+
+ beforeAll(async () => {
+ // Avoid third-party network and DOM dependencies when the browser can reach
+ // the test runner. Deployed tests cannot reach a server on this process.
+ if (isNextDeploy) return
+
+ externalServer = http.createServer((_req, res) => {
+ res.setHeader('Content-Type', 'text/html')
+ res.end('external page
')
+ })
+
+ await new Promise((resolve, reject) => {
+ externalServer!.once('error', reject)
+ externalServer!.listen(0, '127.0.0.1', () => {
+ externalServer!.off('error', reject)
+ const address = externalServer!.address()
+ if (!address || typeof address === 'string') {
+ reject(new Error('Failed to determine the external server port'))
+ return
+ }
+ externalUrl = `http://127.0.0.1:${address.port}`
+ resolve()
+ })
+ })
+ })
+
+ afterAll(async () => {
+ if (!externalServer) return
+
+ await new Promise((resolve, reject) => {
+ externalServer!.close((err) => (err ? reject(err) : resolve()))
+ })
+ })
+
it('should navigate to external site and back', async () => {
- const browser = await next.browser(`${basePath}/external-and-back`)
- const initialText = await browser.elementByCss('p').text()
- expect(initialText).toBe('server')
-
- await browser
- .elementByCss('a')
- .click()
- .waitForElementByCss('input', { state: 'attached' })
- .back()
- .waitForElementByCss('p')
-
- await waitFor(1000)
- const newText = await browser.elementByCss('p').text()
- expect(newText).toBe('server')
+ const browser = await next.browser(
+ `${basePath}/external-and-back?external=${encodeURIComponent(externalUrl)}`
+ )
+ const initialUrl = await browser.url()
+ expect(await browser.elementById('from').text()).toBe('server')
+
+ await browser.elementByCss('a').click()
+ await browser.waitForCondition(
+ `window.location.origin !== ${JSON.stringify(new URL(initialUrl).origin)}`
+ )
+ if (!isNextDeploy) {
+ await browser.waitForElementByCss('[data-external-page]')
+ }
+
+ await browser.back()
+ await browser.waitForCondition(
+ `window.location.href === ${JSON.stringify(initialUrl)} && document.querySelector('#from')`
+ )
+ expect(await browser.elementById('from').text()).toBe('server')
})
if (process.env.BROWSER_NAME === 'safari') {
diff --git a/test/e2e/basepath/pages/external-and-back.js b/test/e2e/basepath/pages/external-and-back.js
index c2932502b2b6..68614a7e5d4d 100644
--- a/test/e2e/basepath/pages/external-and-back.js
+++ b/test/e2e/basepath/pages/external-and-back.js
@@ -1,12 +1,15 @@
-const Page = ({ from }) => (
+const Page = ({ from, external }) => (
)
-Page.getInitialProps = () => {
- return { from: typeof window === 'undefined' ? 'server' : 'client' }
+Page.getInitialProps = ({ query }) => {
+ return {
+ from: typeof window === 'undefined' ? 'server' : 'client',
+ external: query.external || 'https://example.vercel.sh',
+ }
}
export default Page
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/[slug]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/[slug]/page.tsx
new file mode 100644
index 000000000000..09251644c65a
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/[slug]/page.tsx
@@ -0,0 +1,13 @@
+export function generateStaticParams() {
+ return [{ slug: 'one' }]
+}
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ slug: string }>
+}) {
+ const { slug } = await params
+
+ return {slug}
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/fallback-shell/[slug]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/fallback-shell/[slug]/page.tsx
new file mode 100644
index 000000000000..0155d589a8ca
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/fallback-shell/[slug]/page.tsx
@@ -0,0 +1,19 @@
+import { Suspense } from 'react'
+
+export function generateStaticParams() {
+ return [{ slug: 'two' }]
+}
+
+export default function Page({
+ params,
+}: {
+ params: Promise<{ slug: string }>
+}) {
+ return (
+ loading
}>
+ {params.then(({ slug }) => (
+ {slug}
+ ))}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/layout.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/layout.tsx
new file mode 100644
index 000000000000..98294175deb0
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/layout.tsx
@@ -0,0 +1,17 @@
+import { lang } from 'next/root-params'
+
+export function generateStaticParams() {
+ return [{ lang: 'en' }, { lang: 'de' }]
+}
+
+export default async function Root({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/page.tsx
new file mode 100644
index 000000000000..ff7159d9149f
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return hello world
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/ppr/page.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/ppr/page.tsx
new file mode 100644
index 000000000000..ad313fcdb6da
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/ppr/page.tsx
@@ -0,0 +1,12 @@
+import { cookies } from 'next/headers'
+import { Suspense } from 'react'
+
+export default function Page() {
+ return (
+ loading}>
+ {cookies().then(() => (
+ ppr
+ ))}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/static/page.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/static/page.tsx
new file mode 100644
index 000000000000..6c4b84538ac5
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/static/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return static
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/my-adapter.mjs b/test/production/app-dir/adapter-dynamic-routes/cache-components/my-adapter.mjs
new file mode 100644
index 000000000000..0aa9bfb874ae
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/my-adapter.mjs
@@ -0,0 +1,12 @@
+import fs from 'fs/promises'
+
+/** @type {import('next').NextAdapter} */
+export default {
+ name: 'route-table-probe',
+ async onBuildComplete(ctx) {
+ await fs.writeFile(
+ 'build-complete.json',
+ JSON.stringify(ctx.routing, null, 2)
+ )
+ },
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/next.config.js b/test/production/app-dir/adapter-dynamic-routes/cache-components/next.config.js
new file mode 100644
index 000000000000..7a959dd71b06
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/next.config.js
@@ -0,0 +1,22 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ cacheComponents: true,
+ // The option is off by default. These snapshots pin the collapsed route
+ // table, so the fixture enables it.
+ experimental: { collapseAdapterRoutes: true },
+ // A build ID that reaches an entry changes the snapshot on every run. A
+ // fixed build ID keeps the snapshot independent of the run.
+ generateBuildId: () => 'test-build-id',
+ adapterPath: require.resolve('./my-adapter.mjs'),
+}
+
+// `dynamic-routes-base-path.test.ts` sets this variable and builds the fixture a
+// second time under a base path. The value arrives through `nextTestSetup`'s
+// `env`, so it reaches a local build and a deployed build alike.
+if (process.env.BASE_PATH) {
+ nextConfig.basePath = process.env.BASE_PATH
+}
+
+module.exports = nextConfig
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
new file mode 100644
index 000000000000..080ce96def6e
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
@@ -0,0 +1,46 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import { type AdapterRouting } from './dynamic-routes-snapshot'
+
+const basePath = '/base'
+
+// This suite builds the Cache Components fixture a second time under a base
+// path.
+//
+// A base path belongs to the request, not to the route. The build writes
+// artifacts under the route's own path. The adapter prefixes the entries that
+// match incoming requests.
+//
+// A collapse rewrites the source regex and the destination of an entry, and
+// both carry the prefix. A collapse can therefore drop the prefix or add it
+// twice. This suite asserts the prefix as a property of every entry. It does
+// not snapshot the table a second time.
+describe(`adapter dynamic routes (cache components, base path ${basePath})`, () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'cache-components'),
+ env: { BASE_PATH: basePath },
+ // The fixture sets `generateBuildId`, and this option lets that value
+ // take effect. The harness otherwise assigns a new build ID for each run.
+ // A build ID that reaches an entry then changes the assertions on every
+ // run.
+ disableAutoSkewProtection: true,
+ })
+
+ it('prefixes every entry with the base path', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ // A base path prefixes the entries. It does not add or remove any.
+ expect(routing.dynamicRoutes).toHaveLength(7)
+
+ for (const route of routing.dynamicRoutes) {
+ expect(route.sourceRegex.startsWith(`^${basePath}`)).toBe(true)
+ expect(route.destination.startsWith(`${basePath}/`)).toBe(true)
+
+ // The prefix appears exactly once. An entry that carries the prefix
+ // twice still starts with it, so a check on the start alone accepts
+ // that entry. The two occurrences also do not have to be adjacent.
+ expect(route.sourceRegex).toIncludeRepeated(basePath, 1)
+ expect(route.destination).toIncludeRepeated(basePath, 1)
+ }
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
new file mode 100644
index 000000000000..6999f9be4de9
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
@@ -0,0 +1,75 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import {
+ serializeDynamicRoutes,
+ type AdapterRouting,
+} from './dynamic-routes-snapshot'
+
+// This suite pins the dynamic routes that a build passes to an adapter for a
+// Cache Components app. The root layout of the fixture takes one root param and
+// returns two values for it.
+//
+// Each entry in the snapshot becomes one route in the routes document of a
+// deployment. The snapshot covers the routes that this app shape contributes.
+// A deployment also carries a fixed set of adapter routes, and it carries one
+// route for each rewrite that the app config declares.
+//
+// A route without a dynamic segment contributes no entry. A request for that
+// route matches an output during the filesystem check, so it needs no
+// rewrite.
+//
+// The fixture holds the shape that grows with the number of root param values.
+// `generateStaticParams` on the root layout produces one fallback shell for
+// each value. Each shell contributes one entry, and that entry serves three
+// kinds of request:
+//
+// - A request for the page.
+// - A request for its `.rsc` payload.
+// - A per-segment prefetch.
+describe('adapter dynamic routes (cache components)', () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'cache-components'),
+ // The fixture sets `generateBuildId`, and this option lets that value
+ // take effect. The harness otherwise assigns a new build ID for each run.
+ // A build ID that reaches an entry then changes the assertions on every
+ // run.
+ disableAutoSkewProtection: true,
+ })
+
+ it('emits the expected dynamic routes', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ expect(serializeDynamicRoutes(routing.dynamicRoutes))
+ .toMatchInlineSnapshot(`
+ "7 entries
+
+ /[lang]
+ ^[/]?/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[lang]$rscSuffix?nxtPlang=$nxtPlang
+
+ /$shellPrefix/fallback-shell/[slug]
+ ^[/]?/(?de|en)/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /$shellPrefix/fallback-shell/[slug]$rscSuffix?nxtPslug=$nxtPslug
+
+ /[lang]/fallback-shell/[slug]
+ ^[/]?/(?[^/]+?)/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[lang]/fallback-shell/[slug]$rscSuffix?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
+
+ /[lang]/ppr
+ ^[/]?/(?[^/]+?)/ppr(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[lang]/ppr$rscSuffix?nxtPlang=$nxtPlang
+
+ /[lang]/static
+ ^[/]?/(?[^/]+?)/static(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[lang]/static$rscSuffix?nxtPlang=$nxtPlang
+
+ /$shellPrefix/[slug]
+ ^[/]?/(?de|en)/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /$shellPrefix/[slug]$rscSuffix?nxtPslug=$nxtPslug
+
+ /[lang]/[slug]
+ ^[/]?/(?[^/]+?)/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[lang]/[slug]$rscSuffix?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug"
+ `)
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts
new file mode 100644
index 000000000000..445ec8127724
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts
@@ -0,0 +1,84 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import {
+ serializeDynamicRoutes,
+ type AdapterRouting,
+} from './dynamic-routes-snapshot'
+
+// This suite pins the dynamic routes that a build passes to an adapter for
+// an app router project without Cache Components. The fixture also holds one
+// pages router route.
+//
+// The fixture covers the parts of this output that do not depend on Cache
+// Components:
+//
+// - The `.rsc` entry and the plain entry that each dynamic app page receives.
+// - The entries that a route handler receives.
+// - Several pages that share one shape and differ in a static last segment.
+// - A pages router route that sets `fallback: false`.
+// - Two static pages router pages, next to a proxy.
+//
+// The `fallback: false` route decides whether a merge is possible. Its plain
+// entry carries a preview bypass condition. Its `.rsc` entry does not carry
+// that condition.
+//
+// A proxy next to a pages router adds one entry for each static pages router
+// page. That entry maps the `_next/data` URL of the page to the page itself.
+// The count of those entries grows with the number of pages rather than with
+// the number of route shapes.
+describe('adapter dynamic routes (legacy)', () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'legacy'),
+ // The fixture sets `generateBuildId`, and this option lets that value
+ // take effect. The harness otherwise assigns a new build ID for each run.
+ // The source regex of a pages router data route holds the build ID.
+ disableAutoSkewProtection: true,
+ })
+
+ it('emits the expected dynamic routes', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ expect(serializeDynamicRoutes(routing.dynamicRoutes))
+ .toMatchInlineSnapshot(`
+ "9 entries
+
+ /legacy/[id]
+ ^/_next/data/test\\-build\\-id[/]?/legacy/(?[^/]+?)\\.json(?:/)?$
+ -> /_next/data/test-build-id/legacy/[id].json?nxtPid=$nxtPid
+ [has cookie __prerender_bypass, has cookie __next_preview_data]
+
+ /static-one
+ ^/_next/data/test\\-build\\-id[/]?/static\\-one\\.json(?:/)?$
+ -> /static-one
+
+ /static-two
+ ^/_next/data/test\\-build\\-id[/]?/static\\-two\\.json(?:/)?$
+ -> /static-two
+
+ /blog/[slug]
+ ^[/]?/blog/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /blog/[slug]$rscSuffix?nxtPslug=$nxtPslug
+
+ /docs/[lang]/accounts
+ ^[/]?/docs/(?[^/]+?)/accounts(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /docs/[lang]/accounts$rscSuffix?nxtPlang=$nxtPlang
+
+ /docs/[lang]/functions
+ ^[/]?/docs/(?[^/]+?)/functions(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /docs/[lang]/functions$rscSuffix?nxtPlang=$nxtPlang
+
+ /docs/[lang]/guide
+ ^[/]?/docs/(?[^/]+?)/guide(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /docs/[lang]/guide$rscSuffix?nxtPlang=$nxtPlang
+
+ /legacy/[id].rsc
+ ^[/]?/legacy/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /legacy/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /legacy/[id]
+ ^[/]?/legacy/(?[^/]+?)(?:/)?$
+ -> /legacy/[id]?nxtPid=$nxtPid
+ [has cookie __prerender_bypass, has cookie __next_preview_data]"
+ `)
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-no-root-params.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-no-root-params.test.ts
new file mode 100644
index 000000000000..2cc553a0d35c
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-no-root-params.test.ts
@@ -0,0 +1,68 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import {
+ serializeDynamicRoutes,
+ type AdapterRouting,
+} from './dynamic-routes-snapshot'
+
+// This suite pins the dynamic routes for an app that has no root params. The
+// root layout sits above the dynamic segments, so `team` and `locale` are
+// ordinary dynamic params, and `generateStaticParams` on a nested layout
+// enumerates them.
+//
+// The build produces two shapes of entry for one source page here:
+//
+// - An entry that resolves both params, such as `/sparse/en/posts/[id]`.
+// - An entry that resolves only `team`, such as `/sparse/[locale]/posts/[id]`.
+//
+// The two shapes alternate, so only neighbours of one shape collapse into a
+// single entry. The two entries for the `acme.one-two,three` team that resolve
+// both params are such a pair. The entry for the `sparse` team that resolves
+// both params has an entry of the other shape on each side, so it stays as it
+// is.
+//
+// The order of the entries carries the behavior. An entry that resolves both
+// params comes before an entry that resolves one, so a request for
+// `/sparse/en/posts/1` reaches the output for `/sparse/en/posts/[id]` and not
+// the one for `/sparse/[locale]/posts/[id]`. A collapsed entry takes the
+// position of the first entry that it replaces, and a run of neighbours stops
+// at any route of another shape, so every replaced entry keeps its place
+// relative to the routes around it.
+describe('adapter dynamic routes (no root params)', () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'no-root-params'),
+ // The fixture sets `generateBuildId`, and this option lets that value take
+ // effect. The harness otherwise assigns a new build ID for each run. A
+ // build ID that reaches an entry then changes the assertions on every run.
+ disableAutoSkewProtection: true,
+ })
+
+ it('emits the expected dynamic routes', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ expect(serializeDynamicRoutes(routing.dynamicRoutes))
+ .toMatchInlineSnapshot(`
+ "5 entries
+
+ /$shellPrefix/posts/[id]
+ ^[/]?/(?acme\\.one\\-two,three/de|acme\\.one\\-two,three/en)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /$shellPrefix/posts/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /acme.one-two,three/[locale]/posts/[id]
+ ^[/]?/acme\\.one\\-two,three/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /acme.one-two,three/[locale]/posts/[id]$rscSuffix?nxtPlocale=$nxtPlocale&nxtPid=$nxtPid
+
+ /sparse/en/posts/[id]
+ ^[/]?/sparse/en/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /sparse/en/posts/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /sparse/[locale]/posts/[id]
+ ^[/]?/sparse/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /sparse/[locale]/posts/[id]$rscSuffix?nxtPlocale=$nxtPlocale&nxtPid=$nxtPid
+
+ /[team]/[locale]/posts/[id]
+ ^[/]?/(?[^/]+?)/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[team]/[locale]/posts/[id]$rscSuffix?nxtPteam=$nxtPteam&nxtPlocale=$nxtPlocale&nxtPid=$nxtPid"
+ `)
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes-single.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes-single.test.ts
new file mode 100644
index 000000000000..2d7c58651208
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes-single.test.ts
@@ -0,0 +1,40 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import {
+ serializeDynamicRoutes,
+ type AdapterRouting,
+} from './dynamic-routes-snapshot'
+
+// This suite builds the shell prefixes fixture with one combination of root
+// param values, so each source page has one fallback shell.
+//
+// An alternation of one combination saves no entry, and it would replace a
+// literal path with a capture group for no gain. The entry for the shell
+// therefore keeps the combination as it is.
+describe('adapter dynamic routes (shell prefixes, one combination)', () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'shell-prefixes'),
+ env: { SINGLE_COMBINATION: '1' },
+ // The fixture sets `generateBuildId`, and this option lets that value take
+ // effect. The harness otherwise assigns a new build ID for each run. A
+ // build ID that reaches an entry then changes the assertions on every run.
+ disableAutoSkewProtection: true,
+ })
+
+ it('keeps the combination in the entry for a lone shell', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ expect(serializeDynamicRoutes(routing.dynamicRoutes))
+ .toMatchInlineSnapshot(`
+ "2 entries
+
+ /acme.one-two,three/en/posts/[id]
+ ^[/]?/acme\\.one\\-two,three/en/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /acme.one-two,three/en/posts/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /[team]/[locale]/posts/[id]
+ ^[/]?/(?[^/]+?)/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[team]/[locale]/posts/[id]$rscSuffix?nxtPteam=$nxtPteam&nxtPlocale=$nxtPlocale&nxtPid=$nxtPid"
+ `)
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes.test.ts
new file mode 100644
index 000000000000..5135f84e59a3
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes.test.ts
@@ -0,0 +1,43 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import {
+ serializeDynamicRoutes,
+ type AdapterRouting,
+} from './dynamic-routes-snapshot'
+
+// This suite pins the dynamic routes for an app whose root layout takes two
+// root params.
+//
+// The fixture returns three combinations of the two root params, and not the
+// full product of four. The build produces one fallback shell for each of the
+// three, and each shell contributes one entry. No entry matches the fourth
+// combination, `sparse/de`, because the build produces no output for it.
+//
+// One combination contains `.`, `-` and `,`. A regex treats those characters as
+// special, and the patterns below escape them.
+describe('adapter dynamic routes (shell prefixes)', () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'shell-prefixes'),
+ // The fixture sets `generateBuildId`, and this option lets that value take
+ // effect. The harness otherwise assigns a new build ID for each run. A
+ // build ID that reaches an entry then changes the assertions on every run.
+ disableAutoSkewProtection: true,
+ })
+
+ it('emits the expected dynamic routes', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ expect(serializeDynamicRoutes(routing.dynamicRoutes))
+ .toMatchInlineSnapshot(`
+ "2 entries
+
+ /$shellPrefix/posts/[id]
+ ^[/]?/(?acme\\.one\\-two,three/de|acme\\.one\\-two,three/en|sparse/en)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /$shellPrefix/posts/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /[team]/[locale]/posts/[id]
+ ^[/]?/(?[^/]+?)/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[team]/[locale]/posts/[id]$rscSuffix?nxtPteam=$nxtPteam&nxtPlocale=$nxtPlocale&nxtPid=$nxtPid"
+ `)
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-snapshot.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-snapshot.ts
new file mode 100644
index 000000000000..df662333a150
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-snapshot.ts
@@ -0,0 +1,55 @@
+type RouteCondition = {
+ type: string
+ key: string
+ value?: string
+}
+
+export type DynamicRouteEntry = {
+ source: string
+ sourceRegex: string
+ destination: string
+ has?: RouteCondition[]
+ missing?: RouteCondition[]
+}
+
+export type AdapterRouting = {
+ dynamicRoutes: DynamicRouteEntry[]
+}
+
+/**
+ * Formats the dynamic routes as one block per entry.
+ *
+ * The output carries three things:
+ *
+ * - The entry count.
+ * - The fields that a collapse rewrites: `sourceRegex` and `destination`.
+ * - The conditions that allow a merge: `has` and `missing`.
+ *
+ * The output omits every other field of the adapter payload. An unrelated
+ * change to the build output then leaves the snapshot alone.
+ *
+ * The entries keep the order that the build emits. That order is part of the
+ * contract:
+ *
+ * - A fallback shell comes before its source page.
+ * - A static segment comes before a dynamic segment in the same position.
+ */
+export function serializeDynamicRoutes(routes: DynamicRouteEntry[]): string {
+ const blocks = routes.map((route) => {
+ const conditions = [
+ ...(route.has ?? []).map(
+ (condition) => `has ${condition.type} ${condition.key}`
+ ),
+ ...(route.missing ?? []).map(
+ (condition) => `missing ${condition.type} ${condition.key}`
+ ),
+ ]
+
+ const conditionLine =
+ conditions.length > 0 ? `\n [${conditions.join(', ')}]` : ''
+
+ return `${route.source}\n ${route.sourceRegex}\n -> ${route.destination}${conditionLine}`
+ })
+
+ return `${routes.length} entries\n\n${blocks.join('\n\n')}`
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/api/data/route.ts b/test/production/app-dir/adapter-dynamic-routes/legacy/app/api/data/route.ts
new file mode 100644
index 000000000000..fda0065b4b08
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/api/data/route.ts
@@ -0,0 +1,3 @@
+export async function GET() {
+ return Response.json({ from: 'route-handler' })
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/blog/[slug]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/app/blog/[slug]/page.tsx
new file mode 100644
index 000000000000..09251644c65a
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/blog/[slug]/page.tsx
@@ -0,0 +1,13 @@
+export function generateStaticParams() {
+ return [{ slug: 'one' }]
+}
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ slug: string }>
+}) {
+ const { slug } = await params
+
+ return {slug}
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/accounts/page.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/accounts/page.tsx
new file mode 100644
index 000000000000..d9994085d47d
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/accounts/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return accounts
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/functions/page.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/functions/page.tsx
new file mode 100644
index 000000000000..a9d4f6cf4b99
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/functions/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return functions
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/guide/page.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/guide/page.tsx
new file mode 100644
index 000000000000..f653a2cab7cc
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/guide/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return guide
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/layout.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/app/layout.tsx
new file mode 100644
index 000000000000..e7077399c03c
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/layout.tsx
@@ -0,0 +1,7 @@
+export default function Root({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/my-adapter.mjs b/test/production/app-dir/adapter-dynamic-routes/legacy/my-adapter.mjs
new file mode 100644
index 000000000000..0aa9bfb874ae
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/my-adapter.mjs
@@ -0,0 +1,12 @@
+import fs from 'fs/promises'
+
+/** @type {import('next').NextAdapter} */
+export default {
+ name: 'route-table-probe',
+ async onBuildComplete(ctx) {
+ await fs.writeFile(
+ 'build-complete.json',
+ JSON.stringify(ctx.routing, null, 2)
+ )
+ },
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/next.config.js b/test/production/app-dir/adapter-dynamic-routes/legacy/next.config.js
new file mode 100644
index 000000000000..d9fbaae44df9
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/next.config.js
@@ -0,0 +1,19 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ // This value is explicit, not omitted. CI exports
+ // `__NEXT_CACHE_COMPONENTS=true` for the Cache Components matrices, and
+ // that variable overrides a config that omits the field. An omitted field
+ // would let that matrix turn Cache Components on for this fixture.
+ cacheComponents: false,
+ // The option is off by default. These snapshots pin the collapsed route
+ // table, so the fixture enables it.
+ experimental: { collapseAdapterRoutes: true },
+ // The source regex of a pages router data route holds the build ID. A fixed
+ // build ID keeps the snapshot independent of the run.
+ generateBuildId: () => 'test-build-id',
+ adapterPath: require.resolve('./my-adapter.mjs'),
+}
+
+module.exports = nextConfig
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/pages/legacy/[id].tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/legacy/[id].tsx
new file mode 100644
index 000000000000..58c7f055fbeb
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/legacy/[id].tsx
@@ -0,0 +1,13 @@
+import type { GetStaticPaths, GetStaticProps } from 'next'
+
+export const getStaticPaths: GetStaticPaths = () => {
+ return { paths: [{ params: { id: '1' } }], fallback: false }
+}
+
+export const getStaticProps: GetStaticProps<{ id: string }> = ({ params }) => {
+ return { props: { id: String(params?.id) } }
+}
+
+export default function Page({ id }: { id: string }) {
+ return legacy {id}
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-one.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-one.tsx
new file mode 100644
index 000000000000..739deac0de00
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-one.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return static one
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-two.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-two.tsx
new file mode 100644
index 000000000000..3a3cd2a133eb
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-two.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return static two
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/proxy.ts b/test/production/app-dir/adapter-dynamic-routes/legacy/proxy.ts
new file mode 100644
index 000000000000..e6b66cc23e2c
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/proxy.ts
@@ -0,0 +1,5 @@
+import { NextResponse } from 'next/server'
+
+export default function proxy() {
+ return NextResponse.next()
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/layout.tsx b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/layout.tsx
new file mode 100644
index 000000000000..3667053a881a
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/layout.tsx
@@ -0,0 +1,18 @@
+// This layout is not the root layout, so `team` and `locale` are ordinary
+// dynamic params rather than root params. It returns three combinations of
+// them, and not the full product of four.
+export function generateStaticParams() {
+ return [
+ { team: 'acme.one-two,three', locale: 'en' },
+ { team: 'acme.one-two,three', locale: 'de' },
+ { team: 'sparse', locale: 'en' },
+ ]
+}
+
+export default function TeamLocaleLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return <>{children}>
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/posts/[id]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/posts/[id]/page.tsx
new file mode 100644
index 000000000000..c171c3a946aa
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/posts/[id]/page.tsx
@@ -0,0 +1,13 @@
+import { Suspense } from 'react'
+
+// This page does not resolve `id`. The build therefore produces one fallback
+// shell for each root param combination.
+export default function Page({ params }: { params: Promise<{ id: string }> }) {
+ return (
+ loading}>
+ {params.then(({ id }) => (
+ {id}
+ ))}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/layout.tsx b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/layout.tsx
new file mode 100644
index 000000000000..a5e5ca4e0333
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/layout.tsx
@@ -0,0 +1,9 @@
+// The root layout is not inside a dynamic segment, so `team` and `locale` are
+// ordinary dynamic params and not root params.
+export default function Root({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/no-root-params/my-adapter.mjs b/test/production/app-dir/adapter-dynamic-routes/no-root-params/my-adapter.mjs
new file mode 100644
index 000000000000..f49ffcc84320
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/no-root-params/my-adapter.mjs
@@ -0,0 +1,14 @@
+import fs from 'fs/promises'
+
+/**
+ * @type {import('next').NextAdapter}
+ */
+export default {
+ name: 'route-table-probe',
+ async onBuildComplete(ctx) {
+ await fs.writeFile(
+ 'build-complete.json',
+ JSON.stringify(ctx.routing, null, 2)
+ )
+ },
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/no-root-params/next.config.js b/test/production/app-dir/adapter-dynamic-routes/no-root-params/next.config.js
new file mode 100644
index 000000000000..b5d103f83f69
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/no-root-params/next.config.js
@@ -0,0 +1,15 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ cacheComponents: true,
+ // The option is off by default. These snapshots pin the collapsed route
+ // table, so the fixture enables it.
+ experimental: { collapseAdapterRoutes: true },
+ // A build ID that reaches an entry changes the snapshot on every run. A fixed
+ // build ID keeps the snapshot independent of the run.
+ generateBuildId: () => 'test-build-id',
+ adapterPath: require.resolve('./my-adapter.mjs'),
+}
+
+module.exports = nextConfig
diff --git a/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/layout.tsx b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/layout.tsx
new file mode 100644
index 000000000000..c9da36f35539
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/layout.tsx
@@ -0,0 +1,34 @@
+import { locale, team } from 'next/root-params'
+
+// This layout takes two root params. `generateStaticParams` returns three
+// combinations of them, and not the full product of four. The build therefore
+// produces no output for the `sparse` team with the `de` locale.
+//
+// One team value contains `.`, `-` and `,`. A regex treats those characters as
+// special.
+//
+// `SINGLE_COMBINATION` reduces the list to one combination. A suite sets it to
+// build the same app with one fallback shell per source page.
+export function generateStaticParams() {
+ if (process.env.SINGLE_COMBINATION) {
+ return [{ team: 'acme.one-two,three', locale: 'en' }]
+ }
+
+ return [
+ { team: 'acme.one-two,three', locale: 'en' },
+ { team: 'acme.one-two,three', locale: 'de' },
+ { team: 'sparse', locale: 'en' },
+ ]
+}
+
+export default async function Root({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/posts/[id]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/posts/[id]/page.tsx
new file mode 100644
index 000000000000..c171c3a946aa
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/posts/[id]/page.tsx
@@ -0,0 +1,13 @@
+import { Suspense } from 'react'
+
+// This page does not resolve `id`. The build therefore produces one fallback
+// shell for each root param combination.
+export default function Page({ params }: { params: Promise<{ id: string }> }) {
+ return (
+ loading}>
+ {params.then(({ id }) => (
+ {id}
+ ))}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/my-adapter.mjs b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/my-adapter.mjs
new file mode 100644
index 000000000000..f49ffcc84320
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/my-adapter.mjs
@@ -0,0 +1,14 @@
+import fs from 'fs/promises'
+
+/**
+ * @type {import('next').NextAdapter}
+ */
+export default {
+ name: 'route-table-probe',
+ async onBuildComplete(ctx) {
+ await fs.writeFile(
+ 'build-complete.json',
+ JSON.stringify(ctx.routing, null, 2)
+ )
+ },
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/next.config.js b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/next.config.js
new file mode 100644
index 000000000000..b5d103f83f69
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/next.config.js
@@ -0,0 +1,15 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ cacheComponents: true,
+ // The option is off by default. These snapshots pin the collapsed route
+ // table, so the fixture enables it.
+ experimental: { collapseAdapterRoutes: true },
+ // A build ID that reaches an entry changes the snapshot on every run. A fixed
+ // build ID keeps the snapshot independent of the run.
+ generateBuildId: () => 'test-build-id',
+ adapterPath: require.resolve('./my-adapter.mjs'),
+}
+
+module.exports = nextConfig
diff --git a/test/production/typescript-basic/typechecking/cache-handlers/cache-handlers.ts b/test/production/typescript-basic/typechecking/cache-handlers/cache-handlers.ts
new file mode 100644
index 000000000000..e17cc81b9bd0
--- /dev/null
+++ b/test/production/typescript-basic/typechecking/cache-handlers/cache-handlers.ts
@@ -0,0 +1,24 @@
+import type { CacheHandler, CacheEntry } from 'next/cache'
+
+// eslint-disable-next-line @typescript-eslint/no-unused-expressions
+;() => {
+ ;({
+ value: new ReadableStream(),
+ tags: [],
+ stale: 0,
+ timestamp: 0,
+ expire: 0,
+ revalidate: 0,
+ }) satisfies CacheEntry
+ ;({
+ async get(_cacheKey: string, _softTags: string[]) {
+ return undefined
+ },
+ async set(_cacheKey: string, _pendingEntry: Promise) {},
+ async refreshTags() {},
+ async getExpiration(_tags: string[]) {
+ return 0
+ },
+ async updateTags(_tags: string[], _durations?: { expire?: number }) {},
+ }) satisfies CacheHandler
+}
diff --git a/test/unit/parse-page-static-info.test.ts b/test/unit/parse-page-static-info.test.ts
index 73f2f95c6593..8eb9cc20b475 100644
--- a/test/unit/parse-page-static-info.test.ts
+++ b/test/unit/parse-page-static-info.test.ts
@@ -14,31 +14,25 @@ describe('parse page static info', () => {
await installBindings()
})
it('should parse nodejs runtime correctly', async () => {
- const { runtime, getServerSideProps, getStaticProps } =
- await getPagesPageStaticInfo({
- page: 'nodejs-ssr',
- pageFilePath: join(fixtureDir, 'page-runtime/nodejs-ssr.js'),
- nextConfig: createNextConfig(),
- pageType: PAGE_TYPES.PAGES,
- isDev: false,
- })
+ const { runtime } = await getPagesPageStaticInfo({
+ page: 'nodejs-ssr',
+ pageFilePath: join(fixtureDir, 'page-runtime/nodejs-ssr.js'),
+ nextConfig: createNextConfig(),
+ pageType: PAGE_TYPES.PAGES,
+ isDev: false,
+ })
expect(runtime).toBe('nodejs')
- expect(getServerSideProps).toBe(true)
- expect(getStaticProps).toBe(false)
})
it('should parse static runtime correctly', async () => {
- const { runtime, getServerSideProps, getStaticProps } =
- await getPagesPageStaticInfo({
- page: 'nodejs',
- pageFilePath: join(fixtureDir, 'page-runtime/nodejs.js'),
- nextConfig: createNextConfig(),
- pageType: PAGE_TYPES.PAGES,
- isDev: false,
- })
+ const { runtime } = await getPagesPageStaticInfo({
+ page: 'nodejs',
+ pageFilePath: join(fixtureDir, 'page-runtime/nodejs.js'),
+ nextConfig: createNextConfig(),
+ pageType: PAGE_TYPES.PAGES,
+ isDev: false,
+ })
expect(runtime).toBe('nodejs')
- expect(getServerSideProps).toBe(false)
- expect(getStaticProps).toBe(false)
})
it('should parse edge runtime correctly', async () => {
@@ -62,18 +56,4 @@ describe('parse page static info', () => {
})
expect(runtime).toBe(undefined)
})
-
- it('should parse ssr info with variable exported gSSP correctly', async () => {
- const { getServerSideProps, getStaticProps } = await getPagesPageStaticInfo(
- {
- page: 'ssr-variable-gssp',
- pageFilePath: join(fixtureDir, 'page-runtime/ssr-variable-gssp.js'),
- nextConfig: createNextConfig(),
- pageType: PAGE_TYPES.PAGES,
- isDev: false,
- }
- )
- expect(getStaticProps).toBe(false)
- expect(getServerSideProps).toBe(true)
- })
})
diff --git a/turbopack/crates/turbo-tasks-backend/benches/overhead.rs b/turbopack/crates/turbo-tasks-backend/benches/overhead.rs
index 0072c4f677cb..358b81481532 100644
--- a/turbopack/crates/turbo-tasks-backend/benches/overhead.rs
+++ b/turbopack/crates/turbo-tasks-backend/benches/overhead.rs
@@ -68,7 +68,7 @@ pub fn overhead(c: &mut Criterion) {
}
start.elapsed()
})
- .then(|r| async { r.unwrap() })
+ .then(async |r| r.unwrap())
});
});
@@ -113,7 +113,7 @@ pub fn overhead(c: &mut Criterion) {
while futures.next().await.is_some() {}
start.elapsed()
})
- .then(|r| async { r.unwrap() })
+ .then(async |r| r.unwrap())
});
},
);
diff --git a/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs b/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs
index d9dd58894e17..81130f1e4ed5 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs
@@ -19,7 +19,7 @@ static REGISTRATION: Registration = register!();
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_transitive_emitting() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
unmark_top_level_task_may_leak_eventually_consistent_state();
let result_op = my_transitive_emitting_function(rcstr!(""), rcstr!(""));
let result_val = result_op.connect().strongly_consistent().await?;
@@ -38,7 +38,7 @@ async fn test_transitive_emitting() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_transitive_emitting_indirect() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
unmark_top_level_task_may_leak_eventually_consistent_state();
let result_op = my_transitive_emitting_function(rcstr!(""), rcstr!(""));
let collectibles_op = my_transitive_emitting_function_collectibles(rcstr!(""), rcstr!(""));
@@ -57,7 +57,7 @@ async fn test_transitive_emitting_indirect() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_multi_emitting() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
unmark_top_level_task_may_leak_eventually_consistent_state();
let result_op = my_multi_emitting_function();
let result_val = result_op.connect().strongly_consistent().await?;
@@ -76,7 +76,7 @@ async fn test_multi_emitting() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn taking_collectibles() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let result_op = my_collecting_function();
let result_val = result_op.connect().strongly_consistent().await?;
let list = result_op.take_collectibles::>();
@@ -92,7 +92,7 @@ async fn taking_collectibles() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn taking_collectibles_extra_layer() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let result_op = my_collecting_function_indirect();
let result_val = result_op.connect().strongly_consistent().await?;
let list = result_op.take_collectibles::>();
@@ -108,7 +108,7 @@ async fn taking_collectibles_extra_layer() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn taking_collectibles_parallel() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let result_op = my_transitive_emitting_function(rcstr!(""), rcstr!("a"));
let result_val = result_op.connect().strongly_consistent().await?;
let list = result_op.take_collectibles::>();
@@ -150,7 +150,7 @@ async fn taking_collectibles_parallel() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn taking_collectibles_with_resolve() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let result_op = my_transitive_emitting_function_with_resolve(rcstr!("resolve"));
result_op.connect().strongly_consistent().await?;
let list = result_op.take_collectibles::>();
diff --git a/turbopack/crates/turbo-tasks-backend/tests/debug.rs b/turbopack/crates/turbo-tasks-backend/tests/debug.rs
index 340d690919f6..4810c52474bb 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/debug.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/debug.rs
@@ -155,7 +155,7 @@ async fn test_struct_transparent_debug() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_struct_option_debug() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let a = StructWithOption { option: None }.resolved_cell();
assert_eq!(
format!(
@@ -189,7 +189,7 @@ async fn test_struct_option_debug() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_struct_vec_debug() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let a = StructWithVec { vec: Vec::new() }.resolved_cell();
assert_eq!(
format!(
@@ -223,7 +223,7 @@ async fn test_struct_vec_debug() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_struct_ignore_debug() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let a = StructWithIgnore {
dont_ignore: 42,
ignore: Mutex::new(()),
diff --git a/turbopack/crates/turbo-tasks-backend/tests/derive_value_to_string.rs b/turbopack/crates/turbo-tasks-backend/tests/derive_value_to_string.rs
index d1611692ad5c..e752c8ca58d7 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/derive_value_to_string.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/derive_value_to_string.rs
@@ -103,7 +103,7 @@ enum MixedEnum {
/// No attribute: delegates to Display::to_string(self).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_display_delegation() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let v: ResolvedVc> =
ResolvedVc::upcast(SimpleDisplay(42).resolved_cell());
assert_eq!(
@@ -119,7 +119,7 @@ async fn test_display_delegation() {
/// FormatAutoFields on structs: named fields, positional fields, and constant strings.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_struct_format_strings() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let v1: ResolvedVc> = ResolvedVc::upcast(
NamedFields {
name: "foo".into(),
@@ -155,7 +155,7 @@ async fn test_struct_format_strings() {
/// DirectExpr form: single expression delegation.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_struct_direct_expr() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let v: ResolvedVc> = ResolvedVc::upcast(
DirectExpr {
name: "hello".into(),
@@ -176,7 +176,7 @@ async fn test_struct_direct_expr() {
/// FormatExprs on structs: format string with explicit expressions, including Vc delegation.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_struct_format_exprs() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let v1: ResolvedVc> = ResolvedVc::upcast(
FormatExprs {
name: "test".into(),
@@ -210,7 +210,7 @@ async fn test_struct_format_exprs() {
/// Enum with per-variant auto-field format strings and default variant names.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_enum_variants() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
// Per-variant attributes
assert_eq!(
&*to_string_operation(ResolvedVc::upcast(Kind::Module.resolved_cell()))
@@ -261,7 +261,7 @@ async fn test_enum_variants() {
/// Enum with mixed forms: constant literal, Vc delegation, and format exprs.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mixed_enum() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
assert_eq!(
&*to_string_operation(ResolvedVc::upcast(MixedEnum::Literal.resolved_cell()))
.read_strongly_consistent()
@@ -330,7 +330,7 @@ enum TortureEnum {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_torture_enum() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let named_resolved = NamedFields {
name: "x".into(),
count: 1,
diff --git a/turbopack/crates/turbo-tasks-backend/tests/detached.rs b/turbopack/crates/turbo-tasks-backend/tests/detached.rs
index 28106567c28a..8ca612f1c546 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/detached.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/detached.rs
@@ -17,7 +17,7 @@ static REGISTRATION: Registration = register!();
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_spawns_detached() -> anyhow::Result<()> {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
println!("test_spawns_detached");
// HACK: The watch channel we use has an incorrect implementation of `TraceRawVcs`, just
// disable GC for the test so this can't cause any problems.
@@ -86,7 +86,7 @@ async fn spawns_detached(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_spawns_detached_changing() -> anyhow::Result<()> {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
unmark_top_level_task_may_leak_eventually_consistent_state();
// HACK: The watch channel we use has an incorrect implementation of `TraceRawVcs`
prevent_gc();
diff --git a/turbopack/crates/turbo-tasks-backend/tests/dirty_in_progress.rs b/turbopack/crates/turbo-tasks-backend/tests/dirty_in_progress.rs
index 6a95c75e1701..61906dd8c0e1 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/dirty_in_progress.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/dirty_in_progress.rs
@@ -13,7 +13,7 @@ static REGISTRATION: Registration = register!();
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_dirty_in_progress() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
let cases = [
(1, 3, 2, 2, ""),
(11, 13, 12, 42, "12"),
diff --git a/turbopack/crates/turbo-tasks-backend/tests/effects.rs b/turbopack/crates/turbo-tasks-backend/tests/effects.rs
index dc02eb17256c..1ae31195980d 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/effects.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/effects.rs
@@ -128,7 +128,7 @@ impl CapturedEffect for TestEffectCaptured {
async fn apply(&self) -> Result<(), ApplyError> {
let body = if self.content {
- Some(|| async {
+ Some(async || {
self.shared.total_applies.fetch_add(1, Ordering::Relaxed);
*self
.shared
diff --git a/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs b/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs
index 7f42e9e7d739..fb3d041480db 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs
@@ -10,7 +10,7 @@ static REGISTRATION: Registration = register!();
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_emptied_cells() {
- run(®ISTRATION, || async {
+ run(®ISTRATION, async || {
let input_op = get_state_operation();
let input_vc = input_op.resolve().strongly_consistent().await?;
let input = input_op.read_strongly_consistent().await?;
diff --git a/turbopack/crates/turbo-tasks-backend/tests/emptied_cells_session_dependent.rs b/turbopack/crates/turbo-tasks-backend/tests/emptied_cells_session_dependent.rs
index 3630de941c65..5df1a5bf9a5b 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/emptied_cells_session_dependent.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/emptied_cells_session_dependent.rs
@@ -10,7 +10,7 @@ static REGISTRATION: Registration = register!();
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_emptied_cells_session_dependent() {
- run(®ISTRATION, || async {
+ run(®ISTRATION, async || {
let input_op = get_state_operation();
let input_vc = input_op.resolve().strongly_consistent().await?;
let input = input_op.read_strongly_consistent().await?;
diff --git a/turbopack/crates/turbo-tasks-backend/tests/filter_unused_args.rs b/turbopack/crates/turbo-tasks-backend/tests/filter_unused_args.rs
index b7d214d65b84..9c4aade7046d 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/filter_unused_args.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/filter_unused_args.rs
@@ -10,7 +10,7 @@ static REGISTRATION: Registration = register!();
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_filtered_impl_method_args() -> Result<()> {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
unmark_top_level_task_may_leak_eventually_consistent_state();
let uses_arg = UsesArg(0).cell();
@@ -40,7 +40,7 @@ async fn test_filtered_impl_method_args() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_filtered_trait_method_args() -> Result<()> {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
unmark_top_level_task_may_leak_eventually_consistent_state();
let uses_arg = UsesArg(0).cell();
@@ -70,7 +70,7 @@ async fn test_filtered_trait_method_args() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_filtered_impl_method_self() -> Result<()> {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
unmark_top_level_task_may_leak_eventually_consistent_state();
let uses_arg = UsesArg(0).cell();
@@ -106,7 +106,7 @@ async fn test_filtered_impl_method_self() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_filtered_trait_method_self() -> Result<()> {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
unmark_top_level_task_may_leak_eventually_consistent_state();
let uses_arg = UsesArg(0).cell();
@@ -142,7 +142,7 @@ async fn test_filtered_trait_method_self() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_filtered_plain_method_args() -> Result<()> {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
unmark_top_level_task_may_leak_eventually_consistent_state();
assert_eq!(
diff --git a/turbopack/crates/turbo-tasks-backend/tests/hashed_cell_mode.rs b/turbopack/crates/turbo-tasks-backend/tests/hashed_cell_mode.rs
index 94ee2556f390..d7464ea6591a 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/hashed_cell_mode.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/hashed_cell_mode.rs
@@ -72,7 +72,7 @@ async fn consume_hashed(input: ResolvedVc) -> Result> {
/// Test 1: When the value changes, the consumer SHOULD be invalidated and re-execute.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_hashed_cell_mode_change_triggers_invalidation() {
- run(®ISTRATION, || async {
+ run(®ISTRATION, async || {
let state_op = create_state_operation();
let state_vc = state_op.resolve().strongly_consistent().await?;
let state = state_op.read_strongly_consistent().await?;
@@ -103,7 +103,7 @@ async fn test_hashed_cell_mode_change_triggers_invalidation() {
/// With `serialization = "hash"`, the consumer should not be re-executed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_hashed_cell_mode_equal_value_no_invalidation() {
- run(®ISTRATION, || async {
+ run(®ISTRATION, async || {
let state_op = create_state_operation();
let state_vc = state_op.resolve().strongly_consistent().await?;
let state = state_op.read_strongly_consistent().await?;
diff --git a/turbopack/crates/turbo-tasks-backend/tests/immutable.rs b/turbopack/crates/turbo-tasks-backend/tests/immutable.rs
index d46f5adadbab..ca7502720609 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/immutable.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/immutable.rs
@@ -10,7 +10,7 @@ static REGISTRATION: Registration = register!();
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_hidden_mutate() {
- run_once(®ISTRATION, || async {
+ run_once(®ISTRATION, async || {
unmark_top_level_task_may_leak_eventually_consistent_state();
let input = *create_input().to_resolved().await?;
input.await?.state.set(1);
diff --git a/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs b/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs
index e834f17fa1e7..558825881439 100644
--- a/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs
+++ b/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs
@@ -19,7 +19,7 @@ fn create_state_operation() -> Vc {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_invalidation_map() {
- run(®ISTRATION, || async {
+ run(®ISTRATION, async || {
let state_op = create_state_operation();
let state_vc = state_op.resolve().strongly_consistent().await?;
let state = state_op.read_strongly_consistent().await?;
@@ -99,7 +99,7 @@ async fn get_value(map: OperationVc