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 }) => (
-

{from}

- External link +

{from}

+ External link
) -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, key: String) -> Result Vc { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_use_operations() -> Result<()> { - run(®ISTRATION, || async { + run(®ISTRATION, async || { assert_eq!(*use_operations().read_strongly_consistent().await?, 42); Ok(()) }) diff --git a/turbopack/crates/turbo-tasks-backend/tests/random_change.rs b/turbopack/crates/turbo-tasks-backend/tests/random_change.rs index 27b128735878..a51d1e678968 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/random_change.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/random_change.rs @@ -11,7 +11,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_random_change() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let state_op = make_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/read_ref_cell.rs b/turbopack/crates/turbo-tasks-backend/tests/read_ref_cell.rs index 83c816a23d20..eb1242fc4bac 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/read_ref_cell.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/read_ref_cell.rs @@ -15,7 +15,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_read_ref() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let counter = Counter::cell(Counter { value: Mutex::new((0, Default::default())), diff --git a/turbopack/crates/turbo-tasks-backend/tests/recompute.rs b/turbopack/crates/turbo-tasks-backend/tests/recompute.rs index 220c0772c296..4920d62b67e1 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/recompute.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/recompute.rs @@ -12,7 +12,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn recompute() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = ChangingInput { state: State::new(1), @@ -63,7 +63,7 @@ async fn recompute() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn immutable_analysis() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = ChangingInput { state: State::new(1), @@ -149,7 +149,7 @@ async fn compute2(input: Vc) -> Result> { /// This tests the basic dependency propagation through a simple two-task chain. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn recompute_dependency() { - run(®ISTRATION, || async { + run(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = *get_dependency_input().to_resolved().await?; // Reset state to 1 at the start of each iteration (important for multi-run tests) diff --git a/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs b/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs index ba296a62146b..49da89de293d 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs @@ -14,7 +14,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn recompute() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = *ChangingInput::new(1).to_resolved().await?; let input2 = *ChangingInput::new(2).to_resolved().await?; diff --git a/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs b/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs index fe87601a6276..e53ce97f2860 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs @@ -118,7 +118,7 @@ async fn test_into_future() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_sidecast() -> Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let concrete_value = ImplementsAAndB.resolved_cell(); let as_a = ResolvedVc::upcast::>(concrete_value); let as_b = ResolvedVc::try_sidecast::>(as_a); @@ -132,7 +132,7 @@ async fn test_sidecast() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_trait_ref_downcast() -> Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let as_base: Vc> = Vc::upcast(ImplementsSubImplemented.cell()); let ref_base = as_base.into_trait_ref().await?; // The concrete value implements SubImplemented, so downcasting the already-read diff --git a/turbopack/crates/turbo-tasks-backend/tests/shrink_to_fit.rs b/turbopack/crates/turbo-tasks-backend/tests/shrink_to_fit.rs index cd6c62251f9d..314f052e7ef0 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/shrink_to_fit.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/shrink_to_fit.rs @@ -13,7 +13,7 @@ struct Wrapper(Vec); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_shrink_to_fit() -> Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { #[turbo_tasks::function(operation, root)] async fn capacity_operation(wrapper: ResolvedVc) -> Result> { Ok(Vc::cell(wrapper.await?.capacity())) diff --git a/turbopack/crates/turbo-tasks-backend/tests/top_level_task_consistency.rs b/turbopack/crates/turbo-tasks-backend/tests/top_level_task_consistency.rs index 4acf9ee08e34..3e59ef4e03ec 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/top_level_task_consistency.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/top_level_task_consistency.rs @@ -26,7 +26,7 @@ async fn returns_value_operation() -> Result> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[should_panic] async fn test_eventual_read_in_top_level_task_fails() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { // This should fail because we're in a top-level task (run_once) // and doing an eventually consistent read (default .await) returns_value_operation().connect().await @@ -37,7 +37,7 @@ async fn test_eventual_read_in_top_level_task_fails() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_cell_read_in_top_level_task_succeeds() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let cell = returns_value_operation() .resolve() .strongly_consistent() @@ -52,7 +52,7 @@ async fn test_cell_read_in_top_level_task_succeeds() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_manual_mark_unmark_top_level_task() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { // We're in a top-level task initially, but let's unmark it unmark_top_level_task_may_leak_eventually_consistent_state(); @@ -83,9 +83,7 @@ async fn test_manual_mark_top_level_task_causes_error() { Ok(Value { value: 42 }.cell()) } - run_once(®ISTRATION, || async { - operation().read_strongly_consistent().await - }) - .await - .unwrap() + run_once(®ISTRATION, || operation().read_strongly_consistent()) + .await + .unwrap() } diff --git a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs index f0d0710d2137..dd44ed3706e8 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs @@ -15,7 +15,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn trait_ref() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let counter = Counter::cell(Counter { value: Mutex::new((0, Default::default())), diff --git a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs index 055d23a36f6c..3418c4dd7971 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs @@ -13,7 +13,7 @@ static REGISTRATION: Registration = register!(); // value is equal. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_trait_ref_shared_cell_mode() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = CellIdSelector { value: 42, @@ -49,7 +49,7 @@ async fn test_trait_ref_shared_cell_mode() { // value is equal. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_trait_ref_new_cell_mode() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = CellIdSelector { value: 42, diff --git a/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs b/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs index c7936bca43c9..c4255c2a1784 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs @@ -29,7 +29,7 @@ async fn turbobail_operation(value: ResolvedVc) -> anyhow::Result Vc { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn errors_on_failed_connection() { let _guard = GLOBAL_TEST_LOCK.lock().await; - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { #[turbo_tasks::value] struct FetchOutput( ReadRef, @@ -204,12 +204,10 @@ async fn errors_on_failed_connection() { let err_kind = err.kind.await?; let err_url = err.url.owned().await?; - let issue_vc = err_vc.to_issue(IssueSeverity::Error, get_issue_context().owned().await?); + let issue_vc = + err_vc.to_issue(IssueSeverity::Error, get_issue_context().owned().await?); let issue = issue_vc.await?; - let issue_description = issue - .description() - .await? - .expect("description is not None"); + let issue_description = issue.description().await?.expect("description is not None"); Ok(FetchOutput(err_kind, err_url, issue, issue_description).cell()) } @@ -219,16 +217,18 @@ async fn errors_on_failed_connection() { // Other values (e.g. domain name, reserved IP address block) may result in long timeouts. let url = rcstr!("http://127.0.0.1:0/foo.woff"); let FetchOutput(err_kind, err_url, issue, issue_description) = - &*fetch_operation(url.clone()).read_strongly_consistent().await?; + &*fetch_operation(url.clone()) + .read_strongly_consistent() + .await?; assert!(matches!(**err_kind, FetchErrorKind::Connect)); assert_eq!(*err_url, url); assert_eq!(issue.severity(), IssueSeverity::Error); assert_eq!( - issue_description.to_unstyled_string(), - "There was an issue establishing a connection while requesting http://127.0.0.1:0/foo.woff" - ); + issue_description.to_unstyled_string(), + "There was an issue establishing a connection while requesting http://127.0.0.1:0/foo.woff" + ); anyhow::Ok(()) }) .await diff --git a/turbopack/crates/turbo-tasks-fs/src/disk.rs b/turbopack/crates/turbo-tasks-fs/src/disk.rs index 3db12ac5546f..cdbd4df0cbab 100644 --- a/turbopack/crates/turbo-tasks-fs/src/disk.rs +++ b/turbopack/crates/turbo-tasks-fs/src/disk.rs @@ -1144,7 +1144,7 @@ impl FileSystem for DiskFileSystem { async fn apply(&self) -> Result<(), turbo_tasks::ApplyError> { let body = self.content.as_ref().map(|content| { - || async { self.apply_inner(content).await.map_err(AnyhowWrapper::from) } + async || self.apply_inner(content).await.map_err(AnyhowWrapper::from) }); self.inner .effect_state_storage @@ -1347,7 +1347,7 @@ impl FileSystem for DiskFileSystem { async fn apply(&self) -> Result<(), turbo_tasks::ApplyError> { let body = self.content.as_ref().map(|content| { - || async { self.apply_inner(content).await.map_err(AnyhowWrapper::from) } + async || self.apply_inner(content).await.map_err(AnyhowWrapper::from) }); self.inner .effect_state_storage diff --git a/turbopack/crates/turbo-tasks-macros-tests/tests/task_input.rs b/turbopack/crates/turbo-tasks-macros-tests/tests/task_input.rs index 83c460b33721..1e3f9df9e131 100644 --- a/turbopack/crates/turbo-tasks-macros-tests/tests/task_input.rs +++ b/turbopack/crates/turbo-tasks-macros-tests/tests/task_input.rs @@ -22,7 +22,7 @@ fn one_unnamed_field(input: OneUnnamedField) -> Vc { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tests() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { #[turbo_tasks::function(operation, root)] async fn equality_operation() -> Result> { Ok(Vc::cell(ReadRef::ptr_eq( @@ -30,8 +30,7 @@ async fn tests() { &Completion::immutable().await?, ))) } - equality_operation().read_strongly_consistent().await?; - anyhow::Ok(()) + equality_operation().read_strongly_consistent().await }) .await .unwrap() diff --git a/turbopack/crates/turbo-tasks-macros-tests/tests/value_debug.rs b/turbopack/crates/turbo-tasks-macros-tests/tests/value_debug.rs index 5e6c49045c07..93fc619b94e3 100644 --- a/turbopack/crates/turbo-tasks-macros-tests/tests/value_debug.rs +++ b/turbopack/crates/turbo-tasks-macros-tests/tests/value_debug.rs @@ -21,7 +21,7 @@ async fn ignored_indexes() { i32, ); - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { #[turbo_tasks::function(operation, root)] async fn value_debug_format_operation() -> Result> { let input = IgnoredIndexes(-1, 2, -3); diff --git a/turbopack/crates/turbo-tasks-malloc/Cargo.toml b/turbopack/crates/turbo-tasks-malloc/Cargo.toml index ab01665e0aaf..2fff14ae6dd2 100644 --- a/turbopack/crates/turbo-tasks-malloc/Cargo.toml +++ b/turbopack/crates/turbo-tasks-malloc/Cargo.toml @@ -9,8 +9,15 @@ autobenches = false [lib] bench = false +[[bench]] +name = "allocation" +harness = false + [dependencies] +[dev-dependencies] +criterion = { workspace = true } + [target.'cfg(not(target_family = "wasm"))'.dependencies] libmimalloc-sys = { version = "0.1.44", features = [ "extended", diff --git a/turbopack/crates/turbo-tasks-malloc/benches/allocation.rs b/turbopack/crates/turbo-tasks-malloc/benches/allocation.rs new file mode 100644 index 000000000000..c4038556c066 --- /dev/null +++ b/turbopack/crates/turbo-tasks-malloc/benches/allocation.rs @@ -0,0 +1,57 @@ +//! Measures the cost of the raw allocation paths through [`TurboMalloc`], including its +//! thread-local allocation accounting. Useful when changing the allocator's instrumentation, since +//! that code runs on every allocation and deallocation in Turbopack. + +use std::alloc::{Layout, alloc, dealloc, realloc}; + +use criterion::{Criterion, black_box, criterion_group, criterion_main}; + +#[global_allocator] +static ALLOC: turbo_tasks_malloc::TurboMalloc = turbo_tasks_malloc::TurboMalloc; + +const ALIGN: usize = 8; +const SIZE: usize = 64; +const LARGER_SIZE: usize = 128; + +fn make_layout(size: usize) -> Layout { + Layout::from_size_align(size, ALIGN).expect("valid layout") +} + +fn benchmark(c: &mut Criterion) { + let mut group = c.benchmark_group("turbo_malloc"); + + group.bench_function("alloc_dealloc", |b| { + b.iter(|| { + // `black_box` the size so the allocation can't be optimized away or folded. + let layout = make_layout(black_box(SIZE)); + // SAFETY: the layout has a non-zero size, and the pointer is freed with the same + // layout it was allocated with. + unsafe { + let ptr = alloc(layout); + assert!(!ptr.is_null(), "allocation failed"); + dealloc(black_box(ptr), layout); + } + }); + }); + + group.bench_function("alloc_realloc_dealloc", |b| { + b.iter(|| { + let layout = make_layout(black_box(SIZE)); + let new_size = black_box(LARGER_SIZE); + // SAFETY: `ptr` is allocated with `layout`, reallocated to `new_size` with the same + // alignment, and finally freed with the layout it then has. + unsafe { + let ptr = alloc(layout); + assert!(!ptr.is_null(), "allocation failed"); + let ptr = realloc(ptr, layout, new_size); + assert!(!ptr.is_null(), "reallocation failed"); + dealloc(black_box(ptr), make_layout(new_size)); + } + }); + }); + + group.finish(); +} + +criterion_group!(benches, benchmark); +criterion_main!(benches); diff --git a/turbopack/crates/turbo-tasks-malloc/src/counter.rs b/turbopack/crates/turbo-tasks-malloc/src/counter.rs index d5faff91bafa..a61e55c27232 100644 --- a/turbopack/crates/turbo-tasks-malloc/src/counter.rs +++ b/turbopack/crates/turbo-tasks-malloc/src/counter.rs @@ -36,29 +36,28 @@ impl ThreadLocalCounter { allocation_counters: AllocationCounters::new(), } } + #[inline(always)] fn add(&mut self, size: usize) { self.allocation_counters.allocations += size; self.allocation_counters.allocation_count += 1; if self.buffer >= size { self.buffer -= size; } else { - let offset = size - self.buffer + TARGET_BUFFER; - self.buffer = TARGET_BUFFER; - ALLOCATED.fetch_add(offset, Ordering::Relaxed); + add_slow(self, size); } } + #[inline(always)] fn remove(&mut self, size: usize) { self.allocation_counters.deallocations += size; self.allocation_counters.deallocation_count += 1; self.buffer += size; if self.buffer > MAX_BUFFER { - let offset = self.buffer - TARGET_BUFFER; - self.buffer = TARGET_BUFFER; - ALLOCATED.fetch_sub(offset, Ordering::Relaxed); + remove_slow(self); } } + #[inline(always)] fn update(&mut self, old_size: usize, new_size: usize) { self.allocation_counters.deallocations += old_size; self.allocation_counters.deallocation_count += 1; @@ -71,18 +70,14 @@ impl ThreadLocalCounter { if self.buffer >= size { self.buffer -= size; } else { - let offset = size - self.buffer + TARGET_BUFFER; - self.buffer = TARGET_BUFFER; - ALLOCATED.fetch_add(offset, Ordering::Relaxed); + add_slow(self, size); } } std::cmp::Ordering::Greater => { let size = old_size - new_size; self.buffer += size; if self.buffer > MAX_BUFFER { - let offset = self.buffer - TARGET_BUFFER; - self.buffer = TARGET_BUFFER; - ALLOCATED.fetch_sub(offset, Ordering::Relaxed); + remove_slow(self); } } } @@ -97,6 +92,25 @@ impl ThreadLocalCounter { } } +// Keep the uncommon atomic updates out of the allocator's inlined hot path. +#[cold] +#[inline(never)] +fn add_slow(local: &mut ThreadLocalCounter, size: usize) { + debug_assert!(local.buffer < size); + let offset = size - local.buffer + TARGET_BUFFER; + local.buffer = TARGET_BUFFER; + ALLOCATED.fetch_add(offset, Ordering::Relaxed); +} + +#[cold] +#[inline(never)] +fn remove_slow(local: &mut ThreadLocalCounter) { + debug_assert!(local.buffer > MAX_BUFFER); + let offset = local.buffer - TARGET_BUFFER; + local.buffer = TARGET_BUFFER; + ALLOCATED.fetch_sub(offset, Ordering::Relaxed); +} + thread_local! { static LOCAL_COUNTER: UnsafeCell = const {UnsafeCell::new(ThreadLocalCounter::new())}; } @@ -113,6 +127,7 @@ pub fn reset_allocation_counters(start: AllocationCounters) { with_local_counter(|local| local.allocation_counters = start); } +#[inline(always)] fn with_local_counter(f: impl FnOnce(&mut ThreadLocalCounter) -> T) -> T { LOCAL_COUNTER.with(|local| { let ptr = local.get(); @@ -123,16 +138,19 @@ fn with_local_counter(f: impl FnOnce(&mut ThreadLocalCounter) -> T) -> T { } /// Adds some `size` to the global counter in a thread-local buffered way. +#[inline(always)] pub fn add(size: usize) { with_local_counter(|local| local.add(size)); } /// Removes some `size` to the global counter in a thread-local buffered way. +#[inline(always)] pub fn remove(size: usize) { with_local_counter(|local| local.remove(size)); } -/// Adds some `size` to the global counter in a thread-local buffered way. +/// Updates the global counter for a reallocation in a thread-local buffered way. +#[inline(always)] pub fn update(old_size: usize, new_size: usize) { with_local_counter(|local| local.update(old_size, new_size)); } @@ -172,5 +190,18 @@ mod tests { // this means the global counter should reduce by 100 + MAX_BUFFER expected -= MAX_BUFFER + 100; assert_eq!(get(), expected); + + update(100, 200); + // Small reallocations should use the buffer. + assert_eq!(get(), expected); + update(0, MAX_BUFFER); + // Growing beyond the buffer should require more buffer space. The prior small growth + // consumed another 100 bytes from the buffer. + expected += MAX_BUFFER + 100; + assert_eq!(get(), expected); + update(MAX_BUFFER + 1, 0); + // Shrinking beyond MAX_BUFFER should flush the excess. + expected -= MAX_BUFFER + 1; + assert_eq!(get(), expected); } } diff --git a/turbopack/crates/turbo-tasks/src/completion.rs b/turbopack/crates/turbo-tasks/src/completion.rs index f6adc63b1745..a3507de970ef 100644 --- a/turbopack/crates/turbo-tasks/src/completion.rs +++ b/turbopack/crates/turbo-tasks/src/completion.rs @@ -61,11 +61,11 @@ impl Completions { } else { self.0 .iter() - .map(|&c| async move { - // Wraps the completion in a new completion. This makes it cheaper to restore - // since it doesn't need to restore the original task resp task chain. - wrap(*c).await?; - Ok(()) + .map(|&c| { + // Wraps the completion in a new completion. This makes it cheaper to + // restore since it doesn't need to restore the + // original task resp task chain. + wrap(*c) }) .try_join() .await?; diff --git a/turbopack/crates/turbo-tasks/src/task/task_input.rs b/turbopack/crates/turbo-tasks/src/task/task_input.rs index d45e21d99b05..3755af62db33 100644 --- a/turbopack/crates/turbo-tasks/src/task/task_input.rs +++ b/turbopack/crates/turbo-tasks/src/task/task_input.rs @@ -509,8 +509,8 @@ where { fn resolve_input(&self) -> impl Future> + Send + '_ { self.as_ref().map_either( - |l| async move { anyhow::Ok(Self(Either::Left(l.resolve_input().await?))) }, - |r| async move { anyhow::Ok(Self(Either::Right(r.resolve_input().await?))) }, + async |l| anyhow::Ok(Self(Either::Left(l.resolve_input().await?))), + async |r| anyhow::Ok(Self(Either::Right(r.resolve_input().await?))), ) } diff --git a/turbopack/crates/turbopack-core/src/chunk/chunk_group.rs b/turbopack/crates/turbopack-core/src/chunk/chunk_group.rs index 332fd9213dc9..8b118567e53a 100644 --- a/turbopack/crates/turbopack-core/src/chunk/chunk_group.rs +++ b/turbopack/crates/turbopack-core/src/chunk/chunk_group.rs @@ -116,11 +116,10 @@ pub async fn make_chunk_group( let async_loaders = async_modules .iter() .copied() - .map(async |module| { + .map(|module| { chunking_context .async_loader_chunk_item(*module, *module_graph, async_availability_info) .to_resolved() - .await }) .try_join() .await?; diff --git a/turbopack/crates/turbopack-core/src/module_graph/mod.rs b/turbopack/crates/turbopack-core/src/module_graph/mod.rs index 6d8480a32125..29d9f3878454 100644 --- a/turbopack/crates/turbopack-core/src/module_graph/mod.rs +++ b/turbopack/crates/turbopack-core/src/module_graph/mod.rs @@ -751,14 +751,13 @@ impl ImportTracer for ModuleGraphImportTracer { // import/require/dynamic-import?) let path = path .into_iter() - .map(async |n| { + .map(|n| { graph .graph .node_weight(n) .unwrap() // This is safe since `astar`` only returns indices from the graph .module() .ident() - .await }) .try_join() .await?; diff --git a/turbopack/crates/turbopack-core/src/module_graph/style_groups_graph/mod.rs b/turbopack/crates/turbopack-core/src/module_graph/style_groups_graph/mod.rs index 51f98ace474f..f415c81f9dcb 100644 --- a/turbopack/crates/turbopack-core/src/module_graph/style_groups_graph/mod.rs +++ b/turbopack/crates/turbopack-core/src/module_graph/style_groups_graph/mod.rs @@ -401,7 +401,7 @@ async fn collect_chunk_groups( // order. let mut ids: Vec = Vec::new(); let mut seen: FxHashSet = FxHashSet::default(); - let mut handle_module = async |module| -> Result<()> { + let mut handle_module = |module| { let id_slot = match module_id_map.entry(module) { Entry::Occupied(e) => *e.get(), Entry::Vacant(e) => { @@ -420,19 +420,18 @@ async fn collect_chunk_groups( { ids.push(id); } - Ok(()) }; for item in items_in_postorder { match item { ModuleOrBatch::Batch(batch) => { for &module in &batch.await?.modules { - handle_module(module).await?; + handle_module(module); } } ModuleOrBatch::Module(module) => { if let Some(chunkable_module) = ResolvedVc::try_downcast(module) { - handle_module(chunkable_module).await?; + handle_module(chunkable_module); } } ModuleOrBatch::None(_) => {} diff --git a/turbopack/crates/turbopack-core/src/output.rs b/turbopack/crates/turbopack-core/src/output.rs index 76eb209cb2f8..528feae62751 100644 --- a/turbopack/crates/turbopack-core/src/output.rs +++ b/turbopack/crates/turbopack-core/src/output.rs @@ -278,8 +278,8 @@ pub async fn expand_output_assets( inner_output_assets: bool, ) -> Result>>> { let edges = AdjacencyMap::new() - .visit(inputs, async |input| { - get_referenced_assets(inner_output_assets, input).await + .visit(inputs, |input| { + get_referenced_assets(inner_output_assets, input) }) .await .completed()? diff --git a/turbopack/crates/turbopack-css/src/chunk/mod.rs b/turbopack/crates/turbopack-css/src/chunk/mod.rs index b198347cc7d4..bddee48b2565 100644 --- a/turbopack/crates/turbopack-css/src/chunk/mod.rs +++ b/turbopack/crates/turbopack-css/src/chunk/mod.rs @@ -549,7 +549,7 @@ impl ChunkType for CssChunkType { let content = CssChunkContent { chunk_items: chunk_items .iter() - .map(async |ChunkItemWithAsyncModuleInfo { chunk_item, .. }| { + .map(|ChunkItemWithAsyncModuleInfo { chunk_item, .. }| { let Some(chunk_item) = ResolvedVc::try_downcast::>(*chunk_item) else { @@ -558,8 +558,7 @@ impl ChunkType for CssChunkType { // CSS doesn't need to care about async_info, so we can discard it Ok(chunk_item) }) - .try_join() - .await?, + .collect::>>()?, } .cell(); Ok(Vc::upcast(CssChunk::new(*chunking_context, content))) diff --git a/turbopack/crates/turbopack-image/Cargo.toml b/turbopack/crates/turbopack-image/Cargo.toml index ebdab94db4af..aeb0734e810a 100644 --- a/turbopack/crates/turbopack-image/Cargo.toml +++ b/turbopack/crates/turbopack-image/Cargo.toml @@ -21,7 +21,7 @@ workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } -base64 = "0.21.0" +base64 = { workspace = true } bincode = { workspace = true } image = { workspace = true, default-features = false, features = [ "gif", diff --git a/turbopack/crates/turbopack-node/Cargo.toml b/turbopack/crates/turbopack-node/Cargo.toml index b31c3f8786cc..6e5126e186da 100644 --- a/turbopack/crates/turbopack-node/Cargo.toml +++ b/turbopack/crates/turbopack-node/Cargo.toml @@ -24,7 +24,7 @@ anyhow = { workspace = true } async-stream = "0.3.4" async-trait = { workspace = true } dashmap = { workspace = true } -base64 = "0.21.0" +base64 = { workspace = true } bincode = { workspace = true } bytes = { workspace = true } const_format = { workspace = true } diff --git a/turbopack/crates/turbopack-test-utils/src/snapshot.rs b/turbopack/crates/turbopack-test-utils/src/snapshot.rs index 06391d4a8d33..a12ddec77995 100644 --- a/turbopack/crates/turbopack-test-utils/src/snapshot.rs +++ b/turbopack/crates/turbopack-test-utils/src/snapshot.rs @@ -5,7 +5,7 @@ use regex::Regex; use rustc_hash::{FxHashMap, FxHashSet}; use similar::TextDiff; use turbo_rcstr::RcStr; -use turbo_tasks::{ReadRef, TryJoinIterExt, Vc}; +use turbo_tasks::{ReadRef, Vc}; use turbo_tasks_fs::{ DirectoryContent, DirectoryEntry, File, FileContent, FileSystemEntryType, FileSystemPath, }; @@ -187,11 +187,7 @@ async fn diff_paths( ) -> Result> { let mut map = left .iter() - .map(async |p| Ok((p.path.clone(), p.clone()))) - .try_join() - .await? - .iter() - .cloned() + .map(|p| (p.path.clone(), p.clone())) .collect::>(); for p in right { map.remove(&p.path); diff --git a/turbopack/crates/turbopack-wasm/Cargo.toml b/turbopack/crates/turbopack-wasm/Cargo.toml index 5156042b7689..8f2c9ca8379d 100644 --- a/turbopack/crates/turbopack-wasm/Cargo.toml +++ b/turbopack/crates/turbopack-wasm/Cargo.toml @@ -23,6 +23,10 @@ turbo-tasks-fs = { workspace = true } turbo-tasks-hash = { workspace = true } turbopack-core = { workspace = true } turbopack-ecmascript = { workspace = true } -wasmparser = "0.235.0" -wat = "1.0.69" +# `wat` and `wasmparser` version in lockstep: `wat` pulls `wast`, which depends on a +# `wasm-encoder` that in turn pins a `wasmparser`. Keeping both on the 239 family lets that +# `wasmparser` be the same one wasmtime already links, so only one copy is built. Bumping either +# without the other, or letting `wat` float to a newer minor, reintroduces the duplicate. +wasmparser = "0.239.0" +wat = "=1.239.0"