diff --git a/.gitignore b/.gitignore index f78dbd04e33ff..838bf3c3768a6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ pids coverage # test output -test/**/out* +test/**/out/* test/**/next-env.d.ts .DS_Store /e2e-tests @@ -42,7 +42,7 @@ test/traces .nvmrc # examples -examples/**/out +examples/**/out/* examples/**/.env*.local pr-stats.md diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index 937dc1cd58cdc..cb6459fc3003a 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -965,6 +965,13 @@ async fn insert_next_shared_aliases( "next/dist/build/webpack/loaders/next-flight-loader/cache-wrapper", ), ); + import_map.insert_exact_alias( + "private-next-rsc-track-dynamic-import", + request_to_import_mapping( + project_path, + "next/dist/build/webpack/loaders/next-flight-loader/track-dynamic-import", + ), + ); insert_turbopack_dev_alias(import_map).await?; insert_package_alias( diff --git a/crates/next-core/src/next_server/transforms.rs b/crates/next-core/src/next_server/transforms.rs index 4cd409dbcfc65..4eaf62f308516 100644 --- a/crates/next-core/src/next_server/transforms.rs +++ b/crates/next-core/src/next_server/transforms.rs @@ -12,8 +12,8 @@ use crate::{ next_shared::transforms::{ get_next_dynamic_transform_rule, get_next_font_transform_rule, get_next_image_rule, get_next_lint_transform_rule, get_next_modularize_imports_rule, - get_next_pages_transforms_rule, get_server_actions_transform_rule, - next_amp_attributes::get_next_amp_attr_rule, + get_next_pages_transforms_rule, get_next_track_dynamic_imports_transform_rule, + get_server_actions_transform_rule, next_amp_attributes::get_next_amp_attr_rule, next_cjs_optimizer::get_next_cjs_optimizer_rule, next_disallow_re_export_all_in_page::get_next_disallow_export_all_in_page_rule, next_edge_node_api_assert::next_edge_node_api_assert, @@ -178,6 +178,10 @@ pub async fn get_next_server_transforms_rules( ServerContextType::Middleware { .. } | ServerContextType::Instrumentation { .. } => false, }; + if is_app_dir && *next_config.enable_dynamic_io().await? { + rules.push(get_next_track_dynamic_imports_transform_rule(mdx_rs)); + } + if !foreign_code { rules.push( get_next_dynamic_transform_rule(true, is_server_components, is_app_dir, mode, mdx_rs) diff --git a/crates/next-core/src/next_shared/transforms/mod.rs b/crates/next-core/src/next_shared/transforms/mod.rs index b4f085dec56a1..281bd938b67f9 100644 --- a/crates/next-core/src/next_shared/transforms/mod.rs +++ b/crates/next-core/src/next_shared/transforms/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod next_pure; pub(crate) mod next_react_server_components; pub(crate) mod next_shake_exports; pub(crate) mod next_strip_page_exports; +pub(crate) mod next_track_dynamic_imports; pub(crate) mod react_remove_properties; pub(crate) mod relay; pub(crate) mod remove_console; @@ -30,6 +31,7 @@ pub use next_dynamic::get_next_dynamic_transform_rule; pub use next_font::get_next_font_transform_rule; pub use next_lint::get_next_lint_transform_rule; pub use next_strip_page_exports::get_next_pages_transforms_rule; +pub use next_track_dynamic_imports::get_next_track_dynamic_imports_transform_rule; pub use server_actions::get_server_actions_transform_rule; use turbo_tasks::{ReadRef, ResolvedVc, Value}; use turbo_tasks_fs::FileSystemPath; diff --git a/crates/next-core/src/next_shared/transforms/next_track_dynamic_imports.rs b/crates/next-core/src/next_shared/transforms/next_track_dynamic_imports.rs new file mode 100644 index 0000000000000..653df152afce8 --- /dev/null +++ b/crates/next-core/src/next_shared/transforms/next_track_dynamic_imports.rs @@ -0,0 +1,24 @@ +use anyhow::Result; +use async_trait::async_trait; +use next_custom_transforms::transforms::track_dynamic_imports::*; +use swc_core::ecma::ast::Program; +use turbopack::module_options::ModuleRule; +use turbopack_ecmascript::{CustomTransformer, TransformContext}; + +use super::get_ecma_transform_rule; + +pub fn get_next_track_dynamic_imports_transform_rule(mdx_rs: bool) -> ModuleRule { + get_ecma_transform_rule(Box::new(NextTrackDynamicImports {}), mdx_rs, false) +} + +#[derive(Debug)] +struct NextTrackDynamicImports {} + +#[async_trait] +impl CustomTransformer for NextTrackDynamicImports { + #[tracing::instrument(level = tracing::Level::TRACE, name = "next_track_dynamic_imports", skip_all)] + async fn transform(&self, program: &mut Program, ctx: &TransformContext<'_>) -> Result<()> { + program.mutate(track_dynamic_imports(ctx.unresolved_mark)); + Ok(()) + } +} diff --git a/crates/next-custom-transforms/src/chain_transforms.rs b/crates/next-custom-transforms/src/chain_transforms.rs index 2858887423783..1f303bcea0ffe 100644 --- a/crates/next-custom-transforms/src/chain_transforms.rs +++ b/crates/next-custom-transforms/src/chain_transforms.rs @@ -121,6 +121,9 @@ pub struct TransformOptions { #[serde(default)] pub css_env: Option, + + #[serde(default)] + pub track_dynamic_imports: bool, } pub fn custom_before_pass<'a, C>( @@ -333,6 +336,14 @@ where )), None => Either::Right(noop_pass()), }, + match &opts.track_dynamic_imports { + true => Either::Left( + crate::transforms::track_dynamic_imports::track_dynamic_imports( + unresolved_mark, + ), + ), + false => Either::Right(noop_pass()), + }, match &opts.cjs_require_optimizer { Some(config) => Either::Left(visit_mut_pass( crate::transforms::cjs_optimizer::cjs_optimizer( diff --git a/crates/next-custom-transforms/src/transforms/mod.rs b/crates/next-custom-transforms/src/transforms/mod.rs index 71618dd378b24..529a59180c134 100644 --- a/crates/next-custom-transforms/src/transforms/mod.rs +++ b/crates/next-custom-transforms/src/transforms/mod.rs @@ -18,6 +18,7 @@ pub mod react_server_components; pub mod server_actions; pub mod shake_exports; pub mod strip_page_exports; +pub mod track_dynamic_imports; pub mod warn_for_edge_runtime; //[TODO] PACK-1564: need to decide reuse vs. turbopack specific diff --git a/crates/next-custom-transforms/src/transforms/track_dynamic_imports.rs b/crates/next-custom-transforms/src/transforms/track_dynamic_imports.rs new file mode 100644 index 0000000000000..fc2ba0fb441fb --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/track_dynamic_imports.rs @@ -0,0 +1,124 @@ +use swc_core::{ + common::{source_map::PURE_SP, util::take::Take, Mark, SyntaxContext}, + ecma::{ + ast::*, + utils::{prepend_stmt, private_ident, quote_ident, quote_str}, + visit::{noop_visit_mut_type, visit_mut_pass, VisitMut, VisitMutWith}, + }, + quote, +}; + +pub fn track_dynamic_imports(unresolved_mark: Mark) -> impl VisitMut + Pass { + visit_mut_pass(ImportReplacer::new(unresolved_mark)) +} + +struct ImportReplacer { + unresolved_ctxt: SyntaxContext, + has_dynamic_import: bool, + wrapper_function_local_ident: Ident, +} + +impl ImportReplacer { + pub fn new(unresolved_mark: Mark) -> Self { + ImportReplacer { + unresolved_ctxt: SyntaxContext::empty().apply_mark(unresolved_mark), + has_dynamic_import: false, + wrapper_function_local_ident: private_ident!("$$trackDynamicImport__"), + } + } +} + +impl VisitMut for ImportReplacer { + noop_visit_mut_type!(); + + fn visit_mut_program(&mut self, program: &mut Program) { + program.visit_mut_children_with(self); + // if we wrapped a dynamic import while visiting the children, we need to import the wrapper + + if self.has_dynamic_import { + let import_args = MakeNamedImportArgs { + original_ident: quote_ident!("trackDynamicImport").into(), + local_ident: self.wrapper_function_local_ident.clone(), + source: "private-next-rsc-track-dynamic-import", + unresolved_ctxt: self.unresolved_ctxt, + }; + match program { + Program::Module(module) => { + prepend_stmt(&mut module.body, make_named_import_esm(import_args)); + } + Program::Script(script) => { + // CJS modules can still use `import()`. for CJS, we have to inject the helper + // using `require` instead of `import` to avoid accidentally turning them + // into ESM modules. + prepend_stmt(&mut script.body, make_named_import_cjs(import_args)); + } + } + } + } + + fn visit_mut_expr(&mut self, expr: &mut Expr) { + expr.visit_mut_children_with(self); + + // before: `import(...)` + // after: `$$trackDynamicImport__(import(...))` + + if let Expr::Call(CallExpr { + callee: Callee::Import(_), + .. + }) = expr + { + self.has_dynamic_import = true; + let replacement_expr = quote!( + "$wrapper_fn($expr)" as Expr, + wrapper_fn = self.wrapper_function_local_ident.clone(), + expr: Expr = expr.take() + ) + .with_span(PURE_SP); + *expr = replacement_expr + } + } +} + +struct MakeNamedImportArgs<'a> { + original_ident: Ident, + local_ident: Ident, + source: &'a str, + unresolved_ctxt: SyntaxContext, +} + +fn make_named_import_esm(args: MakeNamedImportArgs) -> ModuleItem { + let MakeNamedImportArgs { + original_ident, + local_ident, + source, + .. + } = args; + let mut item = quote!( + "import { $original_ident as $local_ident } from 'dummy'" as ModuleItem, + original_ident = original_ident, + local_ident = local_ident, + ); + // the import source cannot be parametrized in `quote!()`, so patch it manually + let decl = item.as_mut_module_decl().unwrap().as_mut_import().unwrap(); + decl.src = Box::new(source.into()); + item +} + +fn make_named_import_cjs(args: MakeNamedImportArgs) -> Stmt { + let MakeNamedImportArgs { + original_ident, + local_ident, + source, + unresolved_ctxt, + } = args; + quote!( + "const { [$original_name]: $local_ident } = $require($source)" as Stmt, + original_name: Expr = quote_str!(original_ident.sym).into(), + local_ident = local_ident, + source: Expr = quote_str!(source).into(), + // the builtin `require` is considered an unresolved identifier. + // we have to match that, or it won't be recognized as + // a proper `require()` call. + require = quote_ident!(unresolved_ctxt, "require") + ) +} diff --git a/crates/next-custom-transforms/tests/fixture.rs b/crates/next-custom-transforms/tests/fixture.rs index 4463df7d3053a..bcca6c667aa81 100644 --- a/crates/next-custom-transforms/tests/fixture.rs +++ b/crates/next-custom-transforms/tests/fixture.rs @@ -21,6 +21,7 @@ use next_custom_transforms::transforms::{ server_actions::{self, server_actions, ServerActionsMode}, shake_exports::{shake_exports, Config as ShakeExportsConfig}, strip_page_exports::{next_transform_strip_page_exports, ExportFilter}, + track_dynamic_imports::track_dynamic_imports, warn_for_edge_runtime::warn_for_edge_runtime, }; use rustc_hash::FxHashSet; @@ -930,6 +931,29 @@ fn test_source_maps(input: PathBuf) { ); } +#[fixture("tests/fixture/track-dynamic-imports/**/input.js")] +fn track_dynamic_imports_fixture(input: PathBuf) { + let output = input.parent().unwrap().join("output.js"); + test_fixture( + syntax(), + &|_tr| { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + ( + resolver(unresolved_mark, top_level_mark, false), + track_dynamic_imports(unresolved_mark), + ) + }, + &input, + &output, + FixtureTestConfig { + // auto detect script/module to test CJS handling + module: None, + ..Default::default() + }, + ); +} + fn lint_to_fold(r: R) -> impl Pass where R: Visit, diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/1/input.js b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/1/input.js new file mode 100644 index 0000000000000..f93d860c24e40 --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/1/input.js @@ -0,0 +1,4 @@ +export default async function Page() { + const { foo } = await import('some-module') + return foo() +} diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/1/output.js b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/1/output.js new file mode 100644 index 0000000000000..bcf4d19be33bb --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/1/output.js @@ -0,0 +1,5 @@ +import { trackDynamicImport as $$trackDynamicImport__ } from "private-next-rsc-track-dynamic-import"; +export default async function Page() { + const { foo } = await /*#__PURE__*/ $$trackDynamicImport__(import('some-module')); + return foo(); +} diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/2/input.js b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/2/input.js new file mode 100644 index 0000000000000..7d0bd4f650227 --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/2/input.js @@ -0,0 +1,4 @@ +export default async function Page() { + await import((await import('get-name')).default) + return null +} diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/2/output.js b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/2/output.js new file mode 100644 index 0000000000000..20c99119ffd0f --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/2/output.js @@ -0,0 +1,5 @@ +import { trackDynamicImport as $$trackDynamicImport__ } from "private-next-rsc-track-dynamic-import"; +export default async function Page() { + await /*#__PURE__*/ $$trackDynamicImport__(import((await /*#__PURE__*/ $$trackDynamicImport__(import('get-name'))).default)); + return null; +} diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/3/input.js b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/3/input.js new file mode 100644 index 0000000000000..cf5719d3390ff --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/3/input.js @@ -0,0 +1,8 @@ +export default async function Page() { + const { foo } = await import('some-module') + // name conflict + $$trackDynamicImport__() + return foo() +} + +export function $$trackDynamicImport__() {} diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/3/output.js b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/3/output.js new file mode 100644 index 0000000000000..81f97fbe33fad --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/3/output.js @@ -0,0 +1,9 @@ +import { trackDynamicImport as $$trackDynamicImport__ } from "private-next-rsc-track-dynamic-import"; +export default async function Page() { + const { foo } = await /*#__PURE__*/ $$trackDynamicImport__(import('some-module')); + // name conflict + $$trackDynamicImport__1(); + return foo(); +} +function $$trackDynamicImport__1() {} +export { $$trackDynamicImport__1 as $$trackDynamicImport__ }; diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/4/input.js b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/4/input.js new file mode 100644 index 0000000000000..8f7c41948fc83 --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/4/input.js @@ -0,0 +1,6 @@ +const promise = import('some-module') + +export default async function Page() { + const { foo } = await promise + return foo() +} diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/4/output.js b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/4/output.js new file mode 100644 index 0000000000000..6bc4be721895e --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/4/output.js @@ -0,0 +1,6 @@ +import { trackDynamicImport as $$trackDynamicImport__ } from "private-next-rsc-track-dynamic-import"; +const promise = /*#__PURE__*/ $$trackDynamicImport__(import('some-module')); +export default async function Page() { + const { foo } = await promise; + return foo(); +} diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/5/input.js b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/5/input.js new file mode 100644 index 0000000000000..a762d9c472280 --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/5/input.js @@ -0,0 +1,6 @@ +async function foo() { + const { foo } = await import('some-module') + return foo() +} + +exports.foo = foo diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/5/output.js b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/5/output.js new file mode 100644 index 0000000000000..64811de98f9a3 --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/5/output.js @@ -0,0 +1,6 @@ +const { ["trackDynamicImport"]: $$trackDynamicImport__ } = require("private-next-rsc-track-dynamic-import"); +async function foo() { + const { foo } = await /*#__PURE__*/ $$trackDynamicImport__(import('some-module')); + return foo(); +} +exports.foo = foo; diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/index.ts b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/index.ts new file mode 100644 index 0000000000000..20915445a3590 --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/index.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/modules.d.ts b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/modules.d.ts new file mode 100644 index 0000000000000..fb65a5a2b6500 --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/modules.d.ts @@ -0,0 +1,7 @@ +declare module 'some-module' { + export function foo(): null +} +declare module 'get-name' { + const name: string + export default name +} diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/next.d.ts b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/next.d.ts new file mode 100644 index 0000000000000..5178b71fb3cb8 --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/next.d.ts @@ -0,0 +1,3 @@ +declare module 'private-next-rsc-track-dynamic-import' { + export function trackDynamicImport(promise: Promise): Promise +} diff --git a/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/tsconfig.json b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/tsconfig.json new file mode 100644 index 0000000000000..5da6bee985c0c --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/track-dynamic-imports/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + + "allowJs": true, + "checkJs": true, + + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + + "strict": true, + "jsx": "preserve", + + "target": "ESNext", + "esModuleInterop": true, + "module": "Preserve", + "moduleResolution": "bundler", + "moduleDetection": "force" + }, + "files": ["./index.ts"], // loads ambient declarations for modules used in tests + "include": ["./**/*/input.js", "./**/*/output.js"] +} diff --git a/crates/next-custom-transforms/tests/full.rs b/crates/next-custom-transforms/tests/full.rs index cc535bcf60c60..0d6e3cd87a841 100644 --- a/crates/next-custom-transforms/tests/full.rs +++ b/crates/next-custom-transforms/tests/full.rs @@ -82,6 +82,7 @@ fn test(input: &Path, minify: bool) { prefer_esm: false, debug_function_name: false, css_env: None, + track_dynamic_imports: false, }; let unresolved_mark = Mark::new(); diff --git a/lerna.json b/lerna.json index f1d697aee5455..bc51eeffdcdc9 100644 --- a/lerna.json +++ b/lerna.json @@ -16,5 +16,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "15.4.0-canary.23" + "version": "15.4.0-canary.24" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 98ee6e06f4c8d..646f11fa65a0c 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 64405bcbebb92..5a5d543050f9c 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "description": "ESLint configuration used by Next.js.", "main": "index.js", "license": "MIT", @@ -10,7 +10,7 @@ }, "homepage": "https://nextjs.org/docs/app/api-reference/config/eslint", "dependencies": { - "@next/eslint-plugin-next": "15.4.0-canary.23", + "@next/eslint-plugin-next": "15.4.0-canary.24", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 89e863afdd97a..090af19529c8b 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index a7f012850ea2f..cb7730ea00e25 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 5f5a3a3bc6f7e..d6ec2485e9f32 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 5ec998ee558f4..a48c8ca405811 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 1ad72093c4d65..caf9a62c44977 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index f8c153db84b8b..a4a6ab8648078 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index b0e790c62aafd..c224b2b5a9d7e 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 63453c6ad2a1f..da8a6b6a02b2e 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index cfdbc52e9d303..7f307a88712ab 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index cadb66108eff8..96c66f91e3d87 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 298d5474066d1..27507d2e2e4cf 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "private": true, "files": [ "native/" diff --git a/packages/next/errors.json b/packages/next/errors.json index ac8de7e0f53d5..e5c4e05ad623a 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -674,5 +674,8 @@ "673": "Thrown value was ignored. This is a bug in Next.js.", "674": "Route \"%s\" has a \\`generateViewport\\` that depends on Request data (\\`cookies()\\`, etc...) or uncached external data (\\`fetch(...)\\`, etc...) without explicitly allowing fully dynamic rendering. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-viewport", "675": "Expected `generateMetadata` not to block the application shell but it did.", - "676": "Invariant: missing internal router-server-methods this is an internal bug" + "676": "Invariant: missing internal router-server-methods this is an internal bug", + "677": "`trackDynamicImport` should always receive a promise. Something went wrong in the dynamic imports transform.", + "678": "CacheSignal got more endRead() calls than beginRead() calls", + "679": "A CacheSignal cannot subscribe to itself" } diff --git a/packages/next/package.json b/packages/next/package.json index a7b3123bf9ecf..00724c23898eb 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -100,7 +100,7 @@ ] }, "dependencies": { - "@next/env": "15.4.0-canary.23", + "@next/env": "15.4.0-canary.24", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -162,11 +162,11 @@ "@jest/types": "29.5.0", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "15.4.0-canary.23", - "@next/polyfill-module": "15.4.0-canary.23", - "@next/polyfill-nomodule": "15.4.0-canary.23", - "@next/react-refresh-utils": "15.4.0-canary.23", - "@next/swc": "15.4.0-canary.23", + "@next/font": "15.4.0-canary.24", + "@next/polyfill-module": "15.4.0-canary.24", + "@next/polyfill-nomodule": "15.4.0-canary.24", + "@next/react-refresh-utils": "15.4.0-canary.24", + "@next/swc": "15.4.0-canary.24", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.41.2", "@storybook/addon-a11y": "8.6.0", diff --git a/packages/next/src/build/create-compiler-aliases.ts b/packages/next/src/build/create-compiler-aliases.ts index 38205e6bd4a7b..a5a594a32b6cc 100644 --- a/packages/next/src/build/create-compiler-aliases.ts +++ b/packages/next/src/build/create-compiler-aliases.ts @@ -11,6 +11,7 @@ import { RSC_ACTION_ENCRYPTION_ALIAS, RSC_CACHE_WRAPPER_ALIAS, type WebpackLayerName, + RSC_DYNAMIC_IMPORT_WRAPPER_ALIAS, } from '../lib/constants' import type { NextConfigComplete } from '../server/config-shared' import { defaultOverrides } from '../server/require-hook' @@ -177,6 +178,8 @@ export function createWebpackAliases({ [RSC_CACHE_WRAPPER_ALIAS]: 'next/dist/build/webpack/loaders/next-flight-loader/cache-wrapper', + [RSC_DYNAMIC_IMPORT_WRAPPER_ALIAS]: + 'next/dist/build/webpack/loaders/next-flight-loader/track-dynamic-import', ...(isClient || isEdgeServer ? { diff --git a/packages/next/src/build/handle-externals.ts b/packages/next/src/build/handle-externals.ts index 8cf2ed944942d..f1fbabc1b9719 100644 --- a/packages/next/src/build/handle-externals.ts +++ b/packages/next/src/build/handle-externals.ts @@ -189,7 +189,7 @@ export function makeExternalHandler({ } const notExternalModules = - /^(?:private-next-pages\/|next\/(?:dist\/pages\/|(?:app|cache|document|link|form|head|image|legacy\/image|constants|dynamic|script|navigation|headers|router|compat\/router|server)$)|string-hash|private-next-rsc-action-validate|private-next-rsc-action-client-wrapper|private-next-rsc-server-reference|private-next-rsc-cache-wrapper$)/ + /^(?:private-next-pages\/|next\/(?:dist\/pages\/|(?:app|cache|document|link|form|head|image|legacy\/image|constants|dynamic|script|navigation|headers|router|compat\/router|server)$)|string-hash|private-next-rsc-action-validate|private-next-rsc-action-client-wrapper|private-next-rsc-server-reference|private-next-rsc-cache-wrapper|private-next-rsc-track-dynamic-import$)/ if (notExternalModules.test(request)) { return } diff --git a/packages/next/src/build/swc/options.ts b/packages/next/src/build/swc/options.ts index 4a3207c62f3dd..877ff9a9dce1c 100644 --- a/packages/next/src/build/swc/options.ts +++ b/packages/next/src/build/swc/options.ts @@ -73,6 +73,7 @@ function getBaseSWCOptions({ isDynamicIo, cacheHandlers, useCacheEnabled, + trackDynamicImports, }: { filename: string jest?: boolean @@ -93,6 +94,7 @@ function getBaseSWCOptions({ isDynamicIo?: boolean cacheHandlers?: ExperimentalConfig['cacheHandlers'] useCacheEnabled?: boolean + trackDynamicImports?: boolean }) { const isReactServerLayer = isWebpackServerOnlyLayer(bundleLayer) const isAppRouterPagesLayer = isWebpackAppPagesLayer(bundleLayer) @@ -235,6 +237,7 @@ function getBaseSWCOptions({ // On server side of pages router we prefer CJS. preferEsm: esm, lintCodemodComments: true, + trackDynamicImports: trackDynamicImports, debugFunctionName: development, ...(supportedBrowsers && supportedBrowsers.length > 0 @@ -384,6 +387,7 @@ export function getLoaderSWCOptions({ esm, cacheHandlers, useCacheEnabled, + trackDynamicImports, }: { filename: string development: boolean @@ -410,6 +414,7 @@ export function getLoaderSWCOptions({ bundleLayer?: WebpackLayerName cacheHandlers: ExperimentalConfig['cacheHandlers'] useCacheEnabled?: boolean + trackDynamicImports?: boolean }) { let baseOptions: any = getBaseSWCOptions({ filename, @@ -430,6 +435,7 @@ export function getLoaderSWCOptions({ isDynamicIo, cacheHandlers, useCacheEnabled, + trackDynamicImports, }) baseOptions.fontLoaders = { fontLoaders: ['next/font/local', 'next/font/google'], diff --git a/packages/next/src/build/webpack/loaders/next-flight-loader/track-dynamic-import.ts b/packages/next/src/build/webpack/loaders/next-flight-loader/track-dynamic-import.ts new file mode 100644 index 0000000000000..3f3468038ae9d --- /dev/null +++ b/packages/next/src/build/webpack/loaders/next-flight-loader/track-dynamic-import.ts @@ -0,0 +1 @@ +export { trackDynamicImport } from '../../../../server/app-render/module-loading/track-dynamic-import' diff --git a/packages/next/src/build/webpack/loaders/next-swc-loader.ts b/packages/next/src/build/webpack/loaders/next-swc-loader.ts index 15206613a5d72..d6b7be7ef492d 100644 --- a/packages/next/src/build/webpack/loaders/next-swc-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-swc-loader.ts @@ -27,7 +27,7 @@ DEALINGS IN THE SOFTWARE. */ import type { NextConfig } from '../../../types' -import type { WebpackLayerName } from '../../../lib/constants' +import { type WebpackLayerName, WEBPACK_LAYERS } from '../../../lib/constants' import { isWasm, transform } from '../../swc' import { getLoaderSWCOptions } from '../../swc/options' import path, { isAbsolute } from 'path' @@ -76,6 +76,11 @@ export interface SWCLoaderOptions { // for to force transpiling a `node_module` const FORCE_TRANSPILE_CONDITIONS = /next\/font|next\/dynamic|use server|use client|use cache/ +// same as above, but including `import(...)`. +// (note the optional whitespace: `import (...)` is also syntactically valid) +const FORCE_TRANSPILE_CONDITIONS_WITH_IMPORT = new RegExp( + String.raw`(?:${FORCE_TRANSPILE_CONDITIONS.source})|import\s*\(` +) async function loaderTransform( this: LoaderContext & TelemetryLoaderContext, @@ -96,12 +101,18 @@ async function loaderTransform( loaderOptions.transpilePackages || [] ) + const trackDynamicImports = shouldTrackDynamicImports(loaderOptions) + if (shouldMaybeExclude) { if (!source) { throw new Error(`Invariant might be excluded but missing source`) } - if (!FORCE_TRANSPILE_CONDITIONS.test(source)) { + const forceTranspileConditions = trackDynamicImports + ? FORCE_TRANSPILE_CONDITIONS_WITH_IMPORT + : FORCE_TRANSPILE_CONDITIONS + + if (!forceTranspileConditions.test(source)) { return [source, inputSourceMap] } } @@ -150,6 +161,7 @@ async function loaderTransform( esm, cacheHandlers: nextConfig.experimental?.cacheHandlers, useCacheEnabled: nextConfig.experimental?.useCache, + trackDynamicImports, }) const programmaticOptions = { @@ -199,6 +211,18 @@ async function loaderTransform( ) } +function shouldTrackDynamicImports(loaderOptions: SWCLoaderOptions): boolean { + // we only need to track `import()` 1. in dynamicIO, 2. on the server (RSC and SSR) + // (Note: logic duplicated in crates/next-core/src/next_server/transforms.rs) + const { nextConfig, isServer, bundleLayer } = loaderOptions + return ( + !!nextConfig.experimental?.dynamicIO && + isServer && + (bundleLayer === WEBPACK_LAYERS.reactServerComponents || + bundleLayer === WEBPACK_LAYERS.serverSideRendering) + ) +} + const EXCLUDED_PATHS = /[\\/](cache[\\/][^\\/]+\.zip[\\/]node_modules|__virtual__)[\\/]/g diff --git a/packages/next/src/lib/constants.ts b/packages/next/src/lib/constants.ts index 08be418908187..a5dcbefa7119c 100644 --- a/packages/next/src/lib/constants.ts +++ b/packages/next/src/lib/constants.ts @@ -56,6 +56,8 @@ export const RSC_MOD_REF_PROXY_ALIAS = 'private-next-rsc-mod-ref-proxy' export const RSC_ACTION_VALIDATE_ALIAS = 'private-next-rsc-action-validate' export const RSC_ACTION_PROXY_ALIAS = 'private-next-rsc-server-reference' export const RSC_CACHE_WRAPPER_ALIAS = 'private-next-rsc-cache-wrapper' +export const RSC_DYNAMIC_IMPORT_WRAPPER_ALIAS = + 'private-next-rsc-track-dynamic-import' export const RSC_ACTION_ENCRYPTION_ALIAS = 'private-next-rsc-action-encryption' export const RSC_ACTION_CLIENT_WRAPPER_ALIAS = 'private-next-rsc-action-client-wrapper' diff --git a/packages/next/src/lib/scheduler.ts b/packages/next/src/lib/scheduler.ts index 212d87d0d017f..b8ba36c8c9295 100644 --- a/packages/next/src/lib/scheduler.ts +++ b/packages/next/src/lib/scheduler.ts @@ -7,7 +7,7 @@ export type SchedulerFn = (cb: ScheduledFn) => void * * @param cb the function to schedule */ -export const scheduleOnNextTick = (cb: ScheduledFn): void => { +export const scheduleOnNextTick = (cb: ScheduledFn) => { // We use Promise.resolve().then() here so that the operation is scheduled at // the end of the promise job queue, we then add it to the next process tick // to ensure it's evaluated afterwards. @@ -29,7 +29,7 @@ export const scheduleOnNextTick = (cb: ScheduledFn): void => { * * @param cb the function to schedule */ -export const scheduleImmediate = (cb: ScheduledFn): void => { +export const scheduleImmediate = (cb: ScheduledFn): void => { if (process.env.NEXT_RUNTIME === 'edge') { setTimeout(cb, 0) } else { diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 1176ccf4f6c7b..84cfe043d708f 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -192,6 +192,11 @@ import { isUseCacheTimeoutError } from '../use-cache/use-cache-errors' import { createServerInsertedMetadata } from './metadata-insertion/create-server-inserted-metadata' import { getPreviouslyRevalidatedTags } from '../server-utils' import { executeRevalidates } from '../revalidation-utils' +import { + trackPendingChunkLoad, + trackPendingImport, + trackPendingModules, +} from './module-loading/track-module-loading.external' export type GetDynamicParamFromSegment = ( // [slug] / [[slug]] / [...slug] @@ -737,8 +742,10 @@ async function warmupDevRender( } ) - // Wait for all caches to be finished filling + // Wait for all caches to be finished filling and for async imports to resolve + trackPendingModules(cacheSignal) await cacheSignal.cacheReady() + // We unset the cache so any late over-run renders aren't able to write into this cache prerenderStore.prerenderResumeDataCache = null // Abort the render @@ -1196,15 +1203,24 @@ async function renderToHTMLOrFlightImpl( // react-server-dom-webpack. This is a hack until we find a better way. if (ComponentMod.__next_app__) { const instrumented = wrapClientComponentLoader(ComponentMod) - // @ts-ignore - globalThis.__next_require__ = instrumented.require + // When we are prerendering if there is a cacheSignal for tracking - // cache reads we wrap the loadChunk in this tracking. This allows us - // to treat chunk loading with similar semantics as cache reads to avoid - // async loading chunks from causing a prerender to abort too early. + // cache reads we track calls to `loadChunk` and `require`. This allows us + // to treat chunk/module loading with similar semantics as cache reads to avoid + // module loading from causing a prerender to abort too early. + + const __next_require__: typeof instrumented.require = (...args) => { + const exportsOrPromise = instrumented.require(...args) + // requiring an async module returns a promise. + trackPendingImport(exportsOrPromise) + return exportsOrPromise + } + // @ts-expect-error + globalThis.__next_require__ = __next_require__ + const __next_chunk_load__: typeof instrumented.loadChunk = (...args) => { const loadingChunk = instrumented.loadChunk(...args) - trackChunkLoading(loadingChunk) + trackPendingChunkLoad(loadingChunk) return loadingChunk } // @ts-expect-error @@ -2323,19 +2339,13 @@ async function spawnDynamicValidationInDev( const { ServerInsertedMetadataProvider } = createServerInsertedMetadata(nonce) if (initialServerStream) { - const [warmupStream, renderStream] = initialServerStream.tee() - initialServerStream = null - // Before we attempt the SSR initial render we need to ensure all client modules - // are already loaded. - await warmFlightResponse(warmupStream, clientReferenceManifest) - const prerender = require('react-dom/static.edge') .prerender as (typeof import('react-dom/static.edge'))['prerender'] const pendingInitialClientResult = workUnitAsyncStorage.run( initialClientPrerenderStore, prerender, {}} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} @@ -2378,7 +2388,10 @@ async function spawnDynamicValidationInDev( }) } + // Wait for all caches to be finished filling and for async imports to resolve + trackPendingModules(cacheSignal) await cacheSignal.cacheReady() + // It is important that we abort the SSR render first to avoid // connection closed errors from having an incomplete RSC stream initialClientController.abort() @@ -2815,7 +2828,10 @@ async function prerenderToStream( } ) + // Wait for all caches to be finished filling and for async imports to resolve + trackPendingModules(cacheSignal) await cacheSignal.cacheReady() + initialServerRenderController.abort() initialServerPrerenderController.abort() @@ -2841,13 +2857,6 @@ async function prerenderToStream( } if (initialServerResult) { - // Before we attempt the SSR initial render we need to ensure all client modules - // are already loaded. - await warmFlightResponse( - initialServerResult.asStream(), - clientReferenceManifest - ) - const initialClientController = new AbortController() const initialClientPrerenderStore: PrerenderStore = { type: 'prerender', @@ -2856,7 +2865,7 @@ async function prerenderToStream( implicitTags, renderSignal: initialClientController.signal, controller: initialClientController, - cacheSignal: null, + cacheSignal, dynamicTracking: null, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, @@ -2868,52 +2877,46 @@ async function prerenderToStream( const prerender = require('react-dom/static.edge') .prerender as (typeof import('react-dom/static.edge'))['prerender'] - await prerenderAndAbortInSequentialTasks( - () => - workUnitAsyncStorage.run( - initialClientPrerenderStore, - prerender, - , - { - signal: initialClientController.signal, - onError: (err) => { - const digest = getDigestForWellKnownError(err) + const pendingInitialClientResult = workUnitAsyncStorage.run( + initialClientPrerenderStore, + prerender, + , + { + signal: initialClientController.signal, + onError: (err) => { + const digest = getDigestForWellKnownError(err) - if (digest) { - return digest - } + if (digest) { + return digest + } - if (initialClientController.signal.aborted) { - // These are expected errors that might error the prerender. we ignore them. - } else if ( - process.env.NEXT_DEBUG_BUILD || - process.env.__NEXT_VERBOSE_LOGGING - ) { - // We don't normally log these errors because we are going to retry anyway but - // it can be useful for debugging Next.js itself to get visibility here when needed - printDebugThrownValueForProspectiveRender( - err, - workStore.route - ) - } - }, - bootstrapScripts: [bootstrapScript], + if (initialClientController.signal.aborted) { + // These are expected errors that might error the prerender. we ignore them. + } else if ( + process.env.NEXT_DEBUG_BUILD || + process.env.__NEXT_VERBOSE_LOGGING + ) { + // We don't normally log these errors because we are going to retry anyway but + // it can be useful for debugging Next.js itself to get visibility here when needed + printDebugThrownValueForProspectiveRender( + err, + workStore.route + ) } - ), - () => { - initialClientController.abort() + }, + bootstrapScripts: [bootstrapScript], } - ).catch((err) => { + ) + + pendingInitialClientResult.catch((err) => { if ( initialServerRenderController.signal.aborted || isPrerenderInterruptedError(err) @@ -2928,6 +2931,12 @@ async function prerenderToStream( printDebugThrownValueForProspectiveRender(err, workStore.route) } }) + + // This is mostly needed for dynamic `import()`s in client components. + // Promises passed to client were already awaited above (assuming that they came from cached functions) + trackPendingModules(cacheSignal) + await cacheSignal.cacheReady() + initialClientController.abort() } let serverIsDynamic = false @@ -3351,19 +3360,13 @@ async function prerenderToStream( } if (initialServerStream) { - const [warmupStream, renderStream] = initialServerStream.tee() - initialServerStream = null - // Before we attempt the SSR initial render we need to ensure all client modules - // are already loaded. - await warmFlightResponse(warmupStream, clientReferenceManifest) - const prerender = require('react-dom/static.edge') .prerender as (typeof import('react-dom/static.edge'))['prerender'] const pendingInitialClientResult = workUnitAsyncStorage.run( initialClientPrerenderStore, prerender, > = new Set() -const chunkListeners: Array<(x?: unknown) => void> = [] - -function trackChunkLoading(load: Promise) { - loadingChunks.add(load) - load.finally(() => { - if (loadingChunks.has(load)) { - loadingChunks.delete(load) - if (loadingChunks.size === 0) { - // We are not currently loading any chunks. We can notify all listeners - for (let i = 0; i < chunkListeners.length; i++) { - chunkListeners[i]() - } - chunkListeners.length = 0 - } - } - }) -} - -export async function warmFlightResponse( - flightStream: ReadableStream, - clientReferenceManifest: DeepReadonly -) { - const { createFromReadableStream } = - // eslint-disable-next-line import/no-extraneous-dependencies - require('react-server-dom-webpack/client.edge') as typeof import('react-server-dom-webpack/client.edge') - - try { - createFromReadableStream(flightStream, { - serverConsumerManifest: { - moduleLoading: clientReferenceManifest.moduleLoading, - moduleMap: clientReferenceManifest.ssrModuleMapping, - serverModuleMap: null, - }, - }) - } catch { - // We don't want to handle errors here but we don't want it to - // interrupt the outer flow. We simply ignore it here and expect - // it will bubble up during a render - } - - // We'll wait at least one task and then if no chunks have started to load - // we'll we can infer that there are none to load from this flight response - trackChunkLoading(waitAtLeastOneReactRenderTask()) - return new Promise((r) => { - chunkListeners.push(r) - }) -} - const getGlobalErrorStyles = async ( tree: LoaderTree, ctx: AppRenderContext diff --git a/packages/next/src/server/app-render/cache-signal.ts b/packages/next/src/server/app-render/cache-signal.ts index ab4b6892b1c7a..e9e8ace362184 100644 --- a/packages/next/src/server/app-render/cache-signal.ts +++ b/packages/next/src/server/app-render/cache-signal.ts @@ -5,20 +5,16 @@ * and should only be used in codepaths gated with this feature. */ +import { InvariantError } from '../../shared/lib/invariant-error' + export class CacheSignal { - private count: number - private earlyListeners: Array<() => void> - private listeners: Array<() => void> - private tickPending: boolean - private taskPending: boolean - - constructor() { - this.count = 0 - this.earlyListeners = [] - this.listeners = [] - this.tickPending = false - this.taskPending = false - } + private count = 0 + private earlyListeners: Array<() => void> = [] + private listeners: Array<() => void> = [] + private tickPending = false + private taskPending = false + + private subscribedSignals: Set | null = null private noMorePendingCaches() { if (!this.tickPending) { @@ -76,9 +72,21 @@ export class CacheSignal { beginRead() { this.count++ + + if (this.subscribedSignals !== null) { + for (const subscriber of this.subscribedSignals) { + subscriber.beginRead() + } + } } endRead() { + if (this.count === 0) { + throw new InvariantError( + 'CacheSignal got more endRead() calls than beginRead() calls' + ) + } + // If this is the last read we need to wait a task before we can claim the cache is settled. // The cache read will likely ping a Server Component which can read from the cache again and this // will play out in a microtask so we need to only resolve pending listeners if we're still at 0 @@ -89,5 +97,46 @@ export class CacheSignal { if (this.count === 0) { this.noMorePendingCaches() } + + if (this.subscribedSignals !== null) { + for (const subscriber of this.subscribedSignals) { + subscriber.endRead() + } + } + } + + trackRead(promise: Promise) { + this.beginRead() + promise.finally(this.endRead.bind(this)) + return promise + } + + subscribeToReads(subscriber: CacheSignal): () => void { + if (subscriber === this) { + throw new InvariantError('A CacheSignal cannot subscribe to itself') + } + if (this.subscribedSignals === null) { + this.subscribedSignals = new Set() + } + this.subscribedSignals.add(subscriber) + + // we'll notify the subscriber of each endRead() on this signal, + // so we need to give it a corresponding beginRead() for each read we have in flight now. + for (let i = 0; i < this.count; i++) { + subscriber.beginRead() + } + + return this.unsubscribeFromReads.bind(this, subscriber) + } + + unsubscribeFromReads(subscriber: CacheSignal) { + if (!this.subscribedSignals) { + return + } + this.subscribedSignals.delete(subscriber) + + // we don't need to set the set back to `null` if it's empty -- + // if other signals are subscribing to this one, it'll likely get more subscriptions later, + // so we'd have to allocate a fresh set again when that happens. } } diff --git a/packages/next/src/server/app-render/module-loading/track-dynamic-import.ts b/packages/next/src/server/app-render/module-loading/track-dynamic-import.ts new file mode 100644 index 0000000000000..3a8a56ea7dcc6 --- /dev/null +++ b/packages/next/src/server/app-render/module-loading/track-dynamic-import.ts @@ -0,0 +1,48 @@ +import { InvariantError } from '../../../shared/lib/invariant-error' +import { isThenable } from '../../../shared/lib/is-thenable' +import { trackPendingImport } from './track-module-loading.external' + +/** + * in DynamicIO, `import(...)` will be transformed into `trackDynamicImport(import(...))`. + * A dynamic import is essentially a cached async function, except it's cached by the module system. + * + * The promises are tracked globally regardless of if the `import()` happens inside a render or outside of it. + * When rendering, we can make the `cacheSignal` wait for all pending promises via `trackPendingModules`. + * */ +export function trackDynamicImport>( + modulePromise: Promise +): Promise { + if (!isThenable(modulePromise)) { + // We're expecting `import()` to always return a promise. If it's not, something's very wrong. + throw new InvariantError( + '`trackDynamicImport` should always receive a promise. Something went wrong in the dynamic imports transform.' + ) + } + + // Even if we're inside a prerender and have `workUnitStore.cacheSignal`, we always track the promise globally. + // (i.e. via the global `moduleLoadingSignal` that `trackPendingImport` uses internally). + // + // We do this because the `import()` promise might be cached in userspace: + // (which is quite common for e.g. lazy initialization in libraries) + // + // let promise; + // function doDynamicImportOnce() { + // if (!promise) { + // promise = import("..."); + // // transformed into: + // // promise = trackDynamicImport(import("...")); + // } + // return promise; + // } + // + // If multiple prerenders (e.g. multiple pages) depend on `doDynamicImportOnce`, + // we have to wait for the import *in all of them*. + // If we only tracked it using `workUnitStore.cacheSignal.trackRead()`, + // then only the first prerender to call `doDynamicImportOnce` would wait -- + // Subsequent prerenders would re-use the existing `promise`, + // and `trackDynamicImport` wouldn't be called again in their scope, + // so their respective CacheSignals wouldn't wait for the promise. + trackPendingImport(modulePromise) + + return modulePromise +} diff --git a/packages/next/src/server/app-render/module-loading/track-module-loading.external.ts b/packages/next/src/server/app-render/module-loading/track-module-loading.external.ts new file mode 100644 index 0000000000000..95109c2c225e2 --- /dev/null +++ b/packages/next/src/server/app-render/module-loading/track-module-loading.external.ts @@ -0,0 +1,11 @@ +// NOTE: this is marked as shared/external because it's stateful +// and the state needs to be shared between app-render (which waits for pending imports) +// and helpers used in transformed page code (which register pending imports) + +import { + trackPendingChunkLoad, + trackPendingImport, + trackPendingModules, +} from './track-module-loading.instance' with { 'turbopack-transition': 'next-shared' } + +export { trackPendingChunkLoad, trackPendingImport, trackPendingModules } diff --git a/packages/next/src/server/app-render/module-loading/track-module-loading.instance.ts b/packages/next/src/server/app-render/module-loading/track-module-loading.instance.ts new file mode 100644 index 0000000000000..7f95f86dc9f08 --- /dev/null +++ b/packages/next/src/server/app-render/module-loading/track-module-loading.instance.ts @@ -0,0 +1,40 @@ +import { CacheSignal } from '../cache-signal' +import { isThenable } from '../../../shared/lib/is-thenable' + +/** Tracks all in-flight async imports and chunk loads. */ +const moduleLoadingSignal = new CacheSignal() + +export function trackPendingChunkLoad(promise: Promise) { + moduleLoadingSignal.trackRead(promise) +} + +export function trackPendingImport(exportsOrPromise: unknown) { + // requiring an async module returns a promise. + // if it's sync, there's nothing to track. + if (isThenable(exportsOrPromise)) { + // A client reference proxy might look like a promise, but we can only call `.then()` on it, not e.g. `.finally()`. + // Turn it into a real promise to avoid issues elsewhere. + const promise = Promise.resolve(exportsOrPromise) + moduleLoadingSignal.trackRead(promise) + } +} + +/** + * A top-level dynamic import (or chunk load): + * + * 1. delays a prerender (potentially for a task or longer) + * 2. may reveal more caches that need be filled + * + * So if we see one, we want to extend the duration of `cacheSignal` at least until the import/chunk-load is done. + */ +export function trackPendingModules(cacheSignal: CacheSignal): void { + // We can't just use `cacheSignal.trackRead(moduleLoadingSignal.cacheReady())`, + // because we might start and finish multiple batches of module loads while waiting for caches, + // and `moduleLoadingSignal.cacheReady()` would resolve after the first batch. + // Instead, we'll keep notifying `cacheSignal` of each import/chunk-load. + const unsubscribe = moduleLoadingSignal.subscribeToReads(cacheSignal) + + // Later, when `cacheSignal` is no longer waiting for any caches (or imports that we've notified it of), + // we can unsubscribe it. + cacheSignal.cacheReady().then(unsubscribe) +} diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index f970e7f12efc0..067a3fafe21a0 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -83,6 +83,7 @@ import { import { RedirectStatusCode } from '../../../client/components/redirect-status-code' import { INFINITE_CACHE } from '../../../lib/constants' import { executeRevalidates } from '../../revalidation-utils' +import { trackPendingModules } from '../../app-render/module-loading/track-module-loading.external' export class WrappedNextRouterError { constructor( @@ -439,6 +440,8 @@ export class AppRouteRouteModule extends RouteModule< } ) } + + trackPendingModules(cacheSignal) await cacheSignal.cacheReady() if (prospectiveRenderIsDynamic) { diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 8cbffe82205fe..1ecd31853f76b 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index d07ef1bbe4334..1e7ca3ffd4969 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "15.4.0-canary.23", + "version": "15.4.0-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "15.4.0-canary.23", + "next": "15.4.0-canary.24", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "5.8.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 229930ee25a15..3ad43acf77066 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -824,7 +824,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 15.4.0-canary.23 + specifier: 15.4.0-canary.24 version: link:../eslint-plugin-next '@rushstack/eslint-patch': specifier: ^1.10.3 @@ -888,7 +888,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 15.4.0-canary.23 + specifier: 15.4.0-canary.24 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1007,19 +1007,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 15.4.0-canary.23 + specifier: 15.4.0-canary.24 version: link:../font '@next/polyfill-module': - specifier: 15.4.0-canary.23 + specifier: 15.4.0-canary.24 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 15.4.0-canary.23 + specifier: 15.4.0-canary.24 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 15.4.0-canary.23 + specifier: 15.4.0-canary.24 version: link:../react-refresh-utils '@next/swc': - specifier: 15.4.0-canary.23 + specifier: 15.4.0-canary.24 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1704,7 +1704,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 15.4.0-canary.23 + specifier: 15.4.0-canary.24 version: link:../next outdent: specifier: 0.8.0 diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/async-module/async-messages.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/async-module/async-messages.ts new file mode 100644 index 0000000000000..85bd27968c5d8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/async-module/async-messages.ts @@ -0,0 +1,4 @@ +console.log('[inside-component] async-messages :: imported, sleeping') +await new Promise((resolve) => setTimeout(resolve, 500)) +console.log('[inside-component] async-messages :: ready') +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/async-module/client.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/async-module/client.tsx new file mode 100644 index 0000000000000..b7fdd4617c31a --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/async-module/client.tsx @@ -0,0 +1,15 @@ +'use client' + +import * as React from 'react' +import { once } from '../once' + +const doImport = once(() => import('./async-messages')) + +export function Client() { + const messages = React.use(doImport()).default + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/async-module/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/async-module/page.tsx new file mode 100644 index 0000000000000..2f38c374336bc --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/async-module/page.tsx @@ -0,0 +1,6 @@ +import * as React from 'react' +import { Client } from './client' + +export default function Page() { + return +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/once.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/once.ts new file mode 100644 index 0000000000000..5fd2163c41089 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/once.ts @@ -0,0 +1,10 @@ +/** Memoize a callback to only invoke it once. */ +export function once(cb: () => T) { + let cache: null | { value: T } = null + return () => { + if (!cache) { + cache = { value: cb() } + } + return cache.value + } +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/sync-module/client.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/sync-module/client.tsx new file mode 100644 index 0000000000000..783c930a306e6 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/sync-module/client.tsx @@ -0,0 +1,14 @@ +'use client' +import * as React from 'react' +import { once } from '../once' + +const doImport = once(() => import('./messages')) + +export function Client() { + const messages = React.use(doImport()).default + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/sync-module/messages.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/sync-module/messages.ts new file mode 100644 index 0000000000000..b1e71f1b5ee5d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/sync-module/messages.ts @@ -0,0 +1 @@ +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/sync-module/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/sync-module/page.tsx new file mode 100644 index 0000000000000..2f38c374336bc --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/client/sync-module/page.tsx @@ -0,0 +1,6 @@ +import * as React from 'react' +import { Client } from './client' + +export default function Page() { + return +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/async-module/async-messages.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/async-module/async-messages.ts new file mode 100644 index 0000000000000..85bd27968c5d8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/async-module/async-messages.ts @@ -0,0 +1,4 @@ +console.log('[inside-component] async-messages :: imported, sleeping') +await new Promise((resolve) => setTimeout(resolve, 500)) +console.log('[inside-component] async-messages :: ready') +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/async-module/route.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/async-module/route.ts new file mode 100644 index 0000000000000..0185c7bccca96 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/async-module/route.ts @@ -0,0 +1,4 @@ +export async function GET(_request: Request) { + const messages = (await import('./async-messages')).default + return new Response(messages.title) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/sync-module/messages.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/sync-module/messages.ts new file mode 100644 index 0000000000000..b1e71f1b5ee5d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/sync-module/messages.ts @@ -0,0 +1 @@ +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/sync-module/route.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/sync-module/route.ts new file mode 100644 index 0000000000000..808c0b50730d7 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/route-handler/sync-module/route.ts @@ -0,0 +1,4 @@ +export async function GET(_request: Request) { + const messages = (await import('./messages')).default + return new Response(messages.title) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/async-module/async-messages.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/async-module/async-messages.ts new file mode 100644 index 0000000000000..85bd27968c5d8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/async-module/async-messages.ts @@ -0,0 +1,4 @@ +console.log('[inside-component] async-messages :: imported, sleeping') +await new Promise((resolve) => setTimeout(resolve, 500)) +console.log('[inside-component] async-messages :: ready') +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/async-module/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/async-module/page.tsx new file mode 100644 index 0000000000000..89e0a3ec6a56f --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/async-module/page.tsx @@ -0,0 +1,10 @@ +import * as React from 'react' + +export default async function Page() { + const messages = (await import('./async-messages')).default + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/from-node-modules/cjs/sync-module/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/from-node-modules/cjs/sync-module/page.tsx new file mode 100644 index 0000000000000..a6d533fc22505 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/from-node-modules/cjs/sync-module/page.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' +import { getMessages } from 'cjs-pkg-with-async-import' + +export default async function Page() { + const messages = await getMessages() + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/from-node-modules/esm/async-module/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/from-node-modules/esm/async-module/page.tsx new file mode 100644 index 0000000000000..d2cfdf2e8bb19 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/from-node-modules/esm/async-module/page.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' +import { getMessagesAsync } from 'esm-pkg-with-async-import' + +export default async function Page() { + const messages = await getMessagesAsync() + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/from-node-modules/esm/sync-module/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/from-node-modules/esm/sync-module/page.tsx new file mode 100644 index 0000000000000..2c2149dc2780f --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/from-node-modules/esm/sync-module/page.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' +import { getMessages } from 'esm-pkg-with-async-import' + +export default async function Page() { + const messages = await getMessages() + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/sync-module/messages.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/sync-module/messages.ts new file mode 100644 index 0000000000000..b1e71f1b5ee5d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/sync-module/messages.ts @@ -0,0 +1 @@ +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/sync-module/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/sync-module/page.tsx new file mode 100644 index 0000000000000..d0b51f757e7a1 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/inside-render/server/sync-module/page.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' + +export default async function Page() { + const messages = (await import('./messages')).default + + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/layout.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/layout.tsx new file mode 100644 index 0000000000000..432635564b70d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/layout.tsx @@ -0,0 +1,9 @@ +import * as React from 'react' + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/async-module/async-messages.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/async-module/async-messages.ts new file mode 100644 index 0000000000000..1b261c126abaa --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/async-module/async-messages.ts @@ -0,0 +1,4 @@ +console.log('[top-level] async-messages :: imported, sleeping') +await new Promise((resolve) => setTimeout(resolve, 500)) +console.log('[top-level] async-messages :: ready') +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/async-module/client.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/async-module/client.tsx new file mode 100644 index 0000000000000..7d573fdde4a16 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/async-module/client.tsx @@ -0,0 +1,13 @@ +'use client' +import * as React from 'react' + +const modulePromise = import('./async-messages') + +export function Client() { + const messages = React.use(modulePromise).default + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/async-module/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/async-module/page.tsx new file mode 100644 index 0000000000000..86ee0246fb444 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/async-module/page.tsx @@ -0,0 +1,6 @@ +import * as React from 'react' +import { Client } from './client' + +export default async function Page() { + return +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/sync-module/client.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/sync-module/client.tsx new file mode 100644 index 0000000000000..4db2a96c0fcbf --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/sync-module/client.tsx @@ -0,0 +1,13 @@ +'use client' +import * as React from 'react' + +const modulePromise = import('./messages') + +export function Client() { + const messages = React.use(modulePromise).default + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/sync-module/messages.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/sync-module/messages.ts new file mode 100644 index 0000000000000..b1e71f1b5ee5d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/sync-module/messages.ts @@ -0,0 +1 @@ +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/sync-module/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/sync-module/page.tsx new file mode 100644 index 0000000000000..86ee0246fb444 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/client/sync-module/page.tsx @@ -0,0 +1,6 @@ +import * as React from 'react' +import { Client } from './client' + +export default async function Page() { + return +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/async-module/async-messages.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/async-module/async-messages.ts new file mode 100644 index 0000000000000..1b261c126abaa --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/async-module/async-messages.ts @@ -0,0 +1,4 @@ +console.log('[top-level] async-messages :: imported, sleeping') +await new Promise((resolve) => setTimeout(resolve, 500)) +console.log('[top-level] async-messages :: ready') +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/async-module/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/async-module/page.tsx new file mode 100644 index 0000000000000..692c09ebe4c4c --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/async-module/page.tsx @@ -0,0 +1,12 @@ +import * as React from 'react' + +const modulePromise = import('./async-messages') + +export default async function Page() { + const messages = (await modulePromise).default + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/sync-module/messages.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/sync-module/messages.ts new file mode 100644 index 0000000000000..b1e71f1b5ee5d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/sync-module/messages.ts @@ -0,0 +1 @@ +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/sync-module/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/sync-module/page.tsx new file mode 100644 index 0000000000000..872a43a053405 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/app/outside-of-render/server/sync-module/page.tsx @@ -0,0 +1,12 @@ +import * as React from 'react' + +const modulePromise = import('./messages') + +export default async function Page() { + const messages = (await modulePromise).default + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/next.config.js b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/next.config.js new file mode 100644 index 0000000000000..10cc99d480a1f --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/next.config.js @@ -0,0 +1,12 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + ppr: process.env.__NEXT_EXPERIMENTAL_PPR === 'true', + dynamicIO: true, + prerenderEarlyExit: false, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/cjs-pkg-with-async-import/index.js b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/cjs-pkg-with-async-import/index.js new file mode 100644 index 0000000000000..a1b82216ec78a --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/cjs-pkg-with-async-import/index.js @@ -0,0 +1,5 @@ +// @ts-check +exports.getMessages = async function getMessages() { + const { default: messages } = await import('./messages.js') + return messages +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/cjs-pkg-with-async-import/messages.js b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/cjs-pkg-with-async-import/messages.js new file mode 100644 index 0000000000000..0e490bf47c22e --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/cjs-pkg-with-async-import/messages.js @@ -0,0 +1,2 @@ +// @ts-check +module.exports = { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/cjs-pkg-with-async-import/package.json b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/cjs-pkg-with-async-import/package.json new file mode 100644 index 0000000000000..574bc6ace6c3b --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/cjs-pkg-with-async-import/package.json @@ -0,0 +1,6 @@ +{ + "name": "cjs-pkg-with-async-import", + "version": "0.1.0", + "type": "commonjs", + "main": "./index.js" +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/async-messages.js b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/async-messages.js new file mode 100644 index 0000000000000..3b57fc734c914 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/async-messages.js @@ -0,0 +1,5 @@ +// @ts-check +console.log('[node_modules] async-messages :: imported, sleeping') +await new Promise((resolve) => setTimeout(resolve, 500)) +console.log('[node_modules] async-messages :: ready') +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/index.js b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/index.js new file mode 100644 index 0000000000000..5a6818eb21ef0 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/index.js @@ -0,0 +1,10 @@ +// @ts-check +export async function getMessages() { + const { default: messages } = await import('./messages.js') + return messages +} + +export async function getMessagesAsync() { + const { default: messages } = await import('./indirect-async.js') + return messages +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/indirect-async.js b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/indirect-async.js new file mode 100644 index 0000000000000..105f9eb25f0d2 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/indirect-async.js @@ -0,0 +1,7 @@ +// @ts-check +console.log('[node_modules] indirect-async :: imported, sleeping') +await new Promise((resolve) => setTimeout(resolve, 500)) +console.log('[node_modules] indirect-async :: importing messages') +const { default: messages } = await import('./async-messages.js') +console.log('[node_modules] indirect-async :: ready') +export default messages diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/messages.js b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/messages.js new file mode 100644 index 0000000000000..ece555844ccfd --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/messages.js @@ -0,0 +1,2 @@ +// @ts-check +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/package.json b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/package.json new file mode 100644 index 0000000000000..1152e181318ca --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/bundled/node_modules/esm-pkg-with-async-import/package.json @@ -0,0 +1,8 @@ +{ + "name": "esm-pkg-with-async-import", + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./index.js" + } +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/dynamic-io-dynamic-imports.test.ts b/test/e2e/app-dir/dynamic-io-dynamic-imports/dynamic-io-dynamic-imports.test.ts new file mode 100644 index 0000000000000..23ab8a844228f --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/dynamic-io-dynamic-imports.test.ts @@ -0,0 +1,182 @@ +import * as path from 'path' +import { nextTestSetup } from 'e2e-utils' +import { + assertNoRedbox, + getRouteTypeFromDevToolsIndicator, + retry, +} from 'next-test-utils' + +describe('async imports in dynamicIO', () => { + const { next, isNextStart, isNextDev } = nextTestSetup({ + files: path.join(__dirname, 'bundled'), + }) + + if (isNextStart) { + it('does not cause any routes to become (partially) dynamic', async () => { + const prerenderManifest = JSON.parse( + await next.readFile('.next/prerender-manifest.json') + ) + + let prerenderedRoutes = Object.keys(prerenderManifest.routes).sort() + + if (process.env.__NEXT_EXPERIMENTAL_PPR === 'true') { + // For the purpose of this test we don't consider an incomplete shell. + prerenderedRoutes = prerenderedRoutes.filter((route) => { + const filename = route.replace(/^\//, '').replace(/^$/, 'index') + try { + return next + .readFileSync(`.next/server/app/${filename}.html`) + .endsWith('') + } catch (err) { + if ('code' in err && err.code === 'ENOENT') { + // the route was prerendered, but we didn't find a HTML file for it. + // this means it must be a GET route handler, not a page + return true + } else { + throw err + } + } + }) + } + + expect(prerenderedRoutes).toMatchInlineSnapshot(` + [ + "/inside-render/client/async-module", + "/inside-render/client/sync-module", + "/inside-render/route-handler/async-module", + "/inside-render/route-handler/sync-module", + "/inside-render/server/async-module", + "/inside-render/server/from-node-modules/cjs/sync-module", + "/inside-render/server/from-node-modules/esm/async-module", + "/inside-render/server/from-node-modules/esm/sync-module", + "/inside-render/server/sync-module", + "/outside-of-render/client/async-module", + "/outside-of-render/client/sync-module", + "/outside-of-render/server/async-module", + "/outside-of-render/server/sync-module", + ] + `) + }) + } + + const testPage = async (href: string) => { + const browser = await next.browser(href) + expect(await browser.elementByCss('body').text()).toBe('hello') + if (isNextDev) { + await retry(async () => { + // the page should be static + expect(await getRouteTypeFromDevToolsIndicator(browser)).toBe('Static') + // we shouldn't get any errors from `spawnDynamicValidationInDev` + await assertNoRedbox(browser) + }) + } + } + + describe('inside a server component', () => { + it('import of a sync module', async () => { + await testPage('/inside-render/server/sync-module') + }) + + it('import of module with top-level-await', async () => { + await testPage('/inside-render/server/async-module') + }) + + describe('dynamic import in node_modules', () => { + describe('in an ESM package', () => { + it('import of a sync module', async () => { + await testPage( + '/inside-render/server/from-node-modules/esm/sync-module' + ) + }) + + it('import of module with top-level-await', async () => { + await testPage( + '/inside-render/server/from-node-modules/esm/async-module' + ) + }) + }) + + describe('in a CJS package', () => { + // CJS can't do top-level-await, so we're only testing sync modules + it('import of a sync module', async () => { + await testPage( + '/inside-render/server/from-node-modules/cjs/sync-module' + ) + }) + }) + }) + }) + + describe('inside a client component', () => { + it('import of a sync module', async () => { + await testPage('/inside-render/client/sync-module') + }) + + it('import of module with top-level-await', async () => { + await testPage('/inside-render/client/async-module') + }) + }) + + describe('inside a GET route handler', () => { + it('import of a sync module', async () => { + const result = await next + .fetch('/inside-render/route-handler/sync-module') + .then((res) => res.text()) + expect(result).toBe('hello') + }) + + it('import of module with top-level-await', async () => { + const result = await next + .fetch('/inside-render/route-handler/async-module') + .then((res) => res.text()) + expect(result).toBe('hello') + }) + }) + + describe('outside of render', () => { + describe('server', () => { + it('import of a sync module', async () => { + await testPage('/outside-of-render/server/sync-module') + }) + + it('import of module with top-level-await', async () => { + await testPage('/outside-of-render/server/async-module') + }) + }) + + describe('client', () => { + it('import of a sync module', async () => { + await testPage('/outside-of-render/client/sync-module') + }) + + it('import of module with top-level-await', async () => { + await testPage('/outside-of-render/client/async-module') + }) + }) + }) +}) + +describe('async imports in dynamicIO - external packages', () => { + const { next, isNextStart, skipped } = nextTestSetup({ + files: path.join(__dirname, 'external'), + skipDeployment: true, + skipStart: true, + }) + if (skipped) return + + // This is currently expected to fail because we can only track `import()` in bundled code, + // and packages marked as external aren't bundled. + it('does not instrument import() in external packages', async () => { + const expectedError = `Error: Route "/": A component accessed data, headers, params, searchParams, or a short-lived cache without a Suspense boundary nor a "use cache" above it.` + if (isNextStart) { + // in prod, we fail during the build + await expect(() => next.start()).rejects.toThrow() + expect(next.cliOutput).toContain(expectedError) + } else { + // in dev, we fail when visiting the page + await next.start() + await next.browser('/') + await retry(() => expect(next.cliOutput).toContain(expectedError)) + } + }) +}) diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/external/app/layout.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/app/layout.tsx new file mode 100644 index 0000000000000..432635564b70d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/app/layout.tsx @@ -0,0 +1,9 @@ +import * as React from 'react' + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/external/app/page.tsx b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/app/page.tsx new file mode 100644 index 0000000000000..9d0d954e3f4fe --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/app/page.tsx @@ -0,0 +1,12 @@ +import * as React from 'react' +// NOTE: this is not actually external by default, only in one test where we patch `next.config.js` +import { getMessagesAsync } from 'external-esm-pkg-with-async-import' + +export default async function Page() { + const messages = await getMessagesAsync() + return ( +
+

