From c79f09d7976702f31309e8d5120d19c81ec44439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 13:15:09 +0000 Subject: [PATCH 1/2] fix(compile): dynamic import() no longer seeds globalThis.AsyncLocalStorage The entry seeds globalThis.AsyncLocalStorage only when the Next.js path-init list is non-empty, and that list also took every module whose init kind is Deferred, i.e. every module reached only through import(). So any program with a dynamic import got an AsyncLocalStorage global that node does not have. The list now holds modules by where they are (.next/server/** and compilePackages directories), as its seed rule assumes. A deferred module keeps its path anchor through the lazy registrar, exactly like an on-demand CommonJS module: an import() site calls its target's init directly, and only a program that can require by path (not closed) looks the address up, at its first path lookup. --- changelog.d/0-als-seed-dynamic-import.md | 6 +++++ .../src/commands/compile/run_pipeline.rs | 25 +++++++++++++------ test-files/dynamic_import_als_seed_target.ts | 2 ++ .../test_gap_als_seed_dynamic_import.ts | 8 ++++++ 4 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 changelog.d/0-als-seed-dynamic-import.md create mode 100644 test-files/dynamic_import_als_seed_target.ts create mode 100644 test-files/test_gap_als_seed_dynamic_import.ts diff --git a/changelog.d/0-als-seed-dynamic-import.md b/changelog.d/0-als-seed-dynamic-import.md new file mode 100644 index 00000000000..3e978dc9d4f --- /dev/null +++ b/changelog.d/0-als-seed-dynamic-import.md @@ -0,0 +1,6 @@ +A program with a dynamic `import()` no longer gets a +`globalThis.AsyncLocalStorage` that node does not have. The entry seeds that +global only for Next.js programs, and every module loaded by `import()` used +to count as a Next.js module. Such a module now gets its path initializer +registered lazily, like an on-demand CommonJS module, so startup also skips +one registry insert per deferred module. diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 0d5c58f0a44..a3b5530b800 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -3900,9 +3900,14 @@ pub fn run_with_parse_cache( let nextjs_path_init_modules: Vec<(String, String)> = if is_entry { ctx.native_modules .iter() - .filter(|(p, module)| { - module.init_kind == perry_hir::ModuleInitKind::Deferred - || self::collect_modules::is_nextjs_runtime_module(p) + // A module is on this list by WHERE it is (a Next.js server + // module, or a compilePackages module), never by how it is + // loaded: a non-empty list makes the entry seed + // `globalThis.AsyncLocalStorage`, which node has only in a + // Next.js process. A module reached only through `import()` + // gets its path anchor from the lazy list below. + .filter(|(p, _)| { + self::collect_modules::is_nextjs_runtime_module(p) // A `perry.compilePackages` module may be reachable // ONLY through a runtime-computed require (Next's // require-hook aliases `styled-jsx` to its resolved @@ -3919,13 +3924,19 @@ pub fn run_with_parse_cache( } else { Vec::new() }; - // A require edge runs its target's init directly; only a program that - // can require by path (not closed) needs the anchor of an on-demand - // module, and only once it looks a path up: registered lazily. + // A require edge runs its target's init directly, and an `import()` + // site calls its target's init directly; only a program that can + // require by path (not closed) needs the anchor of an on-demand or + // deferred module, and only once it looks a path up: registered lazily. let lazy_path_init_modules: Vec<(String, String)> = if is_entry && !ctx.cjs_closed { ctx.native_modules .iter() - .filter(|(_, module)| module.init_kind == perry_hir::ModuleInitKind::OnDemand) + .filter(|(_, module)| { + matches!( + module.init_kind, + perry_hir::ModuleInitKind::OnDemand | perry_hir::ModuleInitKind::Deferred + ) + }) .map(|(p, m)| (p.to_string_lossy().into_owned(), sanitize_name(&m.name))) .filter(|pair| !nextjs_path_init_modules.contains(pair)) .collect() diff --git a/test-files/dynamic_import_als_seed_target.ts b/test-files/dynamic_import_als_seed_target.ts new file mode 100644 index 00000000000..1e2b62b1086 --- /dev/null +++ b/test-files/dynamic_import_als_seed_target.ts @@ -0,0 +1,2 @@ +// Helper for test_gap_als_seed_dynamic_import.ts: reached only through import(). +export const x: number = 1; diff --git a/test-files/test_gap_als_seed_dynamic_import.ts b/test-files/test_gap_als_seed_dynamic_import.ts new file mode 100644 index 00000000000..51be62ca8ef --- /dev/null +++ b/test-files/test_gap_als_seed_dynamic_import.ts @@ -0,0 +1,8 @@ +// A program with a dynamic `import()` must not get `globalThis.AsyncLocalStorage`: +// node has no such global (only Next.js assigns one). The entry seeds it for +// Next.js server modules alone; a module reached only through `import()` used +// to put the program on that path, so this printed "function". + +const m = await import("./dynamic_import_als_seed_target.ts"); +console.log(typeof (globalThis as any).AsyncLocalStorage, m.x); +console.log("AsyncLocalStorage" in globalThis); From 76555c3d5104b9eed23deff20092c3004cb92590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 15:18:44 +0100 Subject: [PATCH 2/2] changelog: number the PR fragment --- ...ls-seed-dynamic-import.md => 13890-als-seed-dynamic-import.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0-als-seed-dynamic-import.md => 13890-als-seed-dynamic-import.md} (100%) diff --git a/changelog.d/0-als-seed-dynamic-import.md b/changelog.d/13890-als-seed-dynamic-import.md similarity index 100% rename from changelog.d/0-als-seed-dynamic-import.md rename to changelog.d/13890-als-seed-dynamic-import.md