diff --git a/src/ast/e.rs b/src/ast/e.rs index 8bb8385a23c..31917c1ec99 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -2575,6 +2575,12 @@ pub struct Import { pub expr: ExprNodeIndex, pub options: ExprNodeIndex, pub import_record_index: u32, + /// True for `import.defer(...)` — the dynamic form of the TC39 + /// "Deferred Module Evaluation" proposal. The module graph is fetched + /// and linked, but evaluation is deferred until a property of the + /// returned namespace is accessed. + /// https://tc39.es/proposal-defer-import-eval/ + pub phase_defer: bool, // TODO: // Comments inside "import()" expressions have special meaning for Webpack. // Preserving comments inside these expressions makes it possible to use diff --git a/src/ast/expr.rs b/src/ast/expr.rs index 8387382bcdf..1214f8ae51f 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -2703,6 +2703,7 @@ impl Data { expr: el.expr.deep_clone_no_detach(bump)?, options: el.options.deep_clone_no_detach(bump)?, import_record_index: el.import_record_index, + phase_defer: el.phase_defer, }); Ok(Data::EImport(StoreRef::from_bump(item))) } diff --git a/src/ast/import_record.rs b/src/ast/import_record.rs index 79e24904c1a..e4b2e4b45de 100644 --- a/src/ast/import_record.rs +++ b/src/ast/import_record.rs @@ -96,9 +96,10 @@ bitflags::bitflags! { const WRAP_WITH_TO_ESM = 1 << 13; const WRAP_WITH_TO_COMMONJS = 1 << 14; - /// "import defer * as ns from 'path'" — defer evaluation of the - /// imported module until a property on the namespace object is - /// accessed. Requires `CONTAINS_IMPORT_STAR`. + /// "import defer * as ns from 'path'" (kind `Stmt`, requires + /// `CONTAINS_IMPORT_STAR`) or "import.defer('path')" (kind `Dynamic`) + /// — defer evaluation of the imported module until a property on the + /// namespace object is accessed. const PHASE_DEFER = 1 << 15; } } diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 1c4d698fb38..14b6872b882 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1101,6 +1101,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O (state.is_await_target && self.fn_or_arrow_data_visit.try_body_count != 0) || state.is_then_catch_target, ); + self.import_records.items_mut()[import_record_index as usize] + .flags + .set(bun_ast::ImportRecordFlags::PHASE_DEFER, state.phase_defer); self.import_records_for_current_part .push(import_record_index); @@ -1109,6 +1112,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O expr: arg, import_record_index, options: state.import_options, + phase_defer: state.phase_defer, }, state.loc, ); @@ -1132,6 +1136,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O expr: arg, options: state.import_options, import_record_index: u32::MAX, + phase_defer: state.phase_defer, }, state.loc, ) diff --git a/src/js_parser/parse/parse_import_export.rs b/src/js_parser/parse/parse_import_export.rs index 393dafc5502..060570ccfda 100644 --- a/src/js_parser/parse/parse_import_export.rs +++ b/src/js_parser/parse/parse_import_export.rs @@ -12,16 +12,26 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// Note: The caller has already parsed the "import" keyword pub fn parse_import_expr(&mut self, loc: bun_ast::Loc, level: Level) -> Result { let p = self; + // "import.defer(...)" — the dynamic form of the TC39 "Deferred Module + // Evaluation" proposal. Unlike "import.meta" it is a dynamic import, + // so it does not mark the file as ESM. + let mut phase_defer = false; // Parse an "import.meta" expression if p.lexer.token == T::TDot { - p.esm_import_keyword = js_lexer::range_of_identifier(p.source, loc); p.lexer.next()?; if p.lexer.is_contextual_keyword(b"meta") { + p.esm_import_keyword = js_lexer::range_of_identifier(p.source, loc); p.lexer.next()?; p.has_import_meta = true; return Ok(p.new_expr(E::ImportMeta {}, loc)); + } else if p.lexer.is_contextual_keyword(b"defer") { + // ImportCall : `import` `.` `defer` `(` AssignmentExpression `,`? `)` + // `is_contextual_keyword` compares the raw token, so an escaped + // `def\u0065r` falls through to the error branch instead. + p.lexer.next()?; + phase_defer = true; } else { - p.lexer.expected_string(b"\"meta\"")?; + p.lexer.expected_string(b"\"meta\" or \"defer\"")?; } } @@ -83,11 +93,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if let Some(slice) = slice_opt { let import_record_index = p.add_import_record(bun_ast::ImportKind::Dynamic, value.loc, slice); + p.import_records.items_mut()[import_record_index as usize] + .flags + .set(bun_ast::ImportRecordFlags::PHASE_DEFER, phase_defer); return Ok(p.new_expr( E::Import { expr: value, import_record_index, options: import_options, + phase_defer, }, loc, )); @@ -102,6 +116,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // .leading_interior_comments = comments, import_record_index: u32::MAX, options: import_options, + phase_defer, }, loc, )) diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index a39c7d15594..35b2d41db75 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -919,6 +919,8 @@ pub struct TransposeState { pub import_record_tag: Option, pub import_loader: Option, pub import_options: Expr, + /// True when transposing an `import.defer(...)` expression. + pub phase_defer: bool, } impl Default for TransposeState { @@ -931,6 +933,7 @@ impl Default for TransposeState { import_record_tag: None, import_loader: None, import_options: Expr::EMPTY, + phase_defer: false, } } } diff --git a/src/js_parser/repl_transforms.rs b/src/js_parser/repl_transforms.rs index ab028540070..00a20bf5db8 100644 --- a/src/js_parser/repl_transforms.rs +++ b/src/js_parser/repl_transforms.rs @@ -251,6 +251,7 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { // import X from 'mod' -> var X = (await import('mod')).default // import { a, b } from 'mod' -> var {a, b} = await import('mod') // import * as X from 'mod' -> var X = await import('mod') + // import defer * as X from 'mod' -> var X = await import.defer('mod') // import 'mod' -> await import('mod') let path_str: &'static [u8] = self.import_records.items() [import_data.import_record_index as usize] @@ -268,6 +269,7 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { expr: str_expr, options: Expr::EMPTY, import_record_index: u32::MAX, + phase_defer: import_data.phase_defer, }, stmt.loc, ); diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index a85a34a9844..901acfb6715 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1840,6 +1840,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O import_options: e_.options, loc: e_.expr.loc, import_loader: e_.import_record_loader(), + phase_defer: e_.phase_defer, ..Default::default() }; diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index ea71acc5c88..1b2cbea9745 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -2973,7 +2973,17 @@ pub mod __gated_printer { // Allow it to fail at runtime, if it should if module_type != bundle_opts::Format::InternalBakeDev { - self.print(b"import("); + if record.flags.contains(ImportRecordFlags::PHASE_DEFER) && !wrap_with_to_esm { + // `import.defer(...)` — keep the defer phase so the engine + // defers evaluation of the imported module. When the record + // needs the `__toESM` interop wrapper (cross-chunk CommonJS + // target), the `.then((m)=>__toESM(m.default))` below would + // touch the namespace immediately and defeat the defer, so + // degrade to a regular dynamic import instead. + self.print(b"import.defer("); + } else { + self.print(b"import("); + } self.print_import_record_path(record); } else { self.print_symbol(self.options.hmr_ref); @@ -3633,6 +3643,8 @@ pub mod __gated_printer { if self.options.module_type == bundle_opts::Format::InternalBakeDev { self.print_symbol(self.options.hmr_ref); self.print(b".dynamicImport("); + } else if e.phase_defer { + self.print(b"import.defer("); } else { self.print(b"import("); } diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index fd006bcc3ad..7cc38904983 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -261,7 +261,7 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c return function; } -JSPromise* importModule(JSGlobalObject* globalObject, JSString* moduleName, RefPtr parameters, const SourceOrigin& sourceOrigin) +JSPromise* importModule(JSGlobalObject* globalObject, JSString* moduleName, RefPtr parameters, const SourceOrigin& sourceOrigin, bool deferred) { VM& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); @@ -288,7 +288,7 @@ JSPromise* importModule(JSGlobalObject* globalObject, JSString* moduleName, RefP if (isUseMainContextDefaultLoaderConstant(globalObject, dynamicImportCallback)) { auto defer = fetcher->temporarilyUseDefaultLoader(); Zig::GlobalObject* zigGlobalObject = defaultGlobalObject(globalObject); - RELEASE_AND_RETURN(scope, zigGlobalObject->moduleLoaderImportModule(zigGlobalObject, zigGlobalObject->moduleLoader(), moduleName, WTF::move(parameters), sourceOrigin, false)); + RELEASE_AND_RETURN(scope, zigGlobalObject->moduleLoaderImportModule(zigGlobalObject, zigGlobalObject->moduleLoader(), moduleName, WTF::move(parameters), sourceOrigin, deferred)); } else if (!dynamicImportCallback || !dynamicImportCallback.isCallable()) { throwException(globalObject, scope, createError(globalObject, ErrorCode::ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING, "A dynamic import callback was not specified."_s)); return nullptr; @@ -1709,12 +1709,11 @@ static JSPromise* moduleLoaderImportModuleInner(NodeVMGlobalObject* globalObject JSPromise* NodeVMGlobalObject::moduleLoaderImportModule(JSGlobalObject* globalObject, JSC::JSModuleLoader* moduleLoader, JSC::JSString* moduleName, RefPtr parameters, const JSC::SourceOrigin& sourceOrigin, bool deferred) { - UNUSED_PARAM(deferred); auto* nodeVmGlobalObject = static_cast(globalObject); VM& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); - JSPromise* result = NodeVM::importModule(nodeVmGlobalObject, moduleName, parameters, sourceOrigin); + JSPromise* result = NodeVM::importModule(nodeVmGlobalObject, moduleName, parameters, sourceOrigin, deferred); // importModule runs the user's dynamic-import callback, which can throw // and leave a null result; surface that as a rejected promise instead of // falling through with the exception pending. @@ -1726,6 +1725,9 @@ JSPromise* NodeVMGlobalObject::moduleLoaderImportModule(JSGlobalObject* globalOb return result; } + // The `importModuleDynamically` callback API has no notion of an import + // phase, so a deferred import resolved through it behaves like a regular + // dynamic import. RELEASE_AND_RETURN(scope, moduleLoaderImportModuleInner(nodeVmGlobalObject, moduleLoader, moduleName, WTF::move(parameters), sourceOrigin)); } diff --git a/src/jsc/bindings/NodeVM.h b/src/jsc/bindings/NodeVM.h index 590f3ce6945..b9ced4b9d04 100644 --- a/src/jsc/bindings/NodeVM.h +++ b/src/jsc/bindings/NodeVM.h @@ -31,7 +31,7 @@ NodeVMGlobalObject* getGlobalObjectFromContext(JSGlobalObject* globalObject, JSV JSC::EncodedJSValue INVALID_ARG_VALUE_VM_VARIATION(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, WTF::ASCIILiteral name, JSC::JSValue value); // For vm.compileFunction we need to return an anonymous function expression. This code is adapted from/inspired by JSC::constructFunction, which is used for function declarations. JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, const ArgList& args, const SourceOrigin& sourceOrigin, CompileFunctionOptions&& options, JSC::SourceTaintedOrigin sourceTaintOrigin, JSC::JSScope* scope); -JSPromise* importModule(JSGlobalObject* globalObject, JSString* moduleNameValue, RefPtr parameters, const SourceOrigin& sourceOrigin); +JSPromise* importModule(JSGlobalObject* globalObject, JSString* moduleNameValue, RefPtr parameters, const SourceOrigin& sourceOrigin, bool deferred); bool isContext(JSC::JSGlobalObject* globalObject, JSValue); bool getContextArg(JSC::JSGlobalObject* globalObject, JSValue& contextArg); bool isUseMainContextDefaultLoaderConstant(JSC::JSGlobalObject* globalObject, JSValue value); diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index e792f92b1e1..2477aa0acd8 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3503,14 +3503,13 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO const SourceOrigin& sourceOrigin, bool deferred) { - UNUSED_PARAM(deferred); auto* globalObject = static_cast(jsGlobalObject); VM& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); { - JSC::JSPromise* result = NodeVM::importModule(globalObject, moduleNameValue, parameters, sourceOrigin); + JSC::JSPromise* result = NodeVM::importModule(globalObject, moduleNameValue, parameters, sourceOrigin, deferred); RETURN_IF_EXCEPTION(scope, nullptr); if (result) { return result; @@ -3545,7 +3544,7 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO if (auto resolution = globalObject->onLoadPlugins.resolveVirtualModule(moduleName, sourceURL.protocolIsFile() ? sourceOriginStringHolder : String())) { resolvedIdentifier = JSC::Identifier::fromString(vm, resolution.value()); - auto result = JSC::importModule(globalObject, resolvedIdentifier, JSC::Identifier(), parameters, nullptr, /* deferred */ false, referrerAsyncOrder); + auto result = JSC::importModule(globalObject, resolvedIdentifier, JSC::Identifier(), parameters, nullptr, deferred, referrerAsyncOrder); if (scope.exception()) [[unlikely]] { return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); } @@ -3603,9 +3602,10 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO // The C++ module loader now extracts `with.type` into a // ScriptFetchParameters before calling this hook, so `parameters` is - // already the parsed RefPtr (or null). Just forward it. + // already the parsed RefPtr (or null). Just forward it, along with the + // defer phase of `import.defer(...)`. auto result = JSC::importModule(globalObject, resolvedIdentifier, - JSC::Identifier(), WTF::move(parameters), nullptr, /* deferred */ false, referrerAsyncOrder); + JSC::Identifier(), WTF::move(parameters), nullptr, deferred, referrerAsyncOrder); if (scope.exception()) [[unlikely]] { return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); } diff --git a/src/react_compiler/codegen.rs b/src/react_compiler/codegen.rs index fd7a51cbdd2..75c4c309f51 100644 --- a/src/react_compiler/codegen.rs +++ b/src/react_compiler/codegen.rs @@ -1895,6 +1895,7 @@ fn codegen_base_instruction_value( expr: it.next().unwrap_or(orig.expr), options: it.next().unwrap_or(Expr::EMPTY), import_record_index: orig.import_record_index, + phase_defer: orig.phase_defer, }, loc, )); diff --git a/src/runtime/bake/BakeGlobalObject.cpp b/src/runtime/bake/BakeGlobalObject.cpp index 43919747bd8..9757783f979 100644 --- a/src/runtime/bake/BakeGlobalObject.cpp +++ b/src/runtime/bake/BakeGlobalObject.cpp @@ -21,12 +21,11 @@ bakeModuleLoaderImportModule(JSC::JSGlobalObject* global, const JSC::SourceOrigin& sourceOrigin, bool deferred) { - UNUSED_PARAM(deferred); WTF::String keyString = moduleNameValue->getString(global); if (keyString.startsWith("bake:/"_s)) { auto& vm = JSC::getVM(global); return JSC::importModule(global, JSC::Identifier::fromString(vm, keyString), - JSC::Identifier(), WTF::move(parameters), nullptr); + JSC::Identifier(), WTF::move(parameters), nullptr, deferred); } if (!sourceOrigin.isNull() && sourceOrigin.string().startsWith("bake:/"_s)) { @@ -46,11 +45,11 @@ bakeModuleLoaderImportModule(JSC::JSGlobalObject* global, RETURN_IF_EXCEPTION(scope, nullptr); return JSC::importModule(global, JSC::Identifier::fromString(vm, result.toWTFString()), - JSC::Identifier(), WTF::move(parameters), nullptr); + JSC::Identifier(), WTF::move(parameters), nullptr, deferred); } // TODO: make static cast instead of jscast - return uncheckedDowncast(global)->moduleLoaderImportModule(global, moduleLoader, moduleNameValue, WTF::move(parameters), sourceOrigin, false); + return uncheckedDowncast(global)->moduleLoaderImportModule(global, moduleLoader, moduleNameValue, WTF::move(parameters), sourceOrigin, deferred); } JSC::Identifier bakeModuleLoaderResolve(JSC::JSGlobalObject* jsGlobal, diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 55fa8ca785a..00ff9efe6b8 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2771,6 +2771,77 @@ describe("bundler", () => { expect(out).not.toContain("require_foo\u2014bar"); }, }); + // Dynamic `import.defer()` (TC39 deferred module evaluation). When the + // target module is inlined into the bundle, the defer phase is dropped — + // same documented limitation as static `import defer` — and it behaves + // like a regular dynamic import. + itBundled("edgecase/DynamicImportDeferBundled", { + files: { + "/entry.js": /* js */ ` + const ns = await import.defer("./dep.js"); + console.log("after import"); + console.log("value:", ns.value); + `, + "/dep.js": /* js */ ` + console.log("dep evaluated"); + export const value = 42; + `, + }, + target: "bun", + run: { + stdout: "dep evaluated\nafter import\nvalue: 42", + }, + }); + // When the target stays external, the defer phase must be preserved in the + // emitted code so the runtime can still defer evaluation. + itBundled("edgecase/DynamicImportDeferExternal", { + files: { + "/entry.js": /* js */ ` + export async function load() { + const ns = await import.defer("x-external"); + return ns.value; + } + export function loadComputed(name) { + return import.defer(name); + } + `, + }, + external: ["x-external"], + target: "bun", + onAfterBundle(api) { + const out = api.readFile("/out.js"); + expect(out).toContain('import.defer("x-external")'); + expect(out).toContain("import.defer(name)"); + }, + }); + // With code splitting, a deferred dynamic import of a CommonJS module needs + // the `__toESM` interop wrapper, whose `.then((m) => __toESM(m.default))` + // callback touches the namespace immediately. Emitting `import.defer()` + // there would be pointless (it would be defeated on the same line), so the + // defer phase is dropped and a regular dynamic import is emitted. + itBundled("edgecase/DynamicImportDeferSplittingCommonJS", { + files: { + "/entry.js": /* js */ ` + const ns = await import.defer("./dep.cjs"); + console.log("value:", ns.default.value); + `, + "/dep.cjs": /* js */ ` + module.exports = { value: 7 }; + `, + }, + splitting: true, + outdir: "/out", + target: "bun", + onAfterBundle(api) { + const out = api.readFile("/out/entry.js"); + expect(out).toContain("__toESM("); + expect(out).not.toContain("import.defer("); + }, + run: { + file: "/out/entry.js", + stdout: "value: 7", + }, + }); }); for (const backend of ["api", "cli"] as const) { diff --git a/test/js/bun/resolve/import-defer.test.ts b/test/js/bun/resolve/import-defer.test.ts index e4b767653c6..7cca491999e 100644 --- a/test/js/bun/resolve/import-defer.test.ts +++ b/test/js/bun/resolve/import-defer.test.ts @@ -382,3 +382,309 @@ describe.concurrent("import defer", () => { }); }); }); + +// TC39 proposal-defer-import-eval (Stage 3) — dynamic `import.defer(...)` +// https://tc39.es/proposal-defer-import-eval/#sec-import-call-runtime-semantics-evaluation +describe.concurrent("dynamic import.defer()", () => { + test("defers module evaluation until a property is accessed", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + const ns = await import.defer("./dep.js"); + console.log("before access"); + console.log("value:", ns.value); + console.log("add:", ns.add(1, 2)); + `, + "dep.js": ` + console.log("dep evaluated"); + export const value = 42; + export function add(a, b) { return a + b; } + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["before access", "dep evaluated", "value: 42", "add: 3"]); + expect(exitCode).toBe(0); + }); + + test("module is never evaluated if the namespace is never accessed", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + await import.defer("./dep.js"); + console.log("done"); + `, + "dep.js": ` + console.log("dep evaluated"); + export const value = 1; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["done"]); + expect(exitCode).toBe(0); + }); + + test("resolves to a 'Deferred Module' namespace", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + const ns = await import.defer("./dep.js"); + console.log(ns[Symbol.toStringTag]); + console.log("---"); + console.log(ns.value); + `, + "dep.js": ` + console.log("dep evaluated"); + export const value = 7; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["Deferred Module", "---", "dep evaluated", "7"]); + expect(exitCode).toBe(0); + }); + + test("returns the same namespace object as a static 'import defer'", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import defer * as stat from "./dep.js"; + const dyn = await import.defer("./dep.js"); + console.log("same:", dyn === stat); + console.log("value:", dyn.value); + `, + "dep.js": ` + console.log("dep evaluated"); + export const value = 3; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["same: true", "dep evaluated", "value: 3"]); + expect(exitCode).toBe(0); + }); + + test("evaluation error surfaces at property access, not at import time", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + const ns = await import.defer("./throws.js"); + console.log("imported ok"); + for (let i = 0; i < 2; i++) { + try { + void ns.value; + console.log("unreachable"); + } catch (e) { + console.log("caught:", e.message); + } + } + `, + "throws.js": ` + throw new Error("boom"); + export const value = 1; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["imported ok", "caught: boom", "caught: boom"]); + expect(exitCode).toBe(0); + }); + + test("rejects when the module cannot be resolved", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import.defer("./does-not-exist.js").then( + () => console.log("unreachable"), + () => console.log("rejected"), + ); + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["rejected"]); + expect(exitCode).toBe(0); + }); + + test("evaluates async transitive dependencies eagerly, defers the rest", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + const ns = await import.defer("./dep.js"); + console.log("main after import"); + console.log("value:", ns.value); + `, + "dep.js": ` + import { ready } from "./tla.js"; + console.log("dep evaluated"); + export const value = ready; + `, + "tla.js": ` + console.log("tla start"); + await Promise.resolve(); + console.log("tla done"); + export const ready = "ok"; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual([ + "tla start", + "tla done", + "main after import", + "dep evaluated", + "value: ok", + ]); + expect(exitCode).toBe(0); + }); + + test("a deferred module that itself uses top-level await is evaluated during the import", async () => { + // GatherAsynchronousTransitiveDependencies includes the root module when + // it has top-level await, so it can never be left for (impossible) + // synchronous evaluation later. + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + const ns = await import.defer("./tla.js"); + console.log("main after import"); + console.log("value:", ns.ready); + `, + "tla.js": ` + console.log("tla start"); + await Promise.resolve(); + console.log("tla done"); + export const ready = "ok"; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["tla start", "tla done", "main after import", "value: ok"]); + expect(exitCode).toBe(0); + }); + + test("works with a runtime-computed specifier", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + const parts = ["./dep", ".js"]; + const ns = await import.defer(parts.join("")); + console.log("before access"); + console.log("value:", ns.value); + `, + "dep.js": ` + console.log("dep evaluated"); + export const value = 42; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["before access", "dep evaluated", "value: 42"]); + expect(exitCode).toBe(0); + }); + + test("works from a CommonJS module", async () => { + const { stdout, stderr, exitCode } = await run( + { + "main.cjs": ` + module.exports.loaded = true; + (async () => { + const ns = await import.defer("./dep.js"); + console.log("before access"); + console.log("value:", ns.value); + })(); + `, + "dep.js": ` + console.log("dep evaluated"); + export const value = 42; + `, + }, + "main.cjs", + ); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["before access", "dep evaluated", "value: 42"]); + expect(exitCode).toBe(0); + }); + + test("importing a CommonJS module evaluates it at import time (host-defined)", async () => { + // CommonJS modules are executed by the host while building their ESM + // wrapper record, so there is nothing left to defer — the namespace is + // usable but the module body has already run by the time the promise + // resolves. Deferral only applies to ES module evaluation. + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + const ns = await import.defer("./dep.cjs"); + console.log("after import"); + console.log("value:", ns.default.value); + `, + "dep.cjs": ` + console.log("cjs evaluated"); + module.exports = { value: 7 }; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["cjs evaluated", "after import", "value: 7"]); + expect(exitCode).toBe(0); + }); + + test("import.defer() with import attributes", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + const ns = await import.defer("./data.json", { with: { type: "json" } }); + console.log("loaded"); + console.log(ns.default.hello); + `, + "data.json": JSON.stringify({ hello: "world" }), + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["loaded", "world"]); + expect(exitCode).toBe(0); + }); + + test("works in .ts files", async () => { + const { stdout, stderr, exitCode } = await run( + { + "main.ts": ` + const ns = await import.defer("./dep.ts"); + console.log("before access"); + console.log(ns.value); + `, + "dep.ts": ` + console.log("dep evaluated"); + export const value: number = 9; + `, + }, + "main.ts", + ); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["before access", "dep evaluated", "9"]); + expect(exitCode).toBe(0); + }); + + test("Bun.Transpiler preserves import.defer() in output", () => { + const out = new Bun.Transpiler({ loader: "js" }).transformSync(`const p = import.defer("./x");\n`); + expect(out).toContain(`import.defer("./x")`); + }); + + test("Bun.Transpiler preserves import.defer() with a non-literal specifier", () => { + const out = new Bun.Transpiler({ loader: "js" }).transformSync( + `export function load(name) { return import.defer(name); }\n`, + ); + expect(out).toContain("import.defer(name)"); + }); + + test("Bun.Transpiler.scanImports reports import.defer() as a dynamic import", () => { + const scanned = new Bun.Transpiler({ loader: "js" }).scanImports(`import.defer("./x");`); + expect(scanned).toEqual([{ kind: "dynamic-import", path: "./x" }]); + }); + + describe("syntax errors", () => { + test("'import.defer' without a call is a syntax error", async () => { + const { exitCode, stderr } = await run({ + "main.js": `const x = import.defer; console.log(x);`, + }); + expect(stderr.toLowerCase()).toContain("error"); + expect(exitCode).not.toBe(0); + }); + + test("'defer' with an escape sequence is not the phase keyword", async () => { + const { exitCode, stderr } = await run({ + "main.js": `import.def\\u0065r("./dep.js");`, + "dep.js": `export const x = 1;`, + }); + expect(stderr.toLowerCase()).toContain("error"); + expect(exitCode).not.toBe(0); + }); + + test("other identifiers after 'import.' are still rejected", async () => { + const { exitCode, stderr } = await run({ + "main.js": `import.source("./dep.js");`, + "dep.js": `export const x = 1;`, + }); + expect(stderr.toLowerCase()).toContain("error"); + expect(exitCode).not.toBe(0); + }); + }); +});