{messages.title}

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/external/next.config.js b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/next.config.js new file mode 100644 index 0000000000000..026621644ec9b --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/next.config.js @@ -0,0 +1,12 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + ppr: process.env.__NEXT_EXPERIMENTAL_PPR === 'true', + dynamicIO: true, + }, + serverExternalPackages: ['external-esm-pkg-with-async-import'], +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/external/node_modules/external-esm-pkg-with-async-import/async-messages.js b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/node_modules/external-esm-pkg-with-async-import/async-messages.js new file mode 100644 index 0000000000000..0edcc8fb09363 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/node_modules/external-esm-pkg-with-async-import/async-messages.js @@ -0,0 +1,6 @@ +// @ts-check +console.log('[node_modules, external] async-messages :: imported, sleeping') +await new Promise((resolve) => setTimeout(resolve, 500)) +console.log('[node_modules, external] async-messages :: ready') + +export default { title: 'hello' } diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/external/node_modules/external-esm-pkg-with-async-import/index.js b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/node_modules/external-esm-pkg-with-async-import/index.js new file mode 100644 index 0000000000000..86955e82d29ae --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/node_modules/external-esm-pkg-with-async-import/index.js @@ -0,0 +1,5 @@ +// @ts-check +export async function getMessagesAsync() { + const { default: messages } = await import('./async-messages.js') + return messages +} diff --git a/test/e2e/app-dir/dynamic-io-dynamic-imports/external/node_modules/external-esm-pkg-with-async-import/package.json b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/node_modules/external-esm-pkg-with-async-import/package.json new file mode 100644 index 0000000000000..2380b0fd14e84 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io-dynamic-imports/external/node_modules/external-esm-pkg-with-async-import/package.json @@ -0,0 +1,8 @@ +{ + "name": "external-esm-pkg-with-async-import", + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./index.js" + } +}