diff --git a/crates/next-api/src/module_graph.rs b/crates/next-api/src/module_graph.rs index 5bc526e33766..1a4b8e10b2b9 100644 --- a/crates/next-api/src/module_graph.rs +++ b/crates/next-api/src/module_graph.rs @@ -50,7 +50,7 @@ pub struct NextDynamicGraphs(Vec>); #[turbo_tasks::value_impl] impl NextDynamicGraphs { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn new_operation( graphs: ResolvedVc, is_single_page: bool, @@ -71,7 +71,7 @@ impl NextDynamicGraphs { Ok(Self(next_dynamic).cell()) } - #[turbo_tasks::function] + #[turbo_tasks::function(root)] pub async fn new(graphs: ResolvedVc, is_single_page: bool) -> Result> { // TODO get rid of this function once everything inside of // `get_global_information_for_endpoint_inner` calls `take_collectibles()` when needed @@ -248,7 +248,7 @@ pub struct ServerActionsGraphs(Vec>); #[turbo_tasks::value_impl] impl ServerActionsGraphs { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn new_operation( graphs: ResolvedVc, is_single_page: bool, @@ -269,7 +269,7 @@ impl ServerActionsGraphs { Ok(Self(server_actions).cell()) } - #[turbo_tasks::function] + #[turbo_tasks::function(root)] pub async fn new(graphs: ResolvedVc, is_single_page: bool) -> Result> { // TODO get rid of this function once everything inside of // `get_global_information_for_endpoint_inner` calls `take_collectibles()` when needed @@ -426,7 +426,7 @@ pub struct ClientReferencesGraphs(Vec>); #[turbo_tasks::value_impl] impl ClientReferencesGraphs { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn new_operation( graphs: ResolvedVc, is_single_page: bool, @@ -447,7 +447,7 @@ impl ClientReferencesGraphs { Ok(Self(client_references).cell()) } - #[turbo_tasks::function] + #[turbo_tasks::function(root)] pub async fn new(graphs: ResolvedVc, is_single_page: bool) -> Result> { // TODO get rid of this function once everything inside of // `get_global_information_for_endpoint_inner` calls `take_collectibles()` when needed diff --git a/crates/next-api/src/operation.rs b/crates/next-api/src/operation.rs index bd40ea51c0b1..2422329114d1 100644 --- a/crates/next-api/src/operation.rs +++ b/crates/next-api/src/operation.rs @@ -33,7 +33,7 @@ pub struct EntrypointsOperation { /// Removes issues and effects from the top-level `entrypoints` operation so that they're not /// duplicated across many different individual entrypoints or routes. -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn entrypoints_without_collectibles_operation( entrypoints: OperationVc, ) -> Result> { @@ -45,7 +45,7 @@ async fn entrypoints_without_collectibles_operation( #[turbo_tasks::value_impl] impl EntrypointsOperation { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] pub async fn new(entrypoints: OperationVc) -> Result> { let e = entrypoints.connect().await?; let entrypoints = entrypoints_without_collectibles_operation(entrypoints); @@ -143,7 +143,7 @@ pub struct OptionEndpoint(Option>>); /// Given a selector and the `Entrypoints` operation that it comes from, connect the operation and /// return an `OperationVc` containing the selected value. The returned operation will keep the /// entire `Entrypoints` operation alive. -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn pick_endpoint( op: OperationVc, selector: EndpointSelector, diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index 3010ed875fa2..adf4bd759034 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -464,7 +464,7 @@ pub struct ProjectContainer { #[turbo_tasks::value_impl] impl ProjectContainer { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] pub fn new_operation(name: RcStr, dev: bool) -> Result> { Ok(ProjectContainer { name, @@ -481,17 +481,17 @@ impl ProjectContainer { } } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn project_operation(project: ResolvedVc) -> Vc { project.project() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn project_fs_operation(project: ResolvedVc) -> Vc { project.project_fs() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn output_fs_operation(project: ResolvedVc) -> Vc { project.project_fs() } @@ -606,7 +606,7 @@ impl ProjectContainer { } this.options_state.set(Some(options)); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] fn project_from_container_operation( container: OperationVc, ) -> Vc { @@ -654,7 +654,7 @@ impl ProjectContainer { // to upgrade the `ResolvedVc` to an `OperationVc`. This is mostly okay // because we can assume the `ProjectContainer` was originally resolved with // strong consistency, and is rarely updated. - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] fn project_container_operation_hack( container: ResolvedVc, ) -> Vc { @@ -1508,7 +1508,7 @@ impl Project { /// Computes the whole app module graph, dropping issues in development mode so that /// individual routes don't each report every issue from the shared graph. - #[turbo_tasks::function] + #[turbo_tasks::function(root)] pub async fn whole_app_module_graphs( self: ResolvedVc, ) -> Result> { @@ -2408,7 +2408,7 @@ impl Project { // sessions. let _ = session; - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn hmr_version_operation( this: ResolvedVc, chunk_name: RcStr, @@ -2726,7 +2726,7 @@ async fn any_output_changed( Ok(Vc::::cell(completions).completed()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn all_assets_from_entries_operation( operation: OperationVc, ) -> Result> { diff --git a/crates/next-api/src/project_asset_hashes_manifest.rs b/crates/next-api/src/project_asset_hashes_manifest.rs index 5a0df9be0e01..e67a726f1935 100644 --- a/crates/next-api/src/project_asset_hashes_manifest.rs +++ b/crates/next-api/src/project_asset_hashes_manifest.rs @@ -133,7 +133,7 @@ pub async fn expand_outputs( #[turbo_tasks::value_impl] impl Asset for AssetHashesManifestAsset { - #[turbo_tasks::function(root)] + #[turbo_tasks::function] async fn content(&self) -> Result> { let output_assets = expand_outputs(*self.project, self.asset_root.clone()).await?; diff --git a/crates/next-api/src/route.rs b/crates/next-api/src/route.rs index 9fb9390834c8..b3faeb9c1309 100644 --- a/crates/next-api/src/route.rs +++ b/crates/next-api/src/route.rs @@ -220,7 +220,7 @@ async fn endpoint_output_assets_operation( Ok(*output.connect().await?.output_assets) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] pub async fn endpoint_write_to_disk_operation( endpoint: OperationVc, ) -> Result> { @@ -231,7 +231,7 @@ pub async fn endpoint_write_to_disk_operation( }) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] pub async fn endpoint_server_changed_operation( endpoint: OperationVc, ) -> Result> { @@ -242,7 +242,7 @@ pub async fn endpoint_server_changed_operation( }) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] pub async fn endpoint_client_changed_operation( endpoint: OperationVc, ) -> Result> { diff --git a/crates/next-api/src/versioned_content_map.rs b/crates/next-api/src/versioned_content_map.rs index f9eecdced08f..8f3dcbd2cb02 100644 --- a/crates/next-api/src/versioned_content_map.rs +++ b/crates/next-api/src/versioned_content_map.rs @@ -267,7 +267,7 @@ type GetEntriesResultT = Vec<(FileSystemPath, ResolvedVc>)> #[turbo_tasks::value(transparent)] struct GetEntriesResult(GetEntriesResultT); -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_entries(assets: OperationVc) -> Result> { let assets_ref = assets.connect().await?; let entries = assets_ref @@ -281,7 +281,7 @@ async fn get_entries(assets: OperationVc) -> Result, assets_operation: OperationVc, diff --git a/crates/next-build-test/src/lib.rs b/crates/next-build-test/src/lib.rs index e5e0d7a71acb..65170e6ee4dc 100644 --- a/crates/next-build-test/src/lib.rs +++ b/crates/next-build-test/src/lib.rs @@ -49,7 +49,7 @@ pub async fn main_inner( tracing::info!("collecting endpoints"); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] fn project_entrypoints_operation(project: ResolvedVc) -> Vc { project.entrypoints() } @@ -248,7 +248,7 @@ pub async fn render_routes( async fn endpoint_write_to_disk_with_apply( endpoint: ResolvedVc>, ) -> Result> { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] fn inner_operation(endpoint: ResolvedVc>) -> Vc { // we must wrap this in an operation so we can get the Effects collectibles endpoint_write_to_disk(*endpoint) @@ -260,7 +260,7 @@ async fn endpoint_write_to_disk_with_apply( effects: Effects, } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] pub async fn inner_operation_with_effects( endpoint: ResolvedVc>, ) -> Result> { @@ -291,7 +291,7 @@ async fn hmr( tracing::info!("HMR..."); let session = TransientInstance::new(()); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] fn project_hmr_chunk_names_operation(project: ResolvedVc) -> Vc> { project.hmr_chunk_names(HmrTarget::Client) } diff --git a/crates/next-napi-bindings/src/next_api/analyze.rs b/crates/next-napi-bindings/src/next_api/analyze.rs index 9da6c0eca99c..2e63c5fa1c5e 100644 --- a/crates/next-napi-bindings/src/next_api/analyze.rs +++ b/crates/next-napi-bindings/src/next_api/analyze.rs @@ -20,7 +20,7 @@ pub struct WriteAnalyzeResult { pub effects: Arc, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] pub async fn write_analyze_data_with_issues_operation( project: ResolvedVc, app_dir_only: bool, @@ -34,7 +34,7 @@ pub async fn write_analyze_data_with_issues_operation( Ok(WriteAnalyzeResult { issues, effects }.cell()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn write_analyze_data_with_issues_operation_inner( project: ResolvedVc, app_dir_only: bool, diff --git a/crates/next-napi-bindings/src/next_api/endpoint.rs b/crates/next-napi-bindings/src/next_api/endpoint.rs index 2b328616a3c3..6e5dffcadbc5 100644 --- a/crates/next-napi-bindings/src/next_api/endpoint.rs +++ b/crates/next-napi-bindings/src/next_api/endpoint.rs @@ -119,7 +119,7 @@ struct WrittenEndpointWithIssues { effects: Arc, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_written_endpoint_with_issues_operation( endpoint_op: OperationVc, ) -> Result> { @@ -222,7 +222,7 @@ impl PartialEq for EndpointIssuesAndDiags { impl Eq for EndpointIssuesAndDiags {} -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn subscribe_issues_and_diags_operation( endpoint_op: OperationVc, should_include_issues: bool, diff --git a/crates/next-napi-bindings/src/next_api/project.rs b/crates/next-napi-bindings/src/next_api/project.rs index 1ca989314069..e6e9d9f52269 100644 --- a/crates/next-napi-bindings/src/next_api/project.rs +++ b/crates/next-napi-bindings/src/next_api/project.rs @@ -614,7 +614,7 @@ pub fn project_new( let result = tt .clone() .run(async move { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] fn project_node_root_path_operation( container: ResolvedVc, ) -> Vc { @@ -995,7 +995,7 @@ struct EntrypointsWithIssues { effects: Arc, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_entrypoints_with_issues_operation( container: ResolvedVc, ) -> Result> { @@ -1012,7 +1012,7 @@ async fn get_entrypoints_with_issues_operation( .cell()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn project_container_entrypoints_operation( // the container is a long-lived object with internally mutable state, there's no risk of it // becoming stale @@ -1207,7 +1207,7 @@ async fn invalidate_deferred_entry_source_dirs_after_callback( #[turbo_tasks::value(cell = "new", eq = "manual")] struct ProjectInfo(Option, DiskFileSystem); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn project_info_operation( container: ResolvedVc, ) -> Result> { @@ -1326,7 +1326,7 @@ pub async fn project_write_all_entrypoints_to_disk( let container = project.container; let tt = ctx.turbo_tasks(); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn has_deferred_entrypoints_operation( container: ResolvedVc, ) -> Result> { @@ -1366,7 +1366,7 @@ pub async fn project_write_all_entrypoints_to_disk( #[turbo_tasks::value] struct DeferredEntrypointInfo(ReadRef, ReadRef>); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn deferred_entrypoint_info_operation( container: ResolvedVc, ) -> Result> { @@ -1546,7 +1546,7 @@ pub async fn project_write_all_entrypoints_to_disk( }) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_all_written_entrypoints_with_issues_operation( container: ResolvedVc, app_dir_only: bool, @@ -1568,7 +1568,7 @@ async fn get_all_written_entrypoints_with_issues_operation( .cell()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] pub async fn all_entrypoints_write_to_disk_operation( project: ResolvedVc, app_dir_only: bool, @@ -1612,7 +1612,7 @@ async fn output_assets_for_single_emit_operation( Ok(Vc::cell(merged_output_assets.into_iter().collect())) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn emit_all_output_assets_once_operation( container: ResolvedVc, app_dir_only: bool, @@ -1629,7 +1629,7 @@ async fn emit_all_output_assets_once_operation( Ok(container.entrypoints()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn emit_all_output_assets_once_with_issues_operation( container: ResolvedVc, app_dir_only: bool, @@ -1803,7 +1803,7 @@ struct HmrUpdateWithIssues { effects: Arc, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn project_hmr_update_operation( project: ResolvedVc, chunk_name: RcStr, @@ -1813,7 +1813,7 @@ fn project_hmr_update_operation( project.hmr_update(chunk_name, target, *state) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn hmr_update_with_issues_operation( project: ResolvedVc, chunk_name: RcStr, @@ -1943,7 +1943,7 @@ struct HmrChunkNamesWithIssues { effects: Arc, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn project_hmr_chunk_names_operation( container: ResolvedVc, target: HmrTarget, @@ -1951,7 +1951,7 @@ fn project_hmr_chunk_names_operation( container.hmr_chunk_names(target) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_hmr_chunk_names_with_issues_operation( container: ResolvedVc, target: HmrTarget, @@ -2257,7 +2257,7 @@ pub async fn get_source_map_rope( Ok(map) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] pub fn get_source_map_rope_operation( container: ResolvedVc, file_path: RcStr, @@ -2265,7 +2265,7 @@ pub fn get_source_map_rope_operation( get_source_map_rope(*container, file_path) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] pub async fn project_trace_source_operation( container: ResolvedVc, frame: StackFrame, @@ -2392,7 +2392,7 @@ pub async fn project_get_source_for_asset( let ctx = &project.turbopack_ctx; ctx.turbo_tasks() .run(async move { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn source_content_operation( container: ResolvedVc, file_path: RcStr, @@ -2480,7 +2480,7 @@ pub async fn project_write_analyze_data( }) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_all_compilation_issues_inner_operation( container: ResolvedVc, ) -> Result> { @@ -2497,7 +2497,7 @@ async fn get_all_compilation_issues_inner_operation( Ok(Vc::cell(())) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_all_compilation_issues_operation( container: ResolvedVc, ) -> Result> { @@ -2523,7 +2523,7 @@ pub async fn project_feature_usage( .turbopack_ctx .turbo_tasks() .run_once(async move { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn project_feature_usage_operation( container: ResolvedVc, ) -> Result> { diff --git a/packages/next/errors.json b/packages/next/errors.json index bb13cd07d91a..fc31027235c6 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1230,6 +1230,14 @@ "1229": "Route \"%s\": Next.js encountered uncached or runtime data in \\`generateMetadata()\\`.\\n\\nThis route's metadata is blocked, but the rest of its content can be prerendered.\\n\\nWays to fix this:\\n - Use a static metadata export instead of \\`generateMetadata()\\`\\n - Cache the metadata with \\`\"use cache\"\\` in \\`generateMetadata()\\`\\n - Add a dynamic data access (e.g. \\`await connection()\\`) to the page to render it at request time\\n\\nLearn more: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata", "1230": "Route \"%s\": Next.js encountered runtime data in \\`generateMetadata()\\`.\\n\\nThis route's metadata is blocked, but the rest of its content can be prerendered. \\`cookies()\\`, \\`headers()\\`, \\`params\\`, or \\`searchParams\\` accessed in \\`generateMetadata()\\` cause it to run dynamically.\\n\\nWays to fix this:\\n - Use a static metadata export instead of \\`generateMetadata()\\`\\n - Add a dynamic data access (e.g. \\`await connection()\\`) to the page to render it at request time\\n\\nLearn more: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata", "1231": "Route \"%s\": Next.js encountered uncached data in \\`generateMetadata()\\`.\\n\\nThis route's metadata is blocked, but the rest of its content can be prerendered. \\`fetch(...)\\` or \\`connection()\\` accessed in \\`generateMetadata()\\` cause it to run dynamically.\\n\\nWays to fix this:\\n - Cache the metadata with \\`\"use cache\"\\` in \\`generateMetadata()\\`\\n - Add a dynamic data access (e.g. \\`await connection()\\`) to the page to render it at request time\\n\\nLearn more: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata", + "1235": "unreachable sort key: %s", + "1236": "Expected '%s' after entry name in %s at offset %s, got %s.", + "1237": "Could not find 'globalThis.__RSC_MANIFEST[' in %s; client reference manifest format may have changed.", + "1238": "Unterminated entry-name string literal in %s.", + "1239": "Failed to parse JSON body of %s: %s", + "1240": "Expected '{' after 'globalThis.__RSC_MANIFEST[...] =' in %s at offset %s, got %s.", + "1241": "Expected string literal as entry name in %s at offset %s, got %s.", + "1242": "Unterminated JSON object in %s.", "1244": "Route \"%s\": Next.js encountered %s during the initial render.\\n\\nThis value must either be prerendered or computed per request.\\n\\nWays to fix this:\\n - Render at request time by adding a dynamic data access (e.g. \\`await connection()\\`) before this call\\n - Prerender and cache the value with \\`\"use cache\"\\`\\n - Render the value on the client with \\`\"use client\"\\`\\n%s\\nLearn more: %s", "1245": "Route \"%s\": Next.js encountered %s in a Client Component.\\n\\nThis value would be evaluated during the prerender and fixed at build time, instead of recomputed on each visit.\\n\\nWays to fix this:\\n - Wrap the Client Component in \\`\\`\\n - Move the read into a \\`useEffect\\` or event handler\\n\\nLearn more: %s", "1247": "Route \"%s\": Next.js encountered %s without an explicit rendering intent.\\n\\nThis value can change between renders, so it must be either prerendered or computed later.\\n\\nWays to fix this:\\n - Render at request time by adding a dynamic data access (e.g. \\`await connection()\\`) before this call\\n - Prerender and cache the value with \\`\"use cache\"\\`\\n - Render the value on the client with \\`\"use client\"\\`\\n%s\\nLearn more: %s" diff --git a/packages/next/src/bin/next.ts b/packages/next/src/bin/next.ts index c189e6769f6b..89a50fd60252 100755 --- a/packages/next/src/bin/next.ts +++ b/packages/next/src/bin/next.ts @@ -700,4 +700,46 @@ internal }) .usage('[directory] [options]') +internal + .command('static-routes-info') + .description( + 'Analyze a built Next.js app and report per-route bundle sizes across server bundled JS, server source maps, server unbundled, client JS, client source maps, and client CSS categories.' + ) + .argument( + '[directory]', + `A directory containing the built Next.js application. ${italic( + 'If no directory is provided, the current directory will be used.' + )}` + ) + .option('--json', 'Output as JSON instead of markdown.') + .option( + '--limit ', + 'Only show the first N routes after sorting (totals always reflect all routes).', + parseInt + ) + .option( + '--sort ', + 'Sort routes by: name (default, ascending), or one of client, client-js, client-css, client-map, server, server-bundled-js, server-unbundled, server-map, total (descending).' + ) + .option( + '--files', + 'Include the list of files (relative to the output directory) per category in the JSON output. Requires --json.' + ) + .action( + ( + directory: string, + options: { + json?: boolean + limit?: number + sort?: string + files?: boolean + } + ) => { + return import('../cli/internal/static-routes-info.js').then((mod) => + mod.staticRoutesInfoCli(options, directory) + ) + } + ) + .usage('[directory] [options]') + program.parse(process.argv) diff --git a/packages/next/src/cli/internal/static-routes-info.ts b/packages/next/src/cli/internal/static-routes-info.ts new file mode 100644 index 000000000000..fe3d0a7a4cf5 --- /dev/null +++ b/packages/next/src/cli/internal/static-routes-info.ts @@ -0,0 +1,1206 @@ +/** + * `next internal static-routes-info` — analyzes a built Next.js app and + * reports per-route bundle sizes statically (without running the app). + * + * The analysis is split into three steps so it's easy to swap in different + * chunking strategies later: + * + * 1. Capture: for each route, collect a set of files that belong to it, + * partitioned into 6 disjoint categories. + * 2. Deduplicate: per-route sets are already deduplicated (Set<>), and we + * union them across routes for project-wide totals. + * 3. Measure: stat each unique file path to get { count, bytes }. + * + * Output is markdown by default, or JSON with `--json`. `--limit N` keeps + * only the top N routes (totals always reflect all routes). + */ + +import fs from 'fs' +import path from 'path' +import loadConfig from '../../server/config' +import { PHASE_PRODUCTION_BUILD } from '../../shared/lib/constants' + +export interface StaticRoutesInfoOptions { + json?: boolean + limit?: number + sort?: string + files?: boolean +} + +/** + * Available `--sort` keys. `name` sorts ascending alphabetically by route; + * every other key is a numeric byte-total and sorts descending (biggest + * first). Composite keys (`client`, `server`, `total`) sum across multiple + * categories — see `sortValue` for the exact mapping. + */ +const SORT_KEYS = [ + 'name', + 'client', + 'client-js', + 'client-css', + 'client-map', + 'server', + 'server-bundled-js', + 'server-unbundled', + 'server-map', + 'total', +] as const +type SortKey = (typeof SORT_KEYS)[number] + +// --------------------------------------------------------------------------- +// Categories +// --------------------------------------------------------------------------- + +/** + * The 6 file categories we partition each route's files into. Each file is + * placed into exactly one category to avoid double-counting. + * + * To add a new category, extend this tuple, add a label below, and update + * the relevant collector(s). + */ +const CATEGORIES = [ + 'clientJs', + 'clientCss', + 'clientMaps', + 'serverBundled', + 'serverUnbundled', + 'serverMaps', +] as const +type Category = (typeof CATEGORIES)[number] + +/** Human-readable column titles, in the same order as CATEGORIES. */ +const CATEGORY_LABELS: Record = { + clientJs: 'Client JS', + clientCss: 'Client CSS', + clientMaps: 'Client Source Maps', + serverBundled: 'Server Bundled JS', + serverUnbundled: 'Server Unbundled', + serverMaps: 'Server Source Maps', +} + +/** + * Per-route file sets, one entry per category. + * + * Paths are stored either relative to `distDir` (for files that live inside + * the build output) or as absolute paths (for files traced outside `distDir`, + * e.g. `node_modules` deps in `serverUnbundled`, or .map files traced from + * the same place). At measurement time we discriminate via + * `path.isAbsolute()` so each category can mix the two. + * + * Storing paths as plain strings lets us deduplicate by string equality both + * per-route and across routes (for the totals). + */ +type FileSets = Record> + +interface CategoryStats { + count: number + bytes: number +} + +/** + * Shared-with-peers stats. `count` and `bytes` are the average size of the + * intersection of this route's files with each peer route's files (a "peer" + * is another route of the same `type`). `percentCount` and `percentBytes` + * are the same values expressed as a fraction of this route's own + * `count` / `bytes`, in 0..100 — i.e. "what percentage of this route's + * files / bytes are, on average, also shipped by a peer". Both are 0 when + * the route's own value is 0 (degenerate case where the average is 0/0). + */ +interface SharedStats extends CategoryStats { + percentCount: number + percentBytes: number +} + +/** + * Per-route stats for one category, plus the average size of the intersection + * with peer routes (other routes of the same type). `sharedAvg` is `null` when + * the route has no peers (i.e. it's the only route of its type), since the + * average is undefined. + * + * `files` is only populated when the user passes `--files` and lists every + * path that contributed to `count`/`bytes`, in alphabetical order, expressed + * relative to `distDir` (so traced node_modules deps appear as `../...`). + */ +interface CategoryStatsWithShared extends CategoryStats { + sharedAvg: SharedStats | null + files?: string[] +} + +type CategoryStatsByKey = Record +type CategoryStatsWithSharedByKey = Record + +interface RouteInfo extends CategoryStatsWithSharedByKey { + route: string + type: string +} + +function emptyFileSets(): FileSets { + const sets = {} as FileSets + for (const cat of CATEGORIES) sets[cat] = new Set() + return sets +} + +// --------------------------------------------------------------------------- +// Route discovery +// --------------------------------------------------------------------------- + +/** + * One discovered route. Discriminated by `type`; the additional fields + * carry whatever the file collector needs to find this route's files. + */ +type RouteEntry = + /** + * Server-rendered Pages or App route. The `runtime` field describes how + * its bundle is laid out: + * - `node`: a `.js` server entry plus a sibling `.nft.json` listing all + * runtime dependencies (the standard production bundle). + * - `edge`: no `.nft.json` — the bundle's chunks are listed directly + * in `middleware-manifest.json#functions[].files`. + */ + | { + type: 'app-page' | 'app-route' | 'pages' | 'pages-api' + route: string + runtime: + | { kind: 'node'; serverEntry: string } + | { kind: 'edge'; files: string[] } + } + /** Statically pre-rendered Pages page. Only ships client JS. */ + | { type: 'pages-static'; route: string } + /** + * Edge middleware (the `middleware.ts` file). Sourced from + * `middleware-manifest.json#middleware[].files`. There's at most one + * middleware per project, but the manifest format supports several keys. + */ + | { type: 'middleware'; route: string; files: string[] } + +/** + * Pages Router infrastructure entries we never report as routes. + * `_app` / `_document` / `_error` aren't really routes, and `404` / `500` + * are HTML-only error pages. + */ +const SKIP_PAGES_ENTRIES = new Set([ + '/_app', + '/_document', + '/_error', + '/404', + '/500', +]) + +/** App Router infrastructure entries we never report as routes. */ +const SKIP_APP_ENTRIES = new Set(['/_global-error/page']) + +function readJsonFile(filePath: string): T | null { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T + } catch { + return null + } +} + +interface MiddlewareManifestEntry { + files: string[] + page?: string + name?: string +} + +interface MiddlewareManifest { + functions?: Record + middleware?: Record +} + +function discoverRoutes(distDir: string): RouteEntry[] { + const middlewareManifest = readJsonFile( + path.join(distDir, 'server', 'middleware-manifest.json') + ) + // Edge route handlers (per-route runtime: 'edge') live in `functions`. + // Their key matches either an app-paths-manifest internal key (e.g. + // `/api/edge/route`) or a pages-manifest route (e.g. `/api/edge` for + // pages-router edge APIs). We use these inside the pages/app discovery + // below to pick `runtime: edge` instead of node and get the bundled-file + // list from middleware-manifest rather than `.nft.json`. + const edgeFunctions = middlewareManifest?.functions ?? {} + + return [ + ...discoverPagesRoutes(distDir, edgeFunctions), + ...discoverAppRoutes(distDir, edgeFunctions), + ...discoverMiddleware(middlewareManifest?.middleware ?? {}), + ] +} + +function discoverPagesRoutes( + distDir: string, + edgeFunctions: Record +): RouteEntry[] { + const manifest = readJsonFile>( + path.join(distDir, 'server', 'pages-manifest.json') + ) + if (!manifest) return [] + + const routes: RouteEntry[] = [] + for (const [route, entry] of Object.entries(manifest)) { + if (SKIP_PAGES_ENTRIES.has(route)) continue + const isApi = route.startsWith('/api/') + const edge = edgeFunctions[route] + if (edge) { + // Edge runtime — bundle files come from middleware-manifest, not nft. + routes.push({ + type: isApi ? 'pages-api' : 'pages', + route, + runtime: { kind: 'edge', files: edge.files }, + }) + } else if (entry.endsWith('.js')) { + routes.push({ + type: isApi ? 'pages-api' : 'pages', + route, + runtime: { kind: 'node', serverEntry: entry }, + }) + } else if (entry.endsWith('.html')) { + // Statically pre-rendered page — no server JS bundle, but still ships + // client JS via build-manifest.json. + routes.push({ type: 'pages-static', route }) + } + } + return routes +} + +function discoverAppRoutes( + distDir: string, + edgeFunctions: Record +): RouteEntry[] { + const appPathsManifest = readJsonFile>( + path.join(distDir, 'server', 'app-paths-manifest.json') + ) + if (!appPathsManifest) return [] + + // Maps internal entry keys (e.g. "/blog/[slug]/page") to their URL path + // ("/blog/[slug]"). Optional — falls back to the internal key if missing. + const appPathRoutesManifest = readJsonFile>( + path.join(distDir, 'app-path-routes-manifest.json') + ) + + const routes: RouteEntry[] = [] + for (const [internalKey, entry] of Object.entries(appPathsManifest)) { + if (SKIP_APP_ENTRIES.has(internalKey)) continue + const type = internalKey.endsWith('/route') ? 'app-route' : 'app-page' + const route = appPathRoutesManifest?.[internalKey] ?? internalKey + const edge = edgeFunctions[internalKey] + if (edge) { + // Edge runtime — turbopack writes a placeholder entry value (e.g. + // `app-edge-has-no-entrypoint`) here, while webpack writes a real .js + // path; either way the actual bundle files come from the + // middleware-manifest entry. + routes.push({ + type, + route, + runtime: { kind: 'edge', files: edge.files }, + }) + } else if (entry.endsWith('.js')) { + routes.push({ + type, + route, + runtime: { kind: 'node', serverEntry: entry }, + }) + } + } + return routes +} + +function discoverMiddleware( + middleware: Record +): RouteEntry[] { + // The middleware manifest keys an entry by `/` for the project's + // `middleware.ts`. We use the entry's `name` (e.g. "middleware") as the + // displayed route, since `/` would collide with an app-page at "/". + return Object.entries(middleware).map(([key, def]) => ({ + type: 'middleware' as const, + route: def.name ?? key, + files: def.files, + })) +} + +// --------------------------------------------------------------------------- +// File collection +// --------------------------------------------------------------------------- + +/** + * Strip the `_next/` URL prefix that some manifests use (with or without a + * leading slash) so all client paths are consistently relative to `distDir`. + */ +function stripNextPrefix(p: string): string { + return p.replace(/^\/?_next\//, '') +} + +/** + * Walk the entry's `.nft.json` (Node File Trace) and partition its files: + * - `.map` files → `serverMaps` (regardless of in/out of distDir) + * - other `.js` files inside distDir → `serverBundled` (server chunks) + * - any other file outside distDir → `serverUnbundled` (traced node_modules + * and other on-disk deps the server entry needs at runtime) + * + * Files inside distDir that are neither `.js` nor `.map` (e.g. `.json` + * manifests, `_client-reference-manifest.js`) are skipped — they're either + * bundler bookkeeping or already accounted for elsewhere. + */ +function collectServerEntryFiles( + distDir: string, + serverEntry: string, + sets: FileSets +): void { + const entryRel = path.join('server', serverEntry) // e.g. server/app/page.js + const entryDirRel = path.dirname(entryRel) // e.g. server/app + const entryDirAbs = path.join(distDir, entryDirRel) + + // The entry .js is always part of the bundle, even if no nft.json exists. + sets.serverBundled.add(entryRel) + + const nft = readJsonFile<{ files: string[] }>( + path.join(distDir, entryRel + '.nft.json') + ) + if (!nft?.files) return + + for (const relPath of nft.files) { + // Resolve relative to the entry's dir. If the normalized result stays + // inside distDir it's a server chunk; if it leaves distDir it's an + // unbundled trace dep (e.g. ../../../node_modules/...). + const inDistDirPath = path.normalize(path.join(entryDirRel, relPath)) + const outsideDistDir = inDistDirPath.startsWith('..') + const isMap = inDistDirPath.endsWith('.map') + if (isMap) { + // Source maps go into the maps category whether they're in or outside + // distDir, so they don't double-count under serverUnbundled. + sets.serverMaps.add( + outsideDistDir ? path.resolve(entryDirAbs, relPath) : inDistDirPath + ) + } else if (outsideDistDir) { + sets.serverUnbundled.add(path.resolve(entryDirAbs, relPath)) + } else if ( + inDistDirPath.endsWith('.js') && + !inDistDirPath.endsWith('_client-reference-manifest.js') + ) { + sets.serverBundled.add(inDistDirPath) + } + } +} + +/** + * Read a `_client-reference-manifest.js` file and extract the JSON blob. + * + * The file is a JS module that assigns a JSON object to a global. The exact + * shape varies by bundler: + * + * Turbopack (with optional suffix that re-writes `clientModules[k] = val` + * when a deployment ID is set): + * globalThis.__RSC_MANIFEST = globalThis.__RSC_MANIFEST || {}; + * globalThis.__RSC_MANIFEST["/page"] = {...}; + * for (const key in globalThis.__RSC_MANIFEST["/page"].clientModules) { + * globalThis.__RSC_MANIFEST["/page"].clientModules[key] = val; + * ... + * } + * + * Webpack (no spaces around `=`, single line): + * globalThis.__RSC_MANIFEST=(globalThis.__RSC_MANIFEST||{});globalThis.__RSC_MANIFEST["/page"]={...}; + * + * Rather than evaluating user-bundled code, locate the FIRST + * `globalThis.__RSC_MANIFEST[` occurrence (which is always the entry-key + * assignment — the `MANIFEST = MANIFEST || {}` boilerplate has no `[` + * after the global). Then properly walk the JS string literal that holds + * the entry name (handling escapes), so route names containing `]` — + * e.g. dynamic segments like `[teamSlug]` or route groups inside dynamic + * params — don't terminate the bracket early. After the closing `]` we + * expect `=` then `{`, and balance-walk the object body. + * + * Returns `null` only when the file doesn't exist (a normal case for + * server entries that have no client-reference manifest, e.g. middleware + * or non-app routes). Any structural surprise — manifest header missing, + * unterminated string/object, or invalid JSON — throws so we never + * silently undercount client JS/CSS. + */ +function parseClientReferenceManifest(filePath: string): { + entryJSFiles?: Record + entryCSSFiles?: Record> + clientModules?: Record +} | null { + let content: string + try { + content = fs.readFileSync(filePath, 'utf8') + } catch { + return null + } + + const ANCHOR = 'globalThis.__RSC_MANIFEST[' + const anchorIdx = content.indexOf(ANCHOR) + if (anchorIdx === -1) { + throw new Error( + `Could not find 'globalThis.__RSC_MANIFEST[' in ${filePath}; client reference manifest format may have changed.` + ) + } + + // Walk a JS string literal starting at `i` (which must point at the + // opening quote). Returns the index just past the closing quote. + const skipString = (i: number): number => { + const quote = content[i] + if (quote !== '"' && quote !== "'") { + throw new Error( + `Expected string literal as entry name in ${filePath} at offset ${i}, got ${JSON.stringify(content[i])}.` + ) + } + i++ + let escape = false + while (i < content.length) { + const ch = content[i] + if (escape) { + escape = false + } else if (ch === '\\') { + escape = true + } else if (ch === quote) { + return i + 1 + } + i++ + } + throw new Error(`Unterminated entry-name string literal in ${filePath}.`) + } + + // Skip whitespace forward from `i` and assert the next char is `expected`. + const expectChar = (i: number, expected: string): number => { + while (i < content.length && /\s/.test(content[i])) i++ + if (content[i] !== expected) { + throw new Error( + `Expected '${expected}' after entry name in ${filePath} at offset ${i}, got ${JSON.stringify(content[i] ?? '')}.` + ) + } + return i + 1 + } + + // After `globalThis.__RSC_MANIFEST[` walk: ] = { + let i = skipString(anchorIdx + ANCHOR.length) + i = expectChar(i, ']') + i = expectChar(i, '=') + while (i < content.length && /\s/.test(content[i])) i++ + if (content[i] !== '{') { + throw new Error( + `Expected '{' after 'globalThis.__RSC_MANIFEST[...] =' in ${filePath} at offset ${i}, got ${JSON.stringify(content[i] ?? '')}.` + ) + } + + // Balance-walk `{...}`, ignoring `{` / `}` inside string literals so a + // CSS path like "{foo}" inside JSON doesn't throw the count off. + const start = i + let depth = 0 + let inString = false + let quote = '' + let escape = false + for (; i < content.length; i++) { + const ch = content[i] + if (inString) { + if (escape) { + escape = false + } else if (ch === '\\') { + escape = true + } else if (ch === quote) { + inString = false + } + continue + } + if (ch === '"' || ch === "'") { + inString = true + quote = ch + } else if (ch === '{') { + depth++ + } else if (ch === '}') { + depth-- + if (depth === 0) { + const body = content.slice(start, i + 1) + try { + return JSON.parse(body) + } catch (err) { + throw new Error( + `Failed to parse JSON body of ${filePath}: ${(err as Error).message}` + ) + } + } + } + } + throw new Error(`Unterminated JSON object in ${filePath}.`) +} + +/** + * Add a chunk path to the right client-side category. Strips a `?dpl=...` + * (or any other) query suffix that webpack appends, then dispatches by + * extension. Returns true if the path looked like a real asset (had an + * extension we recognize) — used by the webpack `clientModules` walker + * below to filter out chunk IDs. + */ +function addClientChunk(rawPath: string, sets: FileSets): boolean { + // Some manifests append `?dpl=ID` to chunk URLs. + const cleaned = stripNextPrefix(rawPath).split('?')[0] + if (cleaned.endsWith('.map')) sets.clientMaps.add(cleaned) + else if (cleaned.endsWith('.css')) sets.clientCss.add(cleaned) + else if (cleaned.endsWith('.js')) sets.clientJs.add(cleaned) + else return false + return true +} + +/** + * Collect client JS chunks and CSS files for an App Router page/route. + * Source priority: + * 1. `entryJSFiles` from the route's `_client-reference-manifest.js` + * (Turbopack-only field — explicit list of all JS files needed for + * the entry's segments). + * 2. As a fallback, walk `clientModules[*].chunks` (the canonical client- + * reference list, populated by both bundlers). This picks up the + * chunks for any actual `'use client'` components imported by the + * route. Note webpack interleaves chunkIds with file names — the + * extension filter in `addClientChunk` skips the IDs. + * + * Plus `entryCSSFiles` for CSS, and the per-route `build-manifest.json` + * (Turbopack only) for shared App Router root chunks. + * + * `.map` paths are routed to `clientMaps`, `.css` to `clientCss`, `.js` to + * `clientJs` — anything else is dropped. + */ +function collectAppClientFiles( + distDir: string, + serverEntry: string, + sets: FileSets +): void { + const entryDir = path.dirname(serverEntry) + const entryBase = path.basename(serverEntry, '.js') + const baseDir = path.join(distDir, 'server', entryDir) + + const crm = parseClientReferenceManifest( + path.join(baseDir, `${entryBase}_client-reference-manifest.js`) + ) + if (crm) { + if (crm.entryJSFiles) { + // Turbopack: explicit per-segment chunk list. + for (const chunks of Object.values(crm.entryJSFiles)) { + for (const chunk of chunks) addClientChunk(chunk, sets) + } + } else if (crm.clientModules) { + // Webpack: no entryJSFiles — walk clientModules. Each entry's + // `chunks` array is `[chunkId, fileName, chunkId, fileName, ...]` + // (alternating). `addClientChunk` filters by extension so chunkIds + // (which have no extension) are dropped automatically. + for (const mod of Object.values(crm.clientModules)) { + for (const chunk of mod.chunks ?? []) { + if (typeof chunk === 'string') addClientChunk(chunk, sets) + } + } + } + for (const cssFiles of Object.values(crm.entryCSSFiles ?? {})) { + for (const css of cssFiles) { + const cssPath = typeof css === 'string' ? css : css.path + if (cssPath) addClientChunk(cssPath, sets) + } + } + } + + // Add the App Router framework / main-app chunks shared across every + // app-page. Both bundlers list them in the global `build-manifest.json` + // under `rootMainFiles`. Turbopack also writes a per-route + // `build-manifest.json` containing the same files; webpack does not. + const globalBm = readJsonFile<{ rootMainFiles?: string[] }>( + path.join(distDir, 'build-manifest.json') + ) + for (const chunk of globalBm?.rootMainFiles ?? []) addClientChunk(chunk, sets) +} + +/** + * Collect client JS for a Pages Router route. The global `build-manifest.json` + * lists each page's chunks (`pages[route]`), the shared baseline (`/_app`), + * and `polyfillFiles`. Per-page CSS is not tracked in the Pages build output, + * so it's not collected here. `.map` paths are routed to `clientMaps` + * defensively. + */ +function collectPagesClientFiles( + distDir: string, + route: string, + sets: FileSets +): void { + const bm = readJsonFile<{ + pages?: Record + polyfillFiles?: string[] + }>(path.join(distDir, 'build-manifest.json')) + if (!bm) return + const chunks = [ + ...(bm.pages?.['/_app'] ?? []), + ...(bm.pages?.[route] ?? []), + ...(bm.polyfillFiles ?? []), + ] + for (const chunk of chunks) { + if (chunk.endsWith('.map')) sets.clientMaps.add(chunk) + else sets.clientJs.add(chunk) + } +} + +/** + * For each file in `source`, find its source map (if any) and add it to + * `target`. We try two strategies, in order: + * + * 1. Read the `//# sourceMappingURL=...` trailer that bundlers emit at + * the end of `.js` / `.css` files. This is the most accurate way + * because the URL filename can differ from the source filename + * (e.g. Turbopack hashes `.map` content separately). + * 2. If no trailer is present (e.g. tiny "loader" entry files Turbopack + * emits without a comment), fall back to a co-located `.map`. + * + * Only same-directory relative URLs are followed — `data:` URLs (inline + * source maps) and absolute URLs are ignored. + * + * `urlCache` memoizes the trailer read across routes: a chunk shared by N + * routes is only opened once. + */ +function deriveSourceMaps( + distDir: string, + source: Set, + target: Set, + urlCache: Map +): void { + for (const f of source) { + const fullPath = path.isAbsolute(f) ? f : path.join(distDir, f) + let mapFromUrl = urlCache.get(fullPath) + if (mapFromUrl === undefined) { + mapFromUrl = readSourceMappingURL(fullPath) + urlCache.set(fullPath, mapFromUrl) + } + if (mapFromUrl) { + // Resolve relative to the source file's directory, then re-express + // relative to distDir so paths join consistently. + const mapRel = path.normalize(path.join(path.dirname(f), mapFromUrl)) + if ( + !mapRel.startsWith('..') && + fs.existsSync(path.join(distDir, mapRel)) + ) { + target.add(mapRel) + continue + } + } + // Fallback: co-located `.map`. + const adjacent = f + '.map' + const adjacentFull = path.isAbsolute(adjacent) + ? adjacent + : path.join(distDir, adjacent) + if (fs.existsSync(adjacentFull)) target.add(adjacent) + } +} + +/** + * Read the trailing `//# sourceMappingURL=...` (JS) or `/*# sourceMappingURL=... *​/` + * (CSS) comment from a file and return the URL, or null if absent or + * inline (`data:`). + * + * We only need to read the tail of the file — the comment is conventionally + * the very last line — so reading 4 KiB is more than enough. + */ +function readSourceMappingURL(filePath: string): string | null { + let fd: number + try { + fd = fs.openSync(filePath, 'r') + } catch { + return null + } + try { + const stat = fs.fstatSync(fd) + const len = Math.min(stat.size, 4096) + const buf = Buffer.alloc(len) + fs.readSync(fd, buf, 0, len, stat.size - len) + const tail = buf.toString('utf8') + // Match either `//# sourceMappingURL=` or + // `/*# sourceMappingURL= */` near the end. + const match = tail.match(/[/*]#\s*sourceMappingURL=([^\s'"*]+)/) + if (!match) return null + const url = match[1] + if (url.startsWith('data:')) return null + // Skip absolute URLs (http://, https://, /abs). + if (/^[a-z]+:\/\//i.test(url) || url.startsWith('/')) return null + return url + } catch { + return null + } finally { + fs.closeSync(fd) + } +} + +/** + * Collect bundled `.js` files for an edge runtime entry. Edge bundles don't + * have a `.nft.json`; their files are listed inline by middleware-manifest. + * `.map` files are routed to `serverMaps` so they don't pollute the bundle + * count; other extensions (manifest .json siblings) are dropped. + */ +function collectEdgeFiles(files: string[], sets: FileSets): void { + for (const f of files) { + if (f.endsWith('.js')) sets.serverBundled.add(f) + else if (f.endsWith('.map')) sets.serverMaps.add(f) + } +} + +/** Collect all 6 file-sets for a single route. */ +function collectFiles( + distDir: string, + entry: RouteEntry, + urlCache: Map +): FileSets { + const sets = emptyFileSets() + + switch (entry.type) { + case 'middleware': + // Middleware always runs in the edge runtime; same shape as edge + // route handlers (inline files list). + collectEdgeFiles(entry.files, sets) + break + case 'pages-static': + collectPagesClientFiles(distDir, entry.route, sets) + break + case 'pages': + case 'pages-api': + case 'app-page': + case 'app-route': + // Server bundle: node entries are traced via .nft.json; edge entries + // list their bundle files directly in the middleware-manifest. + if (entry.runtime.kind === 'node') { + collectServerEntryFiles(distDir, entry.runtime.serverEntry, sets) + } else { + collectEdgeFiles(entry.runtime.files, sets) + } + // Client-side: pages-router uses the global build-manifest; + // app-router pages use the per-route _client-reference-manifest plus + // shared `rootMainFiles` from the global build-manifest. App-router + // route handlers (`app-route`) and edge runtime entries don't ship + // client JS — skip client collection there. + if (entry.type === 'pages') { + collectPagesClientFiles(distDir, entry.route, sets) + } else if (entry.type === 'app-page' && entry.runtime.kind === 'node') { + collectAppClientFiles(distDir, entry.runtime.serverEntry, sets) + } + break + default: + // Exhaustiveness check — TS will error here if a new RouteEntry + // variant is added without a matching case. + entry satisfies never + } + + // Source maps for everything we collected above. Both .js.map and + // .css.map files are picked up by reading the `sourceMappingURL` + // trailer of each source file. + deriveSourceMaps(distDir, sets.serverBundled, sets.serverMaps, urlCache) + deriveSourceMaps(distDir, sets.clientJs, sets.clientMaps, urlCache) + deriveSourceMaps(distDir, sets.clientCss, sets.clientMaps, urlCache) + + return sets +} + +// --------------------------------------------------------------------------- +// Measurement +// --------------------------------------------------------------------------- + +/** + * File-size cache, keyed by the stored path string (relative to `distDir` + * for in-distDir files, absolute otherwise). `null` means the path doesn't + * resolve to a regular file (symlink, missing, directory, etc.) and should + * be excluded from counts. + * + * A single shared cache covers all categories: a path appears in only one + * category by design, but using one map keeps the API simple and ensures we + * stat each unique file at most once across the whole tool run. + */ +type SizeCache = Map + +/** + * Stat every unique file across every route's file sets and cache the size. + * Symlinks and non-files (directories, etc.) are recorded as `null` so we + * don't re-stat and so they're excluded from later counts. + */ +function buildSizeCache(distDir: string, allFileSets: FileSets[]): SizeCache { + const cache: SizeCache = new Map() + for (const sets of allFileSets) { + for (const cat of CATEGORIES) { + for (const f of sets[cat]) { + if (cache.has(f)) continue + const fullPath = path.isAbsolute(f) ? f : path.join(distDir, f) + try { + const stat = fs.lstatSync(fullPath) + cache.set( + f, + stat.isFile() && !stat.isSymbolicLink() ? stat.size : null + ) + } catch { + cache.set(f, null) + } + } + } + } + return cache +} + +/** Sum sizes for the files in `set` using the precomputed cache. */ +function measureFromCache(set: Set, cache: SizeCache): CategoryStats { + let count = 0 + let bytes = 0 + for (const f of set) { + const size = cache.get(f) + if (size != null) { + count++ + bytes += size + } + } + return { count, bytes } +} + +function measureFileSets(sets: FileSets, cache: SizeCache): CategoryStatsByKey { + const result = {} as CategoryStatsByKey + for (const cat of CATEGORIES) { + result[cat] = measureFromCache(sets[cat], cache) + } + return result +} + +/** + * For each category, compute the average size of the intersection of this + * route's files with each peer route's files (a "peer" is another route of + * the same `type`). Returns `null` per category if there are no peers. + * + * Files counted multiple times across peers contribute to the average each + * time — e.g. if a chunk is shared with all 5 peers, it contributes 5×size + * to the sum, then we divide by 5 to get the average. + */ +function measureSharedAvg( + routeIndex: number, + allFileSets: FileSets[], + routeEntries: RouteEntry[], + cache: SizeCache +): Record { + const myType = routeEntries[routeIndex].type + const peers: number[] = [] + for (let j = 0; j < routeEntries.length; j++) { + if (j !== routeIndex && routeEntries[j].type === myType) peers.push(j) + } + + const result = {} as Record + for (const cat of CATEGORIES) { + if (peers.length === 0) { + result[cat] = null + continue + } + const mySet = allFileSets[routeIndex][cat] + let sumCount = 0 + let sumBytes = 0 + for (const j of peers) { + const peerSet = allFileSets[j][cat] + // Iterate the smaller set and probe the larger; saves work when sizes + // differ a lot (e.g. an empty serverUnbundled vs a big one). + const [small, big] = + mySet.size <= peerSet.size ? [mySet, peerSet] : [peerSet, mySet] + for (const f of small) { + if (!big.has(f)) continue + const size = cache.get(f) + if (size != null) { + sumCount++ + sumBytes += size + } + } + } + result[cat] = { + count: sumCount / peers.length, + bytes: sumBytes / peers.length, + } + } + return result +} + +/** Union of all per-route file sets. Used to compute project-wide totals. */ +function mergeSets(all: FileSets[]): FileSets { + const merged = emptyFileSets() + for (const sets of all) { + for (const cat of CATEGORIES) { + for (const f of sets[cat]) merged[cat].add(f) + } + } + return merged +} + +function totalBytes(stats: CategoryStatsByKey): number { + let sum = 0 + for (const cat of CATEGORIES) sum += stats[cat].bytes + return sum +} + +/** + * Compute the byte total a route should be ordered by, for a given sort key. + * `name` is special-cased by the caller; every other key returns a numeric + * total that sorts descending. + */ +function sortValue(r: RouteInfo, key: Exclude): number { + switch (key) { + case 'client': + return r.clientJs.bytes + r.clientCss.bytes + case 'client-js': + return r.clientJs.bytes + case 'client-css': + return r.clientCss.bytes + case 'client-map': + return r.clientMaps.bytes + case 'server': + return r.serverBundled.bytes + r.serverUnbundled.bytes + case 'server-bundled-js': + return r.serverBundled.bytes + case 'server-unbundled': + return r.serverUnbundled.bytes + case 'server-map': + return r.serverMaps.bytes + case 'total': + return totalBytes(r) + default: + key satisfies never + throw new Error(`unreachable sort key: ${key as string}`) + } +} + +/** + * Sort `routes` in-place by the given key. `name` sorts ascending + * alphabetically; every other key sorts descending by byte total, with a + * stable tiebreaker on the route name (so two routes with identical sizes + * always appear in the same order). + */ +function sortRoutes(routes: RouteInfo[], key: SortKey): void { + if (key === 'name') { + routes.sort((a, b) => a.route.localeCompare(b.route)) + return + } + routes.sort( + (a, b) => + sortValue(b, key) - sortValue(a, key) || a.route.localeCompare(b.route) + ) +} + +/** + * Convert an internal path to one expressed relative to `distDir`. Paths + * already relative are passed through; absolute paths (traced node_modules + * deps) are rewritten so the output JSON is independent of the user's + * absolute filesystem layout. + */ +function toDistRelative(distDir: string, p: string): string { + return path.isAbsolute(p) ? path.relative(distDir, p) : p +} + +/** + * Sorted, dist-relative file list for a single category, used when + * `--files` is enabled. Entries with `null` in the size cache (symlinks, + * directories, missing files) are filtered out so the list stays in sync + * with `count` (which excludes them too). Sorting keeps JSON output + * deterministic across runs / platforms. + */ +function fileListFor( + distDir: string, + set: Set, + sizeCache: SizeCache +): string[] { + const out: string[] = [] + for (const p of set) { + if (sizeCache.get(p) != null) out.push(toDistRelative(distDir, p)) + } + return out.sort() +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +function formatBytes(n: number): string { + if (n >= 1024 * 1024) return (n / (1024 * 1024)).toFixed(2) + ' MB' + if (n >= 1024) return (n / 1024).toFixed(2) + ' KB' + return Math.round(n) + ' B' +} + +/** File counts can be fractional in averages — print 1 decimal in that case. */ +function formatCount(n: number): string { + return Number.isInteger(n) ? `${n}` : n.toFixed(1) +} + +function formatCell(stats: CategoryStats): string { + // Render empty cells as `-` rather than `0 files / 0 B`. The vast + // majority of cells in a typical app have *some* content for every + // category — when a cell IS empty (e.g. a route handler ships no client + // JS) the placeholder makes the table much easier to scan visually + // because non-zero values stand out. + if (stats.count === 0 && stats.bytes === 0) return '-' + return `${formatCount(stats.count)} files / ${formatBytes(stats.bytes)}` +} + +/** + * Cell for the "Shared" table: returns "n/a" if a route has no peers (i.e. + * `stats` is `null`), otherwise the same `count files / bytes` rendering as + * the routes table, augmented with the percentage of own count/bytes that + * the average shared portion represents — e.g. `5 files (83%) / 1.2 MB (40%)`. + * + * Empty intersections render as `-` for the same readability reason as + * `formatCell`. `n/a` (no peers) is preserved separately because it has a + * different meaning from "shared with peers but the intersection is empty". + */ +function formatSharedCell(stats: SharedStats | null): string { + if (stats == null) return 'n/a' + if (stats.count === 0 && stats.bytes === 0) return '-' + return ( + `${formatCount(stats.count)} files (${Math.round(stats.percentCount)}%)` + + ` / ${formatBytes(stats.bytes)} (${Math.round(stats.percentBytes)}%)` + ) +} + +/** + * Compute the percent-shared annotation for a (own, sharedAvg) pair. + * Returns `null` unchanged when the route has no peers; otherwise extends + * the raw {count, bytes} averages with `percentCount` and `percentBytes`. + * Avoids 0/0 by returning 0 when own.count or own.bytes is 0 (the + * intersection of an empty set with anything is also 0, so 0% is a + * coherent answer rather than NaN). + */ +function annotateShared( + own: CategoryStats, + shared: CategoryStats | null +): SharedStats | null { + if (shared == null) return null + return { + count: shared.count, + bytes: shared.bytes, + percentCount: own.count > 0 ? (shared.count / own.count) * 100 : 0, + percentBytes: own.bytes > 0 ? (shared.bytes / own.bytes) * 100 : 0, + } +} + +/** Render a fixed-width markdown table — pads each cell to align columns. */ +function renderMarkdownTable(headers: string[], rows: string[][]): string { + const widths = headers.map((h, i) => + Math.max(h.length, ...rows.map((r) => r[i].length)) + ) + const formatRow = (cells: string[]) => + '| ' + cells.map((c, i) => c.padEnd(widths[i])).join(' | ') + ' |' + const divider = '| ' + widths.map((w) => '-'.repeat(w)).join(' | ') + ' |' + return [formatRow(headers), divider, ...rows.map(formatRow)].join('\n') +} + +function printMarkdown(routes: RouteInfo[], totals: CategoryStatsByKey): void { + const categoryHeaders = CATEGORIES.map((c) => CATEGORY_LABELS[c]) + + const routeRows = routes.map((r) => [ + r.route, + r.type, + ...CATEGORIES.map((c) => formatCell(r[c])), + ]) + console.log('## Routes\n') + console.log( + renderMarkdownTable(['Route', 'Type', ...categoryHeaders], routeRows) + ) + + // Shared (averaged across peers of same type) — printed in the same row + // order as the routes table. Routes with no peers show `n/a`. + const sharedRows = routes.map((r) => [ + r.route, + r.type, + ...CATEGORIES.map((c) => formatSharedCell(r[c].sharedAvg)), + ]) + console.log('\n## Shared (avg per other route of same type)\n') + console.log( + renderMarkdownTable(['Route', 'Type', ...categoryHeaders], sharedRows) + ) + + const totalsRow = [ + '**Total**', + ...CATEGORIES.map((c) => formatCell(totals[c])), + ] + console.log('\n## Totals\n') + console.log(renderMarkdownTable(['', ...categoryHeaders], [totalsRow])) +} + +function printJson(routes: RouteInfo[], totals: CategoryStatsByKey): void { + console.log(JSON.stringify({ routes, totals }, null, 2)) +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export async function staticRoutesInfoCli( + options: StaticRoutesInfoOptions, + directory: string | undefined +): Promise { + // Validate options up front so we fail fast with a clear error before + // doing any expensive work (loading config, reading manifests). + const sortKey: SortKey = options.sort + ? (SORT_KEYS as readonly string[]).includes(options.sort) + ? (options.sort as SortKey) + : (() => { + console.error( + `Error: invalid --sort key '${options.sort}'. Valid keys: ${SORT_KEYS.join(', ')}.` + ) + process.exit(1) + })() + : 'name' + if (options.files && !options.json) { + console.error('Error: --files requires --json.') + process.exit(1) + } + + const dir = path.resolve(directory ?? process.cwd()) + const config = await loadConfig(PHASE_PRODUCTION_BUILD, dir) + const distDir = path.join(dir, config.distDir) + + // BUILD_ID is the standard sentinel that a Next.js build completed. + if (!fs.existsSync(path.join(distDir, 'BUILD_ID'))) { + console.error( + `Error: No build found at ${distDir}. Run \`next build\` first.` + ) + process.exit(1) + } + + // Step 1+2: capture per-route files (sets implicitly deduplicate). The + // `urlCache` memoizes `sourceMappingURL` reads — a chunk shared by N + // routes only opens its file once. + const routeEntries = discoverRoutes(distDir) + const urlCache = new Map() + const allFileSets = routeEntries.map((entry) => + collectFiles(distDir, entry, urlCache) + ) + + // Step 3a: stat every unique file once and cache the size, so per-route + // measurement and shared-avg calculation don't repeat syscalls. + const sizeCache = buildSizeCache(distDir, allFileSets) + + // Step 3b: measure per-route. Each category also carries a `sharedAvg` + // against its same-type peers; under `--files` it also carries the + // dist-relative file list that contributed to the metric. + const routeInfos: RouteInfo[] = routeEntries.map((entry, i) => { + const stats = measureFileSets(allFileSets[i], sizeCache) + const shared = measureSharedAvg(i, allFileSets, routeEntries, sizeCache) + const merged = {} as CategoryStatsWithSharedByKey + for (const cat of CATEGORIES) { + merged[cat] = { + ...stats[cat], + sharedAvg: annotateShared(stats[cat], shared[cat]), + } + if (options.files) { + merged[cat].files = fileListFor(distDir, allFileSets[i][cat], sizeCache) + } + } + return { route: entry.route, type: entry.type, ...merged } + }) + sortRoutes(routeInfos, sortKey) + + // Project-wide totals — union of all route sets, regardless of --limit. + const mergedSets = mergeSets(allFileSets) + const totals = measureFileSets(mergedSets, sizeCache) + if (options.files) { + for (const cat of CATEGORIES) { + totals[cat].files = fileListFor(distDir, mergedSets[cat], sizeCache) + } + } + + const displayRoutes = + options.limit != null && options.limit > 0 + ? routeInfos.slice(0, options.limit) + : routeInfos + + if (options.json) { + printJson(displayRoutes, totals) + } else { + printMarkdown(displayRoutes, totals) + } +} diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 20171ba9d3f7..047691cafd3c 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -6012,8 +6012,6 @@ async function validateStagedShell( workStore, preludeIsEmpty ? PreludeState.Empty : PreludeState.Full, dynamicValidation, - // TODO(instant-validation): if allowEmptyStaticShell is true (likely due to blocking configs), - // we should probably just skip this altogether allowEmptyStaticShell ) } catch (thrownValue) { @@ -6023,8 +6021,6 @@ async function validateStagedShell( workStore, PreludeState.Errored, dynamicValidation, - // TODO(instant-validation): if allowEmptyStaticShell is true (likely due to blocking configs), - // we should probably just skip this altogether allowEmptyStaticShell ) @@ -7843,17 +7839,13 @@ async function prerenderToStream( const { prelude, preludeIsEmpty } = await processPreludeOp(unprocessedPrelude) - // If we've disabled throwing on empty static shell, then we don't need to - // track any dynamic access that occurs above the suspense boundary because - // we'll do so in the route shell. - if (!allowEmptyStaticShell) { - throwIfDisallowedDynamic( - workStore, - preludeIsEmpty ? PreludeState.Empty : PreludeState.Full, - dynamicValidation, - serverDynamicTracking - ) - } + throwIfDisallowedDynamic( + workStore, + preludeIsEmpty ? PreludeState.Empty : PreludeState.Full, + dynamicValidation, + serverDynamicTracking, + allowEmptyStaticShell + ) const getServerInsertedHTML = makeGetServerInsertedHTML({ polyfills, diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index dcd8444b7ec9..93a3b649e01c 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -790,6 +790,42 @@ function resolveInstantStack( return slotStacks[0] ?? null } +/** + * Inspects the component stack of an outlet boundary to discover whether the + * user placed a Suspense boundary above the document body, and records the + * opt-in on `dynamicValidation.hasSuspenseAboveBody` if so. + * + * The outlet itself isn't a meaningful source of dynamic — it only resolves + * when metadata/viewport are dynamic, which we track via their own boundaries. + * However, the outlet renders alongside the page content, so its stack passes + * through the user's layout chain (typically reaching into `` via the + * root layout). That makes the outlet stack our best opportunity to spot a + * Suspense boundary above the body, even when no real body content is dynamic. + * Without this, a route whose only dynamic source is `generateViewport()` would + * miss the Suspense-above-body opt-in, because the viewport's stack lives in + * the head and never sees the user's root layout. + * + * We deliberately only set `hasSuspenseAboveBody`, not `hasAllowedDynamic`. The + * latter tracks whether the body has dynamic content that's been wrapped in + * Suspense (i.e., the page is partially dynamic). The outlet rendering tells us + * about the structural opt-in for an empty shell, not about the body being + * partially dynamic. The distinction matters because dynamic metadata is only + * acceptable when the page is partially dynamic (via real body holes), and we + * don't want this outlet-based detection to mask that case. + */ +function trackOutletSuspenseAboveBody( + componentStack: string, + dynamicValidation: DynamicValidationState +): void { + if ( + hasSuspenseBeforeRootLayoutWithoutBodyOrImplicitBodyRegex.test( + componentStack + ) + ) { + dynamicValidation.hasSuspenseAboveBody = true + } +} + export function trackAllowedDynamicAccess( workStore: WorkStore, componentStack: string, @@ -797,7 +833,7 @@ export function trackAllowedDynamicAccess( clientDynamic: DynamicTrackingState ) { if (hasOutletRegex.test(componentStack)) { - // We don't need to track that this is dynamic. It is only so when something else is also dynamic. + trackOutletSuspenseAboveBody(componentStack, dynamicValidation) return } else if (hasMetadataRegex.test(componentStack)) { dynamicValidation.hasDynamicMetadata = true @@ -1058,7 +1094,7 @@ export function trackDynamicHoleInRuntimeShell( clientDynamic: DynamicTrackingState ) { if (hasOutletRegex.test(componentStack)) { - // We don't need to track that this is dynamic. It is only so when something else is also dynamic. + trackOutletSuspenseAboveBody(componentStack, dynamicValidation) return } else if (hasMetadataRegex.test(componentStack)) { const error = addErrorContext( @@ -1069,9 +1105,6 @@ export function trackDynamicHoleInRuntimeShell( dynamicValidation.dynamicMetadata = error return } else if (hasViewportRegex.test(componentStack)) { - // TODO(instant-validation): If the page only has holes caused by runtime data, - // we won't find out if there's a suspense-above-body and error for dynamic viewport - // even if there is in fact a suspense-above-body const error = addErrorContext( createDynamicViewportError(workStore.route), componentStack, @@ -1118,7 +1151,7 @@ export function trackDynamicHoleInStaticShell( clientDynamic: DynamicTrackingState ) { if (hasOutletRegex.test(componentStack)) { - // We don't need to track that this is dynamic. It is only so when something else is also dynamic. + trackOutletSuspenseAboveBody(componentStack, dynamicValidation) return } else if (hasMetadataRegex.test(componentStack)) { const error = addErrorContext( @@ -1213,7 +1246,8 @@ export function throwIfDisallowedDynamic( workStore: WorkStore, prelude: PreludeState, dynamicValidation: DynamicValidationState, - serverDynamic: DynamicTrackingState + serverDynamic: DynamicTrackingState, + allowEmptyStaticShell: boolean ): void { if (serverDynamic.syncDynamicErrorWithStack) { logDisallowedDynamicError( @@ -1223,14 +1257,31 @@ export function throwIfDisallowedDynamic( throw new StaticGenBailoutError() } - if (prelude !== PreludeState.Full) { - if (dynamicValidation.hasSuspenseAboveBody) { - // This route has opted into allowing fully dynamic rendering - // by including a Suspense boundary above the body. In this case - // a lack of a shell is not considered disallowed so we simply return - return - } + // The dynamic metadata error is a mistake-detection signal. It fires when the + // rest of the shell is otherwise fully static apart from metadata, suggesting + // the dynamic data access in `generateMetadata` was probably unintentional. + // That condition is independent of whether the user or build phase accepted + // an empty shell, so we surface it before any opt-in bypass. + if ( + prelude === PreludeState.Full && + dynamicValidation.hasAllowedDynamic === false && + dynamicValidation.hasDynamicMetadata + ) { + console.error(createDynamicOrRuntimeMetadataError(workStore.route).message) + throw new StaticGenBailoutError() + } + + // Either flag expresses "this shell is allowed to be empty/blocking": + // - `allowEmptyStaticShell` covers `unstable_instant = false` (user opt-in) + // and the build-phase fallback-shell case. + // - `hasSuspenseAboveBody` is the structural opt-in inside the user's root + // layout. + // Treat them as synonyms for the purpose of bypassing shell-failure errors. + if (allowEmptyStaticShell || dynamicValidation.hasSuspenseAboveBody) { + return + } + if (prelude !== PreludeState.Full) { // We didn't have any sync bailouts but there may be user code which // blocked the root. We would have captured these during the prerender // and can log them here and then terminate the build/validating render @@ -1263,16 +1314,6 @@ export function throwIfDisallowedDynamic( ) throw new StaticGenBailoutError() } - } else { - if ( - dynamicValidation.hasAllowedDynamic === false && - dynamicValidation.hasDynamicMetadata - ) { - console.error( - createDynamicOrRuntimeMetadataError(workStore.route).message - ) - throw new StaticGenBailoutError() - } } } @@ -1280,12 +1321,29 @@ export function getStaticShellDisallowedDynamicReasons( workStore: WorkStore, prelude: PreludeState, dynamicValidation: DynamicValidationState, - configAllowsBlocking: boolean + allowEmptyStaticShell: boolean ): Array { - if (configAllowsBlocking || dynamicValidation.hasSuspenseAboveBody) { - // This route has opted into allowing fully dynamic rendering - // by including a Suspense boundary above the body. In this case - // a lack of a shell is not considered disallowed so we simply return + // The dynamic metadata error is a mistake-detection signal. It fires when the + // rest of the shell is otherwise fully static apart from metadata, suggesting + // the dynamic data access in `generateMetadata` was probably unintentional. + // That condition is independent of whether the user or build phase accepted + // an empty shell, so we surface it before any opt-in bypass. + if ( + prelude === PreludeState.Full && + dynamicValidation.hasAllowedDynamic === false && + dynamicValidation.dynamicErrors.length === 0 && + dynamicValidation.dynamicMetadata + ) { + return [dynamicValidation.dynamicMetadata] + } + + // Either flag expresses "this shell is allowed to be empty/blocking": + // - `allowEmptyStaticShell` covers `unstable_instant = false` (user opt-in) + // and the build-phase fallback-shell case. + // - `hasSuspenseAboveBody` is the structural opt-in inside the user's root + // layout. + // Treat them as synonyms for the purpose of bypassing shell-failure errors. + if (allowEmptyStaticShell || dynamicValidation.hasSuspenseAboveBody) { return [] } @@ -1308,15 +1366,6 @@ export function getStaticShellDisallowedDynamicReasons( ), ] } - } else { - // We have a prelude but we might still have dynamic metadata without any other dynamic access - if ( - dynamicValidation.hasAllowedDynamic === false && - dynamicValidation.dynamicErrors.length === 0 && - dynamicValidation.dynamicMetadata - ) { - return [dynamicValidation.dynamicMetadata] - } } // We had a non-empty prelude and there are no dynamic holes return [] diff --git a/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts b/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts index bbf7557db50d..7190fb64ccd6 100644 --- a/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts +++ b/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts @@ -388,6 +388,150 @@ describe('Cache Components Errors', () => { } }) + describe('Dynamic Metadata - Static Route With Suspense Above Body', () => { + const pathname = '/dynamic-metadata-static-with-suspense-above-body' + + if (isNextDev) { + it('should show a collapsed redbox error', async () => { + const browser = await next.browser(pathname) + + await expect(browser).toDisplayCollapsedRedbox(` + { + "code": "E1231", + "description": "Next.js encountered uncached data in generateMetadata().", + "environmentLabel": "Server", + "label": "Instant", + "source": "app/dynamic-metadata-static-with-suspense-above-body/page.tsx (2:9) @ Module.generateMetadata + > 2 | await new Promise((r) => setTimeout(r, 0)) + | ^", + "stack": [ + "Module.generateMetadata app/dynamic-metadata-static-with-suspense-above-body/page.tsx (2:9)", + ], + } + `) + }) + } else { + it('should error the build because Suspense above body is not a documented mitigation for dynamic generateMetadata', async () => { + try { + await prerender(pathname) + } catch { + // we expect the build to fail + } + + const output = getPrerenderOutput( + next.cliOutput.slice(cliOutputLength), + { isMinified: !isDebugPrerender } + ) + + if (isDebugPrerender) { + expect(output).toMatchInlineSnapshot(` + "Route "/dynamic-metadata-static-with-suspense-above-body": Next.js encountered uncached or runtime data in \`generateMetadata()\`. + + This route's metadata is blocked, but the rest of its content can be prerendered. + + Ways to fix this: + - Use a static metadata export instead of \`generateMetadata()\` + - Cache the metadata with \`"use cache"\` in \`generateMetadata()\` + - Add a dynamic data access (e.g. \`await connection()\`) to the page to render it at request time + + Learn more: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata + Error occurred prerendering page "/dynamic-metadata-static-with-suspense-above-body". Read more: https://nextjs.org/docs/messages/prerender-error + + > Export encountered errors on 1 path: + /dynamic-metadata-static-with-suspense-above-body/page: /dynamic-metadata-static-with-suspense-above-body" + `) + } else { + expect(output).toMatchInlineSnapshot(` + "Route "/dynamic-metadata-static-with-suspense-above-body": Next.js encountered uncached or runtime data in \`generateMetadata()\`. + + This route's metadata is blocked, but the rest of its content can be prerendered. + + Ways to fix this: + - Use a static metadata export instead of \`generateMetadata()\` + - Cache the metadata with \`"use cache"\` in \`generateMetadata()\` + - Add a dynamic data access (e.g. \`await connection()\`) to the page to render it at request time + + Learn more: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata + Error occurred prerendering page "/dynamic-metadata-static-with-suspense-above-body". Read more: https://nextjs.org/docs/messages/prerender-error + Export encountered an error on /dynamic-metadata-static-with-suspense-above-body/page: /dynamic-metadata-static-with-suspense-above-body, exiting the build." + `) + } + }) + } + }) + + describe('Dynamic Metadata - Static Route With instant = false', () => { + const pathname = '/dynamic-metadata-static-with-instant-false' + + if (isNextDev) { + it('should show a collapsed redbox error', async () => { + const browser = await next.browser(pathname) + + await expect(browser).toDisplayCollapsedRedbox(` + { + "code": "E1231", + "description": "Next.js encountered uncached data in generateMetadata().", + "environmentLabel": "Server", + "label": "Instant", + "source": "app/dynamic-metadata-static-with-instant-false/page.tsx (4:9) @ Module.generateMetadata + > 4 | await new Promise((r) => setTimeout(r, 0)) + | ^", + "stack": [ + "Module.generateMetadata app/dynamic-metadata-static-with-instant-false/page.tsx (4:9)", + ], + } + `) + }) + } else { + it('should error the build because instant = false is not a documented mitigation for dynamic generateMetadata', async () => { + try { + await prerender(pathname) + } catch { + // we expect the build to fail + } + + const output = getPrerenderOutput( + next.cliOutput.slice(cliOutputLength), + { isMinified: !isDebugPrerender } + ) + + if (isDebugPrerender) { + expect(output).toMatchInlineSnapshot(` + "Route "/dynamic-metadata-static-with-instant-false": Next.js encountered uncached or runtime data in \`generateMetadata()\`. + + This route's metadata is blocked, but the rest of its content can be prerendered. + + Ways to fix this: + - Use a static metadata export instead of \`generateMetadata()\` + - Cache the metadata with \`"use cache"\` in \`generateMetadata()\` + - Add a dynamic data access (e.g. \`await connection()\`) to the page to render it at request time + + Learn more: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata + Error occurred prerendering page "/dynamic-metadata-static-with-instant-false". Read more: https://nextjs.org/docs/messages/prerender-error + + > Export encountered errors on 1 path: + /dynamic-metadata-static-with-instant-false/page: /dynamic-metadata-static-with-instant-false" + `) + } else { + expect(output).toMatchInlineSnapshot(` + "Route "/dynamic-metadata-static-with-instant-false": Next.js encountered uncached or runtime data in \`generateMetadata()\`. + + This route's metadata is blocked, but the rest of its content can be prerendered. + + Ways to fix this: + - Use a static metadata export instead of \`generateMetadata()\` + - Cache the metadata with \`"use cache"\` in \`generateMetadata()\` + - Add a dynamic data access (e.g. \`await connection()\`) to the page to render it at request time + + Learn more: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata + Error occurred prerendering page "/dynamic-metadata-static-with-instant-false". Read more: https://nextjs.org/docs/messages/prerender-error + Export encountered an error on /dynamic-metadata-static-with-instant-false/page: /dynamic-metadata-static-with-instant-false, exiting the build." + `) + } + }) + } + }) + describe('Dynamic Metadata - Dynamic Route', () => { const pathname = '/dynamic-metadata-dynamic-route' @@ -487,6 +631,44 @@ describe('Cache Components Errors', () => { } }) + describe('Dynamic Viewport - Static Route With Suspense Above Body', () => { + const pathname = '/dynamic-viewport-static-with-suspense' + + if (isNextDev) { + it('should not show a collapsed redbox error', async () => { + const browser = await next.browser(pathname) + await waitForNoErrorToast(browser) + }) + } else { + it('should not error the build when generateViewport is dynamic and the root layout wraps body in Suspense', async () => { + try { + await prerender(pathname) + } catch (error) { + throw new Error('expected build not to fail', { cause: error }) + } + }) + } + }) + + describe('Dynamic Viewport - Static Route With instant = false', () => { + const pathname = '/dynamic-viewport-static-with-instant-false' + + if (isNextDev) { + it('should not show a collapsed redbox error', async () => { + const browser = await next.browser(pathname) + await waitForNoErrorToast(browser) + }) + } else { + it('should not error the build when generateViewport is dynamic and the page opts into blocking via instant = false', async () => { + try { + await prerender(pathname) + } catch (error) { + throw new Error('expected build not to fail', { cause: error }) + } + }) + } + }) + describe('Dynamic Viewport - Dynamic Route', () => { const pathname = '/dynamic-viewport-dynamic-route' diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-instant-false/layout.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-instant-false/layout.tsx new file mode 100644 index 000000000000..745e32b8a8d2 --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-instant-false/layout.tsx @@ -0,0 +1,9 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + +
{children}
+ + + ) +} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-instant-false/page.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-instant-false/page.tsx new file mode 100644 index 000000000000..3e237d205088 --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-instant-false/page.tsx @@ -0,0 +1,24 @@ +export const unstable_instant = false + +export async function generateMetadata() { + await new Promise((r) => setTimeout(r, 0)) + return { title: 'Dynamic Metadata' } +} + +export default async function Page() { + return ( + <> +

+ This page is static except for `generateMetadata`. It opts into a fully + dynamic, blocking route via `export const unstable_instant = false`. + That opt-in is a synonym for wrapping the document body in a Suspense + boundary — it says the user accepts a blocking route — but it is NOT a + documented mitigation for dynamic `generateMetadata`. So even with the + opt-in, we still expect the dynamic-metadata error to be shown to nudge + users back toward making `generateMetadata` static (or, secondarily, + making the page partially dynamic). +

+ sentinel + + ) +} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-suspense-above-body/layout.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-suspense-above-body/layout.tsx new file mode 100644 index 000000000000..418df3cfb037 --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-suspense-above-body/layout.tsx @@ -0,0 +1,13 @@ +import { Suspense } from 'react' + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + +
{children}
+ +
+ + ) +} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-suspense-above-body/page.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-suspense-above-body/page.tsx new file mode 100644 index 000000000000..cf6feb72c95c --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-metadata-static-with-suspense-above-body/page.tsx @@ -0,0 +1,22 @@ +export async function generateMetadata() { + await new Promise((r) => setTimeout(r, 0)) + return { title: 'Dynamic Metadata' } +} + +export default async function Page() { + return ( + <> +

+ This page is static except for `generateMetadata`. The root layout wraps + the document body in a Suspense boundary. Wrapping the body in Suspense + is the opt-in for a fully dynamic shell, which is the documented + mitigation for dynamic `generateViewport`, but is NOT a documented + mitigation for dynamic `generateMetadata`. So even with Suspense above + body, we still expect the dynamic-metadata error to be shown to nudge + users back toward making `generateMetadata` static (or, secondarily, + making the page partially dynamic). +

+ sentinel + + ) +} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-instant-false/layout.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-instant-false/layout.tsx new file mode 100644 index 000000000000..745e32b8a8d2 --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-instant-false/layout.tsx @@ -0,0 +1,9 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + +
{children}
+ + + ) +} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-instant-false/page.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-instant-false/page.tsx new file mode 100644 index 000000000000..be2732453d3a --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-instant-false/page.tsx @@ -0,0 +1,22 @@ +export const unstable_instant = false + +export async function generateViewport() { + await new Promise((r) => setTimeout(r, 0)) + return { themeColor: 'black' } +} + +export default async function Page() { + return ( + <> +

+ This page is static except for `generateViewport`. It opts into a fully + dynamic, blocking route via `export const unstable_instant = false`, + which is a documented mitigation for dynamic `generateViewport`. With + that opt-in, the dynamic viewport should not error the build and should + not show a redbox in dev. This is the symmetric counterpart to the + Suspense-above-body opt-in. +

+ sentinel + + ) +} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-suspense/layout.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-suspense/layout.tsx new file mode 100644 index 000000000000..418df3cfb037 --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-suspense/layout.tsx @@ -0,0 +1,13 @@ +import { Suspense } from 'react' + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + +
{children}
+ +
+ + ) +} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-suspense/page.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-suspense/page.tsx new file mode 100644 index 000000000000..f876d30ca93e --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/dynamic-viewport-static-with-suspense/page.tsx @@ -0,0 +1,18 @@ +export async function generateViewport() { + await new Promise((r) => setTimeout(r, 0)) + return { themeColor: 'black' } +} + +export default async function Page() { + return ( + <> +

+ This page is static except for `generateViewport`. The root layout wraps + the document body in a Suspense boundary, which is an explicit opt-in to + a fully dynamic shell. With that opt-in, a dynamic `generateViewport` + should not error the build and should not show a redbox in dev. +

+ sentinel + + ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params/app/(main)/metadata/[slug]/page.tsx b/test/e2e/app-dir/segment-cache/vary-params/app/(main)/metadata/[slug]/page.tsx index d314f826ce0f..f595350428b3 100644 --- a/test/e2e/app-dir/segment-cache/vary-params/app/(main)/metadata/[slug]/page.tsx +++ b/test/e2e/app-dir/segment-cache/vary-params/app/(main)/metadata/[slug]/page.tsx @@ -1,3 +1,6 @@ +import { Suspense } from 'react' +import { connection } from 'next/server' + type Params = { slug: string } /** @@ -7,6 +10,13 @@ type Params = { slug: string } * When the slug changes: * - Head segment should be re-fetched (metadata accesses slug) * - Body segment should be cached (body does NOT access slug) + * + * The body contains a self-contained dynamic marker (`` + * inside Suspense). This is the documented mitigation for dynamic + * `generateMetadata` on an otherwise-static body — it makes the body partially + * dynamic via PPR, which is what we actually want here: the body doesn't depend + * on `slug`, so the static portion of the prefetch is still cacheable across + * slug changes. */ export async function generateStaticParams(): Promise { return [{ slug: 'aaa' }, { slug: 'bbb' }, { slug: 'ccc' }] @@ -26,6 +36,14 @@ export default function MetadataPage() { return (
{`Static page body`}
+ Loading...
}> + +
) } + +async function DynamicContent() { + await connection() + return
Dynamic content loaded
+} diff --git a/test/production/static-routes-info/app/(group)/items/[itemId]/page.tsx b/test/production/static-routes-info/app/(group)/items/[itemId]/page.tsx new file mode 100644 index 000000000000..6c82d2e0260f --- /dev/null +++ b/test/production/static-routes-info/app/(group)/items/[itemId]/page.tsx @@ -0,0 +1,14 @@ +// Route name: `/items/[itemId]` (the `(group)` segment is silent). +// The internal entry name in the client-reference manifest is +// `app/(group)/items/[itemId]/page` — note the `]` characters that +// appear unescaped inside the entry-key string. This fixture exists +// to exercise `parseClientReferenceManifest`'s string-literal walking +// for `]`-containing entry names; a naïve `[^\]]*` regex breaks here. +export default async function ItemPage({ + params, +}: { + params: Promise<{ itemId: string }> +}) { + const { itemId } = await params + return

item {itemId}

+} diff --git a/test/production/static-routes-info/app/about/page.tsx b/test/production/static-routes-info/app/about/page.tsx new file mode 100644 index 000000000000..7190cfcd72ce --- /dev/null +++ b/test/production/static-routes-info/app/about/page.tsx @@ -0,0 +1,15 @@ +// Second app-page so we can test the sharedAvg metric (which requires +// at least 2 routes of the same type). Imports the same shared module +// and the same client component as `app/page.tsx` so the tool's per-route +// file sets actually overlap beyond just framework/layout chunks. +import { sharedHelper } from '../../lib/shared' +import Counter from '../../components/Counter' + +export default function About() { + return ( +

+ about + +

+ ) +} diff --git a/test/production/static-routes-info/app/api/edge/route.ts b/test/production/static-routes-info/app/api/edge/route.ts new file mode 100644 index 000000000000..96f1d988a480 --- /dev/null +++ b/test/production/static-routes-info/app/api/edge/route.ts @@ -0,0 +1,5 @@ +export const runtime = 'edge' + +export async function GET() { + return Response.json({ ok: true }) +} diff --git a/test/production/static-routes-info/app/api/node/route.ts b/test/production/static-routes-info/app/api/node/route.ts new file mode 100644 index 000000000000..417505db47d7 --- /dev/null +++ b/test/production/static-routes-info/app/api/node/route.ts @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic' + +export async function GET() { + return Response.json({ ok: true }) +} diff --git a/test/production/static-routes-info/app/globals.css b/test/production/static-routes-info/app/globals.css new file mode 100644 index 000000000000..bcc3583dffd2 --- /dev/null +++ b/test/production/static-routes-info/app/globals.css @@ -0,0 +1,3 @@ +.hello { + color: red; +} diff --git a/test/production/static-routes-info/app/layout.tsx b/test/production/static-routes-info/app/layout.tsx new file mode 100644 index 000000000000..ee2083b16e4a --- /dev/null +++ b/test/production/static-routes-info/app/layout.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from 'react' +// Layout imports the global stylesheet — this is the typical Next.js +// pattern where `globals.css` is imported by the root layout and so +// applies to every app-page route (not just one). The tool must pick up +// CSS from the layout segment via `entryCSSFiles[]` for all +// app-pages, not just the one that directly imports `globals.css`. +import './globals.css' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/production/static-routes-info/app/no-client/page.tsx b/test/production/static-routes-info/app/no-client/page.tsx new file mode 100644 index 000000000000..98f45d4bf0fa --- /dev/null +++ b/test/production/static-routes-info/app/no-client/page.tsx @@ -0,0 +1,7 @@ +// Third app-page that deliberately does NOT import the `Counter` client +// component. Used as the negative reference in the "client components +// contribute per-route client JS" assertion: routes that import Counter +// must ship strictly more client JS bytes than this route. +export default function NoClient() { + return

