diff --git a/contributing/core/testing.md b/contributing/core/testing.md index 1b84f8a0fb5d..5657fac9eb58 100644 --- a/contributing/core/testing.md +++ b/contributing/core/testing.md @@ -105,12 +105,12 @@ we attempt to capture traces of the playwright run to make debugging the failure A test-trace artifact should be uploaded after the workflow completes which can be downloaded, unzipped, and then inspected with `pnpm playwright show-trace ./path/to/trace` -To attach the chrome debugger to next the easiest approach is to modify the `createNext` call in your test to pass `--inspect` to next. +To attach the chrome debugger to next the easiest approach is to modify the `nextTestSetup` call in your test to pass `--inspect` to next. ```js -const next = await createNext({ +const { next } = nextTestSetup({ ... - startArgs: =['--inspect'], + startArgs: ['--inspect'], }) ``` diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index adf4bd759034..08e920bb5ec7 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -1594,6 +1594,7 @@ impl Project { hash_salt: self.next_config().output_hash_salt().to_resolved().await?, cross_origin: self.next_config().cross_origin(), chunk_loading_global: self.next_config().turbopack_chunk_loading_global(), + style_groups_algorithm: self.next_config().css_chunking().owned().await?, })) } @@ -1629,6 +1630,7 @@ impl Project { asset_prefix: self.next_config().computed_asset_prefix().owned().await?, css_url_suffix, hash_salt: self.next_config().output_hash_salt().to_resolved().await?, + style_groups_algorithm: self.next_config().css_chunking().owned().await?, }; Ok(if client_assets { get_server_chunking_context_with_client_assets(options) @@ -1669,6 +1671,7 @@ impl Project { css_url_suffix, hash_salt: self.next_config().output_hash_salt().to_resolved().await?, cross_origin: self.next_config().cross_origin(), + style_groups_algorithm: self.next_config().css_chunking().owned().await?, }; Ok(if client_assets { get_edge_chunking_context_with_client_assets(options) diff --git a/crates/next-core/src/emit.rs b/crates/next-core/src/emit.rs index b44326317f8e..ba1a7326e678 100644 --- a/crates/next-core/src/emit.rs +++ b/crates/next-core/src/emit.rs @@ -100,15 +100,21 @@ pub async fn emit_assets( path: &FileSystemPath, assets: AssetVec, node_root: &FileSystemPath, - ) -> Result>> { + ) -> Result<()> { let mut iter = assets.into_iter(); let first = iter.next().unwrap(); - for next in iter { - let ext: RcStr = path.extension().unwrap_or_default().into(); - if let Some(detail) = assets_diff(*next, *first, ext, node_root.clone()) - .owned() - .await? - { + 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 + }) + .try_flat_join() + .await?; + if let Some(detail) = conflicts.into_iter().next() { + #[turbo_tasks::function] + fn emit_conflict_issue(path: FileSystemPath, detail: RcStr) { EmitConflictIssue { asset_path: path.clone(), detail, @@ -116,8 +122,11 @@ pub async fn emit_assets( .resolved_cell() .emit(); } + emit_conflict_issue(path.clone(), detail) + .as_side_effect() + .await?; } - Ok(first) + Ok(()) } // Use join! instead of try_join! to collect all errors deterministically @@ -129,14 +138,20 @@ pub async fn emit_assets( let node_root = node_root.clone(); async move { - let asset = check_duplicates(&path, assets, &node_root).await?; + let asset = *assets.first().unwrap(); let span = tracing::info_span!( "emit asset", name = %path.to_string_ref().await? ); - async move { emit(*asset).as_side_effect().await } - .instrument(span) - .await + async move { + emit(*asset).as_side_effect().await?; + // This need to be after `emit()`, so the asset is emitted even if this + // method crashes due to eventual consistency. + check_duplicates(&path, assets, &node_root).await?; + Ok(()) + } + .instrument(span) + .await } }) .try_join(), @@ -148,18 +163,22 @@ pub async fn emit_assets( let client_output_path = client_output_path.clone(); async move { - let asset = check_duplicates(&path, assets, &node_root).await?; let span = tracing::info_span!( "emit asset", name = %path.to_string_ref().await? ); async move { + let asset = *assets.first().unwrap(); // Client assets are emitted to the client output path, which is // prefixed with _next. We need to rebase them to // remove that prefix. emit_rebase(*asset, client_relative_path, client_output_path) .as_side_effect() - .await + .await?; + // This need to be after `emit_rebase()`, so the asset is emitted even if + // this method crashes due to eventual consistency. + check_duplicates(&path, assets, &node_root).await?; + Ok(()) } .instrument(span) .await diff --git a/crates/next-core/src/next_client/context.rs b/crates/next-core/src/next_client/context.rs index 764ddf9a9ddb..cc8843d1f40f 100644 --- a/crates/next-core/src/next_client/context.rs +++ b/crates/next-core/src/next_client/context.rs @@ -23,7 +23,9 @@ use turbopack_core::{ environment::{BrowserEnvironment, Environment, ExecutionEnvironment}, free_var_references, issue::IssueSeverity, - module_graph::binding_usage_info::OptionBindingUsageInfo, + module_graph::{ + binding_usage_info::OptionBindingUsageInfo, style_groups::StyleGroupsAlgorithm, + }, resolve::{parse::Request, pattern::Pattern}, }; use turbopack_css::chunk::CssChunkType; @@ -476,6 +478,7 @@ pub struct ClientChunkingContextOptions { pub hash_salt: ResolvedVc, pub cross_origin: Vc, pub chunk_loading_global: Vc>, + pub style_groups_algorithm: StyleGroupsAlgorithm, } #[turbo_tasks::function] @@ -505,6 +508,7 @@ pub async fn get_client_chunking_context( hash_salt, cross_origin, chunk_loading_global, + style_groups_algorithm, } = options; let next_mode = mode.await?; @@ -575,6 +579,7 @@ pub async fn get_client_chunking_context( Vc::::default().to_resolved().await?, ChunkingConfig { max_merge_chunk_size: 100_000, + style_groups_algorithm: style_groups_algorithm.clone(), ..Default::default() }, ) diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index 24409322890f..dba9ce2f5558 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -26,6 +26,7 @@ use turbopack_core::{ issue::{ IgnoreIssue, IgnoreIssuePattern, Issue, IssueExt, IssueSeverity, IssueStage, StyledString, }, + module_graph::style_groups::StyleGroupsAlgorithm, resolve::ResolveAliasMap, }; use turbopack_ecmascript::{OptionTreeShaking, TreeShakingMode}; @@ -1040,6 +1041,135 @@ pub struct TurbopackIgnoreIssueRule { pub description: Option, } +/// `experimental.cssChunking` accepts the following shapes (all normalized to a single canonical +/// object form via [`CssChunkingConfig::normalize`]): +/// +/// * `true` — equivalent to `{ type: "loose" }` (default loose behaviour). +/// * `false` — disabled chunking. +/// * `"strict"` / `"loose"` / `"graph"` — string shorthands. +/// * `{ type: "strict" }` / `{ type: "loose" }` — object form for the legacy modes. +/// * `{ type: "graph", requestCost?, moduleFactorCost? }` — object form for the graph algorithm. +#[derive( + Clone, Debug, PartialEq, Deserialize, TraceRawVcs, NonLocalValue, OperationValue, Encode, Decode, +)] +#[serde(untagged)] +pub enum CssChunkingConfig { + Bool(bool), + String(CssChunkingMode), + Object(CssChunkingObject), +} + +/// String shorthand variants for [`CssChunkingConfig`]. +#[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + Deserialize, + TraceRawVcs, + NonLocalValue, + OperationValue, + Encode, + Decode, +)] +#[serde(rename_all = "lowercase")] +pub enum CssChunkingMode { + Strict, + Loose, + Graph, +} + +/// Object form of `experimental.cssChunking`. +/// +/// `None` is the normalized representation of `false` ("CSS chunking is disabled"). It is not +/// reachable through deserialization — users write `false`, not `{ type: "none" }`. +#[derive( + Clone, Debug, PartialEq, Deserialize, TraceRawVcs, NonLocalValue, OperationValue, Encode, Decode, +)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum CssChunkingObject { + #[serde(skip)] + None, + Strict, + Loose, + Graph(CssChunkingGraphOptions), +} + +/// Cost parameters for the graph algorithm. See [`CssChunkingConfig`] for details. +#[derive( + Clone, + Debug, + Default, + PartialEq, + Deserialize, + TraceRawVcs, + NonLocalValue, + OperationValue, + Encode, + Decode, +)] +#[serde(rename_all = "camelCase")] +pub struct CssChunkingGraphOptions { + pub request_cost: Option, + pub module_factor_cost: Option, +} + +impl CssChunkingConfig { + /// Normalize all input shapes (booleans, strings, object form) to the canonical object form. + /// `false` maps to [`CssChunkingObject::None`]; `true` is equivalent to `'loose'`. + pub fn normalize(&self) -> CssChunkingObject { + match self { + CssChunkingConfig::Bool(false) => CssChunkingObject::None, + CssChunkingConfig::Bool(true) => CssChunkingObject::Loose, + CssChunkingConfig::String(CssChunkingMode::Strict) => CssChunkingObject::Strict, + CssChunkingConfig::String(CssChunkingMode::Loose) => CssChunkingObject::Loose, + CssChunkingConfig::String(CssChunkingMode::Graph) => { + CssChunkingObject::Graph(CssChunkingGraphOptions::default()) + } + CssChunkingConfig::Object(obj) => obj.clone(), + } + } +} + +/// Default `requestCost` for the graph algorithm (in bytes). +const DEFAULT_REQUEST_COST: f32 = 20_000.0; +/// Default `moduleFactorCost` for the graph algorithm. +const DEFAULT_MODULE_FACTOR_COST: f32 = 1.0; + +/// Resolve `experimental.cssChunking` to the [`StyleGroupsAlgorithm`] Turbopack should use. +/// +/// `strict` and `false` (`CssChunkingObject::None`) are bundler-incompatible with Turbopack and +/// are rejected at config-validation time on the JS side; if one slips through, we bail rather +/// than silently falling back. `loose` and `true` map to [`StyleGroupsAlgorithm::Default`]. +fn resolve_css_chunking_algorithm( + config: Option<&CssChunkingConfig>, +) -> Result { + let Some(config) = config else { + return Ok(StyleGroupsAlgorithm::Default); + }; + Ok(match config.normalize() { + CssChunkingObject::None => { + anyhow::bail!( + "`experimental.cssChunking: false` is not supported by Turbopack; this should \ + have been rejected at config validation time" + ) + } + CssChunkingObject::Strict => { + anyhow::bail!( + "`experimental.cssChunking: \"strict\"` is not supported by Turbopack; this \ + should have been rejected at config validation time" + ) + } + CssChunkingObject::Loose => StyleGroupsAlgorithm::Default, + CssChunkingObject::Graph(opts) => StyleGroupsAlgorithm::graph( + opts.request_cost.unwrap_or(DEFAULT_REQUEST_COST), + opts.module_factor_cost + .unwrap_or(DEFAULT_MODULE_FACTOR_COST), + ), + }) +} + #[derive( Clone, Debug, @@ -1097,6 +1227,9 @@ pub struct ExperimentalConfig { /// no salt. output_hash_salt: Option, + /// CSS chunking strategy. See [`CssChunkingConfig`] for the accepted shapes. + css_chunking: Option, + // --- // UNSUPPORTED // --- @@ -1817,6 +1950,13 @@ impl NextConfig { Vc::cell(self.experimental.inline_css.unwrap_or(false)) } + /// Resolve `experimental.cssChunking` to a [`StyleGroupsAlgorithm`] (with defaults applied + /// for the cost parameters of the graph algorithm). + #[turbo_tasks::function] + pub fn css_chunking(&self) -> Result> { + Ok(resolve_css_chunking_algorithm(self.experimental.css_chunking.as_ref())?.cell()) + } + #[turbo_tasks::function] pub fn mdx_rs(&self) -> Vc { let options = &self.experimental.mdx_rs; diff --git a/crates/next-core/src/next_edge/context.rs b/crates/next-core/src/next_edge/context.rs index d3e9874dbace..c5fa24f9d275 100644 --- a/crates/next-core/src/next_edge/context.rs +++ b/crates/next-core/src/next_edge/context.rs @@ -13,7 +13,9 @@ use turbopack_core::{ environment::{EdgeWorkerEnvironment, Environment, ExecutionEnvironment, NodeJsVersion}, free_var_references, issue::IssueSeverity, - module_graph::binding_usage_info::OptionBindingUsageInfo, + module_graph::{ + binding_usage_info::OptionBindingUsageInfo, style_groups::StyleGroupsAlgorithm, + }, }; use turbopack_css::chunk::CssChunkType; use turbopack_ecmascript::chunk::EcmascriptChunkType; @@ -202,6 +204,7 @@ pub struct EdgeChunkingContextOptions { pub css_url_suffix: Vc>, pub hash_salt: ResolvedVc, pub cross_origin: Vc, + pub style_groups_algorithm: StyleGroupsAlgorithm, } /// Like `get_edge_chunking_context` but all assets are emitted as client assets (so `/_next`) @@ -229,6 +232,7 @@ pub async fn get_edge_chunking_context_with_client_assets( css_url_suffix, hash_salt, cross_origin, + style_groups_algorithm, } = options; let cross_origin_loading = *cross_origin.await?; let output_root = node_root.join("server/edge")?; @@ -280,6 +284,7 @@ pub async fn get_edge_chunking_context_with_client_assets( Vc::::default().to_resolved().await?, ChunkingConfig { max_merge_chunk_size: 100_000, + style_groups_algorithm: style_groups_algorithm.clone(), ..Default::default() }, ) @@ -314,6 +319,7 @@ pub async fn get_edge_chunking_context( css_url_suffix, hash_salt, cross_origin, + style_groups_algorithm, } = options; let cross_origin = *cross_origin.await?; let css_url_suffix = css_url_suffix.to_resolved().await?; @@ -382,6 +388,7 @@ pub async fn get_edge_chunking_context( Vc::::default().to_resolved().await?, ChunkingConfig { max_merge_chunk_size: 100_000, + style_groups_algorithm: style_groups_algorithm.clone(), ..Default::default() }, ) diff --git a/crates/next-core/src/next_server/context.rs b/crates/next-core/src/next_server/context.rs index 2b93d916d887..bfea0490e034 100644 --- a/crates/next-core/src/next_server/context.rs +++ b/crates/next-core/src/next_server/context.rs @@ -22,7 +22,9 @@ use turbopack_core::{ compile_time_info::{CompileTimeDefines, CompileTimeInfo, FreeVarReferences}, environment::{Environment, ExecutionEnvironment, NodeJsEnvironment, NodeJsVersion}, issue::IssueSeverity, - module_graph::binding_usage_info::OptionBindingUsageInfo, + module_graph::{ + binding_usage_info::OptionBindingUsageInfo, style_groups::StyleGroupsAlgorithm, + }, target::CompileTarget, }; use turbopack_css::chunk::CssChunkType; @@ -1021,6 +1023,7 @@ pub struct ServerChunkingContextOptions { pub asset_prefix: RcStr, pub css_url_suffix: Vc>, pub hash_salt: ResolvedVc, + pub style_groups_algorithm: StyleGroupsAlgorithm, } /// Like `get_server_chunking_context` but all assets are emitted as client assets (so `/_next`) @@ -1048,6 +1051,7 @@ pub async fn get_server_chunking_context_with_client_assets( asset_prefix, css_url_suffix, hash_salt, + style_groups_algorithm, } = options; let css_url_suffix = css_url_suffix.to_resolved().await?; @@ -1117,6 +1121,7 @@ pub async fn get_server_chunking_context_with_client_assets( Vc::::default().to_resolved().await?, ChunkingConfig { max_merge_chunk_size: 100_000, + style_groups_algorithm: style_groups_algorithm.clone(), ..Default::default() }, ) @@ -1151,6 +1156,7 @@ pub async fn get_server_chunking_context( asset_prefix, css_url_suffix, hash_salt, + style_groups_algorithm, } = options; let css_url_suffix = css_url_suffix.to_resolved().await?; let next_mode = mode.await?; @@ -1221,6 +1227,7 @@ pub async fn get_server_chunking_context( Vc::::default().to_resolved().await?, ChunkingConfig { max_merge_chunk_size: 100_000, + style_groups_algorithm: style_groups_algorithm.clone(), ..Default::default() }, ) diff --git a/packages/next/errors.json b/packages/next/errors.json index 9c6cc47701c2..eb11c7890745 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1244,5 +1244,8 @@ "1243": "This \"use cache\" has a dynamic cache life that was propagated to its parent.", "1244": "A \"use cache\" with short \\`expire\\` (under 5 minutes) is nested inside another \"use cache\" that has no explicit \\`cacheLife\\`, which is not allowed during prerendering. Add \\`cacheLife()\\` to the outer \"use cache\" to choose whether it should be prerendered (with longer \\`expire\\`) or remain dynamic (with short \\`expire\\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife", "1245": "A \"use cache\" with zero \\`revalidate\\` is nested inside another \"use cache\" that has no explicit \\`cacheLife\\`, which is not allowed during prerendering. Add \\`cacheLife()\\` to the outer \"use cache\" to choose whether it should be prerendered (with non-zero \\`revalidate\\`) or remain dynamic (with zero \\`revalidate\\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife", - "1246": "Could not validate instant UI because an expected segment was not rendered." + "1246": "Could not validate instant UI because an expected segment was not rendered.", + "1247": "\\`experimental.cssChunking: \"graph\"\\` is only supported with Turbopack. Please remove the option or run Next.js with Turbopack in %s.", + "1248": "\\`experimental.cssChunking: \"strict\"\\` is only supported with webpack. Please remove the option or run Next.js with webpack in %s.", + "1249": "\\`experimental.cssChunking: false\\` is only supported with webpack. Please remove the option or run Next.js with webpack in %s." } diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index fbc8e5d6a58e..87ca8879d491 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -32,6 +32,7 @@ import { import type { CompilerNameValues } from '../shared/lib/constants' import { execOnce } from '../shared/lib/utils' import type { NextConfigComplete } from '../server/config-shared' +import { resolveCssChunkingMode } from '../server/config-shared' import { finalizeEntrypoint } from './entries' import * as Log from './output/log' import { buildConfiguration } from './webpack/config' @@ -2188,17 +2189,21 @@ export default async function getBaseWebpackConfig( new NextFontManifestPlugin({ appDir, }), + // CSS chunking plugin. Graph mode is Turbopack-only and is rejected at config-validation + // time for webpack, so we only need to wire up `'loose'` (default) and `'strict'` here. !dev && isClient && - config.experimental.cssChunking && - (isRspack - ? new (getRspackCore().experiments.CssChunkingPlugin)({ - strict: config.experimental.cssChunking === 'strict', - nextjs: true, - }) - : new CssChunkingPlugin( - config.experimental.cssChunking === 'strict' - )), + (() => { + const mode = resolveCssChunkingMode(config.experimental.cssChunking) + if (mode !== 'loose' && mode !== 'strict') return false + const strict = mode === 'strict' + return isRspack + ? new (getRspackCore().experiments.CssChunkingPlugin)({ + strict, + nextjs: true, + }) + : new CssChunkingPlugin(strict) + })(), telemetryPlugin, !dev && isNodeServer && diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index e0f4702ecd22..72cf0849feae 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -272,7 +272,21 @@ export const experimentalSchema = { middlewareClientMaxBodySize: zSizeLimit.optional(), proxyClientMaxBodySize: zSizeLimit.optional(), multiZoneDraftMode: z.boolean().optional(), - cssChunking: z.union([z.boolean(), z.literal('strict')]).optional(), + cssChunking: z + .union([ + z.boolean(), + z.literal('strict'), + z.literal('loose'), + z.literal('graph'), + z.strictObject({ type: z.literal('strict') }), + z.strictObject({ type: z.literal('loose') }), + z.strictObject({ + type: z.literal('graph'), + requestCost: z.number().nonnegative().finite().optional(), + moduleFactorCost: z.number().nonnegative().finite().optional(), + }), + ]) + .optional(), nextScriptWorkers: z.boolean().optional(), // The critter option is unknown, use z.any() here optimizeCss: z.union([z.boolean(), z.any()]).optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 5768421e8219..72c7610e105e 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -405,6 +405,40 @@ export interface LightningCssFeatures { exclude?: LightningCssFeature[] } +/** + * Accepted shapes for `experimental.cssChunking`. See [`ExperimentalConfig.cssChunking`] for the + * accepted values; use [`resolveCssChunkingMode`] to normalize the value at runtime. + */ +export type CssChunkingConfig = + | boolean + | 'strict' + | 'loose' + | 'graph' + | { type: 'strict' } + | { type: 'loose' } + | { type: 'graph'; requestCost?: number; moduleFactorCost?: number } + +/** + * Normalize any [`CssChunkingConfig`] value to one of the four modes the build pipeline cares + * about: + * - `'off'` — `false`/`undefined`: do not run a CSS chunking plugin. + * - `'loose'` — `true` / `'loose'` / `{ type: 'loose' }`: heuristic-based chunking + * (the default). + * - `'strict'` — `'strict'` / `{ type: 'strict' }`: webpack-only ordered-chunking plugin. + * - `'graph'` — `'graph'` / `{ type: 'graph', … }`: Turbopack-only graph algorithm. + */ +export function resolveCssChunkingMode( + value: CssChunkingConfig | undefined +): 'off' | 'loose' | 'strict' | 'graph' { + if (value === undefined || value === false) return 'off' + if (value === true || value === 'loose') return 'loose' + if (value === 'strict' || value === 'graph') return value + // Object form. `requestCost` and `moduleFactorCost` are validated by the schema. + if (value.type === 'strict') return 'strict' + if (value.type === 'graph') return 'graph' + return 'loose' +} + export interface ExperimentalConfig { /** * A string that is incorporated into content-addressed output filenames @@ -508,12 +542,27 @@ export interface ExperimentalConfig { proxyPrefetch?: 'strict' | 'flexible' manualClientBasePath?: boolean /** - * CSS Chunking strategy. Defaults to `true` ("loose" mode), which guesses dependencies - * between CSS files to keep ordering of them. - * An alternative is 'strict', which will try to keep correct ordering as - * much as possible, even when this leads to many requests. - */ - cssChunking?: boolean | 'strict' + * CSS Chunking strategy. Defaults to `true` (loose mode), which guesses dependencies between + * CSS files to keep ordering of them. + * + * - `true` / `'loose'` / `{ type: 'loose' }` — default heuristic-based chunking. + * - `'strict'` / `{ type: 'strict' }` — preserve correct ordering as much as possible, even + * when this leads to many requests. Webpack only. + * - `false` — disable chunking; emit one chunk per CSS module. Webpack only. + * - `'graph'` / `{ type: 'graph', requestCost?, moduleFactorCost? }` — Turbopack only. + * Selects a CSS chunking strategy that analyzes the most common style orderings across the + * application and produces shared chunks accordingly. Compared to the default mode it + * intentionally overships some styles in order to reduce the number of CSS requests per + * page. Cost overrides: + * - `requestCost` (bytes, default `20000`) — additional cost charged for every CSS + * request a chunk group makes. Larger values bias the algorithm toward fewer, larger + * shared chunks; smaller values toward more, smaller chunks. + * - `moduleFactorCost` (default `1`) — controls how much the algorithm cares about + * small chunk groups. `0` distributes overshipped bytes evenly across chunk groups. + * Higher values penalize overshipping in small chunk groups proportionally more, so + * small pages ship fewer unrelated styles at the expense of more requests overall. + */ + cssChunking?: CssChunkingConfig disablePostcssPresetEnv?: boolean cpus?: number memoryBasedWorkersCount?: boolean diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index b2fe24163753..0fc91649ce85 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -13,7 +13,11 @@ import { PHASE_PRODUCTION_SERVER, type PHASE_TYPE, } from '../shared/lib/constants' -import { defaultConfig, normalizeConfig } from './config-shared' +import { + defaultConfig, + normalizeConfig, + resolveCssChunkingMode, +} from './config-shared' import type { ExperimentalConfig, NextConfigComplete, @@ -456,6 +460,35 @@ function assignDefaultsAndValidate( ) } + // Validate experimental.cssChunking compatibility with the active bundler. Graph mode is + // Turbopack-only; strict mode and `false` (single-chunk-per-module) are webpack-only. + // Only validate during build/dev — `next start` doesn't pick a bundler and would otherwise + // see `process.env.TURBOPACK` unset and reject a valid `cssChunking: "graph"` config. + if (phase !== PHASE_PRODUCTION_SERVER) { + const cssChunkingValue = result.experimental.cssChunking + const cssChunkingMode = resolveCssChunkingMode(cssChunkingValue) + if (cssChunkingMode === 'graph' && !process.env.TURBOPACK) { + throw new Error( + `\`experimental.cssChunking: "graph"\` is only supported with Turbopack. ` + + `Please remove the option or run Next.js with Turbopack in ${configFileName}.` + ) + } + if (cssChunkingMode === 'strict' && process.env.TURBOPACK) { + throw new Error( + `\`experimental.cssChunking: "strict"\` is only supported with webpack. ` + + `Please remove the option or run Next.js with webpack in ${configFileName}.` + ) + } + // Only error when `false` was set explicitly. `undefined` (the default) also resolves to + // `'off'` but that's the implicit default and must not error on Turbopack. + if (cssChunkingValue === false && process.env.TURBOPACK) { + throw new Error( + `\`experimental.cssChunking: false\` is only supported with webpack. ` + + `Please remove the option or run Next.js with webpack in ${configFileName}.` + ) + } + } + if (result.experimental.cachedNavigations && !result.cacheComponents) { throw new Error( `\`experimental.cachedNavigations\` requires \`cacheComponents\` to be enabled. Please update your ${configFileName} accordingly.` diff --git a/test/cache-components-tests-manifest.json b/test/cache-components-tests-manifest.json index e2375821ad08..f8856fa44af8 100644 --- a/test/cache-components-tests-manifest.json +++ b/test/cache-components-tests-manifest.json @@ -325,6 +325,7 @@ "test/e2e/next-link-errors/next-link-errors.test.ts", "test/e2e/nonce-head-manager/index.test.ts", "test/e2e/og-api/index.test.ts", + "test/e2e/og-api/standalone.test.ts", "test/e2e/og-routes-custom-font/og-routes-custom-font.test.ts", "test/e2e/on-request-error/basic/basic.test.ts", "test/e2e/on-request-error/dynamic-routes/dynamic-routes.test.ts", diff --git a/test/development/app-dir/create-next-app-default/create-next-app-default.test.ts b/test/development/app-dir/create-next-app-default/create-next-app-default.test.ts index 813eaafeedfa..f22858e8ce66 100644 --- a/test/development/app-dir/create-next-app-default/create-next-app-default.test.ts +++ b/test/development/app-dir/create-next-app-default/create-next-app-default.test.ts @@ -1,5 +1,6 @@ -import { createNext } from 'e2e-utils' -import { retry } from 'next-test-utils' +import { spawn } from 'child_process' +import { findPort, killApp, retry } from 'next-test-utils' +import webdriver from 'next-webdriver' import { join } from 'path' import { resolveNextTgzFilename, @@ -35,18 +36,64 @@ describe('create-next-app default template', () => { expect(exitCode).toBe(0) - const nextBin = 'node_modules/next/dist/bin/next' - const next = await createNext({ - files: join(cwd, projectName), - installCommand: 'true', - skipStart: false, - startCommand: `node ${nextBin} dev`, - startServerTimeout: 60_000, - }) - let browser: Awaited> | undefined + const dir = join(cwd, projectName) + const nextBin = join(dir, 'node_modules/next/dist/bin/next') + const port = await findPort() + const server = spawn( + 'node', + [nextBin, 'dev', '-p', String(port), '-H', '127.0.0.1'], + { + cwd: dir, + env: { ...process.env, HOSTNAME: '127.0.0.1' }, + stdio: ['ignore', 'pipe', 'pipe'], + } + ) + + // Freshly installed CNA projects (with tailwind, eslint plugins, etc.) + // can take well over the default 10s to boot `next dev`. Give them + // generous headroom so this test isn't flaky on loaded CI machines. + const startServerTimeout = 60_000 + + let browser: Awaited> | undefined try { - browser = await next.browser('/') + await new Promise((resolve, reject) => { + const onTimeout = setTimeout(() => { + reject( + new Error( + `next dev did not become ready within ${startServerTimeout}ms` + ) + ) + }, startServerTimeout) + + const onReady = () => { + clearTimeout(onTimeout) + resolve() + } + + const handleData = (chunk: Buffer) => { + const msg = chunk.toString() + process.stdout.write(msg) + if (/- Local:|Ready in|✓ Ready/i.test(msg)) { + onReady() + } + } + + server.stdout!.on('data', handleData) + server.stderr!.on('data', (chunk: Buffer) => { + process.stderr.write(chunk.toString()) + }) + server.on('exit', (code) => { + clearTimeout(onTimeout) + reject( + new Error( + `next dev exited before becoming ready (code=${code})` + ) + ) + }) + }) + + browser = await webdriver(port, '/') const page = browser expect(await page.elementByCss('body').text()).toContain('Deploy Now') @@ -59,13 +106,22 @@ describe('create-next-app default template', () => { expect(imagesReady).toBe(true) }) + // In dev, the browser may fire a "preloaded using link preload but + // not used within a few seconds from the window's load event" + // warning for next/font's woff2 files when the stylesheet that + // references them hasn't been applied by the time the browser's + // internal timer fires (relative to window.load). The font is in + // fact used moments later, so this is a benign timing race that + // doesn't reproduce reliably — filter it out. const messages = (await page.log()).filter( - (log) => log.source === 'warning' || log.source === 'error' + (log) => + (log.source === 'warning' || log.source === 'error') && + !/was preloaded using link preload but not used/.test(log.message) ) expect(messages).toEqual([]) } finally { await browser?.close() - await next.destroy() + await killApp(server).catch(() => {}) } }) }, diff --git a/test/development/app-dir/hmr-asset-prefix-full-url/asset-prefix.test.ts b/test/development/app-dir/hmr-asset-prefix-full-url/asset-prefix.test.ts index 42bb67e99d42..ee490ef58d3e 100644 --- a/test/development/app-dir/hmr-asset-prefix-full-url/asset-prefix.test.ts +++ b/test/development/app-dir/hmr-asset-prefix-full-url/asset-prefix.test.ts @@ -1,20 +1,21 @@ -import { createNext } from 'e2e-utils' -import { findPort, retry } from 'next-test-utils' +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' describe('app-dir assetPrefix full URL', () => { - let next, forcedPort - beforeAll(async () => { - forcedPort = ((await findPort()) ?? '54321').toString() + const { next } = nextTestSetup({ + files: __dirname, + skipStart: true, + forcedPort: 'random', + }) - next = await createNext({ - files: __dirname, - forcedPort, - nextConfig: { - assetPrefix: `http://localhost:${forcedPort}`, - }, - }) + beforeAll(async () => { + const port = next.forcedPort + await next.patchFile( + 'next.config.js', + `module.exports = { assetPrefix: 'http://localhost:${port}' }` + ) + await next.start() }) - afterAll(() => next.destroy()) it('should not break HMR when asset prefix set to full URL', async () => { const browser = await next.browser('/') diff --git a/test/development/app-hmr/server-restart.test.ts b/test/development/app-hmr/server-restart.test.ts index be2e3b6ba799..b2c87ddddb03 100644 --- a/test/development/app-hmr/server-restart.test.ts +++ b/test/development/app-hmr/server-restart.test.ts @@ -1,4 +1,4 @@ -import { FileRef, nextTestSetup, createNext } from 'e2e-utils' +import { FileRef, nextTestSetup } from 'e2e-utils' import { retry, waitFor } from 'next-test-utils' import path from 'path' @@ -6,6 +6,7 @@ describe('app-dir server restart', () => { const { next } = nextTestSetup({ files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')), patchFileDelay: 1000, + forcedPort: 'random', }) it('should reload the page when the server restarts', async () => { @@ -34,15 +35,11 @@ describe('app-dir server restart', () => { }) }) - const appPort = next.appPort - await next.destroy() + await next.stop() - // Start a new server instance on the same port - const secondNext = await createNext({ - files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')), - env: next.env, - forcedPort: appPort, - }) + // Start a new server instance on the same port (forcedPort: 'random' was + // resolved to a concrete port in setup() so next.start() reuses it). + await next.start() // Wait for the new server to be ready await waitFor(1000) @@ -60,7 +57,5 @@ describe('app-dir server restart', () => { await retry(async () => { expect(await browser.elementById('counter-value').text()).toBe('Count: 0') }) - - await secondNext.destroy() }) }) diff --git a/test/development/basic/hmr/run-basic-hmr-test.util.ts b/test/development/basic/hmr/run-basic-hmr-test.util.ts index 76589dd2e2a2..e8f08381d7b0 100644 --- a/test/development/basic/hmr/run-basic-hmr-test.util.ts +++ b/test/development/basic/hmr/run-basic-hmr-test.util.ts @@ -4,7 +4,7 @@ import { retry, waitFor, } from 'next-test-utils' -import { createNext, nextTestSetup } from 'e2e-utils' +import { nextTestSetup } from 'e2e-utils' export function runBasicHmrTest(nextConfig: { basePath: string @@ -14,6 +14,7 @@ export function runBasicHmrTest(nextConfig: { files: __dirname, nextConfig, patchFileDelay: 500, + forcedPort: 'random', }) const { basePath } = nextConfig @@ -90,7 +91,7 @@ export function runBasicHmrTest(nextConfig: { ) }) - await next.destroy() + await next.stop() let reloadPromise = new Promise((resolve) => { browser.on('request', (req) => { @@ -100,13 +101,8 @@ export function runBasicHmrTest(nextConfig: { }) }) - const secondNext = await createNext({ - files: __dirname, - nextConfig, - forcedPort: next.appPort, - }) + await next.start() await reloadPromise - await secondNext.destroy() }) } diff --git a/test/development/basic/next-rs-api.test.ts b/test/development/basic/next-rs-api.test.ts index 4303dd78e3d6..c9a6822fd4e6 100644 --- a/test/development/basic/next-rs-api.test.ts +++ b/test/development/basic/next-rs-api.test.ts @@ -1,5 +1,4 @@ -import { NextInstance, createNext } from 'e2e-utils' -import { trace } from 'next/dist/trace' +import { nextTestSetup } from 'e2e-utils' import { PHASE_DEVELOPMENT_SERVER } from 'next/constants' import { createDefineEnv, loadBindings, HmrTarget } from 'next/dist/build/swc' import type { @@ -121,39 +120,33 @@ export default () =>
${text}
;` } describe('next.rs api writeToDisk multiple times', () => { - let next: NextInstance - afterEach(async () => { - await next?.destroy() - }) - it('should allow to write to disk multiple times', async () => { - next = await createNext({ - skipStart: true, - files: { - 'pages/index.js': pagesIndexCode('hello world'), - 'lib/props.js': 'export default {}', - 'pages/page-nodejs.js': 'export default () =>
hello world
', - 'pages/page-edge.js': - 'export default () =>
hello world
\nexport const config = { runtime: "experimental-edge" }', - 'pages/api/nodejs.js': - 'export default () => Response.json({ hello: "world" })', - 'pages/api/edge.js': - 'export default () => Response.json({ hello: "world" })\nexport const config = { runtime: "edge" }', - 'app/layout.tsx': - 'export default function RootLayout({ children }: { children: any }) { return ({children})}', - 'app/loading.tsx': - 'export default function Loading() { return <>Loading }', - 'app/app/page.tsx': appPageCode('hello world'), - 'app/app/client.tsx': - '"use client";\nexport default () =>
hello world
', - 'app/app-edge/page.tsx': - 'export default () =>
hello world
\nexport const runtime = "edge"', - 'app/app-nodejs/page.tsx': - 'export default () =>
hello world
', - 'app/route-nodejs/route.ts': - 'export function GET() { return Response.json({ hello: "world" }) }', - 'app/route-edge/route.ts': - 'export function GET() { return Response.json({ hello: "world" }) }\nexport const runtime = "edge"', - 'server.js': ` + const { next } = nextTestSetup({ + skipStart: true, + files: { + 'pages/index.js': pagesIndexCode('hello world'), + 'lib/props.js': 'export default {}', + 'pages/page-nodejs.js': 'export default () =>
hello world
', + 'pages/page-edge.js': + 'export default () =>
hello world
\nexport const config = { runtime: "experimental-edge" }', + 'pages/api/nodejs.js': + 'export default () => Response.json({ hello: "world" })', + 'pages/api/edge.js': + 'export default () => Response.json({ hello: "world" })\nexport const config = { runtime: "edge" }', + 'app/layout.tsx': + 'export default function RootLayout({ children }: { children: any }) { return ({children})}', + 'app/loading.tsx': + 'export default function Loading() { return <>Loading }', + 'app/app/page.tsx': appPageCode('hello world'), + 'app/app/client.tsx': + '"use client";\nexport default () =>
hello world
', + 'app/app-edge/page.tsx': + 'export default () =>
hello world
\nexport const runtime = "edge"', + 'app/app-nodejs/page.tsx': 'export default () =>
hello world
', + 'app/route-nodejs/route.ts': + 'export function GET() { return Response.json({ hello: "world" }) }', + 'app/route-edge/route.ts': + 'export function GET() { return Response.json({ hello: "world" }) }\nexport const runtime = "edge"', + 'server.js': ` process.title = 'next.rs api run test'; const path = require('path'); const { PHASE_DEVELOPMENT_SERVER } = require('next/constants'); @@ -263,9 +256,10 @@ main() }); `, - }, - }) + }, + }) + it('should allow to write to disk multiple times', async () => { const result = spawnSync( 'node', ['--expose-gc', join(next.testDir, 'server.js')], @@ -281,41 +275,34 @@ main() }) describe('next.rs api', () => { - let next: NextInstance - beforeAll(async () => { - await trace('setup next instance').traceAsyncFn(async (rootSpan) => { - next = await createNext({ - skipStart: true, - files: { - 'pages/index.js': pagesIndexCode('hello world'), - 'lib/props.js': 'export default {}', - 'pages/page-nodejs.js': 'export default () =>
hello world
', - 'pages/page-edge.js': - 'export default () =>
hello world
\nexport const config = { runtime: "experimental-edge" }', - 'pages/api/nodejs.js': - 'export default () => Response.json({ hello: "world" })', - 'pages/api/edge.js': - 'export default () => Response.json({ hello: "world" })\nexport const config = { runtime: "edge" }', - 'app/layout.tsx': - 'export default function RootLayout({ children }: { children: any }) { return ({children})}', - 'app/loading.tsx': - 'export default function Loading() { return <>Loading }', - 'app/app/page.tsx': appPageCode('hello world'), - 'app/app/client.tsx': - '"use client";\nexport default () =>
hello world
', - 'app/app-edge/page.tsx': - 'export default () =>
hello world
\nexport const runtime = "edge"', - 'app/app-nodejs/page.tsx': - 'export default () =>
hello world
', - 'app/route-nodejs/route.ts': - 'export function GET() { return Response.json({ hello: "world" }) }', - 'app/route-edge/route.ts': - 'export function GET() { return Response.json({ hello: "world" }) }\nexport const runtime = "edge"', - }, - }) - }) + const { next } = nextTestSetup({ + skipStart: true, + files: { + 'pages/index.js': pagesIndexCode('hello world'), + 'lib/props.js': 'export default {}', + 'pages/page-nodejs.js': 'export default () =>
hello world
', + 'pages/page-edge.js': + 'export default () =>
hello world
\nexport const config = { runtime: "experimental-edge" }', + 'pages/api/nodejs.js': + 'export default () => Response.json({ hello: "world" })', + 'pages/api/edge.js': + 'export default () => Response.json({ hello: "world" })\nexport const config = { runtime: "edge" }', + 'app/layout.tsx': + 'export default function RootLayout({ children }: { children: any }) { return ({children})}', + 'app/loading.tsx': + 'export default function Loading() { return <>Loading }', + 'app/app/page.tsx': appPageCode('hello world'), + 'app/app/client.tsx': + '"use client";\nexport default () =>
hello world
', + 'app/app-edge/page.tsx': + 'export default () =>
hello world
\nexport const runtime = "edge"', + 'app/app-nodejs/page.tsx': 'export default () =>
hello world
', + 'app/route-nodejs/route.ts': + 'export function GET() { return Response.json({ hello: "world" }) }', + 'app/route-edge/route.ts': + 'export function GET() { return Response.json({ hello: "world" }) }\nexport const runtime = "edge"', + }, }) - afterAll(() => next.destroy()) let project: Project let projectUpdateSubscription: AsyncIterableIterator diff --git a/test/development/basic/project-directory-rename.test.ts b/test/development/basic/project-directory-rename.test.ts index ccb43a45a379..a42c7c82bc12 100644 --- a/test/development/basic/project-directory-rename.test.ts +++ b/test/development/basic/project-directory-rename.test.ts @@ -1,30 +1,26 @@ import fs from 'fs-extra' import webdriver from 'next-webdriver' -import { waitForNoRedbox, check, findPort } from 'next-test-utils' -import { NextInstance } from 'e2e-utils' -import { createNext } from 'e2e-utils' +import { waitForNoRedbox, check } from 'next-test-utils' +import { nextTestSetup } from 'e2e-utils' import stripAnsi from 'strip-ansi' // TODO: investigate occasional failure describe.skip('Project Directory Renaming', () => { - let next: NextInstance - - beforeAll(async () => { - next = await createNext({ - files: { - 'pages/index.js': ` + const { next } = nextTestSetup({ + files: { + 'pages/index.js': ` export default function Page() { return

hello world

} `, - }, - skipStart: true, - forcedPort: (await findPort()) + '', - }) + }, + skipStart: true, + forcedPort: 'random', + }) + beforeAll(async () => { await next.start() }) - afterAll(() => next.destroy().catch(() => {})) it('should detect project dir rename and restart', async () => { const browser = await webdriver(next.url, '/') diff --git a/test/development/start-no-build/start-no-build.test.ts b/test/development/start-no-build/start-no-build.test.ts index c5582e76399a..a1ab5ff56439 100644 --- a/test/development/start-no-build/start-no-build.test.ts +++ b/test/development/start-no-build/start-no-build.test.ts @@ -1,23 +1,21 @@ -import { createNext } from 'e2e-utils' +import { nextTestSetup } from 'e2e-utils' describe('next start without next build', () => { - it('should show error when there is no production build', async () => { - const next = await createNext({ - files: __dirname, - skipStart: true, - startCommand: `pnpm next start`, - serverReadyPattern: /Local:/, - }) + const { next } = nextTestSetup({ + files: __dirname, + skipStart: true, + startCommand: `pnpm next start`, + serverReadyPattern: /Local:/, + }) + it('should show error when there is no production build', async () => { await next.start() - await new Promise((resolve, reject) => { + await new Promise((resolve) => { next.on('stderr', (msg) => { if (msg.includes('Could not find a production build in the')) { resolve() } }) }) - - await next.destroy() }) }) diff --git a/test/e2e/app-dir/create-root-layout/create-root-layout.test.ts b/test/e2e/app-dir/create-root-layout/create-root-layout.test.ts index a9d9ec19e141..430e116dcd50 100644 --- a/test/e2e/app-dir/create-root-layout/create-root-layout.test.ts +++ b/test/e2e/app-dir/create-root-layout/create-root-layout.test.ts @@ -1,5 +1,5 @@ import path from 'path' -import { createNext, FileRef, nextTestSetup } from 'e2e-utils' +import { FileRef, nextTestSetup } from 'e2e-utils' import { check } from 'next-test-utils' import stripAnsi from 'strip-ansi' @@ -202,24 +202,23 @@ import stripAnsi from 'strip-ansi' }) } else { describe('build', () => { - it('should break the build if a page is missing root layout', async () => { - const next = await createNext({ - skipStart: true, - files: { - 'app/page.js': new FileRef( - path.join(__dirname, 'app/route/page.js') - ), - 'next.config.js': new FileRef( - path.join(__dirname, 'next.config.js') - ), - }, - }) + const { next } = nextTestSetup({ + skipStart: true, + files: { + 'app/page.js': new FileRef( + path.join(__dirname, 'app/route/page.js') + ), + 'next.config.js': new FileRef( + path.join(__dirname, 'next.config.js') + ), + }, + }) + it('should break the build if a page is missing root layout', async () => { await expect(next.start()).rejects.toThrow('next build failed') expect(stripAnsi(next.cliOutput)).toInclude( "page.js doesn't have a root layout. To fix this error, make sure every page has a root layout." ) - await next.destroy() }) }) } diff --git a/test/e2e/app-dir/css-order/app/nav.tsx b/test/e2e/app-dir/css-order/app/nav.tsx index 11b90a82fefb..da949e72b7be 100644 --- a/test/e2e/app-dir/css-order/app/nav.tsx +++ b/test/e2e/app-dir/css-order/app/nav.tsx @@ -86,6 +86,16 @@ export default function Nav() { Partial Reversed B +
  • + + Sandwich A + +
  • +
  • + + Sandwich B + +
  • Global First diff --git a/test/e2e/app-dir/css-order/app/sandwich/a/page.tsx b/test/e2e/app-dir/css-order/app/sandwich/a/page.tsx new file mode 100644 index 000000000000..e392055795c7 --- /dev/null +++ b/test/e2e/app-dir/css-order/app/sandwich/a/page.tsx @@ -0,0 +1,18 @@ +import shared1 from '../shared1.module.css' +import uniqueA from '../uniqueA.module.css' +import shared2 from '../shared2.module.css' +import Nav from '../../nav' + +export default function Page() { + return ( +
    +

    + hello world +

    +
    + ) +} diff --git a/test/e2e/app-dir/css-order/app/sandwich/b/page.tsx b/test/e2e/app-dir/css-order/app/sandwich/b/page.tsx new file mode 100644 index 000000000000..36af46196467 --- /dev/null +++ b/test/e2e/app-dir/css-order/app/sandwich/b/page.tsx @@ -0,0 +1,18 @@ +import shared1 from '../shared1.module.css' +import '../uniqueB.css' +import shared2 from '../shared2.module.css' +import Nav from '../../nav' + +export default function Page() { + return ( +
    +

    + hello world +

    +
    + ) +} diff --git a/test/e2e/app-dir/css-order/app/sandwich/shared1.module.css b/test/e2e/app-dir/css-order/app/sandwich/shared1.module.css new file mode 100644 index 000000000000..e951c0f277c2 --- /dev/null +++ b/test/e2e/app-dir/css-order/app/sandwich/shared1.module.css @@ -0,0 +1,3 @@ +.name { + color: rgb(255, 0, 0); +} diff --git a/test/e2e/app-dir/css-order/app/sandwich/shared2.module.css b/test/e2e/app-dir/css-order/app/sandwich/shared2.module.css new file mode 100644 index 000000000000..936e3f84bcf4 --- /dev/null +++ b/test/e2e/app-dir/css-order/app/sandwich/shared2.module.css @@ -0,0 +1,3 @@ +.name { + color: rgb(0, 0, 255); +} diff --git a/test/e2e/app-dir/css-order/app/sandwich/uniqueA.module.css b/test/e2e/app-dir/css-order/app/sandwich/uniqueA.module.css new file mode 100644 index 000000000000..dab02d511fdd --- /dev/null +++ b/test/e2e/app-dir/css-order/app/sandwich/uniqueA.module.css @@ -0,0 +1,3 @@ +.name { + color: rgb(0, 255, 0); +} diff --git a/test/e2e/app-dir/css-order/app/sandwich/uniqueB.css b/test/e2e/app-dir/css-order/app/sandwich/uniqueB.css new file mode 100644 index 000000000000..b3bd59d04590 --- /dev/null +++ b/test/e2e/app-dir/css-order/app/sandwich/uniqueB.css @@ -0,0 +1,7 @@ +/* Global CSS used by `/sandwich/b` only. Its purpose is to prevent the chunking algorithm from + collapsing all sandwich styles into one shared chunk: a global stylesheet must not be loaded by + chunk groups that don't import it (`/sandwich/a`), so the algorithm has to keep `uniqueB` + isolated. */ +.sandwich-b-marker { + outline: 1px solid rgb(255, 128, 0); +} diff --git a/test/e2e/app-dir/css-order/css-order.test.ts b/test/e2e/app-dir/css-order/css-order.test.ts index c10f589242f7..57253a6e156d 100644 --- a/test/e2e/app-dir/css-order/css-order.test.ts +++ b/test/e2e/app-dir/css-order/css-order.test.ts @@ -30,6 +30,7 @@ const PAGES: Record< brokenLoadingDev?: boolean requests?: number requestsLoose?: number + requestsGraph?: number } > = { first: { @@ -133,6 +134,44 @@ const PAGES: Record< background: 'rgba(0, 0, 0, 0)', requests: 4, }, + // Two pages where shared CSS modules sandwich a unique stylesheet: + // /sandwich/a: shared1 → uniqueA (module) → shared2 + // /sandwich/b: shared1 → uniqueB (GLOBAL) → shared2 + // The chunker must not merge shared1 and shared2 into a single chunk because they sit on + // either side of the unique stylesheet. uniqueB is a global stylesheet specifically so the + // algorithm can't collapse everything into one big shared chunk: a global stylesheet must + // never be loaded by chunk groups that don't import it (i.e. `/sandwich/a`), so uniqueB has + // to stay isolated. + 'sandwich-a': { + group: 'sandwich', + url: '/sandwich/a', + selector: '#hellosba', + color: 'rgb(0, 0, 255)', + // 2 requests = `shared1 + uniqueA` fused into one chunk + `shared2` separate (still shared + // with /sandwich/b). 3 requests is a valid alternative (no overshipping; better cache reuse + // of shared1 across pages), but strict & graph here both prefer overshipping uniqueA. + requests: 2, + requestsLoose: 1, + // Same as `requests`, but spelled out so `requestsLoose` doesn't shadow it via the fallback + // chain in `expectedRequests`. + requestsGraph: 2, + }, + 'sandwich-b': { + group: 'sandwich', + url: '/sandwich/b', + selector: '#hellosbb', + color: 'rgb(0, 0, 255)', + // 3 requests is forced: uniqueB is a global stylesheet so it can't fuse with the CSS + // modules on either side. shared1 and shared2 each stay in their own chunk so they can be + // shared with /sandwich/a. + requests: 3, + // TODO loose merges shared1 and shared2 into a single chunk despite uniqueB sitting + // between them on the page; the correct value is 3 (matching `requests`). + requestsLoose: 2, + // Same as `requests`, but spelled out so `requestsLoose` doesn't shadow it via the fallback + // chain in `expectedRequests`. + requestsGraph: 3, + }, 'pages-first': { group: 'pages-basic', url: '/pages/first', @@ -240,43 +279,113 @@ const PAGES: Record< const allPairs = getPairs(Object.keys(PAGES)) -const options = (mode: string) => ({ +// Each entry is `[label, value]`, where `label` is shown in test names and `value` is what gets +// written into `experimental.cssChunking` (or `undefined` to leave it unset, which is the +// existing Turbopack default). +type GraphCssChunkingObject = { + type: 'graph' + requestCost?: number + moduleFactorCost?: number +} +type CssChunkingValue = + | boolean + | 'strict' + | 'loose' + | 'graph' + | GraphCssChunkingObject + | undefined + +type Mode = readonly [string, CssChunkingValue] + +const TURBO_MODES: readonly Mode[] = [ + ['turbo', undefined], + ['graph', 'graph'], + // Verifies the object form is accepted. We pass the algorithm's defaults so the chunk shape + // matches the plain `'graph'` row, letting us reuse the same `requestsGraph` expectations. + ['graph-object', { type: 'graph', requestCost: 20_000, moduleFactorCost: 1 }], +] +const WEBPACK_MODES_TRUE: readonly Mode[] = [ + ['strict', 'strict'], + ['true', true], +] +const WEBPACK_MODES_LOOSE: readonly Mode[] = [ + ['strict', 'strict'], + ['loose', 'loose'], +] + +function isGraphMode(value: CssChunkingValue): boolean { + return ( + value === 'graph' || + (typeof value === 'object' && value !== null && value.type === 'graph') + ) +} + +function isStrictMode(value: CssChunkingValue): boolean { + return value === 'strict' +} + +const options = (value: CssChunkingValue) => ({ files: { app: new FileRef(path.join(__dirname, 'app')), pages: new FileRef(path.join(__dirname, 'pages')), - 'next.config.js': process.env.IS_TURBOPACK_TEST - ? ` - module.exports = {}` - : ` - module.exports = { - experimental: { - cssChunking: ${JSON.stringify(mode)} - } - }`, + 'next.config.js': + value === undefined + ? `module.exports = {}` + : `module.exports = { experimental: { cssChunking: ${JSON.stringify(value)} } }`, }, dependencies: { sass: 'latest', }, skipDeployment: true, }) -describe.each(process.env.IS_TURBOPACK_TEST ? ['turbo'] : ['strict', true])( + +/** + * Number of CSS request expected for a page in the given mode. Falls back from the most-specific + * to the most-generic per-page expectation. + */ +function expectedRequests( + value: CssChunkingValue, + pageInfo: { + requests?: number + requestsLoose?: number + requestsGraph?: number + } +): number | undefined { + if (isGraphMode(value)) { + return pageInfo.requestsGraph ?? pageInfo.requestsLoose ?? pageInfo.requests + } + if (isStrictMode(value)) { + return pageInfo.requests + } + // `undefined` (Turbopack default), `true`, or `'loose'` all map to loose. + return pageInfo.requestsLoose ?? pageInfo.requests +} + +/** + * Whether a given ordering should be skipped because at least one page in the ordering has a + * conflict that the active chunking mode can't preserve. `'strict'` preserves ordering for + * conflict scenarios; everything else does not. + */ +function shouldSkipConflict(ordering: readonly string[]): boolean { + return ordering + .map((page) => PAGES[page]) + .some((page) => + process.env.IS_TURBOPACK_TEST + ? page.conflictTurbo || page.conflict + : page.conflict + ) +} + +describe.each(process.env.IS_TURBOPACK_TEST ? TURBO_MODES : WEBPACK_MODES_TRUE)( 'css-order %s', - (mode: string) => { - const { next, isNextDev, skipped } = nextTestSetup(options(mode)) + (_label: string, value: CssChunkingValue) => { + const { next, isNextDev, skipped } = nextTestSetup(options(value)) if (skipped) return for (const ordering of allPairs) { const name = `should load correct styles navigating back again ${ordering.join( ' -> ' )} -> ${ordering.join(' -> ')}` - if ( - ordering - .map((page) => PAGES[page]) - .some((page) => - mode === 'turbo' - ? page.conflictTurbo || page.conflict - : page.conflict - ) - ) { + if (shouldSkipConflict(ordering)) { // Conflict scenarios won't support that case continue } @@ -321,94 +430,81 @@ describe.each(process.env.IS_TURBOPACK_TEST ? ['turbo'] : ['strict', true])( } } ) -describe.each(process.env.IS_TURBOPACK_TEST ? ['turbo'] : ['strict', 'loose'])( - 'css-order %s', - (mode: string) => { - const { next, isNextDev } = nextTestSetup(options(mode)) - for (const ordering of allPairs) { - const name = `should load correct styles navigating ${ordering.join( - ' -> ' - )}` - if ( - ordering - .map((page) => PAGES[page]) - .some((page) => - mode === 'turbo' - ? page.conflictTurbo || page.conflict - : page.conflict - ) - ) { - // Conflict scenarios won't support that case - continue - } - // TODO fix this case - let broken = ordering.some( - (page) => - PAGES[page].brokenLoading || - (isNextDev && PAGES[page].brokenLoadingDev) - ) - if (broken) { - it.todo(name) - continue - } - it(name, async () => { - const start = PAGES[ordering[0]] - const browser = await next.browser(start.url) - const check = async (pageInfo) => { - expect( - await browser - .waitForElementByCss(pageInfo.selector) - .getComputedCss('color') - ).toBe(pageInfo.color) - } - const navigate = async (page) => { - await browser.waitForElementByCss('#' + page).click() - } - await check(start) - for (const page of ordering.slice(1)) { - await navigate(page) - await check(PAGES[page]) - } - await browser.close() - }) +describe.each( + process.env.IS_TURBOPACK_TEST ? TURBO_MODES : WEBPACK_MODES_LOOSE +)('css-order %s', (_label: string, value: CssChunkingValue) => { + const { next, isNextDev } = nextTestSetup(options(value)) + for (const ordering of allPairs) { + const name = `should load correct styles navigating ${ordering.join( + ' -> ' + )}` + if (shouldSkipConflict(ordering)) { + // Conflict scenarios won't support that case + continue } - } -) -describe.each(process.env.IS_TURBOPACK_TEST ? ['turbo'] : ['strict', 'loose'])( - 'css-order %s', - (mode: string) => { - const { next, isNextDev } = nextTestSetup(options(mode)) - for (const [page, pageInfo] of Object.entries(PAGES)) { - const name = `should load correct styles on ${page}` - if ( - (mode !== 'strict' && pageInfo.conflict) || - (mode === 'turbo' && pageInfo.conflictTurbo) - ) { - // Conflict scenarios won't support that case - continue - } - it(name, async () => { - const browser = await next.browser(pageInfo.url) + // TODO fix this case + let broken = ordering.some( + (page) => + PAGES[page].brokenLoading || (isNextDev && PAGES[page].brokenLoadingDev) + ) + if (broken) { + it.todo(name) + continue + } + it(name, async () => { + const start = PAGES[ordering[0]] + const browser = await next.browser(start.url) + const check = async (pageInfo) => { expect( await browser .waitForElementByCss(pageInfo.selector) .getComputedCss('color') ).toBe(pageInfo.color) - if (!isNextDev) { - const stylesheets = await browser.elementsByCss( - "link[rel='stylesheet']" - ) - const files = await Promise.all( - Array.from(stylesheets).map((e) => e.getAttribute('href')) - ) - expect(files).toHaveLength( - mode === 'turbo' || mode === 'loose' - ? pageInfo.requestsLoose || pageInfo.requests - : pageInfo.requests - ) - } - await browser.close() - }) + } + const navigate = async (page) => { + await browser.waitForElementByCss('#' + page).click() + } + await check(start) + for (const page of ordering.slice(1)) { + await navigate(page) + await check(PAGES[page]) + } + await browser.close() + }) + } +}) +describe.each( + process.env.IS_TURBOPACK_TEST ? TURBO_MODES : WEBPACK_MODES_LOOSE +)('css-order %s', (_label: string, value: CssChunkingValue) => { + const { next, isNextDev } = nextTestSetup(options(value)) + for (const [page, pageInfo] of Object.entries(PAGES)) { + const name = `should load correct styles on ${page}` + if ( + // `strict` preserves ordering for conflict scenarios; loose/turbo/graph do not. + // `conflictTurbo` applies to any Turbopack mode. + (!isStrictMode(value) && pageInfo.conflict) || + (process.env.IS_TURBOPACK_TEST && pageInfo.conflictTurbo) + ) { + // Conflict scenarios won't support that case + continue } + it(name, async () => { + const browser = await next.browser(pageInfo.url) + expect( + await browser + .waitForElementByCss(pageInfo.selector) + .getComputedCss('color') + ).toBe(pageInfo.color) + if (!isNextDev) { + const stylesheets = await browser.elementsByCss( + "link[rel='stylesheet']" + ) + const files = await Promise.all( + Array.from(stylesheets).map((e) => e.getAttribute('href')) + ) + expect(files).toHaveLength(expectedRequests(value, pageInfo)) + } + await browser.close() + }) } -) +}) diff --git a/test/e2e/app-dir/ppr-navigations/avoid-popstate-flash/avoid-popstate-flash.test.ts b/test/e2e/app-dir/ppr-navigations/avoid-popstate-flash/avoid-popstate-flash.test.ts index 668ec28b13bd..2e989e52e235 100644 --- a/test/e2e/app-dir/ppr-navigations/avoid-popstate-flash/avoid-popstate-flash.test.ts +++ b/test/e2e/app-dir/ppr-navigations/avoid-popstate-flash/avoid-popstate-flash.test.ts @@ -1,4 +1,4 @@ -import { createNext } from 'e2e-utils' +import { nextTestSetup } from 'e2e-utils' import { findPort } from 'next-test-utils' import { createTestDataServer } from 'test-data-service/writer' import { createTestLog } from 'test-log' @@ -11,10 +11,13 @@ describe('avoid-popstate-flash', () => { return } + const { next } = nextTestSetup({ + files: __dirname, + skipStart: true, + }) + let server - let next - afterEach(async () => { - await next?.destroy() + afterEach(() => { server?.close() }) @@ -35,10 +38,8 @@ describe('avoid-popstate-flash', () => { }) const port = await findPort() server.listen(port) - next = await createNext({ - files: __dirname, - env: { TEST_DATA_SERVICE_URL: `http://localhost:${port}` }, - }) + next.env.TEST_DATA_SERVICE_URL = `http://localhost:${port}` + await next.start() TestLog.assert(['REQUEST: Static']) autoresolveRequests = false diff --git a/test/e2e/app-dir/ppr-navigations/search-params/search-params.test.ts b/test/e2e/app-dir/ppr-navigations/search-params/search-params.test.ts index ffdc2a131900..d4ff26780312 100644 --- a/test/e2e/app-dir/ppr-navigations/search-params/search-params.test.ts +++ b/test/e2e/app-dir/ppr-navigations/search-params/search-params.test.ts @@ -1,4 +1,4 @@ -import { createNext } from 'e2e-utils' +import { nextTestSetup } from 'e2e-utils' // TODO(NAR-423): Migrate to Cache Components. describe.skip('search-params', () => { @@ -7,20 +7,14 @@ describe.skip('search-params', () => { return } - let server - let next - afterEach(async () => { - await next?.destroy() - server?.close() + const { next } = nextTestSetup({ + files: __dirname, }) test( 'updates page data during a nav even if no shared layouts have changed ' + '(e.g. updating a search param on the current page)', async () => { - next = await createNext({ - files: __dirname, - }) const browser = await next.browser('/') // Click a link that updates the current page's search params. diff --git a/test/e2e/app-dir/ppr-navigations/stale-prefetch-entry/stale-prefetch-entry.test.ts b/test/e2e/app-dir/ppr-navigations/stale-prefetch-entry/stale-prefetch-entry.test.ts index 33b0c8438993..1edbd0ef7e9f 100644 --- a/test/e2e/app-dir/ppr-navigations/stale-prefetch-entry/stale-prefetch-entry.test.ts +++ b/test/e2e/app-dir/ppr-navigations/stale-prefetch-entry/stale-prefetch-entry.test.ts @@ -1,4 +1,4 @@ -import { createNext } from 'e2e-utils' +import { nextTestSetup } from 'e2e-utils' import { findPort } from 'next-test-utils' import { createTestDataServer } from 'test-data-service/writer' import { createTestLog } from 'test-log' @@ -11,10 +11,13 @@ describe('stale-prefetch-entry', () => { return } + const { next } = nextTestSetup({ + files: __dirname, + skipStart: true, + }) + let server - let next - afterEach(async () => { - await next?.destroy() + afterEach(() => { server?.close() }) @@ -42,10 +45,8 @@ describe('stale-prefetch-entry', () => { }) const port = await findPort() server.listen(port) - next = await createNext({ - files: __dirname, - env: { TEST_DATA_SERVICE_URL: `http://localhost:${port}` }, - }) + next.env.TEST_DATA_SERVICE_URL = `http://localhost:${port}` + await next.start() TestLog.assert(['REQUEST: Some data [static]']) autoresolveRequests = false diff --git a/test/e2e/app-dir/ppr-unstable-cache/ppr-unstable-cache.test.ts b/test/e2e/app-dir/ppr-unstable-cache/ppr-unstable-cache.test.ts index 184415368942..dc2d48ee4dad 100644 --- a/test/e2e/app-dir/ppr-unstable-cache/ppr-unstable-cache.test.ts +++ b/test/e2e/app-dir/ppr-unstable-cache/ppr-unstable-cache.test.ts @@ -1,4 +1,4 @@ -import { NextInstance, createNext, isNextDeploy, isNextDev } from 'e2e-utils' +import { nextTestSetup, isNextDeploy, isNextDev } from 'e2e-utils' import { findPort } from 'next-test-utils' import http from 'node:http' @@ -13,14 +13,13 @@ describe('ppr-unstable-cache', () => { return } - let next: NextInstance | null = null + const { next } = nextTestSetup({ + files: __dirname, + skipStart: true, + }) + let server: http.Server | null = null afterEach(async () => { - if (next) { - await next.destroy() - next = null - } - if (server) { await server.close() server = null @@ -48,10 +47,8 @@ describe('ppr-unstable-cache', () => { const port = await findPort() server.listen(port) - next = await createNext({ - files: __dirname, - env: { TEST_DATA_SERVER: `http://localhost:${port}/` }, - }) + next.env.TEST_DATA_SERVER = `http://localhost:${port}/` + await next.start() expect(generations).toHaveLength(2) diff --git a/test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts b/test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts index 100a46dbd58b..4611cc179023 100644 --- a/test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts +++ b/test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts @@ -1,5 +1,5 @@ import type * as Playwright from 'playwright' -import { isNextDev, isNextDeploy, createNext } from 'e2e-utils' +import { isNextDev, isNextDeploy, nextTestSetup } from 'e2e-utils' import { createRouterAct } from 'router-act' import { createTestDataServer } from 'test-data-service/writer' import { createTestLog } from 'test-log' @@ -16,7 +16,11 @@ describe('segment cache (revalidation)', () => { let dataVersions = new Map() let TestLog = createTestLog() - let next + const { next } = nextTestSetup({ + files: __dirname, + skipStart: true, + }) + beforeAll(async () => { port = await findPort() server = createTestDataServer(async (key, res) => { @@ -34,10 +38,8 @@ describe('segment cache (revalidation)', () => { }) server.listen(port) - next = await createNext({ - files: __dirname, - env: { TEST_DATA_SERVICE_URL: `http://localhost:${port}` }, - }) + next.env.TEST_DATA_SERVICE_URL = `http://localhost:${port}` + await next.start() }) beforeEach(async () => { @@ -46,7 +48,6 @@ describe('segment cache (revalidation)', () => { }) afterAll(async () => { - await next?.destroy() server?.close() }) diff --git a/test/e2e/next-font/with-proxy.test.ts b/test/e2e/next-font/with-proxy.test.ts index 0d781f7cf9e4..09743d791892 100644 --- a/test/e2e/next-font/with-proxy.test.ts +++ b/test/e2e/next-font/with-proxy.test.ts @@ -1,21 +1,24 @@ -import { FileRef, createNext, NextInstance } from 'e2e-utils' +import { FileRef, nextTestSetup } from 'e2e-utils' import { findPort, renderViaHTTP, fetchViaHTTP } from 'next-test-utils' import { join } from 'path' import spawn from 'cross-spawn' describe('next/font/google with proxy', () => { - let next: NextInstance - let proxy: any - let PROXY_PORT: number - let SERVER_PORT: number - if ((global as any).isNextDeploy) { it('should skip next deploy', () => {}) return } + const { next } = nextTestSetup({ + files: new FileRef(join(__dirname, 'with-proxy')), + skipStart: true, + }) + + let proxy: any + let SERVER_PORT: number + beforeAll(async () => { - PROXY_PORT = await findPort() + const PROXY_PORT = await findPort() SERVER_PORT = await findPort() proxy = spawn('node', [require.resolve('./with-proxy/server.js')], { @@ -27,16 +30,12 @@ describe('next/font/google with proxy', () => { }, }) - next = await createNext({ - files: new FileRef(join(__dirname, 'with-proxy')), - env: { - http_proxy: 'http://localhost:' + PROXY_PORT, - }, + await next.start({ + env: { http_proxy: 'http://localhost:' + PROXY_PORT }, }) }) - afterAll(async () => { - await next.destroy() - proxy.kill('SIGKILL') + afterAll(() => { + proxy?.kill('SIGKILL') }) // Reqwest doesn't seem to fully work with https proxy diff --git a/test/e2e/next-script/index.test.ts b/test/e2e/next-script/index.test.ts index a3c73d6cd3a7..2776fd46e948 100644 --- a/test/e2e/next-script/index.test.ts +++ b/test/e2e/next-script/index.test.ts @@ -1,6 +1,5 @@ import webdriver, { Playwright } from 'next-webdriver' -import { createNext, nextTestSetup } from 'e2e-utils' -import { NextInstance } from 'e2e-utils' +import { nextTestSetup } from 'e2e-utils' import { check } from 'next-test-utils' describe('beforeInteractive in document Head', () => { @@ -321,28 +320,8 @@ describe('empty strategy in document body', () => { }) }) - describe('experimental.nextScriptWorkers: true with required Partytown dependency for inline script', () => { - let next: NextInstance - - // Note: previously we were using `finally` cluase inside of test assertion. However, if the test times out - // exceeding jest.setTimeout() value, the finally clause is not executed and subsequent tests will fail due to - // hanging next instance. - afterEach(async () => { - if (next) { - await next.destroy() - next = undefined - } - }) - - const createNextApp = async (script) => - await createNext({ - nextConfig: { - experimental: { - nextScriptWorkers: true, - }, - }, - files: { - 'pages/index.js': ` + function buildInlineScriptPage(script: string) { + return ` import Script from 'next/script' export default function Page() { @@ -353,20 +332,29 @@ describe('empty strategy in document body', () => { ) } - `, - }, - dependencies: { - '@builder.io/partytown': '0.4.2', + ` + } + + describe('experimental.nextScriptWorkers: true with required Partytown dependency for inline script (children)', () => { + const { next } = nextTestSetup({ + nextConfig: { + experimental: { + nextScriptWorkers: true, }, - }) + }, + files: { + 'pages/index.js': buildInlineScriptPage( + `` + ), + }, + dependencies: { + '@builder.io/partytown': '0.4.2', + }, + }) it('Inline worker script through children is modified by Partytown to execute on a worker thread', async () => { let browser: Playwright - next = await createNextApp( - `` - ) - try { browser = await webdriver(next.url, '/') @@ -384,14 +372,28 @@ describe('empty strategy in document body', () => { if (browser) await browser.close() } }) + }) + + describe('experimental.nextScriptWorkers: true with required Partytown dependency for inline script (dangerouslySetInnerHTML)', () => { + const { next } = nextTestSetup({ + nextConfig: { + experimental: { + nextScriptWorkers: true, + }, + }, + files: { + 'pages/index.js': buildInlineScriptPage( + `