diff --git a/crates/napi/src/next_api/project.rs b/crates/napi/src/next_api/project.rs index bc052b877554ee..8f939bd0c613d6 100644 --- a/crates/napi/src/next_api/project.rs +++ b/crates/napi/src/next_api/project.rs @@ -11,15 +11,15 @@ use napi::{ }; use napi_derive::napi; use next_api::{ - entrypoints::Entrypoints, + entrypoints::{Entrypoints, is_deferred_entry}, next_server_nft::next_server_nft_assets, operation::{ EntrypointsOperation, InstrumentationOperation, MiddlewareOperation, OptionEndpoint, RouteOperation, }, project::{ - DefineEnv, DraftModeOptions, PartialProjectOptions, Project, ProjectContainer, - ProjectOptions, WatchOptions, + DeferredEntriesFilter, DefineEnv, DraftModeOptions, PartialProjectOptions, Project, + ProjectContainer, ProjectOptions, WatchOptions, }, route::Endpoint, routes_hashes_manifest::routes_hashes_manifest_asset_if_enabled, @@ -752,6 +752,9 @@ pub struct NapiRoute { pub html_endpoint: Option>, pub rsc_endpoint: Option>, pub data_endpoint: Option>, + + /// Whether this route is deferred (should wait for other entries to compile first) + pub deferred: bool, } impl NapiRoute { @@ -759,6 +762,7 @@ impl NapiRoute { pathname: String, value: RouteOperation, turbopack_ctx: &NextTurbopackContext, + deferred_entries: &[RcStr], ) -> Self { let convert_endpoint = |endpoint: OperationVc| { Some(External::new(ExternalEndpoint(DetachedVc::new( @@ -766,6 +770,7 @@ impl NapiRoute { endpoint, )))) }; + let deferred = is_deferred_entry(&pathname, deferred_entries); match value { RouteOperation::Page { html_endpoint, @@ -775,12 +780,14 @@ impl NapiRoute { r#type: "page", html_endpoint: convert_endpoint(html_endpoint), data_endpoint: convert_endpoint(data_endpoint), + deferred, ..Default::default() }, RouteOperation::PageApi { endpoint } => NapiRoute { pathname, r#type: "page-api", endpoint: convert_endpoint(endpoint), + deferred, ..Default::default() }, RouteOperation::AppPage(pages) => NapiRoute { @@ -796,6 +803,7 @@ impl NapiRoute { }) .collect(), ), + deferred, ..Default::default() }, RouteOperation::AppRoute { @@ -806,11 +814,13 @@ impl NapiRoute { original_name: Some(original_name), r#type: "app-route", endpoint: convert_endpoint(endpoint), + deferred, ..Default::default() }, RouteOperation::Conflict => NapiRoute { pathname, r#type: "conflict", + deferred, ..Default::default() }, } @@ -877,10 +887,13 @@ impl NapiEntrypoints { entrypoints: &EntrypointsOperation, turbopack_ctx: &NextTurbopackContext, ) -> Result { + let deferred_entries = &entrypoints.deferred_entries; let routes = entrypoints .routes .iter() - .map(|(k, v)| NapiRoute::from_route(k.to_string(), v.clone(), turbopack_ctx)) + .map(|(k, v)| { + NapiRoute::from_route(k.to_string(), v.clone(), turbopack_ctx, deferred_entries) + }) .collect(); let middleware = entrypoints .middleware @@ -1011,6 +1024,112 @@ pub async fn project_write_all_entrypoints_to_disk( }) } +/// Writes only non-deferred entrypoints to disk. +/// This should be called first, followed by the onBeforeDeferredEntries callback, +/// and then project_write_deferred_entrypoints_to_disk. +#[tracing::instrument( + level = "info", + name = "write non-deferred entrypoints to disk", + skip_all +)] +#[napi] +pub async fn project_write_non_deferred_entrypoints_to_disk( + #[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: External, + app_dir_only: bool, +) -> napi::Result>> { + let ctx = &project.turbopack_ctx; + let container = project.container; + let tt = ctx.turbo_tasks(); + + let (entrypoints, issues, diags) = tt + .run(async move { + let entrypoints_with_issues_op = get_filtered_written_entrypoints_with_issues_operation( + container, + app_dir_only, + DeferredEntriesFilter::NonDeferredOnly, + ); + + let AllWrittenEntrypointsWithIssues { + entrypoints, + issues, + diagnostics, + effects, + } = &*entrypoints_with_issues_op + .read_strongly_consistent() + .await?; + + effects.apply().await?; + + Ok((entrypoints.clone(), issues.clone(), diagnostics.clone())) + }) + .or_else(|e| ctx.throw_turbopack_internal_result(&e.into())) + .await?; + + Ok(TurbopackResult { + result: if let Some(entrypoints) = entrypoints { + Some(NapiEntrypoints::from_entrypoints_op( + &entrypoints, + &project.turbopack_ctx, + )?) + } else { + None + }, + issues: issues.iter().map(|i| NapiIssue::from(&**i)).collect(), + diagnostics: diags.iter().map(|d| NapiDiagnostic::from(d)).collect(), + }) +} + +/// Writes only deferred entrypoints to disk. +/// This should be called after project_write_non_deferred_entrypoints_to_disk +/// and the onBeforeDeferredEntries callback. +#[tracing::instrument(level = "info", name = "write deferred entrypoints to disk", skip_all)] +#[napi] +pub async fn project_write_deferred_entrypoints_to_disk( + #[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: External, + app_dir_only: bool, +) -> napi::Result>> { + let ctx = &project.turbopack_ctx; + let container = project.container; + let tt = ctx.turbo_tasks(); + + let (entrypoints, issues, diags) = tt + .run(async move { + let entrypoints_with_issues_op = get_filtered_written_entrypoints_with_issues_operation( + container, + app_dir_only, + DeferredEntriesFilter::DeferredOnly, + ); + + let AllWrittenEntrypointsWithIssues { + entrypoints, + issues, + diagnostics, + effects, + } = &*entrypoints_with_issues_op + .read_strongly_consistent() + .await?; + + effects.apply().await?; + + Ok((entrypoints.clone(), issues.clone(), diagnostics.clone())) + }) + .or_else(|e| ctx.throw_turbopack_internal_result(&e.into())) + .await?; + + Ok(TurbopackResult { + result: if let Some(entrypoints) = entrypoints { + Some(NapiEntrypoints::from_entrypoints_op( + &entrypoints, + &project.turbopack_ctx, + )?) + } else { + None + }, + issues: issues.iter().map(|i| NapiIssue::from(&**i)).collect(), + diagnostics: diags.iter().map(|d| NapiDiagnostic::from(d)).collect(), + }) +} + #[turbo_tasks::function(operation)] async fn get_all_written_entrypoints_with_issues_operation( container: ResolvedVc, @@ -1031,21 +1150,58 @@ async fn get_all_written_entrypoints_with_issues_operation( .cell()) } +#[turbo_tasks::function(operation)] +async fn get_filtered_written_entrypoints_with_issues_operation( + container: ResolvedVc, + app_dir_only: bool, + deferred_filter: DeferredEntriesFilter, +) -> Result> { + let entrypoints_operation = EntrypointsOperation::new( + filtered_entrypoints_write_to_disk_operation(container, app_dir_only, deferred_filter), + ); + let (entrypoints, issues, diagnostics, effects) = + strongly_consistent_catch_collectables(entrypoints_operation).await?; + Ok(AllWrittenEntrypointsWithIssues { + entrypoints, + issues, + diagnostics, + effects, + } + .cell()) +} + #[turbo_tasks::function(operation)] pub async fn all_entrypoints_write_to_disk_operation( project: ResolvedVc, app_dir_only: bool, ) -> Result> { - let output_assets_operation = output_assets_operation(project, app_dir_only); + let output_assets_op = output_assets_operation(project, app_dir_only); project .project() - .emit_all_output_assets(output_assets_operation) + .emit_all_output_assets(output_assets_op) .as_side_effect() .await?; - Ok(project.entrypoints()) } +#[turbo_tasks::function(operation)] +pub async fn filtered_entrypoints_write_to_disk_operation( + project: ResolvedVc, + app_dir_only: bool, + deferred_filter: DeferredEntriesFilter, +) -> Result> { + let output_assets_op = filtered_output_assets_operation(project, app_dir_only, deferred_filter); + project + .project() + .emit_all_output_assets(output_assets_op) + .as_side_effect() + .await?; + + // Return filtered entrypoints, not unfiltered, to avoid triggering + // computation of all entrypoints when the caller reads the result + Ok(project.project().filtered_entrypoints(deferred_filter)) +} + #[turbo_tasks::function(operation)] async fn output_assets_operation( container: ResolvedVc, @@ -1067,7 +1223,6 @@ async fn output_assets_operation( .collect(); let nft = next_server_nft_assets(project).await?; - let routes_hashes_manifest = routes_hashes_manifest_asset_if_enabled(project).await?; whole_app_module_graphs.as_side_effect().await?; @@ -1081,6 +1236,53 @@ async fn output_assets_operation( )) } +#[turbo_tasks::function(operation)] +async fn filtered_output_assets_operation( + container: ResolvedVc, + app_dir_only: bool, + deferred_filter: DeferredEntriesFilter, +) -> Result> { + let project = container.project(); + let whole_app_module_graphs = project.whole_app_module_graphs(); + let endpoint_assets = project + .get_filtered_endpoints(app_dir_only, deferred_filter) + .await? + .iter() + .map(|endpoint| async move { endpoint.output().await?.output_assets.await }) + .try_join() + .await?; + + let output_assets: FxIndexSet>> = endpoint_assets + .iter() + .flat_map(|assets| assets.iter().copied()) + .collect(); + + // NFT and routes hashes manifest are only included when not filtering for deferred only + // (they are global assets that should be included with non-deferred entries) + let include_global_assets = deferred_filter != DeferredEntriesFilter::DeferredOnly; + + let mut all_assets: Vec>> = output_assets.into_iter().collect(); + + if include_global_assets { + let nft = next_server_nft_assets(project).await?; + all_assets.extend(nft.iter().copied()); + + let routes_hashes_manifest = routes_hashes_manifest_asset_if_enabled(project).await?; + all_assets.extend(routes_hashes_manifest.iter().copied()); + } + + // whole_app_module_graphs processes ALL modules in the app, which would trigger + // compilation of deferred entries. Only call it during: + // - DeferredOnly phase (after the callback, when deferred entries should be compiled) + // - All phase (no phased compilation, compile everything at once) + // Do NOT call it during NonDeferredOnly phase to avoid compiling deferred entries early. + if deferred_filter != DeferredEntriesFilter::NonDeferredOnly { + whole_app_module_graphs.as_side_effect().await?; + } + + Ok(Vc::cell(all_assets)) +} + #[tracing::instrument(level = "info", name = "get entrypoints", skip_all)] #[napi] pub async fn project_entrypoints( diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index 4f2a4346a821da..fd7dbaa1fff6db 100644 --- a/crates/next-api/src/app.rs +++ b/crates/next-api/src/app.rs @@ -74,6 +74,7 @@ use turbopack_resolve::{ecmascript::cjs_resolve, resolve_options_context::Resolv use crate::{ dynamic_imports::{NextDynamicChunkAvailability, collect_next_dynamic_chunks}, + entrypoints::is_deferred_entry, font::FontManifest, loadable_manifest::create_react_loadable_manifest, module_graph::{ClientReferencesGraphs, NextDynamicGraphs, ServerActionsGraphs}, @@ -82,7 +83,7 @@ use crate::{ all_paths_in_root, all_server_paths, get_asset_paths_from_root, get_js_paths_from_root, get_wasm_paths_from_root, paths_to_bindings, wasm_paths_to_bindings, }, - project::{BaseAndFullModuleGraph, Project}, + project::{BaseAndFullModuleGraph, DeferredEntriesFilter, Project}, route::{ AppPageRoute, Endpoint, EndpointOutput, EndpointOutputPaths, ModuleGraphs, Route, Routes, }, @@ -821,6 +822,51 @@ impl AppProject { )) } + /// Returns routes filtered by deferred status. + /// This skips creating endpoints for filtered-out routes, preventing module resolution. + #[turbo_tasks::function] + pub async fn filtered_routes( + self: Vc, + deferred_entries: Vc>, + deferred_filter: DeferredEntriesFilter, + ) -> Result> { + let deferred_entries_list = deferred_entries.await?; + let has_deferred_config = !deferred_entries_list.is_empty(); + + // Helper to check if a route should be included + let should_include = |pathname: &str| -> bool { + if !has_deferred_config { + return true; + } + let is_deferred = is_deferred_entry(pathname, &deferred_entries_list); + match deferred_filter { + DeferredEntriesFilter::All => true, + DeferredEntriesFilter::NonDeferredOnly => !is_deferred, + DeferredEntriesFilter::DeferredOnly => is_deferred, + } + }; + + let app_entrypoints = self.app_entrypoints(); + Ok(Vc::cell( + app_entrypoints + .await? + .iter() + .filter(|(pathname, _)| should_include(&pathname.to_string())) + .map(|(pathname, app_entrypoint)| async { + Ok(( + pathname.to_string().into(), + app_entry_point_to_route(self, app_entrypoint.clone()) + .owned() + .await?, + )) + }) + .try_join() + .await? + .into_iter() + .collect(), + )) + } + #[turbo_tasks::function] pub async fn client_main_module(self: Vc) -> Result>> { let client_module_context = Vc::upcast(self.client_module_context()); diff --git a/crates/next-api/src/entrypoints.rs b/crates/next-api/src/entrypoints.rs index 732e82d60bfc9e..c169cb9eeabe5b 100644 --- a/crates/next-api/src/entrypoints.rs +++ b/crates/next-api/src/entrypoints.rs @@ -15,4 +15,41 @@ pub struct Entrypoints { pub pages_document_endpoint: ResolvedVc>, pub pages_app_endpoint: ResolvedVc>, pub pages_error_endpoint: ResolvedVc>, + /// Paths that should be deferred until all other entries are compiled + pub deferred_entries: Vec, +} + +/// Checks if a pathname matches any of the deferred entry patterns. +pub fn is_deferred_entry(pathname: &str, deferred_entries: &[RcStr]) -> bool { + if deferred_entries.is_empty() { + return false; + } + + // Normalize the pathname + let normalized_pathname = if pathname.starts_with('/') { + pathname.to_string() + } else { + format!("/{pathname}") + }; + + for pattern in deferred_entries { + // Normalize the pattern + let normalized_pattern = if pattern.starts_with('/') { + pattern.as_str().to_string() + } else { + format!("/{pattern}") + }; + + // Check for exact match + if normalized_pathname == normalized_pattern { + return true; + } + + // Check if the pathname is under the deferred directory + if normalized_pathname.starts_with(&format!("{normalized_pattern}/")) { + return true; + } + } + + false } diff --git a/crates/next-api/src/operation.rs b/crates/next-api/src/operation.rs index a72636d6de2376..b9ff1ca45084b5 100644 --- a/crates/next-api/src/operation.rs +++ b/crates/next-api/src/operation.rs @@ -29,6 +29,8 @@ pub struct EntrypointsOperation { pub pages_document_endpoint: OperationVc, pub pages_app_endpoint: OperationVc, pub pages_error_endpoint: OperationVc, + /// Paths that should be deferred until all other entries are compiled + pub deferred_entries: Vec, } /// Removes diagnostics, issues, and effects from the top-level `entrypoints` operation so that @@ -70,6 +72,7 @@ impl EntrypointsOperation { pages_document_endpoint: pick_endpoint(entrypoints, EndpointSelector::PagesDocument), pages_app_endpoint: pick_endpoint(entrypoints, EndpointSelector::PagesApp), pages_error_endpoint: pick_endpoint(entrypoints, EndpointSelector::PagesError), + deferred_entries: e.deferred_entries.clone(), } .cell()) } diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs index 3b057ba0f66dfb..1d2b69c9ddfa7c 100644 --- a/crates/next-api/src/pages.rs +++ b/crates/next-api/src/pages.rs @@ -70,6 +70,7 @@ use crate::{ dynamic_imports::{ DynamicImportedChunks, NextDynamicChunkAvailability, collect_next_dynamic_chunks, }, + entrypoints::is_deferred_entry, font::FontManifest, loadable_manifest::create_react_loadable_manifest, module_graph::{NextDynamicGraphs, validate_pages_css_imports}, @@ -78,7 +79,7 @@ use crate::{ all_paths_in_root, all_server_paths, get_asset_paths_from_root, get_js_paths_from_root, get_wasm_paths_from_root, paths_to_bindings, wasm_paths_to_bindings, }, - project::Project, + project::{DeferredEntriesFilter, Project}, route::{Endpoint, EndpointOutput, EndpointOutputPaths, ModuleGraphs, Route, Routes}, webpack_stats::generate_webpack_stats, }; @@ -228,6 +229,172 @@ impl PagesProject { Ok(Vc::cell(routes)) } + /// Returns routes filtered by deferred status. + /// This skips creating endpoints for filtered-out routes, preventing module resolution. + #[turbo_tasks::function] + pub async fn filtered_routes( + self: Vc, + deferred_entries: Vc>, + deferred_filter: DeferredEntriesFilter, + ) -> Result> { + let deferred_entries_list = deferred_entries.await?; + let has_deferred_config = !deferred_entries_list.is_empty(); + + // Helper to check if a route should be included + let should_include = |pathname: &str| -> bool { + if !has_deferred_config { + return true; + } + let is_deferred = is_deferred_entry(pathname, &deferred_entries_list); + match deferred_filter { + DeferredEntriesFilter::All => true, + DeferredEntriesFilter::NonDeferredOnly => !is_deferred, + DeferredEntriesFilter::DeferredOnly => is_deferred, + } + }; + + let pages_structure = self.pages_structure(); + let PagesStructure { + api, + pages, + app: _, + document: _, + error: _, + error_500: _, + has_user_pages: _, + should_create_pages_entries, + } = &*pages_structure.await?; + let mut routes = FxIndexMap::default(); + + // If pages entries shouldn't be created (build mode with no pages), return empty routes + if !should_create_pages_entries { + return Ok(Vc::cell(routes)); + } + + async fn add_page_to_routes_filtered( + routes: &mut FxIndexMap, + page: Vc, + make_route: impl Fn( + RcStr, + RcStr, + Vc, + ) -> BoxFuture<'static, Result>, + should_include: &impl Fn(&str) -> bool, + ) -> Result<()> { + let PagesStructureItem { + next_router_path, + original_path, + .. + } = &*page.await?; + let pathname: RcStr = format!("/{}", next_router_path.path).into(); + if !should_include(&pathname) { + return Ok(()); + } + let original_name = format!("/{}", original_path.path).into(); + let route = make_route(pathname.clone(), original_name, page).await?; + routes.insert(pathname, route); + Ok(()) + } + + async fn add_dir_to_routes_filtered( + routes: &mut FxIndexMap, + dir: Vc, + make_route: impl Fn( + RcStr, + RcStr, + Vc, + ) -> BoxFuture<'static, Result>, + should_include: &impl Fn(&str) -> bool, + ) -> Result<()> { + let mut queue = vec![dir]; + while let Some(dir) = queue.pop() { + let PagesDirectoryStructure { + ref items, + ref children, + next_router_path: _, + project_path: _, + } = *dir.await?; + for &item in items.iter() { + add_page_to_routes_filtered(routes, *item, &make_route, should_include).await?; + } + for &child in children.iter() { + queue.push(*child); + } + } + Ok(()) + } + + if let Some(api) = *api { + add_dir_to_routes_filtered( + &mut routes, + *api, + |pathname, original_name, page| { + Box::pin(async move { + Ok(Route::PageApi { + endpoint: ResolvedVc::upcast( + PageEndpoint::new( + PageEndpointType::Api, + self, + pathname, + original_name, + page, + pages_structure, + ) + .to_resolved() + .await?, + ), + }) + }) + }, + &should_include, + ) + .await?; + } + + let make_page_route = |pathname: RcStr, original_name: RcStr, page| -> BoxFuture<_> { + Box::pin(async move { + Ok(Route::Page { + html_endpoint: ResolvedVc::upcast( + PageEndpoint::new( + PageEndpointType::Html, + self, + pathname.clone(), + original_name.clone(), + page, + pages_structure, + ) + .to_resolved() + .await?, + ), + // The data endpoint is only needed in development mode to support HMR + data_endpoint: if self.project().next_mode().await?.is_development() { + Some(ResolvedVc::upcast( + PageEndpoint::new( + PageEndpointType::Data, + self, + pathname, + original_name, + page, + pages_structure, + ) + .to_resolved() + .await?, + )) + } else { + None + }, + }) + }) + }; + + if let Some(pages) = *pages { + add_dir_to_routes_filtered(&mut routes, *pages, make_page_route, &should_include) + .await?; + } + + Ok(Vc::cell(routes)) + } + #[turbo_tasks::function] async fn to_endpoint( self: Vc, diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index 10354016731436..74c33179d77ced 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -95,6 +95,34 @@ use crate::{ versioned_content_map::VersionedContentMap, }; +/// Filter for selecting which entries to include based on deferred status +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + Hash, + Serialize, + Deserialize, + TaskInput, + TraceRawVcs, + NonLocalValue, + OperationValue, + Encode, + Decode, +)] +pub enum DeferredEntriesFilter { + /// Include all entries (no filtering) + #[default] + All, + /// Include only non-deferred entries + NonDeferredOnly, + /// Include only deferred entries + DeferredOnly, +} + #[derive( Debug, Serialize, @@ -1002,15 +1030,24 @@ impl Project { } #[turbo_tasks::function] - pub async fn get_all_endpoint_groups( + pub fn get_all_endpoint_groups(self: Vc, app_dir_only: bool) -> Vc { + self.get_filtered_endpoint_groups(app_dir_only, DeferredEntriesFilter::All) + } + + #[turbo_tasks::function] + pub async fn get_filtered_endpoint_groups( self: Vc, app_dir_only: bool, + deferred_filter: DeferredEntriesFilter, ) -> Result> { let mut endpoint_groups = Vec::new(); - let entrypoints = self.entrypoints().await?; + // Use filtered_entrypoints to get routes that were filtered during creation, + // preventing module resolution for filtered-out routes + let entrypoints = self.filtered_entrypoints(deferred_filter).await?; let mut add_pages_entries = false; + // Middleware and instrumentation are already filtered by filtered_entrypoints if let Some(middleware) = &entrypoints.middleware { endpoint_groups.push(( EndpointGroupKey::Middleware, @@ -1029,6 +1066,7 @@ impl Project { )); } + // Routes are already filtered by filtered_entrypoints for (key, route) in entrypoints.routes.iter() { match route { Route::Page { @@ -1095,7 +1133,8 @@ impl Project { } } - if add_pages_entries { + // Pages entries (_app, _document, _error) are never deferred + if add_pages_entries && deferred_filter != DeferredEntriesFilter::DeferredOnly { endpoint_groups.push(( EndpointGroupKey::PagesError, EndpointGroup::from(entrypoints.pages_error_endpoint), @@ -1114,9 +1153,22 @@ impl Project { } #[turbo_tasks::function] - pub async fn get_all_endpoints(self: Vc, app_dir_only: bool) -> Result> { + pub fn get_all_endpoints(self: Vc, app_dir_only: bool) -> Vc { + self.get_filtered_endpoints(app_dir_only, DeferredEntriesFilter::All) + } + + #[turbo_tasks::function] + pub async fn get_filtered_endpoints( + self: Vc, + app_dir_only: bool, + deferred_filter: DeferredEntriesFilter, + ) -> Result> { let mut endpoints = Vec::new(); - for (_key, group) in self.get_all_endpoint_groups(app_dir_only).await?.iter() { + for (_key, group) in self + .get_filtered_endpoint_groups(app_dir_only, deferred_filter) + .await? + .iter() + { for entry in group.primary.iter() { endpoints.push(entry.endpoint); } @@ -1511,6 +1563,140 @@ impl Project { None }; + // Get deferred entries from config + let deferred_entries = self.next_config().deferred_entries().await?.to_vec(); + + Ok(Entrypoints { + routes, + middleware, + instrumentation, + pages_document_endpoint, + pages_app_endpoint, + pages_error_endpoint, + deferred_entries, + } + .cell()) + } + + /// Returns entrypoints filtered by deferred status. + /// This function filters routes BEFORE creating endpoints, preventing module resolution + /// for filtered-out routes. + #[turbo_tasks::function] + pub async fn filtered_entrypoints( + self: Vc, + deferred_filter: DeferredEntriesFilter, + ) -> Result> { + // For All filter, just return the standard entrypoints + if deferred_filter == DeferredEntriesFilter::All { + return Ok(self.entrypoints()); + } + + self.collect_project_feature_telemetry().await?; + + // Get deferred entries config for the final result + let deferred_entries_config = self.next_config().deferred_entries().await?.to_vec(); + + let mut routes = FxIndexMap::default(); + let app_project = self.app_project(); + let pages_project = self.pages_project(); + let deferred_entries_vc = self.next_config().deferred_entries(); + + // Use filtered_routes() which skips creating endpoints for filtered-out routes, + // preventing module resolution for those routes + if let Some(app_project) = &*app_project.await? { + let app_routes = app_project.filtered_routes(deferred_entries_vc, deferred_filter); + for (pathname, route) in app_routes.await?.iter() { + routes.insert(pathname.clone(), route.clone()); + } + } + + for (pathname, page_route) in pages_project + .filtered_routes(deferred_entries_vc, deferred_filter) + .await? + .iter() + { + match routes.entry(pathname.clone()) { + Entry::Occupied(mut entry) => { + ConflictIssue { + path: self.project_path().owned().await?, + title: StyledString::Text( + format!("App Router and Pages Router both match path: {pathname}") + .into(), + ) + .resolved_cell(), + description: StyledString::Text( + "Next.js does not support having both App Router and Pages Router \ + routes matching the same path. Please remove one of the conflicting \ + routes." + .into(), + ) + .resolved_cell(), + severity: IssueSeverity::Error, + } + .resolved_cell() + .emit(); + *entry.get_mut() = Route::Conflict; + } + Entry::Vacant(entry) => { + entry.insert(page_route.clone()); + } + } + } + + // Pages router special endpoints (_document, _app, _error) are never deferred + let (pages_document_endpoint, pages_app_endpoint, pages_error_endpoint) = + if deferred_filter != DeferredEntriesFilter::DeferredOnly { + ( + self.pages_project() + .document_endpoint() + .to_resolved() + .await?, + self.pages_project().app_endpoint().to_resolved().await?, + self.pages_project().error_endpoint().to_resolved().await?, + ) + } else { + // For DeferredOnly, use placeholder endpoints that won't trigger compilation + // We still need valid ResolvedVc values, so we create them but they won't be used + ( + self.pages_project() + .document_endpoint() + .to_resolved() + .await?, + self.pages_project().app_endpoint().to_resolved().await?, + self.pages_project().error_endpoint().to_resolved().await?, + ) + }; + + // Middleware and instrumentation are never deferred + let middleware = if deferred_filter != DeferredEntriesFilter::DeferredOnly { + let middleware_result = self.find_middleware(); + if let FindContextFileResult::Found(fs_path, _) = &*middleware_result.await? { + let is_proxy = fs_path.file_stem() == Some("proxy"); + Some(Middleware { + endpoint: self.middleware_endpoint().to_resolved().await?, + is_proxy, + }) + } else { + None + } + } else { + None + }; + + let instrumentation = if deferred_filter != DeferredEntriesFilter::DeferredOnly { + let instrumentation_result = self.find_instrumentation(); + if let FindContextFileResult::Found(..) = *instrumentation_result.await? { + Some(Instrumentation { + node_js: self.instrumentation_endpoint(false).to_resolved().await?, + edge: self.instrumentation_endpoint(true).to_resolved().await?, + }) + } else { + None + } + } else { + None + }; + Ok(Entrypoints { routes, middleware, @@ -1518,6 +1704,7 @@ impl Project { pages_document_endpoint, pages_app_endpoint, pages_error_endpoint, + deferred_entries: deferred_entries_config, } .cell()) } diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index e7ca235b642b25..f0a7819a3f67e2 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -1007,6 +1007,9 @@ pub struct ExperimentalConfig { turbopack_infer_module_side_effects: Option, /// Devtool option for the segment explorer. devtool_segment_explorer: Option, + /// An array of paths in app or pages directories that should wait to be processed + /// until all other entries have been processed. + deferred_entries: Option>, } #[derive( @@ -1798,6 +1801,16 @@ impl NextConfig { ) } + #[turbo_tasks::function] + pub fn deferred_entries(&self) -> Vc> { + Vc::cell( + self.experimental + .deferred_entries + .clone() + .unwrap_or_default(), + ) + } + #[turbo_tasks::function] pub fn tree_shaking_mode_for_foreign_code( &self, diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index 25258e16bd7ab4..56de4a4720fa73 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -593,6 +593,47 @@ export interface CreateEntrypointsParams { appPaths?: MappedPages pageExtensions: PageExtensions hasInstrumentationHook?: boolean + /** + * When set to 'exclude', deferred entries are excluded from the result. + * When set to 'only', only deferred entries are included in the result. + * When undefined, all entries are included. + */ + deferredEntriesFilter?: 'exclude' | 'only' +} + +/** + * Checks if a page path matches any of the deferred entry patterns. + * @param page - The page path (e.g., '/about', '/api/hello') + * @param deferredEntries - Array of path patterns to match against + * @returns true if the page matches a deferred entry pattern + */ +export function isDeferredEntry( + page: string, + deferredEntries: string[] | undefined +): boolean { + if (!deferredEntries || deferredEntries.length === 0) { + return false + } + + // Normalize the page path + const normalizedPage = page.startsWith('/') ? page : `/${page}` + + for (const pattern of deferredEntries) { + // Normalize the pattern + const normalizedPattern = pattern.startsWith('/') ? pattern : `/${pattern}` + + // Check for exact match or prefix match for directories + if (normalizedPage === normalizedPattern) { + return true + } + + // Check if the page is under the deferred directory + if (normalizedPage.startsWith(normalizedPattern + '/')) { + return true + } + } + + return false } export function getEdgeServerEntry(opts: { @@ -838,7 +879,10 @@ export async function createEntrypoints( appDir, appPaths, pageExtensions, + deferredEntriesFilter, } = params + + const deferredEntries = config.experimental.deferredEntries const edgeServer: webpack.EntryObject = {} const server: webpack.EntryObject = {} const client: webpack.EntryObject = {} @@ -870,6 +914,19 @@ export async function createEntrypoints( const getEntryHandler = (mappings: MappedPages, pagesType: PAGE_TYPES): ((page: string) => void) => async (page) => { + // Apply deferred entries filter if specified + if (deferredEntriesFilter) { + const isDeferred = isDeferredEntry(page, deferredEntries) + if (deferredEntriesFilter === 'exclude' && isDeferred) { + // Skip deferred entries when excluding them + return + } + if (deferredEntriesFilter === 'only' && !isDeferred) { + // Skip non-deferred entries when only including deferred ones + return + } + } + const bundleFile = normalizePagePath(page) const clientBundlePath = posix.join(pagesType, bundleFile) const serverBundlePath = diff --git a/packages/next/src/build/handle-entrypoints.ts b/packages/next/src/build/handle-entrypoints.ts index cbf157cdccd2e3..a6fd83e08f231b 100644 --- a/packages/next/src/build/handle-entrypoints.ts +++ b/packages/next/src/build/handle-entrypoints.ts @@ -25,6 +25,7 @@ export async function rawEntrypointsToEntrypoints( app.set(p.originalName, { type: 'app-page', ...p, + deferred: route.deferred, }) } break diff --git a/packages/next/src/build/swc/generated-native.d.ts b/packages/next/src/build/swc/generated-native.d.ts index 83f2f1d87e2878..b176c37248881d 100644 --- a/packages/next/src/build/swc/generated-native.d.ts +++ b/packages/next/src/build/swc/generated-native.d.ts @@ -274,6 +274,8 @@ export interface NapiRoute { htmlEndpoint?: ExternalObject rscEndpoint?: ExternalObject dataEndpoint?: ExternalObject + /** Whether this route is deferred (should wait for other entries to compile first) */ + deferred: boolean } export interface NapiMiddleware { endpoint: ExternalObject @@ -295,6 +297,24 @@ export declare function projectWriteAllEntrypointsToDisk( project: { __napiType: 'Project' }, appDirOnly: boolean ): Promise +/** + * Writes only non-deferred entrypoints to disk. + * This should be called first, followed by the onBeforeDeferredEntries callback, + * and then project_write_deferred_entrypoints_to_disk. + */ +export declare function projectWriteNonDeferredEntrypointsToDisk( + project: { __napiType: 'Project' }, + appDirOnly: boolean +): Promise +/** + * Writes only deferred entrypoints to disk. + * This should be called after project_write_non_deferred_entrypoints_to_disk + * and the onBeforeDeferredEntries callback. + */ +export declare function projectWriteDeferredEntrypointsToDisk( + project: { __napiType: 'Project' }, + appDirOnly: boolean +): Promise export declare function projectEntrypoints(project: { __napiType: 'Project' }): Promise diff --git a/packages/next/src/build/swc/index.ts b/packages/next/src/build/swc/index.ts index 18b8ef1d252037..7d71248b0fc195 100644 --- a/packages/next/src/build/swc/index.ts +++ b/packages/next/src/build/swc/index.ts @@ -523,6 +523,8 @@ function bindingToApi( type NapiRoute = { pathname: string + /** Whether this route is deferred (should wait for other entries to compile first) */ + deferred: boolean } & ( | { type: 'page' @@ -713,6 +715,48 @@ function bindingToApi( } } + async writeNonDeferredEntrypointsToDisk( + appDirOnly: boolean + ): Promise>> { + const napiEndpoints = + (await binding.projectWriteNonDeferredEntrypointsToDisk( + this._nativeProject, + appDirOnly + )) as TurbopackResult> + + if ('routes' in napiEndpoints) { + return napiEntrypointsToRawEntrypoints( + napiEndpoints as TurbopackResult + ) + } else { + return { + issues: napiEndpoints.issues, + diagnostics: napiEndpoints.diagnostics, + } + } + } + + async writeDeferredEntrypointsToDisk( + appDirOnly: boolean + ): Promise>> { + const napiEndpoints = + (await binding.projectWriteDeferredEntrypointsToDisk( + this._nativeProject, + appDirOnly + )) as TurbopackResult> + + if ('routes' in napiEndpoints) { + return napiEntrypointsToRawEntrypoints( + napiEndpoints as TurbopackResult + ) + } else { + return { + issues: napiEndpoints.issues, + diagnostics: napiEndpoints.diagnostics, + } + } + } + entrypointsSubscribe() { const subscription = subscribe>( false, @@ -1055,12 +1099,14 @@ function bindingToApi( type: 'page', htmlEndpoint: new EndpointImpl(nativeRoute.htmlEndpoint), dataEndpoint: new EndpointImpl(nativeRoute.dataEndpoint), + deferred: nativeRoute.deferred, } break case 'page-api': route = { type: 'page-api', endpoint: new EndpointImpl(nativeRoute.endpoint), + deferred: nativeRoute.deferred, } break case 'app-page': @@ -1071,6 +1117,7 @@ function bindingToApi( htmlEndpoint: new EndpointImpl(page.htmlEndpoint), rscEndpoint: new EndpointImpl(page.rscEndpoint), })), + deferred: nativeRoute.deferred, } break case 'app-route': @@ -1078,11 +1125,13 @@ function bindingToApi( type: 'app-route', originalName: nativeRoute.originalName, endpoint: new EndpointImpl(nativeRoute.endpoint), + deferred: nativeRoute.deferred, } break case 'conflict': route = { type: 'conflict', + deferred: nativeRoute.deferred, } break default: { diff --git a/packages/next/src/build/swc/types.ts b/packages/next/src/build/swc/types.ts index b9e0fee184c24d..918dfb4fa76acd 100644 --- a/packages/next/src/build/swc/types.ts +++ b/packages/next/src/build/swc/types.ts @@ -239,6 +239,14 @@ export interface Project { appDirOnly: boolean ): Promise>> + writeNonDeferredEntrypointsToDisk( + appDirOnly: boolean + ): Promise>> + + writeDeferredEntrypointsToDisk( + appDirOnly: boolean + ): Promise>> + entrypointsSubscribe(): AsyncIterableIterator< TurbopackResult > @@ -277,6 +285,8 @@ export interface Project { export type Route = | { type: 'conflict' + /** Whether this route is deferred (should wait for other entries to compile first) */ + deferred: boolean } | { type: 'app-page' @@ -285,20 +295,28 @@ export type Route = htmlEndpoint: Endpoint rscEndpoint: Endpoint }[] + /** Whether this route is deferred (should wait for other entries to compile first) */ + deferred: boolean } | { type: 'app-route' originalName: string endpoint: Endpoint + /** Whether this route is deferred (should wait for other entries to compile first) */ + deferred: boolean } | { type: 'page' htmlEndpoint: Endpoint dataEndpoint: Endpoint + /** Whether this route is deferred (should wait for other entries to compile first) */ + deferred: boolean } | { type: 'page-api' endpoint: Endpoint + /** Whether this route is deferred (should wait for other entries to compile first) */ + deferred: boolean } export interface Endpoint { @@ -419,10 +437,14 @@ export type PageRoute = type: 'page' htmlEndpoint: Endpoint dataEndpoint: Endpoint + /** Whether this route is deferred (should wait for other entries to compile first) */ + deferred: boolean } | { type: 'page-api' endpoint: Endpoint + /** Whether this route is deferred (should wait for other entries to compile first) */ + deferred: boolean } export type AppRoute = @@ -430,10 +452,14 @@ export type AppRoute = type: 'app-page' htmlEndpoint: Endpoint rscEndpoint: Endpoint + /** Whether this route is deferred (should wait for other entries to compile first) */ + deferred: boolean } | { type: 'app-route' endpoint: Endpoint + /** Whether this route is deferred (should wait for other entries to compile first) */ + deferred: boolean } // pathname -> route diff --git a/packages/next/src/build/turbopack-build/impl.ts b/packages/next/src/build/turbopack-build/impl.ts index d9019bcd5daabe..8514f80a0d0b5d 100644 --- a/packages/next/src/build/turbopack-build/impl.ts +++ b/packages/next/src/build/turbopack-build/impl.ts @@ -109,8 +109,44 @@ export async function turbopackBuild(): Promise<{ ) let appDirOnly = NextBuildContext.appDirOnly! - const entrypoints = await project.writeAllEntrypointsToDisk(appDirOnly) - printBuildErrors(entrypoints, dev) + + // Check if deferred entries are configured + const hasDeferredEntries = + config.experimental.deferredEntries && + config.experimental.deferredEntries.length > 0 + + let entrypoints: TurbopackResult> + + if (hasDeferredEntries) { + // Phase 1: Write non-deferred entrypoints first + const nonDeferredEntrypoints = + await project.writeNonDeferredEntrypointsToDisk(appDirOnly) + printBuildErrors(nonDeferredEntrypoints, dev) + + // Call onBeforeDeferredEntries callback before compiling deferred entries + if (config.experimental.onBeforeDeferredEntries) { + await config.experimental.onBeforeDeferredEntries() + } + + // Phase 2: Write deferred entrypoints + const deferredEntrypoints = + await project.writeDeferredEntrypointsToDisk(appDirOnly) + printBuildErrors(deferredEntrypoints, dev) + + // Merge the entrypoints from both phases + // Use the non-deferred result as base since it has middleware/instrumentation + entrypoints = { + ...nonDeferredEntrypoints, + routes: new Map([ + ...(nonDeferredEntrypoints.routes || []), + ...(deferredEntrypoints.routes || []), + ]), + } + } else { + // No deferred entries - use the standard single-phase build + entrypoints = await project.writeAllEntrypointsToDisk(appDirOnly) + printBuildErrors(entrypoints, dev) + } let routes = entrypoints.routes if (!routes) { @@ -140,11 +176,12 @@ export async function turbopackBuild(): Promise<{ entrypoints as TurbopackResult ) - const promises: Promise[] = [] + // Process all routes + const routePromises: Promise[] = [] if (!appDirOnly) { for (const [page, route] of currentEntrypoints.page) { - promises.push( + routePromises.push( handleRouteType({ page, route, @@ -155,7 +192,7 @@ export async function turbopackBuild(): Promise<{ } for (const [page, route] of currentEntrypoints.app) { - promises.push( + routePromises.push( handleRouteType({ page, route, @@ -164,7 +201,7 @@ export async function turbopackBuild(): Promise<{ ) } - await Promise.all(promises) + await Promise.all(routePromises) await Promise.all([ // Only load pages router manifests if not app-only diff --git a/packages/next/src/build/webpack-build/impl.ts b/packages/next/src/build/webpack-build/impl.ts index bc398ae634fae9..5ba7e70c282b98 100644 --- a/packages/next/src/build/webpack-build/impl.ts +++ b/packages/next/src/build/webpack-build/impl.ts @@ -89,6 +89,12 @@ export async function webpackBuildImpl( process.env.NEXT_COMPILER_NAME = compilerName || 'server' const runWebpackSpan = nextBuildSpan.traceChild('run-webpack-compiler') + + const hasDeferredEntries = + config.experimental.deferredEntries && + config.experimental.deferredEntries.length > 0 + + // Create entrypoints - exclude deferred entries if configured const entrypoints = await nextBuildSpan .traceChild('create-entrypoints') .traceAsyncFn(() => @@ -106,9 +112,34 @@ export async function webpackBuildImpl( previewMode: NextBuildContext.previewProps!, rootPaths: NextBuildContext.mappedRootPaths!, hasInstrumentationHook: NextBuildContext.hasInstrumentationHook!, + deferredEntriesFilter: hasDeferredEntries ? 'exclude' : undefined, }) ) + // Create deferred entrypoints if configured + const deferredEntrypoints = hasDeferredEntries + ? await nextBuildSpan + .traceChild('create-deferred-entrypoints') + .traceAsyncFn(() => + createEntrypoints({ + buildId: NextBuildContext.buildId!, + config: config, + envFiles: NextBuildContext.loadedEnvFiles!, + isDev: false, + rootDir: dir, + pageExtensions: config.pageExtensions!, + pagesDir: NextBuildContext.pagesDir!, + appDir: NextBuildContext.appDir!, + pages: NextBuildContext.mappedPages!, + appPaths: NextBuildContext.mappedAppPages!, + previewMode: NextBuildContext.previewProps!, + rootPaths: NextBuildContext.mappedRootPaths!, + hasInstrumentationHook: NextBuildContext.hasInstrumentationHook!, + deferredEntriesFilter: 'only', + }) + ) + : null + const commonWebpackOptions = { isServer: false, isCompileMode: NextBuildContext.isCompileMode, @@ -143,6 +174,7 @@ export async function webpackBuildImpl( runWebpackSpan, compilerType: COMPILER_NAMES.client, entrypoints: entrypoints.client, + deferredEntrypoints: deferredEntrypoints?.client, ...info, }), getBaseWebpackConfig(dir, { @@ -151,6 +183,7 @@ export async function webpackBuildImpl( middlewareMatchers: entrypoints.middlewareMatchers, compilerType: COMPILER_NAMES.server, entrypoints: entrypoints.server, + deferredEntrypoints: deferredEntrypoints?.server, ...info, }), getBaseWebpackConfig(dir, { @@ -159,6 +192,7 @@ export async function webpackBuildImpl( middlewareMatchers: entrypoints.middlewareMatchers, compilerType: COMPILER_NAMES.edgeServer, entrypoints: entrypoints.edgeServer, + deferredEntrypoints: deferredEntrypoints?.edgeServer, ...info, }), ]) diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index b12dbc1d9c28be..b58729d89b58de 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -51,6 +51,7 @@ import { CopyFilePlugin } from './webpack/plugins/copy-file-plugin' import { ClientReferenceManifestPlugin } from './webpack/plugins/flight-manifest-plugin' import { FlightClientEntryPlugin as NextFlightClientEntryPlugin } from './webpack/plugins/flight-client-entry-plugin' import { RspackFlightClientEntryPlugin } from './webpack/plugins/rspack-flight-client-entry-plugin' +import { DeferredEntriesPlugin } from './webpack/plugins/deferred-entries-plugin' import { NextTypesPlugin } from './webpack/plugins/next-types-plugin' import type { Feature, @@ -320,6 +321,7 @@ export default async function getBaseWebpackConfig( compilerType, dev = false, entrypoints, + deferredEntrypoints, isDevFallback = false, pagesDir, reactProductionProfiling = false, @@ -347,6 +349,7 @@ export default async function getBaseWebpackConfig( compilerType: CompilerNameValues dev?: boolean entrypoints: webpack.EntryObject + deferredEntrypoints?: webpack.EntryObject isDevFallback?: boolean pagesDir: string | undefined reactProductionProfiling?: boolean @@ -1972,6 +1975,15 @@ export default async function getBaseWebpackConfig( // // TODO: Rspack currently does not support the hooks and chunk methods required by ForceCompleteRuntimePlugin. dev && !isRspack && new ForceCompleteRuntimePlugin(), + // Handle deferred entries - must be added early to intercept entry processing + !isRspack && + config.experimental.deferredEntries?.length && + deferredEntrypoints && + new DeferredEntriesPlugin({ + dev, + config, + deferredEntrypoints, + }), isNodeServer && new bundler.NormalModuleReplacementPlugin( /\.\/(.+)\.shared-runtime$/, diff --git a/packages/next/src/build/webpack/plugins/deferred-entries-plugin.ts b/packages/next/src/build/webpack/plugins/deferred-entries-plugin.ts new file mode 100644 index 00000000000000..285e5c0c13966f --- /dev/null +++ b/packages/next/src/build/webpack/plugins/deferred-entries-plugin.ts @@ -0,0 +1,140 @@ +import { webpack } from 'next/dist/compiled/webpack/webpack' +import type { NextConfigComplete } from '../../../server/config-shared' +import createDebug from 'next/dist/compiled/debug' + +const debug = createDebug('next:deferred-entries-plugin') +const PLUGIN_NAME = 'DeferredEntriesPlugin' + +interface DeferredEntriesPluginOptions { + dev: boolean + config: NextConfigComplete + deferredEntrypoints?: webpack.EntryObject +} + +/** + * A webpack plugin that handles deferred entries by: + * 1. Accepting deferred entrypoints separately from the main config + * 2. After non-deferred entries are compiled, calling the onBeforeDeferredEntries callback + * 3. Then adding and compiling the deferred entries within the same compilation + * + * This approach avoids module ID conflicts that would occur with separate compilations. + */ +export class DeferredEntriesPlugin { + private onBeforeDeferredEntries?: () => Promise + private deferredEntrypoints?: webpack.EntryObject + private callbackCalled: boolean = false + + constructor(options: DeferredEntriesPluginOptions) { + this.onBeforeDeferredEntries = + options.config.experimental.onBeforeDeferredEntries + this.deferredEntrypoints = options.deferredEntrypoints + } + + apply(compiler: webpack.Compiler) { + // Skip if no deferred entrypoints to process + if ( + !this.deferredEntrypoints || + Object.keys(this.deferredEntrypoints).length === 0 + ) { + return + } + + // Use finishMake hook to add deferred entries after all initial entries are processed + // This is the same pattern used by FlightClientEntryPlugin + compiler.hooks.finishMake.tapPromise(PLUGIN_NAME, async (compilation) => { + // Only process if we haven't called callback yet + if (this.callbackCalled) { + return + } + + this.callbackCalled = true + + // Call the onBeforeDeferredEntries callback + if (this.onBeforeDeferredEntries) { + debug('calling onBeforeDeferredEntries callback') + await this.onBeforeDeferredEntries() + debug('onBeforeDeferredEntries callback completed') + } + + // Add deferred entries to compilation + const addEntryPromises: Promise[] = [] + const bundler = webpack + + debug('adding deferred entries:', Object.keys(this.deferredEntrypoints!)) + + for (const [name, entryData] of Object.entries( + this.deferredEntrypoints! + )) { + debug('processing deferred entry:', name, entryData) + // Normalize entry data structure + let entry: { + import?: string | string[] + layer?: string + runtime?: string | false + dependOn?: string | string[] + } + + if (typeof entryData === 'string') { + entry = { import: [entryData] } + } else if (Array.isArray(entryData)) { + entry = { import: entryData } + } else { + entry = entryData as typeof entry + } + + // Get imports array + const imports = entry.import + ? Array.isArray(entry.import) + ? entry.import + : [entry.import] + : [] + + if (imports.length === 0) { + continue + } + + // Normalize dependOn to always be an array + const dependOn = entry.dependOn + ? Array.isArray(entry.dependOn) + ? entry.dependOn + : [entry.dependOn] + : undefined + + // Create dependencies for all imports + for (const importPath of imports) { + if (typeof importPath !== 'string') { + continue + } + + const dep = bundler.EntryPlugin.createDependency(importPath, { + name, + }) + + addEntryPromises.push( + new Promise((resolve, reject) => { + compilation.addEntry( + compiler.context, + dep, + { + name, + layer: entry.layer, + runtime: entry.runtime, + dependOn, + }, + (err) => { + if (err) { + reject(err) + } else { + resolve() + } + } + ) + }) + ) + } + } + + await Promise.all(addEntryPromises) + }) + } +} diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 4a3f925f47c0f2..9428bc5d367335 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -353,6 +353,8 @@ export const experimentalSchema = { hideLogsAfterAbort: z.boolean().optional(), runtimeServerDeploymentId: z.boolean().optional(), devCacheControlNoCache: z.boolean().optional(), + deferredEntries: z.array(z.string()).optional(), + onBeforeDeferredEntries: z.function().returns(z.promise(z.void())).optional(), } export const configSchema: zod.ZodType = z.lazy(() => diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 11a921895a9db1..16652494976f3e 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -885,6 +885,19 @@ export interface ExperimentalConfig { * @default false */ devCacheControlNoCache?: boolean + + /** + * An array of paths in app or pages directories that should wait to be processed + * until all other entries have been processed. This is useful for deferring + * compilation of certain routes during development and build. + */ + deferredEntries?: string[] + + /** + * An async function that is called and awaited before processing deferred entries. + * This callback runs after all non-deferred entries have been compiled. + */ + onBeforeDeferredEntries?: () => Promise } export type ExportPathMap = { diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index c0b9d4c8262ffa..6ed3625d1fbb46 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -336,6 +336,53 @@ export async function createHotReloaderTurbopack( const assetMapper = new AssetMapper() + // Deferred entries state management + const deferredEntriesConfig = nextConfig.experimental.deferredEntries + const hasDeferredEntriesConfig = + deferredEntriesConfig && deferredEntriesConfig.length > 0 + let onBeforeDeferredEntriesCalled = false + let onBeforeDeferredEntriesPromise: Promise | null = null + + // Function to wait for all non-deferred entries to be built + async function waitForNonDeferredEntries(): Promise { + // In turbopack, entries are built on-demand, so we wait for the current + // entrypoints handling to complete which means initial entrypoints are processed + await currentEntriesHandling + } + + // Reset deferred entries state for a new HMR cycle + function resetDeferredEntriesState(): void { + onBeforeDeferredEntriesCalled = false + onBeforeDeferredEntriesPromise = null + } + + // Call the onBeforeDeferredEntries callback + async function callOnBeforeDeferredEntries(): Promise { + if (!hasDeferredEntriesConfig) return + if (!nextConfig.experimental.onBeforeDeferredEntries) return + + if (!onBeforeDeferredEntriesCalled) { + onBeforeDeferredEntriesCalled = true + onBeforeDeferredEntriesPromise = + nextConfig.experimental.onBeforeDeferredEntries() + await onBeforeDeferredEntriesPromise + } else if (onBeforeDeferredEntriesPromise) { + // Wait for any in-progress callback + await onBeforeDeferredEntriesPromise + } + } + + // Function to handle deferred entry processing + async function processDeferredEntry(): Promise { + if (!hasDeferredEntriesConfig) return + + // Wait for initial entrypoints to be processed + await waitForNonDeferredEntries() + + // Call the onBeforeDeferredEntries callback + await callOnBeforeDeferredEntries() + } + function clearRequireCache( key: EntryKey, writtenEndpoint: WrittenEndpoint, @@ -1410,6 +1457,11 @@ export async function createHotReloaderTurbopack( throw new Error(`mis-matched route type: isApp && page for ${page}`) } + // Check if this is a deferred entry and wait for non-deferred entries first + if (hasDeferredEntriesConfig && route.deferred) { + await processDeferredEntry() + } + const finishBuilding = startBuilding(pathname, requestUrl, false) try { await handleRouteType({ @@ -1475,11 +1527,21 @@ export async function createHotReloaderTurbopack( switch (updateMessage.updateType) { case 'start': { hotReloader.send({ type: HMR_MESSAGE_SENT_TO_BROWSER.BUILDING }) + // Reset deferred entries state for this update cycle + // This ensures onBeforeDeferredEntries will be called again + resetDeferredEntriesState() break } case 'end': { sendEnqueuedMessages() + // Call onBeforeDeferredEntries during any update cycle (HMR or hot-update) + // This is needed because the callback might generate code that deferred entries depend on + // We always call it on 'end' since we reset state on 'start' + if (hasDeferredEntriesConfig) { + await callOnBeforeDeferredEntries() + } + function addToErrorsMap( errorsMap: Map, issueMap: IssuesMap diff --git a/packages/next/src/server/dev/on-demand-entry-handler.ts b/packages/next/src/server/dev/on-demand-entry-handler.ts index 6054c5331f3dc7..8d172b87d68d57 100644 --- a/packages/next/src/server/dev/on-demand-entry-handler.ts +++ b/packages/next/src/server/dev/on-demand-entry-handler.ts @@ -12,7 +12,7 @@ import type { RouteDefinition } from '../route-definitions/route-definition' import createDebug from 'next/dist/compiled/debug' import { EventEmitter } from 'events' import { findPageFile } from '../lib/find-page-file' -import { runDependingOnPageType } from '../../build/entries' +import { runDependingOnPageType, isDeferredEntry } from '../../build/entries' import { getStaticInfoIncludingLayouts } from '../../build/get-static-info-including-layouts' import { join, posix } from 'path' import { normalizePathSep } from '../../shared/lib/page-path/normalize-path-sep' @@ -568,9 +568,83 @@ export function onDemandEntryHandler({ invalidators.set(multiCompiler.outputPath, curInvalidator) } + // Deferred entries state management + const deferredEntriesConfig = nextConfig.experimental.deferredEntries + const hasDeferredEntriesConfig = + deferredEntriesConfig && deferredEntriesConfig.length > 0 + let onBeforeDeferredEntriesCalled = false + let onBeforeDeferredEntriesPromise: Promise | null = null + + // Function to wait for all non-deferred entries to be built + async function waitForNonDeferredEntries(): Promise { + return new Promise((resolve) => { + const checkEntries = () => { + // Check if there are any non-deferred entries that are still building or added + const hasNonDeferredEntriesBuilding = Object.entries(curEntries).some( + ([, entry]) => { + const entryData = entry as Entry | ChildEntry + if (entryData.type !== EntryTypes.ENTRY) return false + + const isDeferred = isDeferredEntry( + (entryData as Entry).absolutePagePath + .replace(appDir || '', '') + .replace(pagesDir || '', '') + .replace(rootDir, ''), + deferredEntriesConfig + ) + + return ( + !isDeferred && + (entryData.status === ADDED || entryData.status === BUILDING) + ) + } + ) + + if (!hasNonDeferredEntriesBuilding) { + resolve() + } else { + // Check again after a short delay + setTimeout(checkEntries, 100) + } + } + + checkEntries() + }) + } + + // Function to handle deferred entry processing + async function processDeferredEntry(): Promise { + if (!hasDeferredEntriesConfig) return + + // Wait for all non-deferred entries to be built + await waitForNonDeferredEntries() + + // Call the onBeforeDeferredEntries callback once + if (!onBeforeDeferredEntriesCalled) { + onBeforeDeferredEntriesCalled = true + + if (nextConfig.experimental.onBeforeDeferredEntries) { + debug('calling onBeforeDeferredEntries callback') + if (!onBeforeDeferredEntriesPromise) { + onBeforeDeferredEntriesPromise = + nextConfig.experimental.onBeforeDeferredEntries() + } + await onBeforeDeferredEntriesPromise + debug('onBeforeDeferredEntries callback completed') + } + } else if (onBeforeDeferredEntriesPromise) { + // Wait for any in-progress callback + await onBeforeDeferredEntriesPromise + } + } + const startBuilding = (compilation: webpack.Compilation) => { const compilationName = compilation.name as any as CompilerNameValues curInvalidator.startBuilding(compilationName) + // Reset deferred entries state for this compilation cycle + // This ensures onBeforeDeferredEntries will be called again during HMR + onBeforeDeferredEntriesCalled = false + onBeforeDeferredEntriesPromise = null } for (const compiler of multiCompiler.compilers) { compiler.hooks.make.tap('NextJsOnDemandEntries', startBuilding) @@ -643,6 +717,17 @@ export function onDemandEntryHandler({ } getInvalidator(multiCompiler.outputPath)?.doneBuilding([...COMPILER_KEYS]) + + // Call onBeforeDeferredEntries after compilation completes during HMR + // This ensures the callback is invoked even when non-deferred entries change + if (hasDeferredEntriesConfig && !onBeforeDeferredEntriesCalled) { + onBeforeDeferredEntriesCalled = true + if (nextConfig.experimental.onBeforeDeferredEntries) { + debug('calling onBeforeDeferredEntries callback after HMR') + onBeforeDeferredEntriesPromise = + nextConfig.experimental.onBeforeDeferredEntries() + } + } }) const pingIntervalTime = Math.max(1000, Math.min(5000, maxInactiveAge)) @@ -762,6 +847,16 @@ export function onDemandEntryHandler({ const isInsideAppDir = !!appDir && route.filename.startsWith(appDir) + // Check if this is a deferred entry and wait for non-deferred entries first + if (hasDeferredEntriesConfig) { + const isDeferred = isDeferredEntry(route.page, deferredEntriesConfig) + if (isDeferred) { + debug(`Page ${page} is a deferred entry, waiting for other entries`) + await processDeferredEntry() + debug(`Deferred entry ${page} can now be processed`) + } + } + if (typeof isApp === 'boolean' && isApp !== isInsideAppDir) { Error.stackTraceLimit = 15 throw new Error( diff --git a/packages/next/src/server/dev/turbopack-utils.ts b/packages/next/src/server/dev/turbopack-utils.ts index f221ad5fe42903..21eedcd12448ae 100644 --- a/packages/next/src/server/dev/turbopack-utils.ts +++ b/packages/next/src/server/dev/turbopack-utils.ts @@ -615,6 +615,7 @@ export async function handleEntrypoints({ currentEntrypoints.app.set(page.originalName, { type: 'app-page', ...page, + deferred: route.deferred, }) }) break diff --git a/test/e2e/deferred-entries/app/deferred/page.tsx b/test/e2e/deferred-entries/app/deferred/page.tsx new file mode 100644 index 00000000000000..cb09c31de4a6fa --- /dev/null +++ b/test/e2e/deferred-entries/app/deferred/page.tsx @@ -0,0 +1,3 @@ +export default function DeferredPage() { + return

Deferred Page

+} diff --git a/test/e2e/deferred-entries/app/layout.tsx b/test/e2e/deferred-entries/app/layout.tsx new file mode 100644 index 00000000000000..08eaa94fdc8896 --- /dev/null +++ b/test/e2e/deferred-entries/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/deferred-entries/app/page.tsx b/test/e2e/deferred-entries/app/page.tsx new file mode 100644 index 00000000000000..4c9c0e91ab223c --- /dev/null +++ b/test/e2e/deferred-entries/app/page.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return

Home Page

+} diff --git a/test/e2e/deferred-entries/deferred-entries.test.ts b/test/e2e/deferred-entries/deferred-entries.test.ts new file mode 100644 index 00000000000000..b1ec51190dcc72 --- /dev/null +++ b/test/e2e/deferred-entries/deferred-entries.test.ts @@ -0,0 +1,202 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' +import fs from 'fs' +import path from 'path' + +interface LogEntry { + timestamp: number + entry: string +} + +function parseEntryLog(logPath: string): LogEntry[] { + if (!fs.existsSync(logPath)) { + return [] + } + const content = fs.readFileSync(logPath, 'utf-8') + return content + .split('\n') + .filter(Boolean) + .map((line) => { + const [timestamp, ...rest] = line.split(':') + return { timestamp: parseInt(timestamp, 10), entry: rest.join(':') } + }) +} + +function parseCallbackLog(logPath: string): number | null { + if (!fs.existsSync(logPath)) { + return null + } + const content = fs.readFileSync(logPath, 'utf-8') + const lines = content.split('\n').filter(Boolean) + if (lines.length === 0) { + return null + } + const [, timestamp] = lines[0].split(':') + return parseInt(timestamp, 10) +} + +describe('deferred-entries webpack', () => { + const { next, isNextStart, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + skipStart: true, + dependencies: {}, + }) + + if (skipped) return + + beforeAll(async () => { + // Clear log files before starting + const entryLogPath = path.join(next.testDir, '.entry-log') + const callbackLogPath = path.join(next.testDir, '.callback-log') + try { + fs.writeFileSync(entryLogPath, '') + fs.writeFileSync(callbackLogPath, '') + } catch (e) { + // Ignore + } + await next.start() + }) + + afterAll(async () => { + await next.stop() + }) + + it('should build deferred entry successfully', async () => { + // Access the deferred page - use retry to handle on-demand compilation timing + await retry(async () => { + const deferredRes = await next.fetch('/deferred') + expect(deferredRes.status).toBe(200) + expect(await deferredRes.text()).toContain('Deferred Page') + }) + }) + + it('should call onBeforeDeferredEntries before building deferred entry', async () => { + // Verify the callback was executed + const callbackLogPath = path.join(next.testDir, '.callback-log') + await retry(async () => { + const callbackTimestamp = parseCallbackLog(callbackLogPath) + expect(callbackTimestamp).not.toBeNull() + }) + }) + + if (!isNextStart) { + it('should call onBeforeDeferredEntries during HMR even when non-deferred entry changes', async () => { + const callbackLogPath = path.join(next.testDir, '.callback-log') + + // First, access the deferred page to trigger the initial callback + await retry(async () => { + const deferredRes = await next.fetch('/deferred') + expect(deferredRes.status).toBe(200) + }) + + // Access the home page so it gets added to tracked entries for HMR + await retry(async () => { + const homeRes = await next.fetch('/') + expect(homeRes.status).toBe(200) + }) + + // Get the initial callback timestamp (should now be set) + let initialTimestamp: number | null = null + await retry(async () => { + initialTimestamp = parseCallbackLog(callbackLogPath) + expect(initialTimestamp).not.toBeNull() + }) + + // Wait a bit to ensure timestamps will be different + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Modify the home page (non-deferred entry) to trigger HMR + await next.patchFile('app/page.tsx', (content) => + content.replace('Home Page', 'Home Page Updated') + ) + + // Wait for HMR to complete and callback to be called again + await retry(async () => { + const newTimestamp = parseCallbackLog(callbackLogPath) + expect(newTimestamp).not.toBeNull() + // The callback should have been called again with a newer timestamp + expect(newTimestamp).toBeGreaterThan(initialTimestamp!) + }) + + // Verify the home page was updated + await retry(async () => { + const homeRes = await next.fetch('/') + expect(homeRes.status).toBe(200) + expect(await homeRes.text()).toContain('Home Page Updated') + }) + }) + } + + if (isNextStart) { + it('should call onBeforeDeferredEntries before processing deferred entries during build', async () => { + const entryLogPath = path.join(next.testDir, '.entry-log') + const callbackLogPath = path.join(next.testDir, '.callback-log') + + // Parse the logs + const entryLog = parseEntryLog(entryLogPath) + const callbackTimestamp = parseCallbackLog(callbackLogPath) + + // Debug output + console.log('Entry log:', entryLog) + console.log('Callback timestamp:', callbackTimestamp) + + // Verify the callback was executed + expect(callbackTimestamp).not.toBeNull() + + // Find the CALLBACK_EXECUTED marker in the entry log + // The callback runs in finishMake hook before the build phase starts + const callbackIndex = entryLog.findIndex( + (e) => e.entry === 'CALLBACK_EXECUTED' + ) + expect(callbackIndex).toBeGreaterThan(-1) + + // The loader runs during the build phase (after finishMake completes) + // So CALLBACK_EXECUTED should appear before loader entries + // Find loader entries (entries that are file paths, not CALLBACK_EXECUTED) + const loaderEntries = entryLog.filter( + (e) => e.entry !== 'CALLBACK_EXECUTED' + ) + + // Verify we have loader entries for both home page and deferred page + const homePageEntries = loaderEntries.filter( + (e) => e.entry.includes('page.tsx') && !e.entry.includes('deferred') + ) + const deferredPageEntries = loaderEntries.filter((e) => + e.entry.includes('deferred') + ) + + console.log('Home page entries:', homePageEntries) + console.log('Deferred page entries:', deferredPageEntries) + + expect(homePageEntries.length).toBeGreaterThan(0) + expect(deferredPageEntries.length).toBeGreaterThan(0) + + // Verify the callback is called AFTER non-deferred entries + // (non-deferred entries are built first) + const latestNonDeferredTimestamp = Math.max( + ...homePageEntries.map((e) => e.timestamp) + ) + expect(callbackTimestamp).toBeGreaterThanOrEqual( + latestNonDeferredTimestamp + ) + + // Verify the callback is called BEFORE deferred entries + // (deferred entries wait for the callback) + const earliestDeferredTimestamp = Math.min( + ...deferredPageEntries.map((e) => e.timestamp) + ) + expect(callbackTimestamp).toBeLessThanOrEqual(earliestDeferredTimestamp) + + // Verify the home page works + const homeRes = await next.fetch('/') + expect(homeRes.status).toBe(200) + expect(await homeRes.text()).toContain('Home Page') + + // Verify the deferred page works + const deferredRes = await next.fetch('/deferred') + expect(deferredRes.status).toBe(200) + expect(await deferredRes.text()).toContain('Deferred Page') + }) + } +}) diff --git a/test/e2e/deferred-entries/entry-logger-loader.js b/test/e2e/deferred-entries/entry-logger-loader.js new file mode 100644 index 00000000000000..72229d5c30eb82 --- /dev/null +++ b/test/e2e/deferred-entries/entry-logger-loader.js @@ -0,0 +1,29 @@ +const fs = require('fs') +const path = require('path') + +// A simple webpack loader that logs when an entry is being processed +module.exports = function entryLoggerLoader(source) { + const callback = this.async() + const resourcePath = this.resourcePath + const logFile = path.join(__dirname, '.entry-log') + + console.log('loader', resourcePath) + + // Extract the page name from the resource path + let pageName = resourcePath + if (resourcePath.includes('/app/')) { + pageName = resourcePath.split('/app/')[1] || resourcePath + } else if (resourcePath.includes('/pages/')) { + pageName = resourcePath.split('/pages/')[1] || resourcePath + } + + // Log the entry processing with timestamp + const logEntry = `${Date.now()}:${pageName}\n` + + fs.appendFile(logFile, logEntry, (err) => { + if (err) { + console.error('Failed to write entry log:', err) + } + callback(null, source) + }) +} diff --git a/test/e2e/deferred-entries/next.config.js b/test/e2e/deferred-entries/next.config.js new file mode 100644 index 00000000000000..b1a74618da6792 --- /dev/null +++ b/test/e2e/deferred-entries/next.config.js @@ -0,0 +1,53 @@ +const fs = require('fs') +const path = require('path') + +const logFile = path.join(__dirname, '.entry-log') +const callbackLogFile = path.join(__dirname, '.callback-log') + +/** @type {import('next').NextConfig} */ +module.exports = { + experimental: { + deferredEntries: ['/deferred'], + onBeforeDeferredEntries: async () => { + const timestamp = Date.now() + // Write the callback log file - this file existing proves callback was called + fs.writeFileSync(callbackLogFile, `callback:${timestamp}\n`) + console.log( + `[TEST] onBeforeDeferredEntries callback executed at ${timestamp}` + ) + + // Small delay to ensure we can verify timing + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Append to entry log to mark callback position in the build sequence + fs.appendFileSync(logFile, `${timestamp}:CALLBACK_EXECUTED\n`) + }, + }, + // Turbopack loader configuration + turbopack: { + rules: { + '*.tsx': { + loaders: [ + { + loader: path.join(__dirname, 'entry-logger-loader.js'), + }, + ], + }, + }, + }, + // Webpack loader configuration + webpack: (config, { isServer }) => { + // Add the entry logger loader to track when entries are processed + config.module.rules.push({ + test: /\.(tsx|ts|js|jsx)$/, + include: [path.join(__dirname, 'app')], + use: [ + { + loader: path.join(__dirname, 'entry-logger-loader.js'), + }, + ], + }) + + return config + }, +}