no-client

+} diff --git a/test/production/static-routes-info/app/page.tsx b/test/production/static-routes-info/app/page.tsx new file mode 100644 index 000000000000..5e7146fbc4cf --- /dev/null +++ b/test/production/static-routes-info/app/page.tsx @@ -0,0 +1,11 @@ +import { sharedHelper } from '../lib/shared' +import Counter from '../components/Counter' + +export default function Page() { + return ( +

+ app-page + +

+ ) +} diff --git a/test/production/static-routes-info/components/Counter.tsx b/test/production/static-routes-info/components/Counter.tsx new file mode 100644 index 000000000000..f29738e85d6c --- /dev/null +++ b/test/production/static-routes-info/components/Counter.tsx @@ -0,0 +1,18 @@ +'use client' + +// Client component imported by App Router pages. Imports a CSS module +// (`counter.module.css`) so that `clientModules` in the per-route +// `_client-reference-manifest.js` references a CSS module entry — and the +// tool must surface its extracted .css file via `entryCSSFiles` for routes +// that transitively import this component. +import { useState } from 'react' +import styles from './counter.module.css' + +export default function Counter() { + const [count, setCount] = useState(0) + return ( + + ) +} diff --git a/test/production/static-routes-info/components/counter.module.css b/test/production/static-routes-info/components/counter.module.css new file mode 100644 index 000000000000..fad9ef94975e --- /dev/null +++ b/test/production/static-routes-info/components/counter.module.css @@ -0,0 +1,9 @@ +.btn { + color: white; + background: black; + font-weight: bold; + padding: 8px 16px; + border-radius: 4px; + border: none; + cursor: pointer; +} diff --git a/test/production/static-routes-info/lib/shared.ts b/test/production/static-routes-info/lib/shared.ts new file mode 100644 index 000000000000..96768b208119 --- /dev/null +++ b/test/production/static-routes-info/lib/shared.ts @@ -0,0 +1,10 @@ +// Shared module imported by multiple routes so the static-routes-info tool +// can observe deliberate chunk sharing in its `sharedAvg` metric. The string +// is large enough that, however the bundler decides to chunk it (a separate +// shared chunk, or inlined into each importer), the resulting bundle output +// includes it and any shared chunk picked by the bundler is detectable. +export const SHARED_PAYLOAD = 'shared-route-info-payload-' + 'x'.repeat(4096) + +export function sharedHelper(): number { + return SHARED_PAYLOAD.length +} diff --git a/test/production/static-routes-info/middleware.ts b/test/production/static-routes-info/middleware.ts new file mode 100644 index 000000000000..31fc219fd95b --- /dev/null +++ b/test/production/static-routes-info/middleware.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server' + +// `middleware.ts` is reported as the `middleware` route type. We only need it +// to exist for the test — its behavior doesn't matter. +export function middleware() { + return NextResponse.next() +} + +export const config = { + matcher: '/about', +} diff --git a/test/production/static-routes-info/next.config.js b/test/production/static-routes-info/next.config.js new file mode 100644 index 000000000000..807126e4cf0b --- /dev/null +++ b/test/production/static-routes-info/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/production/static-routes-info/pages/api/hello.ts b/test/production/static-routes-info/pages/api/hello.ts new file mode 100644 index 000000000000..4fe639f8fec7 --- /dev/null +++ b/test/production/static-routes-info/pages/api/hello.ts @@ -0,0 +1,6 @@ +// Pages Router API route — exercises the `pages-api` route type. +import type { NextApiRequest, NextApiResponse } from 'next' + +export default function handler(_req: NextApiRequest, res: NextApiResponse) { + res.status(200).json({ ok: true }) +} diff --git a/test/production/static-routes-info/pages/pages-ssr-2.tsx b/test/production/static-routes-info/pages/pages-ssr-2.tsx new file mode 100644 index 000000000000..8c1c20388c5b --- /dev/null +++ b/test/production/static-routes-info/pages/pages-ssr-2.tsx @@ -0,0 +1,12 @@ +// Second SSR page so the `pages` route type has a peer for the sharedAvg +// metric. Imports the same shared module as `pages-ssr.tsx` so we get +// observable chunk overlap beyond the standard `_app` / framework chunks. +import { sharedHelper } from '../lib/shared' + +export default function ServerPage2({ len }: { len: number }) { + return

pages-ssr-2

+} + +export async function getServerSideProps() { + return { props: { len: sharedHelper() } } +} diff --git a/test/production/static-routes-info/pages/pages-ssr.tsx b/test/production/static-routes-info/pages/pages-ssr.tsx new file mode 100644 index 000000000000..fc3d5fd6b531 --- /dev/null +++ b/test/production/static-routes-info/pages/pages-ssr.tsx @@ -0,0 +1,11 @@ +// Server-rendered Pages Router page — `getServerSideProps` forces a `.js` +// server entry, exercises the `pages` route type. +import { sharedHelper } from '../lib/shared' + +export default function ServerPage({ len }: { len: number }) { + return

pages-ssr

+} + +export async function getServerSideProps() { + return { props: { len: sharedHelper() } } +} diff --git a/test/production/static-routes-info/pages/pages-static.tsx b/test/production/static-routes-info/pages/pages-static.tsx new file mode 100644 index 000000000000..4b65e4c66f22 --- /dev/null +++ b/test/production/static-routes-info/pages/pages-static.tsx @@ -0,0 +1,5 @@ +// Statically pre-rendered Pages Router page (no data-fetching) — produces a +// `.html` server entry, exercises the `pages-static` route type. +export default function StaticPage() { + return

pages-static

+} diff --git a/test/production/static-routes-info/static-routes-info.test.ts b/test/production/static-routes-info/static-routes-info.test.ts new file mode 100644 index 000000000000..086ebdaa6439 --- /dev/null +++ b/test/production/static-routes-info/static-routes-info.test.ts @@ -0,0 +1,772 @@ +import { nextTestSetup, isNextStart } from 'e2e-utils' +import { runNextCommand } from 'next-test-utils' + +interface CategoryStats { + count: number + bytes: number +} + +interface SharedStats extends CategoryStats { + percentCount: number + percentBytes: number +} + +interface CategoryStatsWithShared extends CategoryStats { + sharedAvg: SharedStats | null + files?: string[] +} + +interface CategoryStatsWithFiles extends CategoryStats { + files?: string[] +} + +interface RouteInfo { + route: string + type: string + serverBundled: CategoryStatsWithShared + serverMaps: CategoryStatsWithShared + serverUnbundled: CategoryStatsWithShared + clientJs: CategoryStatsWithShared + clientMaps: CategoryStatsWithShared + clientCss: CategoryStatsWithShared +} + +interface Totals { + serverBundled: CategoryStatsWithFiles + serverMaps: CategoryStatsWithFiles + serverUnbundled: CategoryStatsWithFiles + clientJs: CategoryStatsWithFiles + clientMaps: CategoryStatsWithFiles + clientCss: CategoryStatsWithFiles +} + +interface ToolOutput { + routes: RouteInfo[] + totals: Totals +} + +const ALL_CATEGORIES = [ + 'serverBundled', + 'serverMaps', + 'serverUnbundled', + 'clientJs', + 'clientMaps', + 'clientCss', +] as const + +describe('next internal static-routes-info', () => { + if (!isNextStart) { + it('skipped for non-start mode', () => {}) + return + } + + const { next, isTurbopack, skipped } = nextTestSetup({ + files: __dirname, + skipStart: true, + }) + + if (skipped) return + + beforeAll(async () => { + const buildResult = await next.build() + if (buildResult.exitCode !== 0) { + throw new Error( + `next build failed with exit code ${buildResult.exitCode}` + ) + } + }) + + async function runTool( + args: string[] + ): Promise<{ stdout: string; stderr: string; code: number | null }> { + const result = await runNextCommand( + ['internal', 'static-routes-info', next.testDir, ...args], + { + // Run from the next.js package dir (default) so internal modules + // resolve correctly regardless of the test app's setup. + stdout: true, + stderr: true, + } + ) + if (result.code !== 0) { + console.log('static-routes-info stdout:', result.stdout) + console.log('static-routes-info stderr:', result.stderr) + } + return result + } + + function getRoute(output: ToolOutput, route: string): RouteInfo { + const found = output.routes.find((r) => r.route === route) + if (!found) { + throw new Error( + `Route ${route} not found. Got: ${output.routes + .map((r) => `${r.route} (${r.type})`) + .join(', ')}` + ) + } + return found + } + + it('--help should print usage', async () => { + const result = await runNextCommand( + ['internal', 'static-routes-info', '--help'], + { stdout: true, stderr: true } + ) + expect(result.code).toBe(0) + const out = result.stdout + result.stderr + expect(out).toContain('static-routes-info') + expect(out).toContain('--json') + expect(out).toContain('--limit') + expect(out).toContain('--sort') + expect(out).toContain('--files') + }) + + it('--json should report all expected route types', async () => { + const result = await runTool(['--json']) + expect(result.code).toBe(0) + + const output = JSON.parse(result.stdout) as ToolOutput + + // Every route type from the fixture is represented at least once. + const types = new Set(output.routes.map((r) => r.type)) + expect(types).toContain('app-page') // app/page.tsx + app/about/page.tsx + expect(types).toContain('app-route') // app/api/node/route.ts + app/api/edge/route.ts + expect(types).toContain('pages') // pages/pages-ssr.tsx + pages/pages-ssr-2.tsx + expect(types).toContain('pages-static') // pages/pages-static.tsx + expect(types).toContain('pages-api') // pages/api/hello.ts + expect(types).toContain('middleware') // middleware.ts + // edge-function is no longer a route type — edge route handlers are + // reported under their actual type (e.g. app-route for App Router). + expect(types).not.toContain('edge-function') + + // Specific URLs should be present. + const routes = output.routes.map((r) => r.route) + expect(routes).toEqual( + expect.arrayContaining([ + '/', + '/about', + '/api/node', + '/api/edge', + '/pages-ssr', + '/pages-ssr-2', + '/pages-static', + '/api/hello', + ]) + ) + + // /api/edge is an App Router route handler with `runtime: 'edge'` and + // is now reported as `app-route` (peer of /api/node). + expect(getRoute(output, '/api/edge').type).toBe('app-route') + + // /items/[itemId] sits inside a `(group)` route group AND has a + // dynamic segment. The internal client-reference manifest entry name + // contains unescaped `]` characters, which a naïve `[^\]]*` regex + // would terminate early. This route forces the parser to walk a JS + // string literal correctly across `]`s. + const dyn = getRoute(output, '/items/[itemId]') + expect(dyn.type).toBe('app-page') + expect(dyn.serverBundled.count).toBeGreaterThan(0) + expect(dyn.clientJs.count).toBeGreaterThan(0) + + // Each category on each route is well-formed. + for (const r of output.routes) { + for (const cat of ALL_CATEGORIES) { + expect(typeof r[cat].count).toBe('number') + expect(typeof r[cat].bytes).toBe('number') + // count/bytes consistency: 0 files ↔ 0 bytes; >0 files → >0 bytes. + if (r[cat].count === 0) { + expect(r[cat].bytes).toBe(0) + } else { + expect(r[cat].bytes).toBeGreaterThan(0) + } + // sharedAvg is either null (no peers) or shaped like CategoryStats. + if (r[cat].sharedAvg !== null) { + expect(typeof r[cat].sharedAvg!.count).toBe('number') + expect(typeof r[cat].sharedAvg!.bytes).toBe('number') + } + } + } + }) + + it('--json should expose the right files per route type', async () => { + const result = await runTool(['--json']) + const output = JSON.parse(result.stdout) as ToolOutput + + // app-page: has server JS, client JS, and client CSS. The fixture + // imports a `'use client'` Counter component (which itself imports a + // CSS module) so the route's `_client-reference-manifest.js` carries + // per-route client chunks for both bundlers (Turbopack populates + // `entryJSFiles`, webpack populates `clientModules.chunks`). The + // global `globals.css` is imported from `app/layout.tsx`, exercising + // the layout-segment entry in `entryCSSFiles`. This catches the + // regression where the parser only matched Turbopack's `'] = '` + // marker and missed webpack's `']='` form. + const appPage = getRoute(output, '/') + expect(appPage.type).toBe('app-page') + expect(appPage.serverBundled.count).toBeGreaterThan(0) + expect(appPage.clientJs.count).toBeGreaterThan(0) + // Both bundlers populate `entryCSSFiles` with at least globals.css + // (from the layout). Turbopack additionally attributes the Counter + // CSS module only to routes that transitively import it; webpack + // merges all entries' CSS into every route's manifest, so it reports + // the same count for every app-page. We only assert "at least one" + // here; the next assertion exercises per-route differentiation. + expect(appPage.clientCss.count).toBeGreaterThan(0) + + // Client component contribution check (Turbopack only). The fixture + // imports the `'use client'` Counter component from `/` and `/about` + // but not from `/no-client`. On Turbopack, per-route client-reference + // manifests are independent, so `/about` ships strictly more client + // JS and CSS than `/no-client` — the `Counter.tsx` chunk plus its + // `counter.module.css` are only attributed to routes that import them. + // + // Webpack's flight-manifest plugin runs `mergeManifest` across all + // app-pages (see `flight-manifest-plugin.ts`'s `mergeManifest`), so + // every per-route CRM ends up with the union of every other route's + // `clientModules` and `entryCSSFiles`. This makes per-route + // attribution impossible on webpack — we skip the assertion there. + if (isTurbopack) { + const noClient = getRoute(output, '/no-client') + const about = getRoute(output, '/about') + expect(about.clientJs.count).toBeGreaterThan(noClient.clientJs.count) + expect(about.clientJs.bytes).toBeGreaterThan(noClient.clientJs.bytes) + expect(about.clientCss.count).toBeGreaterThan(noClient.clientCss.count) + expect(about.clientCss.bytes).toBeGreaterThan(noClient.clientCss.bytes) + } + + // app-route (Node runtime): has server JS, no client JS / CSS. + const appRoute = getRoute(output, '/api/node') + expect(appRoute.type).toBe('app-route') + expect(appRoute.serverBundled.count).toBeGreaterThan(0) + expect(appRoute.clientJs.count).toBe(0) + expect(appRoute.clientCss.count).toBe(0) + + // app-route (Edge runtime): has server JS, no client JS, no nft.json + // (so unbundled is always 0 — the bundle includes everything inline). + const edgeAppRoute = getRoute(output, '/api/edge') + expect(edgeAppRoute.type).toBe('app-route') + expect(edgeAppRoute.serverBundled.count).toBeGreaterThan(0) + expect(edgeAppRoute.serverUnbundled.count).toBe(0) + expect(edgeAppRoute.clientJs.count).toBe(0) + + // middleware: has server JS, no client JS, no unbundled. + const middleware = output.routes.find((r) => r.type === 'middleware')! + expect(middleware).toBeDefined() + expect(middleware.serverBundled.count).toBeGreaterThan(0) + expect(middleware.serverUnbundled.count).toBe(0) + expect(middleware.clientJs.count).toBe(0) + + // pages (SSR): has server JS, has client JS. + const pagesSsr = getRoute(output, '/pages-ssr') + expect(pagesSsr.type).toBe('pages') + expect(pagesSsr.serverBundled.count).toBeGreaterThan(0) + expect(pagesSsr.clientJs.count).toBeGreaterThan(0) + + // pages-static: no server JS, only client JS. + const pagesStatic = getRoute(output, '/pages-static') + expect(pagesStatic.type).toBe('pages-static') + expect(pagesStatic.serverBundled.count).toBe(0) + expect(pagesStatic.serverUnbundled.count).toBe(0) + expect(pagesStatic.clientJs.count).toBeGreaterThan(0) + + // pages-api: has server JS, no client JS. + const pagesApi = getRoute(output, '/api/hello') + expect(pagesApi.type).toBe('pages-api') + expect(pagesApi.serverBundled.count).toBeGreaterThan(0) + expect(pagesApi.clientJs.count).toBe(0) + }) + + it('--json totals should be sums of unique files (not per-route sums)', async () => { + const result = await runTool(['--json']) + const output = JSON.parse(result.stdout) as ToolOutput + + // Per-category sum across routes (counts duplicates) + const perRouteSum = (cat: keyof ToolOutput['totals']) => + output.routes.reduce((acc, r) => acc + r[cat].bytes, 0) + + // Totals are deduplicated, so each total <= sum of per-route values. + // For this fixture there are shared server chunks (Next.js runtime + // included via nft.json on every route) and shared client chunks + // (framework, polyfills, _app), so totals must be strictly smaller + // than the sum across routes. + for (const cat of [ + 'serverBundled', + 'serverMaps', + 'serverUnbundled', + 'clientJs', + ] as const) { + expect(output.totals[cat].bytes).toBeLessThanOrEqual(perRouteSum(cat)) + } + expect(output.totals.serverBundled.bytes).toBeLessThan( + perRouteSum('serverBundled') + ) + expect(output.totals.clientJs.bytes).toBeLessThan(perRouteSum('clientJs')) + }) + + it('--json routes should be sorted alphabetically by name by default', async () => { + const result = await runTool(['--json']) + const output = JSON.parse(result.stdout) as ToolOutput + + for (let i = 1; i < output.routes.length; i++) { + // localeCompare is what the tool uses internally; comparing with `<=` + // here would be wrong for unicode-aware ordering. + expect( + output.routes[i - 1].route.localeCompare(output.routes[i].route) + ).toBeLessThanOrEqual(0) + } + }) + + it.each([ + ['client-js', (r: RouteInfo) => r.clientJs.bytes], + ['client-css', (r: RouteInfo) => r.clientCss.bytes], + ['client-map', (r: RouteInfo) => r.clientMaps.bytes], + ['client', (r: RouteInfo) => r.clientJs.bytes + r.clientCss.bytes], + ['server-bundled-js', (r: RouteInfo) => r.serverBundled.bytes], + ['server-unbundled', (r: RouteInfo) => r.serverUnbundled.bytes], + ['server-map', (r: RouteInfo) => r.serverMaps.bytes], + [ + 'server', + (r: RouteInfo) => r.serverBundled.bytes + r.serverUnbundled.bytes, + ], + [ + 'total', + (r: RouteInfo) => + r.serverBundled.bytes + + r.serverMaps.bytes + + r.serverUnbundled.bytes + + r.clientJs.bytes + + r.clientMaps.bytes + + r.clientCss.bytes, + ], + ] as const)( + '--sort %s should order routes descending by that metric', + async (key, metric) => { + const result = await runTool(['--json', '--sort', key]) + expect(result.code).toBe(0) + const output = JSON.parse(result.stdout) as ToolOutput + + for (let i = 1; i < output.routes.length; i++) { + expect(metric(output.routes[i - 1])).toBeGreaterThanOrEqual( + metric(output.routes[i]) + ) + } + } + ) + + it('--sort with an invalid key should error', async () => { + const result = await runTool(['--json', '--sort', 'bogus']) + expect(result.code).not.toBe(0) + expect(result.stderr).toContain("invalid --sort key 'bogus'") + }) + + it('--limit should keep only the top N routes; totals reflect all routes', async () => { + const full = JSON.parse( + (await runTool(['--json', '--sort', 'total'])).stdout + ) as ToolOutput + const limited = JSON.parse( + (await runTool(['--json', '--sort', 'total', '--limit', '2'])).stdout + ) as ToolOutput + + expect(limited.routes).toHaveLength(2) + expect(limited.routes[0].route).toBe(full.routes[0].route) + expect(limited.routes[1].route).toBe(full.routes[1].route) + + // Totals are independent of --limit. + expect(limited.totals).toEqual(full.totals) + }) + + it('markdown (default) output should be a valid table containing all routes', async () => { + const result = await runTool([]) + expect(result.code).toBe(0) + const out = result.stdout + + // Section headers + expect(out).toContain('## Routes') + expect(out).toContain('## Totals') + + // Column headers + for (const header of [ + 'Route', + 'Type', + 'Client JS', + 'Client CSS', + 'Client Source Maps', + 'Server Bundled JS', + 'Server Unbundled', + 'Server Source Maps', + ]) { + expect(out).toContain(header) + } + + // Each route appears in the markdown. + for (const route of [ + '/api/node', + '/api/edge', + '/pages-ssr', + '/pages-static', + '/api/hello', + ]) { + expect(out).toContain(route) + } + + // The "**Total**" row in the totals table. + expect(out).toContain('**Total**') + + // Rows look like markdown table rows. + expect(out).toMatch(/\|\s+Route\s+\|/) + expect(out).toMatch(/\|\s+-+\s+\|/) + }) + + it('--json sharedAvg should be null for routes with no peers', async () => { + const output = JSON.parse((await runTool(['--json'])).stdout) as ToolOutput + + // The fixture has exactly one route of each of these types. + for (const route of [ + '/pages-static', // only pages-static + '/api/hello', // only pages-api + ]) { + const r = getRoute(output, route) + for (const cat of ALL_CATEGORIES) { + expect(r[cat].sharedAvg).toBeNull() + } + } + // Middleware is also a singleton. + const mw = output.routes.find((r) => r.type === 'middleware')! + expect(mw).toBeDefined() + for (const cat of ALL_CATEGORIES) { + expect(mw[cat].sharedAvg).toBeNull() + } + }) + + it('--json sharedAvg should be present for routes with peers, and never exceed the route itself', async () => { + const output = JSON.parse((await runTool(['--json'])).stdout) as ToolOutput + + // Routes with at least one peer of the same type: + // - app-page: `/`, `/about`, `/_not-found` + // - app-route: `/api/node`, `/api/edge` + // - pages: `/pages-ssr`, `/pages-ssr-2` + const withPeers = output.routes.filter( + (r) => + r.type === 'app-page' || r.type === 'app-route' || r.type === 'pages' + ) + expect(withPeers.length).toBeGreaterThanOrEqual(7) + for (const r of withPeers) { + for (const cat of ALL_CATEGORIES) { + expect(r[cat].sharedAvg).not.toBeNull() + expect(r[cat].sharedAvg!.count).toBeLessThanOrEqual(r[cat].count) + expect(r[cat].sharedAvg!.bytes).toBeLessThanOrEqual(r[cat].bytes) + // percentCount / percentBytes are always between 0 and 100 + // inclusive (sharedAvg cannot exceed own). + expect(r[cat].sharedAvg!.percentCount).toBeGreaterThanOrEqual(0) + expect(r[cat].sharedAvg!.percentCount).toBeLessThanOrEqual(100) + expect(r[cat].sharedAvg!.percentBytes).toBeGreaterThanOrEqual(0) + expect(r[cat].sharedAvg!.percentBytes).toBeLessThanOrEqual(100) + // Percentages are exactly the ratio of sharedAvg to own (or 0 when + // own is 0). Use a small epsilon for floating-point. + const expectedPctCount = + r[cat].count > 0 ? (r[cat].sharedAvg!.count / r[cat].count) * 100 : 0 + const expectedPctBytes = + r[cat].bytes > 0 ? (r[cat].sharedAvg!.bytes / r[cat].bytes) * 100 : 0 + expect(r[cat].sharedAvg!.percentCount).toBeCloseTo(expectedPctCount, 8) + expect(r[cat].sharedAvg!.percentBytes).toBeCloseTo(expectedPctBytes, 8) + } + } + }) + + it('--json sharedAvg should observe deliberate chunk sharing across peer routes', async () => { + // Both `/pages-ssr` and `/pages-ssr-2` import `lib/shared.ts` and use + // the standard Pages Router shared chunks (`_app`, framework, main, + // polyfills). They MUST report meaningful sharing. Likewise `/` and + // `/about` both import `lib/shared.ts` and share the App Router + // root layout + framework chunks. + const output = JSON.parse((await runTool(['--json'])).stdout) as ToolOutput + + // Pages Router peers — Pages Router's chunking is deterministic across + // both bundlers (build-manifest lists pages chunks explicitly). + const ssr1 = getRoute(output, '/pages-ssr') + const ssr2 = getRoute(output, '/pages-ssr-2') + expect(ssr1.type).toBe('pages') + expect(ssr2.type).toBe('pages') + + // At least 3 of the 6 client JS chunks should be shared between the + // two pages (framework, main, polyfills, _app — minus per-page entry). + expect(ssr1.clientJs.sharedAvg!.count).toBeGreaterThanOrEqual(3) + expect(ssr1.clientJs.sharedAvg!.bytes).toBeGreaterThan(0) + // Most of the route's client JS comes from shared infra; expect the + // shared portion to be a substantial fraction of the total. + expect(ssr1.clientJs.sharedAvg!.bytes).toBeGreaterThan( + ssr1.clientJs.bytes * 0.5 + ) + // Raw intersection count/bytes are commutative (intersection is + // symmetric). Percentages are NOT commutative because they're divided + // by each route's own count/bytes, which can differ between peers. + expect(ssr2.clientJs.sharedAvg!.count).toBe(ssr1.clientJs.sharedAvg!.count) + expect(ssr2.clientJs.sharedAvg!.bytes).toBe(ssr1.clientJs.sharedAvg!.bytes) + + // Similarly, server-bundled JS for the two pages should mostly overlap + // (Next.js runtime chunks dominate the bundle). + expect(ssr1.serverBundled.sharedAvg!.count).toBeGreaterThanOrEqual(3) + expect(ssr1.serverBundled.sharedAvg!.bytes).toBeGreaterThan( + ssr1.serverBundled.bytes * 0.5 + ) + + // App Router peers — both `/` and `/about` import the same shared lib + // and the same root layout, so a substantial share is expected. + const root = getRoute(output, '/') + const about = getRoute(output, '/about') + expect(root.serverBundled.sharedAvg!.bytes).toBeGreaterThan( + root.serverBundled.bytes * 0.5 + ) + expect(about.serverBundled.sharedAvg!.bytes).toBeGreaterThan( + about.serverBundled.bytes * 0.5 + ) + }) + + it('--json sharedAvg should match a hand-computed average for app-pages', async () => { + const output = JSON.parse((await runTool(['--json'])).stdout) as ToolOutput + + // Reproduce the tool's algorithm in the test: for each route, average + // the intersection size across same-type peers. We can't recompute file + // intersections here (we don't have the file lists in the JSON), but we + // can verify a known invariant: when ALL app-pages have the same set of + // server-unbundled files (which is the case in our small fixture, since + // they all trace identical Node deps), the sharedAvg.count for that + // category equals the route's own count. Likewise for serverUnbundled + // bytes. + const appPages = output.routes.filter((r) => r.type === 'app-page') + expect(appPages.length).toBeGreaterThan(1) + const serverUnbundledCounts = appPages.map((r) => r.serverUnbundled.count) + const allEqual = serverUnbundledCounts.every( + (c) => c === serverUnbundledCounts[0] + ) + if (allEqual) { + for (const r of appPages) { + expect(r.serverUnbundled.sharedAvg!.count).toBe(r.serverUnbundled.count) + expect(r.serverUnbundled.sharedAvg!.bytes).toBe(r.serverUnbundled.bytes) + } + } + }) + + it('--json sharedAvg should match a from-scratch reimplementation for every route × category', async () => { + // The strongest guarantee we can offer for the sharedAvg metric: walk the + // dist-relative file lists from --files and re-run the exact algorithm in + // the test, then compare every (route, category) cell against what the + // tool reports. Any divergence — including spurious 100% values caused by + // path normalization mismatches, off-by-one peer counts, or asymmetric + // intersection — fails the test. + const output = JSON.parse( + (await runTool(['--json', '--files'])).stdout + ) as ToolOutput + + // Build a fast file-size lookup from `count` and `bytes`. We don't have + // bytes-per-file in the JSON, but for a hand-computed *average size of + // intersection* we need them. So re-derive sizes by reading totals: any + // file in totals[cat].files maps to a size we can estimate? No — totals + // only have count/bytes too. + // + // Instead, we cross-check ONLY the file *count* against a hand-computed + // average. Bytes are checked separately by the byte-level test below + // using the size info that does exist (per-route own bytes). + const byType = new Map() + for (const r of output.routes) { + const list = byType.get(r.type) ?? [] + list.push(r) + byType.set(r.type, list) + } + + for (const r of output.routes) { + const peers = (byType.get(r.type) ?? []).filter((p) => p !== r) + for (const cat of ALL_CATEGORIES) { + const sa = r[cat].sharedAvg + if (peers.length === 0) { + expect(sa).toBeNull() + continue + } + const myFiles = new Set(r[cat].files) + let totalIntersectCount = 0 + for (const p of peers) { + const peerFiles = new Set(p[cat].files) + let intersect = 0 + for (const f of myFiles) if (peerFiles.has(f)) intersect++ + totalIntersectCount += intersect + } + const expectedCount = totalIntersectCount / peers.length + expect(sa).not.toBeNull() + // Floating-point exact: division by integer peer count of an integer + // sum is exactly representable for the sizes we have here. + expect(sa!.count).toBe(expectedCount) + } + } + }) + + it('--json sharedAvg.count == own.count IFF every peer is a strict superset (100%-shared sanity check)', async () => { + // A 100% sharedAvg.count is only legitimate when, for every peer, this + // route's set is a (possibly equal) subset of the peer's set. This test + // independently checks: every (route, category) pair where + // `sharedAvg.count == own.count` must satisfy `myFiles ⊆ peerFiles` for + // every peer; conversely, every pair where some peer is missing a file + // must have `sharedAvg.count < own.count`. + // + // This catches bugs where the intersection accidentally over-counts — + // e.g. counting the same file twice across the small/big swap, returning + // |self ∪ peer| instead of |self ∩ peer|, or comparing the wrong route. + const output = JSON.parse( + (await runTool(['--json', '--files'])).stdout + ) as ToolOutput + const byType = new Map() + for (const r of output.routes) { + const list = byType.get(r.type) ?? [] + list.push(r) + byType.set(r.type, list) + } + + for (const r of output.routes) { + const peers = (byType.get(r.type) ?? []).filter((p) => p !== r) + if (peers.length === 0) continue + for (const cat of ALL_CATEGORIES) { + const myFiles = new Set(r[cat].files) + if (myFiles.size === 0) continue + const everyPeerIsSuperset = peers.every((p) => { + const peer = new Set(p[cat].files) + for (const f of myFiles) if (!peer.has(f)) return false + return true + }) + const sa = r[cat].sharedAvg! + if (everyPeerIsSuperset) { + expect(sa.count).toBe(r[cat].count) + expect(sa.bytes).toBe(r[cat].bytes) + } else { + // Some peer is missing at least one of my files; the average + // intersection size MUST be strictly less than my own count. + expect(sa.count).toBeLessThan(r[cat].count) + } + } + } + }) + + it('--json sharedAvg should be < own for routes with unique files (regression check)', async () => { + // `/` and `/about` import the `'use client'` `Counter` component; + // `/no-client`, `/_not-found`, and `/items/[itemId]` do not. So `/`'s + // Counter chunk is shared with exactly one peer (`/about`) and absent + // from the other three. This forces a strictly-below-100% average for + // `/.clientJs`, regardless of how many framework chunks happen to be + // shared across all five app-pages. + // + // If the algorithm were broken to return |self ∪ peer| or to skip + // certain peers, this assertion would still trigger. + const output = JSON.parse((await runTool(['--json'])).stdout) as ToolOutput + const root = getRoute(output, '/') + expect(root.type).toBe('app-page') + expect(root.clientJs.sharedAvg).not.toBeNull() + expect(root.clientJs.sharedAvg!.count).toBeLessThan(root.clientJs.count) + expect(root.clientJs.sharedAvg!.bytes).toBeLessThan(root.clientJs.bytes) + }) + + it('totals should not include sharedAvg', async () => { + const output = JSON.parse((await runTool(['--json'])).stdout) as ToolOutput + for (const cat of ALL_CATEGORIES) { + expect(output.totals[cat]).toEqual({ + count: expect.any(Number), + bytes: expect.any(Number), + }) + expect( + (output.totals[cat] as unknown as Record).sharedAvg + ).toBeUndefined() + } + }) + + it('markdown should render empty cells as `-` (not `0 files / 0 B`)', async () => { + const md = (await runTool([])).stdout + // The empty placeholder must appear at least once: middleware ships + // no client JS / CSS / maps so its row in the routes table will have + // a `-` for those columns. `/pages-static` has no server entries. + expect(md).toContain('| -') + // It should NOT render `0 files / 0 B` anywhere — every empty cell + // is replaced. + expect(md).not.toMatch(/0 files\s+\/\s+0 B/) + }) + + it('markdown should include a Shared section', async () => { + const md = (await runTool([])).stdout + expect(md).toContain('## Shared') + // Routes with no peers should appear as `n/a`. Routes with peers but + // no files in a category render as `-` (matching the routes table + // placeholder), so `n/a` and `-` are both expected and have distinct + // meanings. + expect(md).toContain('n/a') + // Shared cells render with both count and byte percentages, e.g. + // `5 files (100%) / 424 KB (100%)`. This is the marker for the + // user-visible part of the percent-shared annotation. + expect(md).toMatch(/\d+ files \(\d+%\) \/ [^|]*\(\d+%\)/) + // Empty shared intersections render as `-`, not as `0 files (0%) / 0 B (0%)`. + expect(md).not.toMatch(/0 files\s+\(0%\)/) + }) + + it('markdown numbers should agree with --json numbers for shared routes', async () => { + const md = (await runTool([])).stdout + const output = JSON.parse((await runTool(['--json'])).stdout) as ToolOutput + + // Pick a route that should always have non-zero server JS and confirm + // its ` files` count appears in the markdown output. This is a + // sanity check that markdown rendering uses the same data as JSON. + const ssr = getRoute(output, '/pages-ssr') + expect(md).toContain(`${ssr.serverBundled.count} files`) + }) + + it('--files without --json should error', async () => { + const result = await runTool(['--files']) + expect(result.code).not.toBe(0) + expect(result.stderr).toContain('--files requires --json') + }) + + it('--files --json should add a sorted, dist-relative file list per category', async () => { + const result = await runTool(['--json', '--files']) + expect(result.code).toBe(0) + const output = JSON.parse(result.stdout) as ToolOutput + + const root = getRoute(output, '/') + for (const cat of ALL_CATEGORIES) { + const files = root[cat].files + expect(Array.isArray(files)).toBe(true) + expect(files!.length).toBe(root[cat].count) + // Files are sorted ascending and deduplicated. + const sorted = [...files!].sort() + expect(files).toEqual(sorted) + expect(new Set(files).size).toBe(files!.length) + } + + // Bundled JS chunks live inside distDir, so their paths must be plain + // relative (no leading `..`). Traced node_modules deps land in + // serverUnbundled and are expressed as `../...` from distDir — at + // least one such path must appear there. + for (const f of root.serverBundled.files!) { + expect(f.startsWith('..')).toBe(false) + } + if (root.serverUnbundled.files!.length > 0) { + expect(root.serverUnbundled.files!.some((f) => f.startsWith('..'))).toBe( + true + ) + } + + // Totals also expose file lists; their length must match totals.count + // (which reflects the union across every route). + for (const cat of ALL_CATEGORIES) { + expect(output.totals[cat].files).toBeDefined() + expect(output.totals[cat].files!.length).toBe(output.totals[cat].count) + } + }) + + it('--json without --files should NOT include the files field', async () => { + const result = await runTool(['--json']) + expect(result.code).toBe(0) + const output = JSON.parse(result.stdout) as ToolOutput + for (const r of output.routes) { + for (const cat of ALL_CATEGORIES) { + expect(r[cat]).not.toHaveProperty('files') + } + } + for (const cat of ALL_CATEGORIES) { + expect(output.totals[cat]).not.toHaveProperty('files') + } + }) +}) diff --git a/turbopack/crates/turbo-tasks-backend/benches/scope_stress.rs b/turbopack/crates/turbo-tasks-backend/benches/scope_stress.rs index 74082fba6a99..4487e431afa5 100644 --- a/turbopack/crates/turbo-tasks-backend/benches/scope_stress.rs +++ b/turbopack/crates/turbo-tasks-backend/benches/scope_stress.rs @@ -71,7 +71,7 @@ pub fn scope_stress(c: &mut Criterion) { /// This fills a rectagle from (0, 0) to (a, b) by /// first filling (0, 0) to (a - 1, b) and then (0, 0) to (a, b - 1) recursively -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn rectangle_operation(a: u32, b: u32) -> Result> { if a > 0 { rectangle_operation(a - 1, b).connect().await?; diff --git a/turbopack/crates/turbo-tasks-backend/fuzz/src/graph.rs b/turbopack/crates/turbo-tasks-backend/fuzz/src/graph.rs index 518b4b11b10e..24e57465d134 100644 --- a/turbopack/crates/turbo-tasks-backend/fuzz/src/graph.rs +++ b/turbopack/crates/turbo-tasks-backend/fuzz/src/graph.rs @@ -169,7 +169,7 @@ fn create_state() -> Vc { Vc::cell(State::new(0)) } -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn run_task_chain( spec: Arc>, iteration: Vc, @@ -186,7 +186,7 @@ async fn run_task_chain( Ok(Vc::cell(())) } -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn run_task( spec: Arc>, iteration: Vc, diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs b/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs index 528589f5f77f..1cf69b33bf8c 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs @@ -61,8 +61,8 @@ use crate::{ AggregationUpdateJob, AggregationUpdateQueue, ChildExecuteContext, CleanupOldEdgesOperation, ConnectChildOperation, ExecuteContext, ExecuteContextImpl, LeafDistanceUpdateQueue, Operation, OutdatedEdge, TaskGuard, TaskType, - connect_children, get_aggregation_number, get_uppers, is_root_node, - make_task_dirty_internal, prepare_new_children, + connect_children, get_aggregation_number, get_uppers, make_task_dirty_internal, + prepare_new_children, }, snapshot_coordinator::{OperationGuard, SnapshotCoordinator}, storage::Storage, @@ -519,36 +519,21 @@ impl TurboTasksBackendInner { } if matches!(options.consistency, ReadConsistency::Strong) { - // Ensure it's an root node - loop { - let aggregation_number = get_aggregation_number(&task); - if is_root_node(aggregation_number) { - break; - } + if task + .get_persistent_task_type() + .is_some_and(|t| !t.native_fn.is_root) + { drop(task); drop(reader_task); - { - let _span = tracing::trace_span!( - "make root node for strongly consistent read", - task = self.debug_get_task_description(task_id) + panic!( + "Strongly consistent read of non-root task {} (reader: {}). The `root` \ + attribute is missing on the task.", + self.debug_get_task_description(task_id), + reader.map_or_else( + || "unknown".to_string(), + |r| self.debug_get_task_description(r) ) - .entered(); - AggregationUpdateQueue::run( - AggregationUpdateJob::UpdateAggregationNumber { - task_id, - base_aggregation_number: u32::MAX, - distance: None, - }, - &mut ctx, - ); - } - (task, reader_task) = if let Some(reader_id) = need_reader_task { - // TODO(sokra): see comment above - let (task, reader) = ctx.task_pair(task_id, reader_id, TaskDataCategory::All); - (task, Some(reader)) - } else { - (ctx.task(task_id, TaskDataCategory::All), None) - } + ); } let is_dirty = task.is_dirty(); @@ -1973,6 +1958,7 @@ impl TurboTasksBackendInner { immutable = tracing::field::Empty, new_output = tracing::field::Empty, output_dependents = tracing::field::Empty, + aggregation_number = tracing::field::Empty, stale = tracing::field::Empty, ) .entered(); @@ -2217,7 +2203,11 @@ impl TurboTasksBackendInner { if has_children { // Prepare all new children - prepare_new_children(task_id, &mut task, &new_children, &mut queue); + let _aggregation_number = + prepare_new_children(task_id, &mut task, &new_children, &mut queue); + + #[cfg(feature = "trace_task_details")] + span.record("aggregation_number", _aggregation_number); // Filter actual new children old_edges.extend( @@ -2353,6 +2343,9 @@ impl TurboTasksBackendInner { ) { debug_assert!(!output_dependent_tasks.is_empty()); + #[cfg(feature = "trace_task_dirty")] + let task_description = self.debug_get_task_description(task_id); + if output_dependent_tasks.len() > 1 { ctx.prepare_tasks( output_dependent_tasks @@ -2365,6 +2358,7 @@ impl TurboTasksBackendInner { fn process_output_dependents( ctx: &mut impl ExecuteContext<'_>, task_id: TaskId, + #[cfg(feature = "trace_task_dirty")] task_description: &str, dependent_task_id: TaskId, queue: &mut AggregationUpdateQueue, ) { @@ -2405,7 +2399,9 @@ impl TurboTasksBackendInner { dependent_task_id, make_stale, #[cfg(feature = "trace_task_dirty")] - TaskDirtyCause::OutputChange { task_id }, + TaskDirtyCause::OutputChange { + task_description: task_description.to_string(), + }, queue, ctx, ); @@ -2419,6 +2415,8 @@ impl TurboTasksBackendInner { let _ = scope_and_block(chunks.len(), |scope| { for chunk in chunks { let child_ctx = ctx.child_context(); + #[cfg(feature = "trace_task_dirty")] + let task_description = &task_description; scope.spawn(move || { let mut ctx = child_ctx.create(); let mut queue = AggregationUpdateQueue::new(); @@ -2426,6 +2424,8 @@ impl TurboTasksBackendInner { process_output_dependents( &mut ctx, task_id, + #[cfg(feature = "trace_task_dirty")] + task_description, dependent_task_id, &mut queue, ) @@ -2437,7 +2437,14 @@ impl TurboTasksBackendInner { } else { let mut queue = AggregationUpdateQueue::new(); for dependent_task_id in output_dependent_tasks { - process_output_dependents(ctx, task_id, dependent_task_id, &mut queue); + process_output_dependents( + ctx, + task_id, + #[cfg(feature = "trace_task_dirty")] + &task_description, + dependent_task_id, + &mut queue, + ); } queue.execute(ctx); } @@ -2931,22 +2938,20 @@ impl TurboTasksBackendInner { let mut collectibles = AutoMap::default(); { let mut task = ctx.task(task_id, TaskDataCategory::All); - // Ensure it's an root node - loop { - let aggregation_number = get_aggregation_number(&task); - if is_root_node(aggregation_number) { - break; - } + if task + .get_persistent_task_type() + .is_some_and(|t| !t.native_fn.is_root) + { drop(task); - AggregationUpdateQueue::run( - AggregationUpdateJob::UpdateAggregationNumber { - task_id, - base_aggregation_number: u32::MAX, - distance: None, - }, - &mut ctx, + panic!( + "Reading collectibles of non-root task {} (reader: {}). The `root` attribute \ + is missing on the task.", + self.debug_get_task_description(task_id), + reader_id.map_or_else( + || "unknown".to_string(), + |r| self.debug_get_task_description(r) + ) ); - task = ctx.task(task_id, TaskDataCategory::All); } for (collectible, count) in task.iter_aggregated_collectibles() { if *count > 0 && collectible.collectible_type == collectible_type { diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/operation/invalidate.rs b/turbopack/crates/turbo-tasks-backend/src/backend/operation/invalidate.rs index a1c765d270d9..e3ce46ea19a2 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backend/operation/invalidate.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backend/operation/invalidate.rs @@ -97,7 +97,7 @@ pub enum TaskDirtyCause { value_type: turbo_tasks::ValueTypeId, }, OutputChange { - task_id: TaskId, + task_description: String, }, CollectiblesChange { collectible_type: turbo_tasks::TraitTypeId, @@ -106,31 +106,18 @@ pub enum TaskDirtyCause { Unknown, } +// NOTE: `TaskDirtyCause` is formatted for tracing inside `make_task_dirty_internal`, which +// already holds the dependent task's `StorageWriteGuard`. The `Display` impl below must NOT +// acquire any task guard — doing so would take a second map shard write lock with no ordering +// guarantee against the first and two concurrent invalidations of each other's outputs would +// form a classic hold-and-wait deadlock on the dashmap. `OutputChange::task_description` is +// therefore filled at the call site (before any guard is held) and only formatted here. +// The `TaskLockCounter` debug-assert that normally catches this kind of nested acquire is +// `cfg(debug_assertions)`-only, so release builds hang silently. #[cfg(feature = "trace_task_dirty")] -struct TaskDirtyCauseInContext<'l> { - cause: &'l TaskDirtyCause, - task_description: String, -} - -#[cfg(feature = "trace_task_dirty")] -impl<'l> TaskDirtyCauseInContext<'l> { - fn new(cause: &'l TaskDirtyCause, ctx: &'l mut impl ExecuteContext<'_>) -> Self { - Self { - cause, - task_description: match cause { - TaskDirtyCause::OutputChange { task_id } => ctx - .task(*task_id, TaskDataCategory::Data) - .get_task_description(), - _ => String::new(), - }, - } - } -} - -#[cfg(feature = "trace_task_dirty")] -impl std::fmt::Display for TaskDirtyCauseInContext<'_> { +impl std::fmt::Display for TaskDirtyCause { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.cause { + match self { TaskDirtyCause::InitialDirty => write!(f, "initial dirty"), TaskDirtyCause::CellChange { value_type, keys } => { if keys.is_empty() { @@ -161,8 +148,8 @@ impl std::fmt::Display for TaskDirtyCauseInContext<'_> { turbo_tasks::registry::get_value_type(*value_type).ty.name ) } - TaskDirtyCause::OutputChange { .. } => { - write!(f, "task {} output changed", self.task_description) + TaskDirtyCause::OutputChange { task_description } => { + write!(f, "task {task_description} output changed") } TaskDirtyCause::CollectiblesChange { collectible_type } => { write!( @@ -208,10 +195,7 @@ pub fn make_task_dirty_internal( #[cfg(any(debug_assertions, feature = "verify_immutable"))] if task.immutable() { #[cfg(feature = "trace_task_dirty")] - let extra_info = format!( - " Invalidation cause: {}", - TaskDirtyCauseInContext::new(&cause, ctx) - ); + let extra_info = format!(" Invalidation cause: {cause}"); #[cfg(not(feature = "trace_task_dirty"))] let extra_info = ""; @@ -234,7 +218,7 @@ pub fn make_task_dirty_internal( "make task stale", task_id = display(task_id), name = task_name, - cause = %TaskDirtyCauseInContext::new(&cause, ctx) + cause = %cause ) .entered(); *stale = true; @@ -247,7 +231,7 @@ pub fn make_task_dirty_internal( "task already dirty", task_id = display(task_id), name = task_name, - cause = %TaskDirtyCauseInContext::new(&cause, ctx) + cause = %cause ) .entered(); // already dirty @@ -273,7 +257,7 @@ pub fn make_task_dirty_internal( let _span = tracing::trace_span!( "session-dependent task already dirty", name = task_name, - cause = %TaskDirtyCauseInContext::new(&cause, ctx) + cause = %cause ) .entered(); // already dirty @@ -305,7 +289,7 @@ pub fn make_task_dirty_internal( "make task dirty", task_id = display(task_id), name = task_name, - cause = %TaskDirtyCauseInContext::new(&cause, ctx) + cause = %cause ) .entered(); diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/operation/prepare_new_children.rs b/turbopack/crates/turbo-tasks-backend/src/backend/operation/prepare_new_children.rs index e31f67cc7112..693549a9c3e5 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backend/operation/prepare_new_children.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backend/operation/prepare_new_children.rs @@ -14,7 +14,7 @@ pub fn prepare_new_children( parent_task: &mut impl TaskGuard, new_children: &FxHashSet, queue: &mut AggregationUpdateQueue, -) { +) -> u32 { debug_assert!(!new_children.is_empty()); let children_count = new_children.len(); @@ -52,4 +52,6 @@ pub fn prepare_new_children( }); } }; + + future_parent_aggregation } diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_collectible.rs b/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_collectible.rs index b74476bda9f6..a6c35f4a5a61 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_collectible.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_collectible.rs @@ -8,7 +8,7 @@ use crate::{ TaskDataCategory, operation::{ AggregatedDataUpdate, AggregationUpdateJob, AggregationUpdateQueue, ExecuteContext, - Operation, get_aggregation_number, is_root_node, + Operation, }, storage_schema::TaskStorageAccessors, }, @@ -25,31 +25,18 @@ impl UpdateCollectibleOperation { mut ctx: impl ExecuteContext<'_>, ) { let mut task = ctx.task(task_id, TaskDataCategory::All); - if count < 0 { - // Ensure it's an root node - loop { - let aggregation_number = get_aggregation_number(&task); - if is_root_node(aggregation_number) { - break; - } - drop(task); - { - let _span = tracing::trace_span!( - "make root node for removing collectible", - task = ctx.debug_get_task_description(task_id) - ) - .entered(); - AggregationUpdateQueue::run( - AggregationUpdateJob::UpdateAggregationNumber { - task_id, - base_aggregation_number: u32::MAX, - distance: None, - }, - &mut ctx, - ); - } - task = ctx.task(task_id, TaskDataCategory::All); - } + if count < 0 + && task + .get_persistent_task_type() + .is_some_and(|t| !t.native_fn.is_root) + { + drop(task); + panic!( + "Removing collectibles from non-root task {} (emitting task: {}). The `root` \ + attribute is missing on the task.", + ctx.debug_get_task_description(task_id), + ctx.debug_get_task_description(task_id) + ); } let mut queue = AggregationUpdateQueue::new(); let outdated = task.get_outdated_collectibles(&collectible).copied(); diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs b/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs index dbb56326196e..3120b8947c7c 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs @@ -149,6 +149,18 @@ pub struct Storage { /// Contains a copy of the pre-snapshot state that needs to be persisted. /// - `None`: Task was first modified during snapshot mode (not part of current snapshot). Will /// be marked as modified at the beginning of the next snapshot cycle. + /// + /// Lock Ordering: `snapshots` locks are acquired **after** `map` locks (see the comment on + /// `map` below). Holding a `snapshots` shard write lock and then trying to take a `map` shard + /// write lock is forbidden — it would deadlock against `track_modification_internal` / + /// `SnapshotShardIter::next`, which take map first. + /// + /// Shard Invariant: `snapshots` is constructed with the same `shard_amount`, the same key + /// type (`TaskId`), and the same stateless hasher (`FxBuildHasher`) as `map`. Therefore shard + /// index `N` in `snapshots` corresponds exactly to shard index `N` in `map`: any `TaskId` + /// present in `snapshots.shards()[N]` (if present in `map` at all) is in `map.shards()[N]`. + /// Code that walks both maps in parallel (e.g. `end_snapshot`) relies on this to lock pairs + /// of shards by index instead of going through the top-level `DashMap` accessors. snapshots: FxDashMap>>, /// The main storage map /// @@ -156,6 +168,11 @@ pub struct Storage { /// Because both datastructures are sharded on different keys, the locks are not 'strictly' /// ordered but we should treat them as such /// Acquiring locks in the opposite order should be defensive + /// + /// Lock Ordering vs. `snapshots`: `map` locks are acquired **before** `snapshots` locks. + /// `track_modification_internal` and `SnapshotShardIter::next` both hold a `map` shard write + /// lock (via `StorageWriteGuard` / `map.get_mut`) and then take a `snapshots` shard lock. + /// `end_snapshot` must lock in the same order — see the shard-zipping pattern there. map: FxDashMap>, /// A shared event notified whenever any task finishes restoring (successfully or not). /// @@ -372,24 +389,57 @@ impl Storage { // The snapshots map should be small (only tasks concurrently accessed during snapshot // mode). Increment the per-shard modified counts for promoted tasks. - // Lock Ordering: Note, in track_modification_internal, we modify the snapshots map while - // holding a StorageWriteGuard and here we do the opposite. This is fine because that code - // only runs when `snapshot_mode==true` and this loop only runs when it is false. - parallel::for_each(self.snapshots.shards(), |shard| { - let mut shard_guard = shard.write(); - for (key, _) in shard_guard.drain() { - if let Some(mut inner) = self.map.get_mut(&key) { - self.promote_during_snapshot_flags(&mut inner, self.shard_index(&key)); + // Lock Ordering: we must acquire `map` shards BEFORE `snapshots` shards, matching the + // order used by `track_modification_internal` and `SnapshotShardIter::next`. The + // previous implementation drained `snapshots` first and then called `self.map.get_mut`, + // which is the opposite order — a concurrent `track_modification` (holding map[N], about + // to insert into snapshots[N]) could deadlock against it through the + // `snapshot_mode = false` race window. + // + // Shard pairing: `map` and `snapshots` are constructed with the same `shard_amount`, + // same `TaskId` keys, and the same stateless `FxBuildHasher`. Therefore shard `N` in + // `snapshots` pairs with shard `N` in `map`: every key drained from `snapshots[N]` (if + // it still exists in `map`) lives in `map[N]`. We zip them and lock each pair in order. + let map_shards = self.map.shards(); + let snapshot_shards = self.snapshots.shards(); + debug_assert_eq!( + map_shards.len(), + snapshot_shards.len(), + "map and snapshots must share shard count for zipped locking; see Shard Invariant on \ + `snapshots` field" + ); + + let shard_indices: Vec = (0..map_shards.len()).collect(); + parallel::for_each(&shard_indices, |&shard_idx| { + let map_shard = &map_shards[shard_idx]; + let snap_shard = &snapshot_shards[shard_idx]; + + // Acquire in documented order: map first, snapshots second. + let map_guard = map_shard.write(); + let mut snap_guard = snap_shard.write(); + + for (key, _) in snap_guard.drain() { + // The key is in this shard's `map` (or absent entirely), by the shard + // invariant above. Resolve directly in the held map guard rather than going + // through `self.map.get_mut`, which would attempt to re-acquire this shard's + // write lock and would also obscure the pairing. + let hash = self.map.hasher().hash_one(key); + if let Some(bucket) = map_guard.find(hash, |(k, _)| *k == key) { + // SAFETY: We hold `map_shard`'s write lock for the duration of this + // access, so the bucket pointer is valid and no other thread can alias it. + let (_, shared_value) = unsafe { bucket.as_mut() }; + self.promote_during_snapshot_flags(shared_value.get_mut(), shard_idx); } } // If we are saving a non-trivial amount of memory just clear it out. - if shard_guard.capacity() > 1024 { - shard_guard.shrink_to(0, |_entry| { + if snap_guard.capacity() > 1024 { + snap_guard.shrink_to(0, |_entry| { unreachable!("nothing is hashed when resizing an empty shard to zero"); }); } - // Safety: shard_guard must outlive the iterator. - drop(shard_guard); + + drop(snap_guard); + drop(map_guard); }); } diff --git a/turbopack/crates/turbo-tasks-backend/src/utils/dash_map_multi.rs b/turbopack/crates/turbo-tasks-backend/src/utils/dash_map_multi.rs index f6782b2b8a7f..53ec3c92079c 100644 --- a/turbopack/crates/turbo-tasks-backend/src/utils/dash_map_multi.rs +++ b/turbopack/crates/turbo-tasks-backend/src/utils/dash_map_multi.rs @@ -1,5 +1,6 @@ use std::{ hash::{BuildHasher, Hash}, + marker::PhantomData, ops::{Deref, DerefMut}, sync::Arc, }; @@ -18,18 +19,29 @@ pub enum RefMut<'a, K, V> { Shared { _guard: Arc>, bucket: Bucket<(K, SharedValue)>, + // Ensures that RefMut is !Send, preventing holding RefMut across .await points in async + // code, which can cause deadlocks. See safety comment on `unsafe impl Sync for RefMut` + // below. + phantom: std::marker::PhantomData<*const ()>, }, } -// SAFETY: `RefMut` contains a raw `Bucket` pointer into a `DashMap` shard's `RawTable`. -// Sending/sharing is safe because: +// `RefMut` is intentionally **not** `Send`. While sending the guard across threads would be sound +// under the same reasoning that justifies `Sync` below, allowing `Send` makes it possible to hold +// a `RefMut` (and therefore a `StorageWriteGuard`) across an `.await` in async code, since the +// compiler will then accept the resulting future as `Send`. That pattern causes hard async +// deadlocks: the guard parks together with the suspended future and pins the shard's write lock, +// while every other tokio worker piles up trying to take the same lock — leaving no thread free +// to poll the parked future. Marking the type `!Send` makes the borrow checker reject those call +// sites at compile time. +// SAFETY (Sync): `RefMut` contains a raw `Bucket` pointer into a `DashMap` shard's `RawTable`. +// Sharing `&RefMut` is safe because: // - `Simple` variant: The `Bucket` is accessed under an exclusive `RwLockWriteGuard` on a single // shard. The guard provides exclusive access to all data in that shard. // - `Shared` variant: The `Bucket` is accessed under an `Arc`. The // `get_multiple_mut` function asserts that bucket pointers do not alias, so each `RefMut` has // exclusive access to its bucket even when sharing a guard. // - `K: Sync + V: Sync` bounds ensure the key and value types are safe to share across threads. -unsafe impl Send for RefMut<'_, K, V> {} unsafe impl Sync for RefMut<'_, K, V> {} impl RefMut<'_, K, V> { @@ -161,10 +173,12 @@ where RefMut::Shared { _guard: guard.clone(), bucket: bucket1, + phantom: PhantomData, }, RefMut::Shared { _guard: guard, bucket: bucket2, + phantom: PhantomData, }, ) } else { diff --git a/turbopack/crates/turbo-tasks-backend/tests/all_in_one.rs b/turbopack/crates/turbo-tasks-backend/tests/all_in_one.rs index 785437a9baab..b67042c7745b 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/all_in_one.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/all_in_one.rs @@ -25,7 +25,7 @@ async fn test_all_in_one() { .unwrap() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn test_all_in_one_operation(nonce: u32) -> Result> { let _ = nonce; // ensure the nonce is part of our cache key diff --git a/turbopack/crates/turbo-tasks-backend/tests/basic.rs b/turbopack/crates/turbo-tasks-backend/tests/basic.rs index a4c2e827518e..0a145fcc0fc8 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/basic.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/basic.rs @@ -20,7 +20,7 @@ async fn test_basic() { .unwrap() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn test_basic_operation(nonce: u32) -> Result> { let _ = nonce; // ensure the nonce is part of our cache key diff --git a/turbopack/crates/turbo-tasks-backend/tests/bug.rs b/turbopack/crates/turbo-tasks-backend/tests/bug.rs index d80e7f38fd21..7a78c70daeef 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/bug.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/bug.rs @@ -38,7 +38,7 @@ async fn test_graph_bug() { .unwrap() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn test_graph_bug_operation(nonce: u32) -> Result> { let _ = nonce; // ensure the nonce is part of our cache key @@ -190,7 +190,7 @@ async fn test_graph_bug_operation(nonce: u32) -> Result> { Ok(Vc::cell(())) } -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn run_task(spec: Vc, task: u16) -> Result> { let spec_ref = spec.await?; let task = &spec_ref[task as usize]; diff --git a/turbopack/crates/turbo-tasks-backend/tests/bug2.rs b/turbopack/crates/turbo-tasks-backend/tests/bug2.rs index 4e79888c051b..89ef590845c4 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/bug2.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/bug2.rs @@ -49,7 +49,7 @@ async fn test_graph_bug() { .unwrap() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn test_graph_bug_operation(nonce: u32) -> Result> { let _ = nonce; // ensure the nonce is part of our cache key @@ -98,7 +98,7 @@ fn create_iteration() -> Vc { Vc::cell(State::new(0)) } -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn run_task_chain( spec: Arc>, iteration: Vc, @@ -116,7 +116,7 @@ async fn run_task_chain( Ok(Vc::cell(())) } -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn run_task( spec: Arc>, iteration: Vc, diff --git a/turbopack/crates/turbo-tasks-backend/tests/call_types.rs b/turbopack/crates/turbo-tasks-backend/tests/call_types.rs index 9a9b5e400fa2..8c619c3af197 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/call_types.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/call_types.rs @@ -24,7 +24,7 @@ async fn test_functions() { .unwrap() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn test_functions_operation(nonce: u32) -> Result> { let _ = nonce; // ensure the nonce is part of our cache key @@ -77,7 +77,7 @@ async fn test_methods() { .unwrap() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn test_methods_operation() -> Result> { assert_eq!(*Value::static_method().await?, 42); assert_eq!(*Value::async_static_method().await?, 42); @@ -138,7 +138,7 @@ async fn test_trait_methods() { .unwrap() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn test_trait_methods_operation() -> Result> { assert_eq!(*Value::static_trait_method().await?, 42); assert_eq!(*Value::async_static_trait_method().await?, 42); diff --git a/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs b/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs index 003825907b5b..d9dd58894e17 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs @@ -165,7 +165,7 @@ async fn taking_collectibles_with_resolve() { #[turbo_tasks::value(transparent)] struct Collectibles(AutoSet>>); -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn my_collecting_function() -> Result> { let result_op = my_transitive_emitting_function(rcstr!(""), rcstr!("")); let result_vc = result_op.connect(); @@ -174,7 +174,7 @@ async fn my_collecting_function() -> Result> { Ok(result_vc) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn my_collecting_function_indirect() -> Result> { let result_op = my_collecting_function(); let result_vc = result_op.connect(); @@ -186,7 +186,7 @@ async fn my_collecting_function_indirect() -> Result> { Ok(result_vc) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn my_multi_emitting_function() -> Result> { my_transitive_emitting_function(rcstr!(""), rcstr!("a")) .connect() @@ -198,14 +198,14 @@ async fn my_multi_emitting_function() -> Result> { Ok(Thing::cell(Thing(0))) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn my_transitive_emitting_function(key: RcStr, key2: RcStr) -> Result> { let _ = key2; my_emitting_function(key).await?; Ok(Thing::cell(Thing(0))) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn my_transitive_emitting_function_collectibles( key: RcStr, key2: RcStr, @@ -216,7 +216,7 @@ fn my_transitive_emitting_function_collectibles( )) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn my_transitive_emitting_function_with_child_scope( key: RcStr, key2: RcStr, @@ -248,7 +248,7 @@ fn my_transitive_emitting_function_with_thing(key: RcStr, _thing: Vc) -> Ok(()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn my_transitive_emitting_function_with_resolve(key: RcStr) -> Result<()> { let _ = my_transitive_emitting_function_with_thing(key, get_thing(0)); Ok(()) diff --git a/turbopack/crates/turbo-tasks-backend/tests/debug.rs b/turbopack/crates/turbo-tasks-backend/tests/debug.rs index ff21b3bd0965..340d690919f6 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/debug.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/debug.rs @@ -10,7 +10,7 @@ use turbo_tasks_testing::{Registration, register, run_once}; static REGISTRATION: Registration = register!(); -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn dbg_operation(value: ResolvedVc>) -> anyhow::Result> { let trait_ref = value.into_trait_ref().await?; let s = trait_ref.dbg().await?; 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 1c1f6cbed755..d1611692ad5c 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 @@ -10,7 +10,7 @@ use turbo_tasks_testing::{Registration, register, run_once}; static REGISTRATION: Registration = register!(); -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn to_string_operation(value: ResolvedVc>) -> Vc { value.to_string() } diff --git a/turbopack/crates/turbo-tasks-backend/tests/detached.rs b/turbopack/crates/turbo-tasks-backend/tests/detached.rs index 8af61c0657cc..28106567c28a 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/detached.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/detached.rs @@ -68,7 +68,7 @@ impl TraceRawVcs for WatchSenderTaskInput { } } -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn spawns_detached( notify: TransientInstance, sender: TransientInstance>>>, @@ -138,7 +138,7 @@ struct ChangingInput { state: State, } -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn spawns_detached_changing( sender: TransientInstance>>>, changing_input_detached: Vc, 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 565db339ccfc..6a95c75e1701 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/dirty_in_progress.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/dirty_in_progress.rs @@ -70,7 +70,7 @@ impl ValueToString for Collectible { } } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn inner_compute(input: ResolvedVc) -> Result> { println!("start inner_compute"); let value = *input.await?.state.get(); @@ -88,7 +88,7 @@ async fn inner_compute(input: ResolvedVc) -> Result> { } } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn compute_operation(input: ResolvedVc) -> Result> { println!("start compute"); let operation = inner_compute(input); diff --git a/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs b/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs index f9f779a816de..7f42e9e7d739 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs @@ -45,7 +45,7 @@ async fn test_emptied_cells() { .unwrap(); } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn get_state_operation() -> Vc { ChangingInput { state: State::new(0), @@ -58,7 +58,7 @@ struct ChangingInput { state: State, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn compute_operation(input: ResolvedVc) -> Result> { println!("compute_operation()"); let value = *inner_compute(*input).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 e0a6ab32e81b..aa5266bff16c 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 @@ -45,7 +45,7 @@ async fn test_emptied_cells_session_dependent() { .unwrap(); } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn get_state_operation() -> Vc { ChangingInput { state: State::new(0), @@ -58,7 +58,7 @@ struct ChangingInput { state: State, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn compute_operation(input: ResolvedVc) -> Result> { println!("compute_operation()"); let value = *inner_compute(*input).await?; diff --git a/turbopack/crates/turbo-tasks-backend/tests/eviction.rs b/turbopack/crates/turbo-tasks-backend/tests/eviction.rs index 8e49afea171b..2fa4a7088a01 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/eviction.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/eviction.rs @@ -230,7 +230,7 @@ async fn eviction_dependency_chain() { #[turbo_tasks::value(transparent)] struct Step(State); -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn create_state(initial: u32) -> Vc { Step(State::new(initial)).cell() } @@ -241,7 +241,7 @@ struct Output { random: u32, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn compute(input: ResolvedVc) -> Result> { let value = *input.await?.get(); Ok(Output { @@ -259,7 +259,7 @@ async fn double(input: ResolvedVc) -> Result> { } /// Outer function that depends on `double` -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn compute_chain(input: ResolvedVc) -> Result> { let doubled = double(input); let value = *doubled.connect().await?; @@ -274,25 +274,25 @@ async fn compute_chain(input: ResolvedVc) -> Result> { // Deep chain helpers — each layer reads the previous layer's output // ========================================================================= -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn add_one(input: ResolvedVc) -> Result> { let value = *input.await?.get(); Ok(Vc::cell(value + 1)) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn times_three(input: ResolvedVc) -> Result> { let value = *input.await?; Ok(Vc::cell(value * 3)) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn plus_ten(input: ResolvedVc) -> Result> { let value = *input.await?; Ok(Vc::cell(value + 10)) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn deep_chain(input: ResolvedVc) -> Result> { // input → add_one → times_three → plus_ten → Output // For input=10: (10+1)*3+10 = 43 @@ -324,7 +324,7 @@ struct SessionCounter { /// Because this task is only resolved (not directly read) by the top-level /// transient task, it has no transient dependents and is eligible for eviction /// consideration — but should be blocked by the session-stateful flag. -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn create_session_counter(initial: u32) -> Vc { SessionCounter { count: initial }.cell() } @@ -333,7 +333,7 @@ fn create_session_counter(initial: u32) -> Vc { /// doesn't need to resolve it directly (which would add a transient dependent /// edge to create_session_counter, preventing us from testing the /// session-stateful eviction gate). -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn read_session_counter(initial: u32) -> Result> { let counter = create_session_counter(initial) .resolve() @@ -465,14 +465,14 @@ async fn eviction_transient_reader_invalidated() { /// Adds an offset to a value — the offset parameter makes each call a unique /// memoized task, creating truly independent intermediate tasks for fan-out. -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn add_offset(input: ResolvedVc, offset: u32) -> Result> { let value = *input.await?.get(); Ok(Vc::cell(value.wrapping_add(offset))) } /// Multiplies by a factor — unique per factor argument. -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn multiply(input: ResolvedVc, factor: u32) -> Result> { let value = *input.await?; Ok(Vc::cell(value.wrapping_mul(factor))) @@ -482,7 +482,7 @@ async fn multiply(input: ResolvedVc, factor: u32) -> Result> { /// single state. Each chain uses unique arguments (offset/factor) so they /// produce distinct memoized tasks — `width * 2` intermediate persistent tasks /// that are candidates for eviction. -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn fan_out(input: ResolvedVc, width: u32) -> Result> { let mut total = 0u32; for i in 0..width { @@ -626,7 +626,7 @@ fn create_session_alive() -> Vc { /// run_once — and is eligible for the eviction sweep. The `Step` input /// gives us a knob to invalidate this reader (forcing re-read of the /// writer's cell) without invalidating `create_session_alive` itself. -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn read_session_alive_id(state: ResolvedVc) -> Result> { let _state = *state.await?.get(); let v = create_session_alive().resolve().await?; 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 c21a206a7703..94ee2556f390 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/hashed_cell_mode.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/hashed_cell_mode.rs @@ -45,14 +45,14 @@ struct ConsumeResult { random: u32, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn create_state_operation() -> Vc { Step(State::new(0)).cell() } /// Produces a HashedValue from a state. The noise field changes each execution /// but does not affect hash or equality. -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn produce_hashed(input: ResolvedVc) -> Result> { let value = *input.await?.get(); let noise = EXECUTION_COUNTER.fetch_add(1, Ordering::Relaxed) as u64; @@ -60,7 +60,7 @@ async fn produce_hashed(input: ResolvedVc) -> Result> { } /// Consumes the HashedValue and records a random number to detect re-execution. -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn consume_hashed(input: ResolvedVc) -> Result> { let hashed = produce_hashed(input).connect(); let v = hashed.await?; diff --git a/turbopack/crates/turbo-tasks-backend/tests/immutable.rs b/turbopack/crates/turbo-tasks-backend/tests/immutable.rs index 16ed05236d7e..d46f5adadbab 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/immutable.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/immutable.rs @@ -68,7 +68,7 @@ async fn create_input() -> Result> { .cell()) } -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn compute(input: Vc) -> Result> { println!("compute()"); let input = input.await?; @@ -76,14 +76,14 @@ async fn compute(input: Vc) -> Result> { Ok(Value { value: *value }.cell()) } -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn read_input(input: Vc) -> Result> { println!("read_input()"); let value = input.await?; Ok(Vc::cell(value.value)) } -#[turbo_tasks::function] +#[turbo_tasks::function(root)] fn immutable_fn(input: Vc) -> Vc { let _ = input; println!("immutable_fn()"); @@ -92,13 +92,13 @@ fn immutable_fn(input: Vc) -> Vc { #[turbo_tasks::value_impl] impl Value { - #[turbo_tasks::function] + #[turbo_tasks::function(root)] fn read_self(&self) -> Vc { println!("read_self()"); Vc::cell(self.value) } - #[turbo_tasks::function] + #[turbo_tasks::function(root)] fn immutable_self_fn(self: Vc) -> Vc { let _ = self; println!("immutable_self_fn()"); diff --git a/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs b/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs index 99801e1d9e15..e834f17fa1e7 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs @@ -12,7 +12,7 @@ static REGISTRATION: Registration = register!(); #[turbo_tasks::value(transparent)] struct Step(State); -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn create_state_operation() -> Vc { Step(State::new(0)).cell() } @@ -70,7 +70,7 @@ async fn test_invalidation_map() { #[turbo_tasks::value(transparent, cell = "keyed")] struct Map(FxHashMap); -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn create_map(step: ResolvedVc) -> Result> { let step = step.await?; let step_value = step.get(); @@ -89,7 +89,7 @@ struct GetValueResult { random: u32, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_value(map: OperationVc, key: String) -> Result> { let map = map.connect(); let value = map.get(&key).await?.as_deref().copied(); @@ -151,7 +151,7 @@ async fn test_invalidation_set() { #[turbo_tasks::value(transparent, cell = "keyed")] struct Set(FxHashSet); -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn create_set(step: ResolvedVc) -> Result> { let step = step.await?; let step_value = step.get(); @@ -170,7 +170,7 @@ struct HasValueResult { random: u32, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn has_value(set: OperationVc, key: String) -> Result> { let set = set.connect(); let value = set.contains_key(&key).await?; diff --git a/turbopack/crates/turbo-tasks-backend/tests/non_root_task_panic.rs b/turbopack/crates/turbo-tasks-backend/tests/non_root_task_panic.rs new file mode 100644 index 000000000000..22669c60c8ae --- /dev/null +++ b/turbopack/crates/turbo-tasks-backend/tests/non_root_task_panic.rs @@ -0,0 +1,65 @@ +#![feature(arbitrary_self_types)] +#![feature(arbitrary_self_types_pointers)] +#![allow(clippy::needless_return)] + +use anyhow::Result; +use turbo_tasks::Vc; +use turbo_tasks_testing::{Registration, register, run_once_without_cache_check}; + +static REGISTRATION: Registration = register!(); + +#[turbo_tasks::value] +#[derive(Clone, Debug)] +struct Value { + value: u32, +} + +// NOT marked with `root` — this is the key +#[turbo_tasks::function(operation)] +async fn non_root_operation() -> Result> { + Ok(Value { value: 42 }.cell()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_strongly_consistent_read_of_non_root_task_panics() { + // The panic happens on a worker thread. Capture the message via a panic hook. + let panic_message = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let panic_message_clone = panic_message.clone(); + + let prev_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let msg = if let Some(s) = info.payload().downcast_ref::() { + s.clone() + } else if let Some(s) = info.payload().downcast_ref::<&str>() { + s.to_string() + } else { + format!("{info}") + }; + if msg.contains("root") { + *panic_message_clone.lock().unwrap() = Some(msg); + } + })); + + // Spawn to catch the unwinding panic from the channel close on the test task. + let handle = tokio::task::spawn(async move { + run_once_without_cache_check(®ISTRATION, async move { + non_root_operation().read_strongly_consistent().await + }) + .await + }); + // The spawned task will fail because the worker thread panics and the channel closes. + let _result = handle.await; + + std::panic::set_hook(prev_hook); + + let msg = panic_message.lock().unwrap().take(); + assert!( + msg.is_some(), + "Expected a panic about missing `root` attribute on the worker thread" + ); + let msg = msg.unwrap(); + assert!( + msg.contains("root"), + "Panic message should mention 'root', got: {msg}" + ); +} diff --git a/turbopack/crates/turbo-tasks-backend/tests/operation_vc.rs b/turbopack/crates/turbo-tasks-backend/tests/operation_vc.rs index aef0f936383e..e7acd8656a8d 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/operation_vc.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/operation_vc.rs @@ -7,18 +7,18 @@ use turbo_tasks_testing::{Registration, register, run}; static REGISTRATION: Registration = register!(); -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn bare_op_fn() -> Vc { Vc::cell(21) } // operations can take `ResolvedVc`s too (anything that's a `NonLocalValue`). -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn multiply(value: OperationVc, coefficient: ResolvedVc) -> Result> { Ok(Vc::cell((*value.connect().await?) * (*coefficient.await?))) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn use_operations() -> Vc { let twenty_one: OperationVc = bare_op_fn(); let forty_two: OperationVc = multiply(twenty_one, ResolvedVc::cell(2)); diff --git a/turbopack/crates/turbo-tasks-backend/tests/random_change.rs b/turbopack/crates/turbo-tasks-backend/tests/random_change.rs index 2e87a84e447d..27b128735878 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/random_change.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/random_change.rs @@ -48,7 +48,7 @@ struct ValueContainer { state: State, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn make_state_operation() -> Vc { ValueContainer { state: State::new(0), @@ -56,7 +56,7 @@ fn make_state_operation() -> Vc { .cell() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn func2_operation(input: ResolvedVc) -> Result> { let state = input.await?; let value = state.state.get(); @@ -64,7 +64,7 @@ async fn func2_operation(input: ResolvedVc) -> Result> Ok(func(*input, -*value)) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn func_operation(input: ResolvedVc) -> Vc { func(*input, 0) } 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 cdbcbbdd6841..83c816a23d20 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/read_ref_cell.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/read_ref_cell.rs @@ -77,7 +77,7 @@ impl Counter { #[turbo_tasks::value_impl] impl Counter { - #[turbo_tasks::function] + #[turbo_tasks::function(root)] fn get_value(&self) -> Result> { let mut lock = self.value.lock().unwrap(); lock.1.insert(get_invalidator().unwrap()); @@ -87,7 +87,7 @@ impl Counter { #[turbo_tasks::value_impl] impl CounterValue { - #[turbo_tasks::function] + #[turbo_tasks::function(root)] fn get_value(self: Vc) -> Vc { self } diff --git a/turbopack/crates/turbo-tasks-backend/tests/recompute.rs b/turbopack/crates/turbo-tasks-backend/tests/recompute.rs index f818137a1d5f..220c0772c296 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/recompute.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/recompute.rs @@ -103,7 +103,7 @@ struct VcHolder { #[turbo_tasks::value_impl] impl VcHolder { - #[turbo_tasks::function] + #[turbo_tasks::function(root)] fn compute(&self) -> Vc { compute(*self.vc, *self.vc) } @@ -116,7 +116,7 @@ struct Output { random_value: u32, } -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn compute(input: Vc, input2: Vc) -> Result> { let state_value = *input.await?.state.get(); let state_value2 = if state_value < 5 { @@ -227,7 +227,7 @@ async fn inner_compute(input: Vc) -> Result> { } /// Outer task - depends on inner_compute -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn outer_compute(input: Vc) -> Result> { println!("outer_compute()"); let inner_result = *inner_compute(input).await?; diff --git a/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs b/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs index c11d057209d4..ba296a62146b 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs @@ -80,7 +80,7 @@ impl ValueToString for Collectible { } } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn inner_compute( input: ResolvedVc, input2: ResolvedVc, @@ -103,7 +103,7 @@ async fn inner_compute2(input: Vc, innerness: u32) -> Result, input2: ResolvedVc, diff --git a/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs b/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs index 44eba3581d0e..7deaa6d0bdf7 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs @@ -30,7 +30,7 @@ async fn test_conversion() -> Result<()> { run_once(®ISTRATION, move || { nonce += 1; async move { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn test_operation(nonce: u32) -> Result> { let _ = nonce; // ensure the nonce is part of our cache key let unresolved: Vc = Vc::cell(42); @@ -54,7 +54,7 @@ async fn test_cell_construction() -> Result<()> { run_once(®ISTRATION, move || { nonce += 1; async move { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn test_operation(nonce: u32) -> Result> { let _ = nonce; let a: ResolvedVc = ResolvedVc::cell(42); @@ -75,7 +75,7 @@ async fn test_resolved_vc_as_arg() -> Result<()> { run_once(®ISTRATION, move || { nonce += 1; async move { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn test_operation(nonce: u32) -> Result> { dbg!(nonce); let _ = nonce; @@ -97,7 +97,7 @@ async fn test_into_future() -> Result<()> { run_once(®ISTRATION, move || { nonce += 1; async move { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn test_operation(nonce: u32) -> Result> { let _ = nonce; let mut resolved = ResolvedVc::cell(42); diff --git a/turbopack/crates/turbo-tasks-backend/tests/scope_stress.rs b/turbopack/crates/turbo-tasks-backend/tests/scope_stress.rs index d2a398f59952..f44073088052 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/scope_stress.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/scope_stress.rs @@ -35,7 +35,7 @@ async fn rectangle_stress() -> Result<()> { /// This fills a rectagle from (0, 0) to (a, b) by /// first filling (0, 0) to (a - 1, b) and then (0, 0) to (a, b - 1) recursively -#[turbo_tasks::function] +#[turbo_tasks::function(root)] async fn rectangle(a: u32, b: u32) -> Result> { if a > 0 { rectangle(a - 1, b).await?; 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 4087c21927f2..cd6c62251f9d 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/shrink_to_fit.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/shrink_to_fit.rs @@ -14,7 +14,7 @@ struct Wrapper(Vec); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_shrink_to_fit() -> Result<()> { run_once(®ISTRATION, || async { - #[turbo_tasks::function(operation)] + #[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 7549a86f4a54..4acf9ee08e34 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 @@ -16,7 +16,7 @@ struct Value { value: u32, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn returns_value_operation() -> Result> { Ok(Value { value: 42 }.cell()) } @@ -72,7 +72,7 @@ async fn test_manual_mark_unmark_top_level_task() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[should_panic] async fn test_manual_mark_top_level_task_causes_error() { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn operation() -> Result> { // Manually mark as top-level task mark_top_level_task(); diff --git a/turbopack/crates/turbo-tasks-backend/tests/trace_transient.rs b/turbopack/crates/turbo-tasks-backend/tests/trace_transient.rs index 6b991b992b0d..5a95cb6c2fb4 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/trace_transient.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/trace_transient.rs @@ -35,7 +35,7 @@ async fn test_trace_transient() { assert!(message.contains(&EXPECTED_TRACE.to_string())); } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn test_trace_transient_operation( arg1: ResolvedVc<()>, arg2: ResolvedVc, 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 b5df9862c6fd..f0d0710d2137 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs @@ -86,7 +86,7 @@ impl Counter { #[turbo_tasks::value_trait] trait CounterTrait { - #[turbo_tasks::function] + #[turbo_tasks::function(root)] fn get_value(&self) -> Vc; fn get_value_sync(&self) -> CounterValue; @@ -94,7 +94,7 @@ trait CounterTrait { #[turbo_tasks::value_impl] impl CounterTrait for Counter { - #[turbo_tasks::function] + #[turbo_tasks::function(root)] fn get_value(&self) -> Result> { let mut lock = self.value.lock().unwrap(); lock.1.insert(get_invalidator().unwrap()); @@ -108,13 +108,13 @@ impl CounterTrait for Counter { #[turbo_tasks::value_trait] trait CounterValueTrait { - #[turbo_tasks::function] + #[turbo_tasks::function(root)] fn get_value(&self) -> Vc; } #[turbo_tasks::value_impl] impl CounterValueTrait for CounterValue { - #[turbo_tasks::function] + #[turbo_tasks::function(root)] fn get_value(self: Vc) -> Vc { self } diff --git a/turbopack/crates/turbo-tasks-backend/tests/transient_collectible.rs b/turbopack/crates/turbo-tasks-backend/tests/transient_collectible.rs index 542a843c39bc..ddbcb6e5d694 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/transient_collectible.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/transient_collectible.rs @@ -24,7 +24,7 @@ async fn test_transient_emit_from_persistent() { assert!(message.contains(&EXPECTED_MSG.to_string())); } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn emit_incorrect_task_input_operation(value: IncorrectTaskInput) { turbo_tasks::emit(ResolvedVc::upcast::>(value.0)); } diff --git a/turbopack/crates/turbo-tasks-backend/tests/transient_vc.rs b/turbopack/crates/turbo-tasks-backend/tests/transient_vc.rs index 9d1e93826cd3..3ee97c1f0993 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/transient_vc.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/transient_vc.rs @@ -17,7 +17,7 @@ async fn test_transient_vc() -> Result<()> { .await } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn test_transient_operation(transient_arg: TransientValue) -> Result<()> { let called_with_transient = has_transient_arg(transient_arg); let called_with_persistent = has_persistent_arg(123); diff --git a/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs b/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs index f1c3cf83c6ba..c7936bca43c9 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs @@ -16,13 +16,13 @@ struct FmtTest { count: u32, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn turbofmt_operation(value: ResolvedVc) -> anyhow::Result> { let s: RcStr = turbofmt!("prefix {} vc {}", 42u32, value).await?; Ok(Vc::cell(s)) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn turbobail_operation(value: ResolvedVc) -> anyhow::Result> { turbobail!("error: {} with {}", 42u32, value) } diff --git a/turbopack/crates/turbo-tasks-fetch/tests/fetch.rs b/turbopack/crates/turbo-tasks-fetch/tests/fetch.rs index 57f893501e14..5b9002c5dbd2 100644 --- a/turbopack/crates/turbo-tasks-fetch/tests/fetch.rs +++ b/turbopack/crates/turbo-tasks-fetch/tests/fetch.rs @@ -41,7 +41,7 @@ async fn basic_get() { #[turbo_tasks::value] struct FetchOutput(u16, RcStr); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn fetch_operation(url: RcStr) -> Result> { let client_vc = FetchClientConfig::default().cell(); let response = &*client_vc @@ -88,7 +88,7 @@ async fn sends_user_agent() { #[turbo_tasks::value] struct FetchOutput(u16, RcStr); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn fetch_operation(url: RcStr) -> Result> { let client_vc = FetchClientConfig::default().cell(); let response = &*client_vc @@ -137,7 +137,7 @@ async fn invalidation_does_not_invalidate() { #[turbo_tasks::value] struct FetchOutput(u16, RcStr, u16, RcStr); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn fetch_operation(url: RcStr) -> Result> { let client_vc = FetchClientConfig::default().cell(); let response = &*client_vc @@ -195,7 +195,7 @@ async fn errors_on_failed_connection() { StyledString, ); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn fetch_operation(url: RcStr) -> Result> { let client_vc = FetchClientConfig::default().cell(); let response_vc = client_vc.fetch(url.clone(), None); @@ -259,7 +259,7 @@ async fn errors_on_404() { StyledString, ); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn fetch_operation(url: RcStr) -> Result> { let client_vc = FetchClientConfig::default().cell(); let response_vc = client_vc.fetch(url.clone(), None); @@ -315,7 +315,7 @@ async fn client_cache() { let server_url = RcStr::from(server.url()); // a simple fetch that should always succeed - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn simple_fetch_operation(server_url: RcStr, path: RcStr) -> anyhow::Result<()> { let url = RcStr::from(format!("{}{}", server_url, path)); let response = match &*FetchClientConfig::default() diff --git a/turbopack/crates/turbo-tasks-fs/src/lib.rs b/turbopack/crates/turbo-tasks-fs/src/lib.rs index 6cb96168156c..3cc2bfcceae7 100644 --- a/turbopack/crates/turbo-tasks-fs/src/lib.rs +++ b/turbopack/crates/turbo-tasks-fs/src/lib.rs @@ -2952,7 +2952,7 @@ mod tests { use super::*; - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn extract_effects_operation(op: OperationVc<()>) -> anyhow::Result> { let _ = op.resolve().strongly_consistent().await?; Ok(take_effects(op).await?.cell()) @@ -3060,7 +3060,7 @@ mod tests { .unwrap(); } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn assert_try_from_sys_path_operation(sys_root: RcStr) -> anyhow::Result<()> { let sys_root = Path::new(sys_root.as_str()); let fs_vc = DiskFileSystem::new( @@ -3163,7 +3163,7 @@ mod tests { use super::extract_effects_operation; use crate::{DiskFileSystem, FileSystem, FileSystemPath, LinkContent, LinkType}; - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn test_write_link_effect_operation( fs: ResolvedVc, path: FileSystemPath, @@ -3273,7 +3273,7 @@ mod tests { const STRESS_TARGET_COUNT: usize = 20; const STRESS_SYMLINK_COUNT: usize = 16; - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] fn disk_file_system_operation(fs_root: RcStr) -> Vc { DiskFileSystem::new(rcstr!("test"), Vc::cell(fs_root)) } @@ -3285,7 +3285,7 @@ mod tests { } } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn write_symlink_stress_batch( fs: ResolvedVc, symlinks_dir: FileSystemPath, @@ -3450,7 +3450,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_denied_path_read() { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn test_operation(root: RcStr, denied_path: RcStr) -> anyhow::Result<()> { let fs = DiskFileSystem::new_with_denied_paths( rcstr!("test"), @@ -3513,7 +3513,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_denied_path_read_dir() { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn test_operation(root: RcStr, denied_path: RcStr) -> anyhow::Result<()> { let fs = DiskFileSystem::new_with_denied_paths( rcstr!("test"), @@ -3575,7 +3575,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_denied_path_read_glob() { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn test_operation(root: RcStr, denied_path: RcStr) -> anyhow::Result<()> { let fs = DiskFileSystem::new_with_denied_paths( rcstr!("test"), @@ -3641,7 +3641,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_denied_path_write() { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn write_file_operation( path: FileSystemPath, contents: RcStr, @@ -3656,7 +3656,7 @@ mod tests { /// Writes the allowed file and captures effects to be applied at /// the top level. - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn write_allowed_file_operation( root: RcStr, denied_path: RcStr, @@ -3675,7 +3675,7 @@ mod tests { Ok(take_effects(write_op).await?.cell()) } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn test_denied_writes_operation( root: RcStr, denied_path: RcStr, diff --git a/turbopack/crates/turbo-tasks-fs/src/read_glob.rs b/turbopack/crates/turbo-tasks-fs/src/read_glob.rs index 7061ab72628c..5fc904e31495 100644 --- a/turbopack/crates/turbo-tasks-fs/src/read_glob.rs +++ b/turbopack/crates/turbo-tasks-fs/src/read_glob.rs @@ -263,7 +263,7 @@ pub mod tests { } } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn assert_read_glob_basic_operation(path: RcStr) -> anyhow::Result<()> { let fs = DiskFileSystem::new(rcstr!("temp"), Vc::cell(path)); let root = fs.root().await?; @@ -306,7 +306,7 @@ pub mod tests { Ok(()) } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn assert_read_glob_symlinks_operation(path: RcStr) -> anyhow::Result<()> { let fs = DiskFileSystem::new(rcstr!("temp"), Vc::cell(path)); let root = fs.root().await?; @@ -357,7 +357,7 @@ pub mod tests { Ok(()) } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn assert_dead_symlink_read_glob_operation(path: RcStr) -> anyhow::Result<()> { let fs = Vc::upcast::>(DiskFileSystem::new(rcstr!("temp"), Vc::cell(path))); @@ -460,13 +460,13 @@ pub mod tests { .unwrap(); } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] pub async fn delete(path: FileSystemPath) -> anyhow::Result<()> { path.write(FileContent::NotFound.cell()).await?; Ok(()) } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] pub async fn write(path: FileSystemPath, contents: RcStr) -> anyhow::Result<()> { path.write( FileContent::Content(crate::File::from_bytes(contents.to_string().into_bytes())).cell(), @@ -475,25 +475,25 @@ pub mod tests { Ok(()) } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] pub fn track_star_star_glob(path: FileSystemPath) -> Vc { path.track_glob(Glob::new(rcstr!("**"), GlobOptions::default()), false) } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] fn disk_file_system_root_operation(path: RcStr) -> Vc { let fs = Vc::upcast::>(DiskFileSystem::new(rcstr!("temp"), Vc::cell(path))); fs.root() } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn extract_effects_operation(op: OperationVc<()>) -> anyhow::Result> { let _ = op.resolve().strongly_consistent().await?; Ok(take_effects(op).await?.cell()) } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn track_glob_operation(path: RcStr, glob: RcStr) -> anyhow::Result<()> { let root = disk_file_system_root_operation(path) .read_strongly_consistent() @@ -503,7 +503,7 @@ pub mod tests { Ok(()) } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn read_glob_operation(path: RcStr, glob: RcStr) -> anyhow::Result<()> { let root = disk_file_system_root_operation(path) .read_strongly_consistent() diff --git a/turbopack/crates/turbo-tasks-fuzz/src/fs_watcher.rs b/turbopack/crates/turbo-tasks-fuzz/src/fs_watcher.rs index 979dec89e792..ea84475bb95b 100644 --- a/turbopack/crates/turbo-tasks-fuzz/src/fs_watcher.rs +++ b/turbopack/crates/turbo-tasks-fuzz/src/fs_watcher.rs @@ -89,7 +89,7 @@ impl SymlinkMode { #[derive(Default, NonLocalValue, TraceRawVcs)] struct PathInvalidations(#[turbo_tasks(trace_ignore)] Arc>>); -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn extract_effects_operation(op: OperationVc<()>) -> anyhow::Result> { let _ = op.resolve().strongly_consistent().await?; Ok(take_effects(op).await?.cell()) diff --git a/turbopack/crates/turbo-tasks-fuzz/src/symlink_stress.rs b/turbopack/crates/turbo-tasks-fuzz/src/symlink_stress.rs index fd127676c603..8c9a716731d6 100644 --- a/turbopack/crates/turbo-tasks-fuzz/src/symlink_stress.rs +++ b/turbopack/crates/turbo-tasks-fuzz/src/symlink_stress.rs @@ -30,7 +30,7 @@ pub struct SymlinkStress { duration_secs: u64, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn extract_effects_operation(op: OperationVc<()>) -> anyhow::Result> { let _ = op.resolve().strongly_consistent().await?; Ok(take_effects(op).await?.cell()) 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 d904b04f47fb..b067e98ce5a0 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 { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn equality_operation() -> Result> { Ok(Vc::cell(ReadRef::ptr_eq( &one_unnamed_field(OneUnnamedField(42)).await?, 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 278fd87f99c1..5e6c49045c07 100644 --- a/turbopack/crates/turbo-tasks-macros-tests/tests/value_debug.rs +++ b/turbopack/crates/turbo-tasks-macros-tests/tests/value_debug.rs @@ -22,7 +22,7 @@ async fn ignored_indexes() { ); run_once(®ISTRATION, || async { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn value_debug_format_operation() -> Result> { let input = IgnoredIndexes(-1, 2, -3); let debug = input.value_debug_format(usize::MAX).try_to_string().await?; diff --git a/turbopack/crates/turbo-tasks-macros/src/value_impl_macro.rs b/turbopack/crates/turbo-tasks-macros/src/value_impl_macro.rs index 7c61b712a8fc..5b8c5173aecd 100644 --- a/turbopack/crates/turbo-tasks-macros/src/value_impl_macro.rs +++ b/turbopack/crates/turbo-tasks-macros/src/value_impl_macro.rs @@ -101,6 +101,7 @@ pub fn value_impl(args: TokenStream, input: TokenStream) -> TokenStream { } }; let is_self_used = func_args.operation.is_some() || is_self_used(block); + let is_root = func_args.root.is_some(); let Some(turbo_fn) = TurboFn::new( sig, @@ -112,7 +113,6 @@ pub fn value_impl(args: TokenStream, input: TokenStream) -> TokenStream { // An error occurred while parsing the function signature. }; }; - let inline_function_ident = turbo_fn.inline_ident(); let (inline_signature, inline_block) = turbo_fn.inline_signature_and_block(block); let inline_attrs = filter_inline_attributes(attrs.iter().copied()); @@ -123,7 +123,7 @@ pub fn value_impl(args: TokenStream, input: TokenStream) -> TokenStream { is_method: turbo_fn.is_method(), is_self_used, filter_trait_call_args: None, // not a trait method - is_root: false, + is_root, }; let native_function_ident = get_inherent_impl_function_ident(ty_ident, ident); @@ -206,6 +206,7 @@ pub fn value_impl(args: TokenStream, input: TokenStream) -> TokenStream { }; // operations are not currently compatible with methods let is_self_used = func_args.operation.is_some() || is_self_used(block); + let is_root = func_args.root.is_some(); let Some(turbo_fn) = TurboFn::new( sig, @@ -238,7 +239,7 @@ pub fn value_impl(args: TokenStream, input: TokenStream) -> TokenStream { is_method: turbo_fn.is_method(), is_self_used, filter_trait_call_args: turbo_fn.filter_trait_call_args(), - is_root: false, + is_root, }; let native_function_ident = diff --git a/turbopack/crates/turbo-tasks-macros/src/value_trait_macro.rs b/turbopack/crates/turbo-tasks-macros/src/value_trait_macro.rs index 152c17a1e46b..76338497f600 100644 --- a/turbopack/crates/turbo-tasks-macros/src/value_trait_macro.rs +++ b/turbopack/crates/turbo-tasks-macros/src/value_trait_macro.rs @@ -181,6 +181,7 @@ pub fn value_trait(args: TokenStream, input: TokenStream) -> TokenStream { } let is_self_used = default.as_ref().map(is_self_used).unwrap_or(false); + let is_root = func_args.root.is_some(); let Some(turbo_fn) = TurboFn::new( sig, DefinitionContext::ValueTrait, @@ -216,7 +217,7 @@ pub fn value_trait(args: TokenStream, input: TokenStream) -> TokenStream { is_method: turbo_fn.is_method(), is_self_used, filter_trait_call_args: turbo_fn.filter_trait_call_args(), - is_root: false, + is_root, }; let native_function_ident = get_trait_default_impl_function_ident(trait_ident, ident); @@ -232,6 +233,7 @@ pub fn value_trait(args: TokenStream, input: TokenStream) -> TokenStream { method_name: #method_name_str, default_method: Some(&#native_function_ident), index: #index, + is_root: #is_root, }, }); default_methods.push(quote! { Some(&#native_function_ident) }); @@ -269,6 +271,7 @@ pub fn value_trait(args: TokenStream, input: TokenStream) -> TokenStream { method_name: #method_name_str, default_method: None, index: #index, + is_root: #is_root, }, }); default_methods.push(quote! { None }); diff --git a/turbopack/crates/turbo-tasks/src/value_type.rs b/turbopack/crates/turbo-tasks/src/value_type.rs index 79336aa82f12..87b4f32ea998 100644 --- a/turbopack/crates/turbo-tasks/src/value_type.rs +++ b/turbopack/crates/turbo-tasks/src/value_type.rs @@ -271,6 +271,24 @@ turbo_registry!("Value", ValueType); // Single-threaded during Lazy init. pub(crate) fn register_all_trait_methods(_: &[&'static ValueType]) { for entry in inventory::iter:: { + for (i, impl_method) in entry.methods.iter().enumerate() { + let trait_method = &entry.trait_type.methods[i]; + if trait_method.is_root != impl_method.is_root { + let attr = if trait_method.is_root { + "the trait method has `#[turbo_tasks::function(root)]` but the impl does not" + } else { + "the impl has `#[turbo_tasks::function(root)]` but the trait method does not" + }; + panic!( + "`root` attribute mismatch on `{}::{}` for `{}`: {}. The `root` attribute \ + must match between trait and impl methods.", + trait_method.trait_name, + trait_method.method_name, + entry.value_type.ty.name, + attr, + ); + } + } entry .value_type .register_trait(entry.trait_type, entry.methods); @@ -285,6 +303,9 @@ pub struct TraitMethod { pub trait_name: &'static str, pub method_name: &'static str, pub default_method: Option<&'static NativeFunction>, + /// Whether the trait method declared `#[turbo_tasks::function(root)]`. All impls of a trait + /// method must agree on this attribute (enforced at registration time). + pub is_root: bool, } impl Hash for TraitMethod { fn hash(&self, state: &mut H) { diff --git a/turbopack/crates/turbopack-analyze/tests/split_chunk.rs b/turbopack/crates/turbopack-analyze/tests/split_chunk.rs index 49afcf0705da..e57d32b7d562 100644 --- a/turbopack/crates/turbopack-analyze/tests/split_chunk.rs +++ b/turbopack/crates/turbopack-analyze/tests/split_chunk.rs @@ -55,7 +55,7 @@ async fn split_chunk() { } .resolved_cell(); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] fn split_parts_operation(asset: ResolvedVc) -> Vc { split_output_asset_into_parts(Vc::upcast(*asset)) } @@ -96,7 +96,7 @@ async fn split_chunk() { }] ); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn compressed_size_operation( parts: OperationVc, index: usize, diff --git a/turbopack/crates/turbopack-cli/src/build/mod.rs b/turbopack/crates/turbopack-cli/src/build/mod.rs index 01467e051f5f..509638b5a718 100644 --- a/turbopack/crates/turbopack-cli/src/build/mod.rs +++ b/turbopack/crates/turbopack-cli/src/build/mod.rs @@ -180,13 +180,13 @@ impl TurbopackBuildBuilder { } } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn extract_effects_operation(op: OperationVc<()>) -> Result> { let _ = op.resolve().strongly_consistent().await?; Ok(take_effects(op).await?.cell()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn build_internal( project_dir: RcStr, root_dir: RcStr, diff --git a/turbopack/crates/turbopack-cli/src/dev/mod.rs b/turbopack/crates/turbopack-cli/src/dev/mod.rs index 6e87ba0eeeee..ddd128a89fb7 100644 --- a/turbopack/crates/turbopack-cli/src/dev/mod.rs +++ b/turbopack/crates/turbopack-cli/src/dev/mod.rs @@ -252,7 +252,7 @@ impl TurbopackDevServerBuilder { } } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn source( root_dir: RcStr, project_dir: RcStr, diff --git a/turbopack/crates/turbopack-core/src/ident.rs b/turbopack/crates/turbopack-core/src/ident.rs index ffbf1d030a0f..d7c12ea723ef 100644 --- a/turbopack/crates/turbopack-core/src/ident.rs +++ b/turbopack/crates/turbopack-core/src/ident.rs @@ -429,7 +429,7 @@ pub mod tests { noop_backing_storage(), )); tt.run_once(async move { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn output_name_operation() -> anyhow::Result> { let fs = VirtualFileSystem::new_with_name(rcstr!("test")); let root = fs.root().owned().await?; diff --git a/turbopack/crates/turbopack-core/src/module_graph/async_module_info.rs b/turbopack/crates/turbopack-core/src/module_graph/async_module_info.rs index 74b96b454e48..bc2ac3f9ed66 100644 --- a/turbopack/crates/turbopack-core/src/module_graph/async_module_info.rs +++ b/turbopack/crates/turbopack-core/src/module_graph/async_module_info.rs @@ -18,7 +18,7 @@ impl AsyncModulesInfo { } } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] pub async fn compute_async_module_info( graphs: ResolvedVc, ) -> Result> { @@ -32,7 +32,7 @@ pub async fn compute_async_module_info( .connect()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn compute_async_module_info_single( graph: OperationVc, parent_async_modules: Option>, diff --git a/turbopack/crates/turbopack-core/src/module_graph/mod.rs b/turbopack/crates/turbopack-core/src/module_graph/mod.rs index d4995330f460..350a19f4ada7 100644 --- a/turbopack/crates/turbopack-core/src/module_graph/mod.rs +++ b/turbopack/crates/turbopack-core/src/module_graph/mod.rs @@ -714,7 +714,7 @@ impl ModuleGraph { Ok(ReadRef::cell(graph)) } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn from_graphs_inner( graphs: Vec>, binding_usage: Option>, @@ -763,7 +763,7 @@ impl ModuleGraph { compute_style_groups(self, chunking_context, &config).await } - #[turbo_tasks::function] + #[turbo_tasks::function(root)] pub async fn async_module_info(self: Vc) -> Result> { // `compute_async_module_info` calls `module.is_self_async()`, so we need to again ignore // all issues such that they aren't emitted multiple times. @@ -833,7 +833,7 @@ pub struct ModuleGraphLayer { #[turbo_tasks::value_impl] impl ModuleGraphLayer { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn new( graph: OperationVc, graph_idx: u32, @@ -2108,7 +2108,7 @@ pub mod tests { reverse_from_b: Vec, } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn reverse_traversal_results_operation() -> Result> { let fs = VirtualFileSystem::new_with_name(rcstr!("test")); let root = fs.root().await?; @@ -2281,7 +2281,7 @@ pub mod tests { iter_modules_single: Vec>, } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn reverse_traversal_results_operation() -> Result> { let fs = VirtualFileSystem::new_with_name(rcstr!("test")); let root = fs.root().await?; @@ -2567,7 +2567,7 @@ pub mod tests { module_to_name: FxHashMap>, RcStr>, } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn setup_graph( entries: Vec, graph_entries: Vec<(RcStr, Vec)>, diff --git a/turbopack/crates/turbopack-core/src/resolve/mod.rs b/turbopack/crates/turbopack-core/src/resolve/mod.rs index 8826462050ec..1ccb41ec1004 100644 --- a/turbopack/crates/turbopack-core/src/resolve/mod.rs +++ b/turbopack/crates/turbopack-core/src/resolve/mod.rs @@ -3761,7 +3761,7 @@ mod tests { #[turbo_tasks::value(transparent)] struct ResolveRelativeRequestOutput(Vec<(String, String)>); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn resolve_relative_request_operation( path: RcStr, pattern: Pattern, @@ -3911,7 +3911,7 @@ mod tests { BackendOptions::default(), noop_backing_storage(), )); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn run_test() -> Result> { let m_a = make_module(rcstr!("a.js")).to_resolved().await?; let m_b = make_module(rcstr!("b.js")).to_resolved().await?; @@ -3948,7 +3948,7 @@ mod tests { BackendOptions::default(), noop_backing_storage(), )); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn run_test() -> Result> { let m = make_module(rcstr!("a.js")).to_resolved().await?; @@ -3981,7 +3981,7 @@ mod tests { BackendOptions::default(), noop_backing_storage(), )); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn run_test() -> Result> { let m = make_module(rcstr!("a.js")).to_resolved().await?; @@ -4024,7 +4024,7 @@ mod tests { BackendOptions::default(), noop_backing_storage(), )); - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn run_test() -> Result> { let m_a = make_module(rcstr!("a.js")).to_resolved().await?; let m_b = make_module(rcstr!("b.js")).to_resolved().await?; diff --git a/turbopack/crates/turbopack-core/src/resolve/pattern.rs b/turbopack/crates/turbopack-core/src/resolve/pattern.rs index 5a3d9ccd7ed5..d8c38a677ce1 100644 --- a/turbopack/crates/turbopack-core/src/resolve/pattern.rs +++ b/turbopack/crates/turbopack-core/src/resolve/pattern.rs @@ -2666,7 +2666,7 @@ mod tests { subpath_ordering: Vec, } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn read_matches_operation() -> anyhow::Result> { let root = DiskFileSystem::new( rcstr!("test"), diff --git a/turbopack/crates/turbopack-core/src/source_map/utils.rs b/turbopack/crates/turbopack-core/src/source_map/utils.rs index b4a09b970bfe..597e25480602 100644 --- a/turbopack/crates/turbopack-core/src/source_map/utils.rs +++ b/turbopack/crates/turbopack-core/src/source_map/utils.rs @@ -363,7 +363,7 @@ mod tests { rooted_sources: Vec>, } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn resolve_source_map_sources_operation() -> anyhow::Result> { let sys_root = if cfg!(windows) { diff --git a/turbopack/crates/turbopack-dev-server/src/http.rs b/turbopack/crates/turbopack-dev-server/src/http.rs index 58b5565c51b4..14e2fd72629f 100644 --- a/turbopack/crates/turbopack-dev-server/src/http.rs +++ b/turbopack/crates/turbopack-dev-server/src/http.rs @@ -42,7 +42,7 @@ enum GetFromSourceResult { /// Resolves a [SourceRequest] within a [super::ContentSource], returning the /// corresponding content as a -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_from_source_operation( source: OperationVc>, request: TransientInstance, @@ -78,7 +78,7 @@ struct GetFromSourceResultWithCollectibles { content_source_side_effects: AutoSet>>, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_from_source_with_collectibles_operation( source_op: OperationVc>, request: TransientInstance, diff --git a/turbopack/crates/turbopack-dev-server/src/lib.rs b/turbopack/crates/turbopack-dev-server/src/lib.rs index 132001a29ce1..fa9231d652f9 100644 --- a/turbopack/crates/turbopack-dev-server/src/lib.rs +++ b/turbopack/crates/turbopack-dev-server/src/lib.rs @@ -61,7 +61,7 @@ struct ContentSourceWithIssues { effects: Effects, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_source_with_issues_operation( source_op: OperationVc>, ) -> Result> { diff --git a/turbopack/crates/turbopack-dev-server/src/source/asset_graph.rs b/turbopack/crates/turbopack-dev-server/src/source/asset_graph.rs index 26a5f3bc4461..9dbd4aef2a59 100644 --- a/turbopack/crates/turbopack-dev-server/src/source/asset_graph.rs +++ b/turbopack/crates/turbopack-dev-server/src/source/asset_graph.rs @@ -193,7 +193,7 @@ fn get_sub_paths(sub_path: &str) -> ([RcStr; 3], usize) { (sub_paths_buffer, n) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn all_assets_map_operation(source: ResolvedVc) -> Vc { source.all_assets_map() } diff --git a/turbopack/crates/turbopack-dev-server/src/source/resolve.rs b/turbopack/crates/turbopack-dev-server/src/source/resolve.rs index e27d034aa327..572779538623 100644 --- a/turbopack/crates/turbopack-dev-server/src/source/resolve.rs +++ b/turbopack/crates/turbopack-dev-server/src/source/resolve.rs @@ -30,14 +30,14 @@ pub enum ResolveSourceRequestResult { HttpProxy(OperationVc), } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn content_source_get_routes_operation( source: OperationVc>, ) -> Vc { source.connect().get_routes() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn route_tree_get_operation( route_tree: ResolvedVc, asset_path: RcStr, @@ -45,14 +45,14 @@ fn route_tree_get_operation( route_tree.get(asset_path) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn get_content_source_content_vary_operation( get_content: ResolvedVc>, ) -> Vc { get_content.vary() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] fn get_content_source_content_get_operation( get_content: ResolvedVc>, path: RcStr, @@ -77,7 +77,7 @@ fn get_content_source_content_get_operation( /// TODO: The callers of this function now read this operation using strong consistency. This may /// have re-introduced performance issues that were solved in /// . -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] pub async fn resolve_source_request( source: OperationVc>, request: TransientInstance, diff --git a/turbopack/crates/turbopack-dev-server/src/source/wrapping_source.rs b/turbopack/crates/turbopack-dev-server/src/source/wrapping_source.rs index ecad29c48aa4..55b681fbd94c 100644 --- a/turbopack/crates/turbopack-dev-server/src/source/wrapping_source.rs +++ b/turbopack/crates/turbopack-dev-server/src/source/wrapping_source.rs @@ -39,7 +39,7 @@ impl WrappedGetContentSourceContent { } } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn wrap_sources_operation( sources: OperationVc, processor: ResolvedVc>, diff --git a/turbopack/crates/turbopack-dev-server/src/update/stream.rs b/turbopack/crates/turbopack-dev-server/src/update/stream.rs index b9b67a1bc622..e6af47a4c8ee 100644 --- a/turbopack/crates/turbopack-dev-server/src/update/stream.rs +++ b/turbopack/crates/turbopack-dev-server/src/update/stream.rs @@ -103,7 +103,7 @@ fn extend_issues(issues: &mut Vec>, new_issues: Vec>, from: ResolvedVc>, @@ -111,7 +111,7 @@ fn versioned_content_update_operation( content.update(*from) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn get_update_stream_item_operation( resource: RcStr, from: ResolvedVc, @@ -413,7 +413,7 @@ pub mod test { use super::*; - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] pub fn noop_operation() -> Vc { ResolveSourceRequestResult::NotFound.cell() } diff --git a/turbopack/crates/turbopack-node/src/evaluate.rs b/turbopack/crates/turbopack-node/src/evaluate.rs index 9e1dbf9d9c84..9eeec9876768 100644 --- a/turbopack/crates/turbopack-node/src/evaluate.rs +++ b/turbopack/crates/turbopack-node/src/evaluate.rs @@ -140,7 +140,7 @@ struct EmittedEvaluatePoolAssets { entrypoint: FileSystemPath, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn emit_evaluate_pool_assets_operation( entries: ResolvedVc, chunking_context: ResolvedVc>, @@ -184,7 +184,7 @@ async fn emit_evaluate_pool_assets_operation( .cell()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn create_evaluate_pool_assets_operation( entries: ResolvedVc, chunking_context: ResolvedVc>, @@ -215,7 +215,7 @@ pub enum EnvVarTracking { Untracked, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] /// Pass the file you cared as `runtime_entries` to invalidate and reload the /// evaluated result automatically. pub async fn get_evaluate_pool( diff --git a/turbopack/crates/turbopack-tests/tests/execution.rs b/turbopack/crates/turbopack-tests/tests/execution.rs index 1d521d6f3db8..757d3108100b 100644 --- a/turbopack/crates/turbopack-tests/tests/execution.rs +++ b/turbopack/crates/turbopack-tests/tests/execution.rs @@ -219,7 +219,7 @@ async fn run(resource: PathBuf, snapshot_mode: IssueSnapshotMode) -> Result Result Result> { .cell()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn run_test_operation(prepared_test: ResolvedVc) -> Result> { let PreparedTest { path, diff --git a/turbopack/crates/turbopack-tests/tests/snapshot.rs b/turbopack/crates/turbopack-tests/tests/snapshot.rs index fcf5e15025bd..adea92a631de 100644 --- a/turbopack/crates/turbopack-tests/tests/snapshot.rs +++ b/turbopack/crates/turbopack-tests/tests/snapshot.rs @@ -222,7 +222,7 @@ async fn run(resource: PathBuf) -> Result<()> { noop_backing_storage(), )); tt.run_once(async move { - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn inner_operation(resource: RcStr) -> Result> { let out_op = run_test_operation(resource); let out_vc = out_op @@ -243,7 +243,7 @@ async fn run(resource: PathBuf) -> Result<()> { Ok(Vc::cell(())) } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] async fn extract_effects(op: OperationVc<()>) -> Result> { let _ = op.resolve().strongly_consistent().await?; Ok(take_effects(op).await?.cell()) @@ -262,7 +262,7 @@ async fn run(resource: PathBuf) -> Result<()> { Ok(()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn run_test_operation(resource: RcStr) -> Result> { let test_path = canonicalize(&resource)?; assert!(test_path.exists(), "{resource} does not exist"); diff --git a/turbopack/crates/turbopack-tracing/benches/node_file_trace.rs b/turbopack/crates/turbopack-tracing/benches/node_file_trace.rs index 904366cfa32a..85b74146dbd0 100644 --- a/turbopack/crates/turbopack-tracing/benches/node_file_trace.rs +++ b/turbopack/crates/turbopack-tracing/benches/node_file_trace.rs @@ -22,7 +22,7 @@ use turbopack_core::{ }; use turbopack_resolve::resolve_options_context::ResolveOptionsContext; -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn extract_effects_operation(op: OperationVc<()>) -> anyhow::Result> { let _ = op.resolve().strongly_consistent().await?; Ok(take_effects(op).await?.cell()) diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace.rs b/turbopack/crates/turbopack-tracing/tests/node-file-trace.rs index 71bddfd304f2..9a5ec0125585 100644 --- a/turbopack/crates/turbopack-tracing/tests/node-file-trace.rs +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace.rs @@ -352,7 +352,7 @@ struct NodeFileTraceResult { effects: Effects, } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn node_file_trace_operation( package_root: RcStr, input: RcStr, @@ -768,12 +768,12 @@ impl std::str::FromStr for CaseInput { } } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn asset_path_operation(asset: ResolvedVc>) -> Vc { asset.path() } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn print_graph_operation(asset: ResolvedVc>) -> Result<()> { let mut visited = HashSet::new(); let mut queue = Vec::new(); diff --git a/turbopack/crates/turbopack-tracing/tests/unit.rs b/turbopack/crates/turbopack-tracing/tests/unit.rs index d0cb5e6292af..9ffe936e6344 100644 --- a/turbopack/crates/turbopack-tracing/tests/unit.rs +++ b/turbopack/crates/turbopack-tracing/tests/unit.rs @@ -197,7 +197,7 @@ fn unit_test(#[case] input: &str) -> Result<()> { node_file_trace(input) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] async fn node_file_trace_operation(package_root: RcStr, input: RcStr) -> Result>> { let workspace_fs: Vc> = Vc::upcast(DiskFileSystem::new( rcstr!("workspace"), diff --git a/turbopack/crates/turbopack/src/lib.rs b/turbopack/crates/turbopack/src/lib.rs index 15229f14b191..b7849383000c 100644 --- a/turbopack/crates/turbopack/src/lib.rs +++ b/turbopack/crates/turbopack/src/lib.rs @@ -1218,7 +1218,7 @@ pub async fn emit_assets_into_dir( Ok(()) } -#[turbo_tasks::function(operation)] +#[turbo_tasks::function(operation, root)] pub async fn emit_assets_into_dir_operation( assets: ResolvedVc, output_dir: FileSystemPath, diff --git a/turbopack/crates/turbopack/src/module_options/rule_condition.rs b/turbopack/crates/turbopack/src/module_options/rule_condition.rs index 36f29373b420..234f1ca2e84d 100644 --- a/turbopack/crates/turbopack/src/module_options/rule_condition.rs +++ b/turbopack/crates/turbopack/src/module_options/rule_condition.rs @@ -485,7 +485,7 @@ pub mod tests { .unwrap(); } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] pub async fn run_leaves_test_operation() -> Result<()> { let fs = VirtualFileSystem::new(); let virtual_path = fs.root().await?.join("foo.js")?; @@ -622,7 +622,7 @@ pub mod tests { .unwrap(); } - #[turbo_tasks::function(operation)] + #[turbo_tasks::function(operation, root)] pub async fn run_rule_condition_tree_test_operation() -> Result<()> { let fs = VirtualFileSystem::new(); let virtual_path = fs.root().await?.join("foo.js")?;