From e52b8c31abc6f5930b3d580f52011028e5884841 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 06:27:46 +0000 Subject: [PATCH 01/11] wtf-bindings: add missing include wtf/Int128.h dropped its include in the latest WebKit bump, which was the only thing declaring assert() for uv__tty_make_raw() in the unified build. --- src/jsc/bindings/wtf-bindings.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jsc/bindings/wtf-bindings.cpp b/src/jsc/bindings/wtf-bindings.cpp index 0d4968f805f..9a1232fde06 100644 --- a/src/jsc/bindings/wtf-bindings.cpp +++ b/src/jsc/bindings/wtf-bindings.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "wtf/SIMDUTF.h" #if OS(WINDOWS) From b8e5f9143bb124383171d3e604593dc3cec91489 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 06:27:46 +0000 Subject: [PATCH 02/11] Implement static `import defer * as ns from "..."` Implements the static form of the Stage 3 Deferred Module Evaluation proposal. The module graph is loaded and linked eagerly; evaluation of the deferred module (and its synchronous dependencies) is postponed until a non-symbol property of the namespace object is accessed. JavaScriptCore already carries the full runtime semantics in the pinned WebKit (ModulePhase::Defer, deferred namespace objects, GatherAsynchronousTransitiveDependencies). This change threads the phase through Bun's pipeline: - js_parser: recognise `defer` as a contextual phase keyword when followed by `*`; `import defer from "x"` and `import defer, {x}` keep treating `defer` as an ordinary default-binding identifier. - S::Import / ImportRecord: carry a phase_defer bit. - js_printer: emit `import defer` back out so JSC's bytecode parser sees it; record ImportInfoNamespaceDefer and a per-request phase in ModuleInfo. - ModuleInfo wire format: dedup requested modules by (specifier, phase) to match ModuleAnalyzer, serialise a parallel phase-byte array, bump the transpiler-cache version. - BunAnalyzeTranspiledModule: pass ModulePhase::Defer to appendRequestedModule() and set ImportEntry.phase on the deferred namespace import entry; include phase in the debug record diff. - ZigGlobalObject: enable JSC::Options::useImportDefer. Re-exporting a deferred namespace (`import defer * as ns; export {ns}`) stays a local export, matching ModuleAnalyzer::exportVariable. Dynamic `import.defer()` is intentionally out of scope. --- src/ast/import_record.rs | 5 +- src/ast/s.rs | 6 + src/bundler/analyze_transpiled_module.rs | 26 +- src/bundler_jsc/analyze_jsc.rs | 141 ++++++-- src/js_parser/lower/lower_esm_exports_hmr.rs | 1 + src/js_parser/p.rs | 6 + src/js_parser/parse/parse_entry.rs | 2 + src/js_parser/parse/parse_stmt.rs | 26 ++ src/js_printer/lib.rs | 170 +++++++--- src/jsc/RuntimeTranspilerCache.rs | 3 +- .../bindings/BunAnalyzeTranspiledModule.cpp | 47 ++- src/jsc/bindings/ZigGlobalObject.cpp | 1 + test/js/bun/resolve/import-defer.test.ts | 316 ++++++++++++++++++ 13 files changed, 670 insertions(+), 80 deletions(-) create mode 100644 test/js/bun/resolve/import-defer.test.ts diff --git a/src/ast/import_record.rs b/src/ast/import_record.rs index 65640e163bd..445db5b5a28 100644 --- a/src/ast/import_record.rs +++ b/src/ast/import_record.rs @@ -104,7 +104,10 @@ bitflags::bitflags! { const WRAP_WITH_TO_ESM = 1 << 13; const WRAP_WITH_TO_COMMONJS = 1 << 14; - // bit 15 (_padding: u1) intentionally unused + /// "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`. + const PHASE_DEFER = 1 << 15; } } diff --git a/src/ast/s.rs b/src/ast/s.rs index b0a7b2ee5e3..42adb498f14 100644 --- a/src/ast/s.rs +++ b/src/ast/s.rs @@ -191,6 +191,7 @@ pub struct Switch { /// import * as ns from 'path' /// import defaultItem, {item1, item2} from 'path' /// import defaultItem, * as ns from 'path' +/// import defer * as ns from 'path' /// /// Many parts are optional and can be combined in different ways. The only /// restriction is that you cannot have both a clause and a star namespace. @@ -207,6 +208,10 @@ pub struct Import { pub star_name_loc: Option, // = None pub import_record_index: u32, pub is_single_line: bool, // = false + /// "import defer * as ns from 'path'" — the TC39 Deferred Module Evaluation + /// proposal. Only valid with a namespace import (`star_name_loc` is set, + /// `default_name`/`items` are empty). + pub phase_defer: bool, // = false } impl Default for Import { @@ -218,6 +223,7 @@ impl Default for Import { star_name_loc: None, import_record_index: u32::MAX, is_single_line: false, + phase_defer: false, } } } diff --git a/src/bundler/analyze_transpiled_module.rs b/src/bundler/analyze_transpiled_module.rs index 853f169fbd4..a36ce918608 100644 --- a/src/bundler/analyze_transpiled_module.rs +++ b/src/bundler/analyze_transpiled_module.rs @@ -16,7 +16,7 @@ use bun_core::{self, err, slice_as_bytes}; // the print boundary. // ────────────────────────────────────────────────────────────────────────── pub use bun_js_printer::analyze_transpiled_module::{ - FetchParameters, ModuleInfo, StringID, VarKind, + FetchParameters, ModuleInfo, ModulePhase, StringID, VarKind, }; /// Downstream name for `FetchParameters` — mirrors how @@ -65,6 +65,8 @@ impl RecordKind { pub const EXPORT_INFO_NAMESPACE: Self = Self(7); /// module_name pub const EXPORT_INFO_STAR: Self = Self(8); + /// module_name, import_name = '*', local_name (ModulePhase::Defer) + pub const IMPORT_INFO_NAMESPACE_DEFER: Self = Self(9); // PascalCase aliases — `bundler_jsc::analyze_jsc` pattern-matches on these // (the SCREAMING_CASE consts above are kept for intra-crate use). @@ -73,6 +75,7 @@ impl RecordKind { pub const ImportInfoSingle: Self = Self::IMPORT_INFO_SINGLE; pub const ImportInfoSingleTypeScript: Self = Self::IMPORT_INFO_SINGLE_TYPE_SCRIPT; pub const ImportInfoNamespace: Self = Self::IMPORT_INFO_NAMESPACE; + pub const ImportInfoNamespaceDefer: Self = Self::IMPORT_INFO_NAMESPACE_DEFER; pub const ExportInfoIndirect: Self = Self::EXPORT_INFO_INDIRECT; pub const ExportInfoLocal: Self = Self::EXPORT_INFO_LOCAL; pub const ExportInfoNamespace: Self = Self::EXPORT_INFO_NAMESPACE; @@ -84,6 +87,7 @@ impl RecordKind { Self::IMPORT_INFO_SINGLE => Ok(3), Self::IMPORT_INFO_SINGLE_TYPE_SCRIPT => Ok(3), Self::IMPORT_INFO_NAMESPACE => Ok(3), + Self::IMPORT_INFO_NAMESPACE_DEFER => Ok(3), Self::EXPORT_INFO_INDIRECT => Ok(3), Self::EXPORT_INFO_LOCAL => Ok(3), Self::EXPORT_INFO_NAMESPACE => Ok(2), @@ -154,6 +158,7 @@ pub struct ModuleInfoDeserialized { pub strings_lens: bun_ptr::RawSlice, pub requested_modules_keys: bun_ptr::RawSlice, pub requested_modules_values: bun_ptr::RawSlice, + pub requested_modules_phases: bun_ptr::RawSlice, pub buffer: bun_ptr::RawSlice, pub record_kinds: bun_ptr::RawSlice, pub flags: Flags, @@ -200,6 +205,10 @@ impl ModuleInfoDeserialized { self.requested_modules_values.slice() } #[inline] + pub fn requested_modules_phases(&self) -> &[u8] { + self.requested_modules_phases.slice() + } + #[inline] pub fn buffer(&self) -> &[StringID] { self.buffer.slice() } @@ -287,6 +296,8 @@ impl ModuleInfoDeserialized { &mut rem, requested_modules_len as usize * size_of::(), )?)?; + let requested_modules_phases = Self::eat(&mut rem, requested_modules_len as usize)?; + let _ = Self::eat(&mut rem, ((4 - (requested_modules_len % 4)) % 4) as usize)?; // alignment padding let flags = Flags::from_bits_retain(Self::eat_c::<1>(&mut rem)?[0]); let _ = Self::eat(&mut rem, 3)?; // alignment padding @@ -311,6 +322,7 @@ impl ModuleInfoDeserialized { strings_lens: bun_ptr::RawSlice::new(strings_lens), requested_modules_keys: bun_ptr::RawSlice::new(requested_modules_keys), requested_modules_values: bun_ptr::RawSlice::new(requested_modules_values), + requested_modules_phases: bun_ptr::RawSlice::new(requested_modules_phases), buffer: bun_ptr::RawSlice::new(buffer), record_kinds: bun_ptr::RawSlice::new(record_kinds), flags, @@ -347,6 +359,9 @@ impl ModuleInfoDeserialized { writer.write_all(&(rm_keys.len() as u32).to_le_bytes())?; writer.write_all(slice_as_bytes(rm_keys))?; writer.write_all(slice_as_bytes(self.requested_modules_values()))?; + writer.write_all(self.requested_modules_phases())?; + let pad = (4 - (rm_keys.len() % 4)) % 4; + writer.write_all(&[0u8; 4][..pad])?; // alignment padding writer.write_all(&[self.flags.bits()])?; writer.write_all(&[0u8; 3])?; // alignment padding @@ -469,13 +484,17 @@ impl ModuleInfoExt for ModuleInfo { } // PORT NOTE: reshaped for borrowck — capture lifetime-erased `RawSlice` // views before `heap::into_raw(self)` consumes the box. - let (strings_buf, strings_lens, rm_keys, rm_values, buffer, record_kinds, flags); + let (strings_buf, strings_lens, rm_keys, rm_values, rm_phases, buffer, record_kinds, flags); { let view = self.as_deserialized(); strings_buf = bun_ptr::RawSlice::new(view.strings_buf); strings_lens = bun_ptr::RawSlice::new(view.strings_lens); rm_keys = bun_ptr::RawSlice::new(view.requested_modules_keys); rm_values = bun_ptr::RawSlice::new(view.requested_modules_values); + // Printer's `ModulePhase` is `#[repr(u8)] NoUninit` — safe to view as `&[u8]`. + rm_phases = bun_ptr::RawSlice::new(bytemuck::cast_slice::<_, u8>( + view.requested_modules_phases, + )); buffer = bun_ptr::RawSlice::new(view.buffer); // Printer's `RecordKind` is `#[repr(u8)] NoUninit` with the same // discriminant layout as this crate's `#[repr(transparent)] u8` @@ -488,7 +507,7 @@ impl ModuleInfoExt for ModuleInfo { f.set(Flags::HAS_TLA, view.flags.has_tla); flags = f; } - // All six views point into the `Box`'s vectors, moved into + // All seven views point into the `Box`'s vectors, moved into // `owner` below; they stay valid and stable for the lifetime of every // `RawSlice` copied from this struct. Box::new(ModuleInfoDeserialized { @@ -496,6 +515,7 @@ impl ModuleInfoExt for ModuleInfo { strings_lens, requested_modules_keys: rm_keys, requested_modules_values: rm_values, + requested_modules_phases: rm_phases, buffer, record_kinds, flags, diff --git a/src/bundler_jsc/analyze_jsc.rs b/src/bundler_jsc/analyze_jsc.rs index 17fdfc28c96..fd3447361fc 100644 --- a/src/bundler_jsc/analyze_jsc.rs +++ b/src/bundler_jsc/analyze_jsc.rs @@ -38,6 +38,7 @@ pub extern "C" fn zig__ModuleInfoDeserialized__toJSModuleRecord( let strings_lens: &[u32] = res.strings_lens(); let requested_modules_keys: &[StringID] = res.requested_modules_keys(); let requested_modules_values: &[RequestedModuleValue] = res.requested_modules_values(); + let requested_modules_phases: &[u8] = res.requested_modules_phases(); let buffer: &[StringID] = res.buffer(); let record_kinds: &[RecordKind] = res.record_kinds(); @@ -73,6 +74,7 @@ pub extern "C" fn zig__ModuleInfoDeserialized__toJSModuleRecord( RecordKind::ImportInfoSingle | RecordKind::ImportInfoSingleTypeScript | RecordKind::ImportInfoNamespace + | RecordKind::ImportInfoNamespaceDefer | RecordKind::ExportInfoIndirect | RecordKind::ExportInfoLocal | RecordKind::ExportInfoNamespace @@ -96,29 +98,38 @@ pub extern "C" fn zig__ModuleInfoDeserialized__toJSModuleRecord( ); debug_assert_eq!(requested_modules_keys.len(), requested_modules_values.len()); - for (&reqk, &reqv) in requested_modules_keys + debug_assert_eq!(requested_modules_keys.len(), requested_modules_phases.len()); + for ((&reqk, &reqv), &reqp) in requested_modules_keys .iter() .zip(requested_modules_values.iter()) + .zip(requested_modules_phases.iter()) { + // 0 = ModulePhase::Evaluation, 1 = ModulePhase::Defer + let phase_defer = reqp != 0; match reqv { - RequestedModuleValue::None => { - module_record.add_requested_module_null_attributes_ptr(identifiers, reqk) - } + RequestedModuleValue::None => module_record.add_requested_module_null_attributes_ptr( + identifiers, + reqk, + phase_defer, + ), RequestedModuleValue::Javascript => { - module_record.add_requested_module_java_script(identifiers, reqk) + module_record.add_requested_module_java_script(identifiers, reqk, phase_defer) } RequestedModuleValue::Webassembly => { - module_record.add_requested_module_web_assembly(identifiers, reqk) + module_record.add_requested_module_web_assembly(identifiers, reqk, phase_defer) } RequestedModuleValue::Json => { - module_record.add_requested_module_json(identifiers, reqk) + module_record.add_requested_module_json(identifiers, reqk, phase_defer) } // Zig open-enum tail: `else => |uv| @enumFromInt(@intFromEnum(uv))` — // FetchParameters and StringID are both `#[repr(transparent)] u32`, so this // is a bitcast of the raw discriminant back into the interned-string index. - uv => { - module_record.add_requested_module_host_defined(identifiers, reqk, StringID(uv.0)) - } + uv => module_record.add_requested_module_host_defined( + identifiers, + reqk, + StringID(uv.0), + phase_defer, + ), } } @@ -149,6 +160,13 @@ pub extern "C" fn zig__ModuleInfoDeserialized__toJSModuleRecord( buffer[i + 2], buffer[i], ), + RecordKind::ImportInfoNamespaceDefer => module_record + .add_import_entry_namespace_defer( + identifiers, + buffer[i + 1], + buffer[i + 2], + buffer[i], + ), RecordKind::ExportInfoIndirect => { if buffer[i + 1] == StringID::STAR_NAMESPACE { module_record.add_namespace_export( @@ -291,27 +309,32 @@ unsafe extern "C" { module_record: *mut JSModuleRecord, identifier_array: *mut IdentifierArray, module_name: StringID, + phase_defer: bool, ); fn JSC_JSModuleRecord__addRequestedModuleJavaScript( module_record: *mut JSModuleRecord, identifier_array: *mut IdentifierArray, module_name: StringID, + phase_defer: bool, ); fn JSC_JSModuleRecord__addRequestedModuleWebAssembly( module_record: *mut JSModuleRecord, identifier_array: *mut IdentifierArray, module_name: StringID, + phase_defer: bool, ); fn JSC_JSModuleRecord__addRequestedModuleJSON( module_record: *mut JSModuleRecord, identifier_array: *mut IdentifierArray, module_name: StringID, + phase_defer: bool, ); fn JSC_JSModuleRecord__addRequestedModuleHostDefined( module_record: *mut JSModuleRecord, identifier_array: *mut IdentifierArray, module_name: StringID, host_defined_import_type: StringID, + phase_defer: bool, ); fn JSC_JSModuleRecord__addImportEntrySingle( @@ -335,6 +358,13 @@ unsafe extern "C" { local_name: StringID, module_name: StringID, ); + fn JSC_JSModuleRecord__addImportEntryNamespaceDefer( + module_record: *mut JSModuleRecord, + identifier_array: *mut IdentifierArray, + import_name: StringID, + local_name: StringID, + module_name: StringID, + ); } impl JSModuleRecord { #[inline] @@ -406,15 +436,32 @@ trait JSModuleRecordExt { self, ia: *mut IdentifierArray, module_name: StringID, + phase_defer: bool, + ); + fn add_requested_module_java_script( + self, + ia: *mut IdentifierArray, + module_name: StringID, + phase_defer: bool, + ); + fn add_requested_module_web_assembly( + self, + ia: *mut IdentifierArray, + module_name: StringID, + phase_defer: bool, + ); + fn add_requested_module_json( + self, + ia: *mut IdentifierArray, + module_name: StringID, + phase_defer: bool, ); - fn add_requested_module_java_script(self, ia: *mut IdentifierArray, module_name: StringID); - fn add_requested_module_web_assembly(self, ia: *mut IdentifierArray, module_name: StringID); - fn add_requested_module_json(self, ia: *mut IdentifierArray, module_name: StringID); fn add_requested_module_host_defined( self, ia: *mut IdentifierArray, module_name: StringID, host_defined_import_type: StringID, + phase_defer: bool, ); fn add_import_entry_single( self, @@ -437,6 +484,13 @@ trait JSModuleRecordExt { local_name: StringID, module_name: StringID, ); + fn add_import_entry_namespace_defer( + self, + ia: *mut IdentifierArray, + import_name: StringID, + local_name: StringID, + module_name: StringID, + ); } impl JSModuleRecordExt for *mut JSModuleRecord { // SAFETY (all below): `self` is the non-null pointer returned by JSC_JSModuleRecord__create; @@ -480,20 +534,47 @@ impl JSModuleRecordExt for *mut JSModuleRecord { self, ia: *mut IdentifierArray, module_name: StringID, + phase_defer: bool, ) { - unsafe { JSC_JSModuleRecord__addRequestedModuleNullAttributesPtr(self, ia, module_name) } + unsafe { + JSC_JSModuleRecord__addRequestedModuleNullAttributesPtr( + self, + ia, + module_name, + phase_defer, + ) + } } #[inline] - fn add_requested_module_java_script(self, ia: *mut IdentifierArray, module_name: StringID) { - unsafe { JSC_JSModuleRecord__addRequestedModuleJavaScript(self, ia, module_name) } + fn add_requested_module_java_script( + self, + ia: *mut IdentifierArray, + module_name: StringID, + phase_defer: bool, + ) { + unsafe { + JSC_JSModuleRecord__addRequestedModuleJavaScript(self, ia, module_name, phase_defer) + } } #[inline] - fn add_requested_module_web_assembly(self, ia: *mut IdentifierArray, module_name: StringID) { - unsafe { JSC_JSModuleRecord__addRequestedModuleWebAssembly(self, ia, module_name) } + fn add_requested_module_web_assembly( + self, + ia: *mut IdentifierArray, + module_name: StringID, + phase_defer: bool, + ) { + unsafe { + JSC_JSModuleRecord__addRequestedModuleWebAssembly(self, ia, module_name, phase_defer) + } } #[inline] - fn add_requested_module_json(self, ia: *mut IdentifierArray, module_name: StringID) { - unsafe { JSC_JSModuleRecord__addRequestedModuleJSON(self, ia, module_name) } + fn add_requested_module_json( + self, + ia: *mut IdentifierArray, + module_name: StringID, + phase_defer: bool, + ) { + unsafe { JSC_JSModuleRecord__addRequestedModuleJSON(self, ia, module_name, phase_defer) } } #[inline] fn add_requested_module_host_defined( @@ -501,6 +582,7 @@ impl JSModuleRecordExt for *mut JSModuleRecord { ia: *mut IdentifierArray, module_name: StringID, host_defined_import_type: StringID, + phase_defer: bool, ) { unsafe { JSC_JSModuleRecord__addRequestedModuleHostDefined( @@ -508,6 +590,7 @@ impl JSModuleRecordExt for *mut JSModuleRecord { ia, module_name, host_defined_import_type, + phase_defer, ) } } @@ -559,6 +642,24 @@ impl JSModuleRecordExt for *mut JSModuleRecord { ) } } + #[inline] + fn add_import_entry_namespace_defer( + self, + ia: *mut IdentifierArray, + import_name: StringID, + local_name: StringID, + module_name: StringID, + ) { + unsafe { + JSC_JSModuleRecord__addImportEntryNamespaceDefer( + self, + ia, + import_name, + local_name, + module_name, + ) + } + } } // ported from: src/bundler_jsc/analyze_jsc.zig diff --git a/src/js_parser/lower/lower_esm_exports_hmr.rs b/src/js_parser/lower/lower_esm_exports_hmr.rs index 59df59f0ca6..a7043c10019 100644 --- a/src/js_parser/lower/lower_esm_exports_hmr.rs +++ b/src/js_parser/lower/lower_esm_exports_hmr.rs @@ -561,6 +561,7 @@ impl<'a> ConvertESMExportsForHmr<'a> { items, namespace_ref, star_name_loc, + phase_defer: false, }, loc, )); diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index aed559be598..a9da5442b27 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -2079,6 +2079,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O is_single_line: true, default_name: None, star_name_loc: None, + phase_defer: false, }, bun_ast::Loc::default(), ); @@ -2217,6 +2218,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O is_single_line: true, default_name: None, star_name_loc: None, + phase_defer: false, }, bun_ast::Loc::default(), ); @@ -2380,6 +2382,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O is_single_line: false, default_name: None, star_name_loc: None, + phase_defer: false, }, bun_ast::Loc::EMPTY, ) @@ -4087,6 +4090,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O bun_ast::ImportRecordFlags::WAS_ORIGINALLY_BARE_IMPORT, was_originally_bare_import, ); + self.import_records.items_mut()[stmt.import_record_index as usize] + .flags + .set(bun_ast::ImportRecordFlags::PHASE_DEFER, stmt.phase_defer); if let Some(star) = stmt.star_name_loc { let name = self.load_name_from_ref(stmt.namespace_ref); diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 6e99510779c..ce16a19a56a 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -1254,6 +1254,7 @@ impl<'a> Parser<'a> { default_name: None, items: bun_ast::StoreSlice::EMPTY, is_single_line: false, + phase_defer: false, }, ns_loc, ); @@ -2067,6 +2068,7 @@ impl<'a> Parser<'a> { default_name: None, star_name_loc: None, is_single_line: false, + phase_defer: false, }, bun_ast::Loc::EMPTY, ); diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 3990fa9b8f2..a0c6de6c65f 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1468,6 +1468,32 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; p.lexer.next()?; + // "import defer * as ns from 'path'" + // + // https://tc39.es/proposal-defer-import-eval/ + // + // `defer` is only a phase keyword when followed by `*`; in + // every other position (`import defer from 'x'`, + // `import defer, {x} from 'y'`) it is an ordinary default + // binding named `defer`. + if default_name == b"defer" && p.lexer.token == T::TAsterisk { + p.lexer.next()?; + p.lexer.expect_contextual_keyword(b"as")?; + stmt = S::Import { + namespace_ref: p.store_name_in_ref(p.lexer.identifier)?, + star_name_loc: Some(p.lexer.loc()), + import_record_index: u32::MAX, + phase_defer: true, + ..Default::default() + }; + p.lexer.expect(T::TIdentifier)?; + p.lexer.expect_contextual_keyword(b"from")?; + + let path = p.parse_path()?; + p.lexer.expect_or_insert_semicolon()?; + return p.process_import_statement(stmt, path, loc, false); + } + if Self::IS_TYPESCRIPT_ENABLED { // Skip over type-only imports if default_name == b"type" { diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 5bbfc8e4317..cb62fdbecb9 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -105,6 +105,12 @@ pub mod analyze_transpiled_module { ExportInfoNamespace, /// module_name ExportInfoStar, + /// module_name, import_name = '*', local_name + /// + /// `import defer * as ns from "mod"` — same payload as + /// `ImportInfoNamespace` but the resulting `ImportEntry` carries + /// `ModulePhase::Defer`. + ImportInfoNamespaceDefer, } impl RecordKind { pub fn len(self) -> usize { @@ -113,6 +119,7 @@ pub mod analyze_transpiled_module { Self::ImportInfoSingle => 3, Self::ImportInfoSingleTypeScript => 3, Self::ImportInfoNamespace => 3, + Self::ImportInfoNamespaceDefer => 3, Self::ExportInfoIndirect => 3, Self::ExportInfoLocal => 3, Self::ExportInfoNamespace => 2, @@ -131,6 +138,7 @@ pub mod analyze_transpiled_module { 6 => Self::ExportInfoLocal, 7 => Self::ExportInfoNamespace, 8 => Self::ExportInfoStar, + 9 => Self::ImportInfoNamespaceDefer, _ => return None, }) } @@ -203,6 +211,18 @@ pub mod analyze_transpiled_module { Lexical, } + /// `AbstractModuleRecord::ModulePhase` — only `Evaluation` and `Defer` + /// exist. Stored as a `u8` parallel to `requested_modules_keys` so the + /// serialized format stays dense. + #[repr(u8)] + #[derive(Clone, Copy, PartialEq, Eq, Hash)] + pub enum ModulePhase { + Evaluation = 0, + Defer = 1, + } + // SAFETY: `#[repr(u8)]` enum with no fields → single initialized byte, no padding. + unsafe impl bytemuck::NoUninit for ModulePhase {} + /// Borrowing view over a finalized/serialized `ModuleInfo`. /// Zig kept this self-referentially inside `ModuleInfo`; Rust builds it on demand /// (`ModuleInfo::as_deserialized`) or borrows from an owned byte buffer @@ -212,6 +232,7 @@ pub mod analyze_transpiled_module { pub strings_lens: &'a [u32], pub requested_modules_keys: &'a [StringID], pub requested_modules_values: &'a [FetchParameters], + pub requested_modules_phases: &'a [ModulePhase], pub buffer: &'a [StringID], pub record_kinds: &'a [RecordKind], pub flags: Flags, @@ -238,6 +259,9 @@ pub mod analyze_transpiled_module { )?; w.write_all(slice_as_bytes(self.requested_modules_keys))?; w.write_all(slice_as_bytes(self.requested_modules_values))?; + w.write_all(slice_as_bytes(self.requested_modules_phases))?; + let pad = (4 - (self.requested_modules_phases.len() % 4)) % 4; + w.write_all(&[0u8; 4][..pad])?; // alignment padding w.write_all(&[self.flags.to_byte()])?; w.write_all(&[0u8; 3])?; // alignment padding @@ -295,6 +319,8 @@ pub mod analyze_transpiled_module { // the validated discriminants are decoded once in `create()` and owned // here instead of being reinterpreted from `backing` on every `as_ref()`. record_kinds: Box<[RecordKind]>, + // Same story for `ModulePhase` — validated once. + requested_modules_phases: Box<[ModulePhase]>, // Offsets/lengths into `backing` — reconstructed as slices in `as_ref()`. buffer: (usize, usize), requested_modules_keys: (usize, usize), @@ -354,6 +380,17 @@ pub mod analyze_transpiled_module { eat!(requested_modules_len * core::mem::size_of::()); let requested_modules_values = eat!(requested_modules_len * core::mem::size_of::()); + let (ph_off, ph_len) = eat!(requested_modules_len); + let mut requested_modules_phases = Vec::with_capacity(ph_len); + for &b in &duped[ph_off..ph_off + ph_len] { + requested_modules_phases.push(match b { + 0 => ModulePhase::Evaluation, + 1 => ModulePhase::Defer, + _ => return Err(BadModuleInfo), + }); + } + let requested_modules_phases = requested_modules_phases.into_boxed_slice(); + let _ = eat!((4 - (requested_modules_len % 4)) % 4); // alignment padding let (flags_off, _) = eat!(1); let flags = Flags::from_byte(duped[flags_off]); @@ -366,6 +403,7 @@ pub mod analyze_transpiled_module { Ok(Box::new(Self { backing: duped, record_kinds, + requested_modules_phases, buffer, requested_modules_keys, requested_modules_values, @@ -395,6 +433,7 @@ pub mod analyze_transpiled_module { bytes, self.requested_modules_values, ), + requested_modules_phases: &self.requested_modules_phases, strings_lens: sub::(bytes, self.strings_lens), strings_buf: &bytes[self.strings_buf.0..self.strings_buf.0 + self.strings_buf.1], flags: self.flags, @@ -405,60 +444,69 @@ pub mod analyze_transpiled_module { #[derive(Debug)] pub struct BadModuleInfo; - /// Insertion-ordered (key, value) store with O(1) duplicate-key rejection. - /// Stand-in for Zig's `AutoArrayHashMapUnmanaged` until `bun_collections::ArrayHashMap` - /// grows slice-yielding `keys()`/`values()`. - // PERF(port): two allocations + a side HashMap; revisit with a real IndexMap. - struct OrderedMap { - keys: Vec, - values: Vec, - index: HashMap, - } - impl Default for OrderedMap { + /// Insertion-ordered list of requested modules. Dedup key is + /// `(specifier, phase)` to match JSC's `ModuleAnalyzer::appendRequestedModule`, + /// which appends one entry per unique pair — so the same specifier can be + /// requested at both Evaluation and Defer phase. + // PERF(port): three allocations + a side HashMap; revisit with a real IndexMap. + struct RequestedModules { + keys: Vec, + values: Vec, + phases: Vec, + index: HashMap<(StringID, ModulePhase), usize>, + } + impl Default for RequestedModules { fn default() -> Self { Self { keys: Vec::new(), values: Vec::new(), + phases: Vec::new(), index: HashMap::default(), } } } - impl OrderedMap { - fn keys(&self) -> &[K] { + impl RequestedModules { + fn keys(&self) -> &[StringID] { &self.keys } - fn values(&self) -> &[V] { + fn values(&self) -> &[FetchParameters] { &self.values } - /// Returns `true` if `key` was already present (Zig `getOrPut().found_existing`). - fn insert_if_absent(&mut self, key: K, value: V) -> bool { - if self.index.contains_key(&key) { + fn phases(&self) -> &[ModulePhase] { + &self.phases + } + /// Returns `true` if `(key, phase)` was already present. + fn insert_if_absent( + &mut self, + key: StringID, + value: FetchParameters, + phase: ModulePhase, + ) -> bool { + if self.index.contains_key(&(key, phase)) { return true; } - self.index.insert(key, self.keys.len()); + self.index.insert((key, phase), self.keys.len()); self.keys.push(key); self.values.push(value); + self.phases.push(phase); false } - #[allow(dead_code)] - fn swap_remove(&mut self, key: &K) -> Option { - let i = self.index.remove(key)?; - self.keys.swap_remove(i); - let v = self.values.swap_remove(i); - if i < self.keys.len() { - self.index.insert(self.keys[i], i); - } - Some(v) - } - /// Replace `old` with `new` **in place**, preserving insertion order. - /// Mirrors Zig `keys()[idx] = new; reIndex()`. - fn rename_key(&mut self, old: &K, new: K) -> bool { - let Some(i) = self.index.remove(old) else { - return false; - }; - self.keys[i] = new; - self.index.insert(new, i); - true + /// Replace every occurrence of `old` with `new` **in place**, + /// preserving insertion order. Mirrors Zig `keys()[idx] = new; reIndex()`. + fn rename_key(&mut self, old: StringID, new: StringID) { + let mut touched = false; + for k in self.keys.iter_mut() { + if *k == old { + *k = new; + touched = true; + } + } + if touched { + self.index.clear(); + for (i, (&k, &p)) in self.keys.iter().zip(self.phases.iter()).enumerate() { + self.index.insert((k, p), i); + } + } } } @@ -470,7 +518,7 @@ pub mod analyze_transpiled_module { strings_map: HashMap, u32>, strings_buf: Vec, strings_lens: Vec, - requested_modules: OrderedMap, + requested_modules: RequestedModules, buffer: Vec, record_kinds: Vec, pub flags: Flags, @@ -487,7 +535,7 @@ pub mod analyze_transpiled_module { strings_map: HashMap::default(), strings_buf: Vec::new(), strings_lens: Vec::new(), - requested_modules: OrderedMap::default(), + requested_modules: RequestedModules::default(), buffer: Vec::new(), record_kinds: Vec::new(), flags: Flags { @@ -509,6 +557,7 @@ pub mod analyze_transpiled_module { strings_lens: &self.strings_lens, requested_modules_keys: self.requested_modules.keys(), requested_modules_values: self.requested_modules.values(), + requested_modules_phases: self.requested_modules.phases(), buffer: &self.buffer, record_kinds: &self.record_kinds, flags: self.flags, @@ -556,6 +605,16 @@ pub mod analyze_transpiled_module { &[module_name, StringID::STAR_NAMESPACE, local_name], ); } + pub fn add_import_info_namespace_defer( + &mut self, + module_name: StringID, + local_name: StringID, + ) { + self.add_record( + RecordKind::ImportInfoNamespaceDefer, + &[module_name, StringID::STAR_NAMESPACE, local_name], + ); + } pub fn add_export_info_indirect( &mut self, export_name: StringID, @@ -621,8 +680,21 @@ pub mod analyze_transpiled_module { fetch_parameters: FetchParameters, ) { // jsc only records the attributes of the first import with the given import_record_path. so only put if not exists. + self.requested_modules.insert_if_absent( + import_record_path, + fetch_parameters, + ModulePhase::Evaluation, + ); + } + + pub fn request_module_with_phase( + &mut self, + import_record_path: StringID, + fetch_parameters: FetchParameters, + phase: ModulePhase, + ) { self.requested_modules - .insert_if_absent(import_record_path, fetch_parameters); + .insert_if_absent(import_record_path, fetch_parameters, phase); } /// Replace all occurrences of `old_id` with `new_id` in records and requested_modules. @@ -636,7 +708,7 @@ pub mod analyze_transpiled_module { } // Zig: `requested_modules.keys()[idx] = new_id; reIndex()` — must preserve // insertion order (serialized verbatim into ModuleInfo for JSC). - self.requested_modules.rename_key(&old_id, new_id); + self.requested_modules.rename_key(old_id, new_id); } /// find any exports marked as 'local' that are actually 'indirect' and fix them @@ -6012,6 +6084,11 @@ pub mod __gated_printer { self.print(b"import"); + let phase_defer = record.flags.contains(ImportRecordFlags::PHASE_DEFER); + if phase_defer { + self.print(b" defer"); + } + let mut item_count: usize = 0; if let Some(name) = &s.default_name { @@ -6198,7 +6275,12 @@ pub mod __gated_printer { } else { FP::None }; - mi.request_module(irp_id, fetch_parameters); + let phase = if phase_defer { + analyze_transpiled_module::ModulePhase::Defer + } else { + analyze_transpiled_module::ModulePhase::Evaluation + }; + mi.request_module_with_phase(irp_id, fetch_parameters, phase); irp_id }; @@ -6230,7 +6312,11 @@ pub mod __gated_printer { let mi = self.module_info().expect("infallible: module_info enabled"); let local_name_id = mi.str(local_name); mi.add_var(local_name_id, analyze_transpiled_module::VarKind::Lexical); - mi.add_import_info_namespace(irp_id, local_name_id); + if phase_defer { + mi.add_import_info_namespace_defer(irp_id, local_name_id); + } else { + mi.add_import_info_namespace(irp_id, local_name_id); + } } } } diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 6c60627bd25..8384d406af5 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -39,7 +39,8 @@ bun_core::declare_scope!(cache, visible); /// Version 18: Include ESM record (module info) with an ES Module, see #15758 /// Version 19: Sourcemap blob is InternalSourceMap (varint stream + sync points), not VLQ. /// Version 20: InternalSourceMap stream is bit-packed windows. -const EXPECTED_VERSION: u32 = 20; +/// Version 21: ModuleInfo records a phase byte per requested module (`import defer`). +const EXPECTED_VERSION: u32 = 21; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp index f33f9469222..baa7ad395ac 100644 --- a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp +++ b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp @@ -93,30 +93,35 @@ extern "C" void JSC_JSModuleRecord__addStarExport(JSModuleRecord* moduleRecord, { moduleRecord->addStarExportEntry(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName)); } -extern "C" void JSC_JSModuleRecord__addRequestedModuleNullAttributesPtr(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t moduleName) +static inline AbstractModuleRecord::ModulePhase toModulePhase(bool phaseDefer) +{ + return phaseDefer ? AbstractModuleRecord::ModulePhase::Defer : AbstractModuleRecord::ModulePhase::Evaluation; +} + +extern "C" void JSC_JSModuleRecord__addRequestedModuleNullAttributesPtr(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t moduleName, bool phaseDefer) { RefPtr attributes = RefPtr {}; - moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes)); + moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes), toModulePhase(phaseDefer)); } -extern "C" void JSC_JSModuleRecord__addRequestedModuleJavaScript(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t moduleName) +extern "C" void JSC_JSModuleRecord__addRequestedModuleJavaScript(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t moduleName, bool phaseDefer) { Ref attributes = ScriptFetchParameters::create(ScriptFetchParameters::Type::JavaScript); - moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes)); + moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes), toModulePhase(phaseDefer)); } -extern "C" void JSC_JSModuleRecord__addRequestedModuleWebAssembly(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t moduleName) +extern "C" void JSC_JSModuleRecord__addRequestedModuleWebAssembly(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t moduleName, bool phaseDefer) { Ref attributes = ScriptFetchParameters::create(ScriptFetchParameters::Type::WebAssembly); - moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes)); + moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes), toModulePhase(phaseDefer)); } -extern "C" void JSC_JSModuleRecord__addRequestedModuleJSON(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t moduleName) +extern "C" void JSC_JSModuleRecord__addRequestedModuleJSON(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t moduleName, bool phaseDefer) { Ref attributes = ScriptFetchParameters::create(ScriptFetchParameters::Type::JSON); - moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes)); + moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes), toModulePhase(phaseDefer)); } -extern "C" void JSC_JSModuleRecord__addRequestedModuleHostDefined(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t moduleName, uint32_t hostDefinedImportType) +extern "C" void JSC_JSModuleRecord__addRequestedModuleHostDefined(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t moduleName, uint32_t hostDefinedImportType, bool phaseDefer) { Ref attributes = ScriptFetchParameters::create(getFromIdentifierArray(moduleRecord->vm(), identifierArray, hostDefinedImportType).string()); - moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes)); + moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes), toModulePhase(phaseDefer)); } extern "C" void JSC_JSModuleRecord__addImportEntrySingle(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t importName, uint32_t localName, uint32_t moduleName) @@ -146,6 +151,16 @@ extern "C" void JSC_JSModuleRecord__addImportEntryNamespace(JSModuleRecord* modu .localName = getFromIdentifierArray(moduleRecord->vm(), identifierArray, localName), }); } +extern "C" void JSC_JSModuleRecord__addImportEntryNamespaceDefer(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t importName, uint32_t localName, uint32_t moduleName) +{ + moduleRecord->addImportEntry(JSModuleRecord::ImportEntry { + .type = JSModuleRecord::ImportEntryType::Namespace, + .phase = AbstractModuleRecord::ModulePhase::Defer, + .moduleRequest = getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), + .importName = getFromIdentifierArray(moduleRecord->vm(), identifierArray, importName), + .localName = getFromIdentifierArray(moduleRecord->vm(), identifierArray, localName), + }); +} static EncodedJSValue fallbackParse(JSGlobalObject* globalObject, const Identifier& moduleKey, const SourceCode& sourceCode, JSPromise* promise, JSModuleRecord* resultValue = nullptr); extern "C" EncodedJSValue Bun__analyzeTranspiledModule(JSGlobalObject* globalObject, const Identifier& moduleKey, const SourceCode& sourceCode, JSPromise* promise) @@ -273,9 +288,12 @@ String dumpRecordInfo(JSModuleRecord* moduleRecord) for (const auto& request : moduleRecord->requestedModules()) { WTF::StringPrintStream line; if (request.m_attributes == nullptr) - line.print(" module(", request.m_specifier, ")\n"); + line.print(" module(", request.m_specifier, ")"); else - line.print(" module(", request.m_specifier, "),attributes(", (uint8_t)request.m_attributes->type(), ", ", request.m_attributes->hostDefinedImportType(), ")\n"); + line.print(" module(", request.m_specifier, "),attributes(", (uint8_t)request.m_attributes->type(), ", ", request.m_attributes->hostDefinedImportType(), ")"); + if (request.m_phase == AbstractModuleRecord::ModulePhase::Defer) + line.print(",phase(defer)"); + line.print("\n"); sortedDeps.append(line.toString()); } std::sort(sortedDeps.begin(), sortedDeps.end(), [](const String& a, const String& b) { @@ -291,7 +309,10 @@ String dumpRecordInfo(JSModuleRecord* moduleRecord) for (const auto& pair : moduleRecord->importEntries()) { WTF::StringPrintStream line; auto& importEntry = pair.value; - line.print(" import(", importEntry.importName, "), local(", importEntry.localName, "), module(", importEntry.moduleRequest, ")\n"); + line.print(" import(", importEntry.importName, "), local(", importEntry.localName, "), module(", importEntry.moduleRequest, ")"); + if (importEntry.phase == AbstractModuleRecord::ModulePhase::Defer) + line.print(", phase(defer)"); + line.print("\n"); sortedImports.append(line.toString()); } std::sort(sortedImports.begin(), sortedImports.end(), [](const String& a, const String& b) { diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index d90901468d3..ddf8ad06b0f 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -307,6 +307,7 @@ extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(c JSC::Options::heapGrowthMaxIncrease() = 2.0; JSC::Options::useAsyncStackTrace() = true; JSC::Options::useExplicitResourceManagement() = true; + JSC::Options::useImportDefer() = true; JSC::dangerouslyOverrideJSCBytecodeCacheVersion(getWebKitBytecodeCacheVersion()); #ifdef BUN_DEBUG diff --git a/test/js/bun/resolve/import-defer.test.ts b/test/js/bun/resolve/import-defer.test.ts new file mode 100644 index 00000000000..f20f886291d --- /dev/null +++ b/test/js/bun/resolve/import-defer.test.ts @@ -0,0 +1,316 @@ +// TC39 proposal-defer-import-eval (Stage 3) — static `import defer * as ns from "..."` +// https://tc39.es/proposal-defer-import-eval/ + +import { test, expect, describe } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +async function run(files: Record, entry = "main.js") { + using dir = tempDir("import-defer", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), entry], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +describe("import defer", () => { + test("defers module evaluation until a property is accessed", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import defer * as ns from "./dep.js"; + console.log("before access"); + console.log("value:", ns.value); + console.log("after access"); + 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", + "after access", + "add: 3", + ]); + expect(exitCode).toBe(0); + }); + + test("does not re-evaluate on subsequent access", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import defer * as ns from "./dep.js"; + console.log(ns.x); + console.log(ns.x); + console.log(ns.y); + `, + "dep.js": ` + console.log("dep evaluated"); + export const x = 1; + export const y = 2; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["dep evaluated", "1", "1", "2"]); + expect(exitCode).toBe(0); + }); + + test("reading Symbol.toStringTag does not trigger evaluation", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import defer * as ns from "./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("evaluation is triggered by 'in' and Object.keys", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import defer * as a from "./a.js"; + import defer * as b from "./b.js"; + console.log("before"); + console.log("has:", "value" in a); + console.log("keys:", Object.keys(b).sort().join(",")); + `, + "a.js": `console.log("a evaluated"); export const value = 1;`, + "b.js": `console.log("b evaluated"); export const x = 1; export const y = 2;`, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual([ + "before", + "a evaluated", + "has: true", + "b evaluated", + "keys: x,y", + ]); + expect(exitCode).toBe(0); + }); + + test("throwing module re-throws on each access", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import defer * as ns from "./throws.js"; + 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(["caught: boom", "caught: boom"]); + expect(exitCode).toBe(0); + }); + + test("same module imported at both evaluation and defer phase", async () => { + // The eager import runs the module before main; the deferred namespace + // shares the already-evaluated module. + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import * as eager from "./dep.js"; + import defer * as lazy from "./dep.js"; + console.log("main start"); + console.log("eager:", eager.value); + console.log("lazy:", lazy.value); + `, + "dep.js": ` + console.log("dep evaluated"); + export const value = 1; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["dep evaluated", "main start", "eager: 1", "lazy: 1"]); + expect(exitCode).toBe(0); + }); + + test("deferred module with an async transitive dependency evaluates the async dep eagerly", async () => { + // Per spec, GatherAsynchronousTransitiveDependencies: modules reachable + // from a defer-phase request that contain top-level await are evaluated + // up-front so the deferred namespace can be satisfied synchronously. + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import defer * as ns from "./dep.js"; + console.log("main start"); + 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 start", + "dep evaluated", + "value: ok", + ]); + expect(exitCode).toBe(0); + }); + + test("re-export of a deferred namespace is a local export", async () => { + // `import defer * as ns; export { ns }` exports the *deferred* namespace + // object as a local binding — it does not turn into a namespace re-export, + // so the target stays unevaluated until touched. + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import { ns } from "./middle.js"; + console.log("main start"); + console.log("value:", ns.value); + `, + "middle.js": ` + import defer * as ns from "./dep.js"; + console.log("middle evaluated"); + export { ns }; + `, + "dep.js": ` + console.log("dep evaluated"); + export const value = 5; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["middle evaluated", "main start", "dep evaluated", "value: 5"]); + expect(exitCode).toBe(0); + }); + + test("import defer with import attributes", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import defer * as data from "./data.json" with { type: "json" }; + console.log("before"); + console.log(data.default.hello); + `, + "data.json": JSON.stringify({ hello: "world" }), + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["before", "world"]); + expect(exitCode).toBe(0); + }); + + test("works in .ts files", async () => { + const { stdout, stderr, exitCode } = await run( + { + "main.ts": ` + import defer * as ns from "./dep.ts"; + console.log("before"); + 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", "dep evaluated", "9"]); + expect(exitCode).toBe(0); + }); + + describe("'defer' remains a valid identifier", () => { + test("import defer from '...' (default binding)", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import defer from "./dep.js"; + console.log(defer); + `, + "dep.js": ` + console.log("dep evaluated"); + export default "hello"; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["dep evaluated", "hello"]); + expect(exitCode).toBe(0); + }); + + test("import defer, { x } from '...'", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import defer, { x } from "./dep.js"; + console.log(defer, x); + `, + "dep.js": ` + console.log("dep evaluated"); + export default "D"; + export const x = "X"; + `, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["dep evaluated", "D X"]); + expect(exitCode).toBe(0); + }); + + test("import { defer } from '...'", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + import { defer } from "./dep.js"; + console.log(defer); + `, + "dep.js": `export const defer = 123;`, + }); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["123"]); + expect(exitCode).toBe(0); + }); + }); + + test("transpiler preserves 'import defer' in output", async () => { + const out = new Bun.Transpiler({ loader: "js" }).transformSync(`import defer * as ns from "./x";\nns.a;\n`); + expect(out).toContain("import defer"); + expect(out).toContain("* as ns"); + }); + + describe("syntax errors", () => { + test("import defer { x } from '...' is a syntax error", async () => { + const { exitCode, stderr } = await run({ + "main.js": `import defer { x } from "./dep.js";`, + "dep.js": `export const x = 1;`, + }); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("error"); + }); + + test("import defer x from '...' is a syntax error", async () => { + const { exitCode, stderr } = await run({ + "main.js": `import defer x from "./dep.js";`, + "dep.js": `export default 1;`, + }); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("error"); + }); + }); +}); From 4fa979e900895b66085f8366b20a1a2e4fafd39e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 06:30:47 +0000 Subject: [PATCH 03/11] [autofix.ci] apply automated fixes --- test/js/bun/resolve/import-defer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/resolve/import-defer.test.ts b/test/js/bun/resolve/import-defer.test.ts index f20f886291d..5dcb27e65ee 100644 --- a/test/js/bun/resolve/import-defer.test.ts +++ b/test/js/bun/resolve/import-defer.test.ts @@ -1,7 +1,7 @@ // TC39 proposal-defer-import-eval (Stage 3) — static `import defer * as ns from "..."` // https://tc39.es/proposal-defer-import-eval/ -import { test, expect, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; async function run(files: Record, entry = "main.js") { From 34c4f6ba9a6834dbe54749867d84aa3288cea775 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 06:45:14 +0000 Subject: [PATCH 04/11] Harden phase-byte validation when building JSModuleRecord Reject the deserialized module-info record in release builds if the three requested-module arrays have mismatched lengths or a phase byte is neither Evaluation (0) nor Defer (1). The buffer can originate from the on-disk transpiler cache, so this matches the existing treatment of unknown RecordKind bytes. --- src/bundler/analyze_transpiled_module.rs | 4 +++- src/bundler_jsc/analyze_jsc.rs | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/bundler/analyze_transpiled_module.rs b/src/bundler/analyze_transpiled_module.rs index a36ce918608..5c127ff1977 100644 --- a/src/bundler/analyze_transpiled_module.rs +++ b/src/bundler/analyze_transpiled_module.rs @@ -359,7 +359,9 @@ impl ModuleInfoDeserialized { writer.write_all(&(rm_keys.len() as u32).to_le_bytes())?; writer.write_all(slice_as_bytes(rm_keys))?; writer.write_all(slice_as_bytes(self.requested_modules_values()))?; - writer.write_all(self.requested_modules_phases())?; + let rm_phases = self.requested_modules_phases(); + debug_assert_eq!(rm_phases.len(), rm_keys.len()); + writer.write_all(rm_phases)?; let pad = (4 - (rm_keys.len() % 4)) % 4; writer.write_all(&[0u8; 4][..pad])?; // alignment padding diff --git a/src/bundler_jsc/analyze_jsc.rs b/src/bundler_jsc/analyze_jsc.rs index fd3447361fc..9c7f8688aab 100644 --- a/src/bundler_jsc/analyze_jsc.rs +++ b/src/bundler_jsc/analyze_jsc.rs @@ -97,15 +97,23 @@ pub extern "C" fn zig__ModuleInfoDeserialized__toJSModuleRecord( res.flags.has_tla(), ); - debug_assert_eq!(requested_modules_keys.len(), requested_modules_values.len()); - debug_assert_eq!(requested_modules_keys.len(), requested_modules_phases.len()); + if requested_modules_keys.len() != requested_modules_values.len() + || requested_modules_keys.len() != requested_modules_phases.len() + { + return core::ptr::null_mut(); + } for ((&reqk, &reqv), &reqp) in requested_modules_keys .iter() .zip(requested_modules_values.iter()) .zip(requested_modules_phases.iter()) { - // 0 = ModulePhase::Evaluation, 1 = ModulePhase::Defer - let phase_defer = reqp != 0; + // 0 = ModulePhase::Evaluation, 1 = ModulePhase::Defer. Reject anything + // else — the buffer may have come from an on-disk cache. + let phase_defer = match reqp { + 0 => false, + 1 => true, + _ => return core::ptr::null_mut(), + }; match reqv { RequestedModuleValue::None => module_record.add_requested_module_null_attributes_ptr( identifiers, From daced8605bdd137c62f1aed69bb87b4e19dc9d7f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 06:53:08 +0000 Subject: [PATCH 05/11] Reject escaped `def\u0065r` as a phase keyword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare the raw token bytes (not the decoded identifier) when recognising `import defer`, so escape sequences cannot spell the phase keyword — matching the treatment of `as`/`from`/`async`. Also fix the stale "six views" comment in ModuleInfoDeserialized::create(). --- src/bundler/analyze_transpiled_module.rs | 2 +- src/js_parser/parse/parse_stmt.rs | 6 ++++-- test/js/bun/resolve/import-defer.test.ts | 12 ++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/bundler/analyze_transpiled_module.rs b/src/bundler/analyze_transpiled_module.rs index 5c127ff1977..6289f39f3ba 100644 --- a/src/bundler/analyze_transpiled_module.rs +++ b/src/bundler/analyze_transpiled_module.rs @@ -312,7 +312,7 @@ impl ModuleInfoDeserialized { // Disarm the errdefer: ownership moves into the result. let duped_raw = scopeguard::ScopeGuard::into_inner(guard); - // All six views borrow `duped_raw` (the boxed allocation moved into + // All seven views borrow `duped_raw` (the boxed allocation moved into // `owner` below); they stay valid and at a stable address for the // lifetime of every `RawSlice` copied from this struct. `RawSlice::new` // erases the borrow lifetime — the structural invariant is upheld by diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index a0c6de6c65f..548e8a735a3 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1457,6 +1457,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } let mut default_name = p.lexer.identifier; + let default_name_raw = p.lexer.raw(); stmt = S::Import { namespace_ref: Ref::NONE, import_record_index: u32::MAX, @@ -1475,8 +1476,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // `defer` is only a phase keyword when followed by `*`; in // every other position (`import defer from 'x'`, // `import defer, {x} from 'y'`) it is an ordinary default - // binding named `defer`. - if default_name == b"defer" && p.lexer.token == T::TAsterisk { + // binding named `defer`. Compare the raw token so + // `def\u0065r` is not treated as the phase keyword. + if default_name_raw == b"defer" && p.lexer.token == T::TAsterisk { p.lexer.next()?; p.lexer.expect_contextual_keyword(b"as")?; stmt = S::Import { diff --git a/test/js/bun/resolve/import-defer.test.ts b/test/js/bun/resolve/import-defer.test.ts index 5dcb27e65ee..e07b3a99ab9 100644 --- a/test/js/bun/resolve/import-defer.test.ts +++ b/test/js/bun/resolve/import-defer.test.ts @@ -312,5 +312,17 @@ describe("import defer", () => { expect(exitCode).not.toBe(0); expect(stderr.toLowerCase()).toContain("error"); }); + + test("'defer' with an escape sequence is not the phase keyword", async () => { + // `import def\u0065r *` must not be treated as `import defer *`; since + // `import *` is not valid grammar either, it is a + // syntax error. + const { exitCode, stderr } = await run({ + "main.js": `import def\\u0065r * as ns from "./dep.js"; console.log(ns);`, + "dep.js": `export const x = 1;`, + }); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("error"); + }); }); }); From baef48d55c585ee6d9ded6b0e364359c5f58f163 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 07:31:57 +0000 Subject: [PATCH 06/11] Reject `import defer * as` outside module scope Apply the same scope gate as regular namespace imports: the declaration is only valid at module scope (or inside a TypeScript `declare namespace`). Previously `namespace X { import defer * as ns from "y" }` slipped through because the defer branch returned early before the TIdentifier arm's namespace-scope fall-through to parse_type_script_import_equals_stmt. --- src/js_parser/parse/parse_stmt.rs | 9 +++++++++ test/js/bun/resolve/import-defer.test.ts | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 548e8a735a3..e522a00c2fa 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1479,6 +1479,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // binding named `defer`. Compare the raw token so // `def\u0065r` is not treated as the phase keyword. if default_name_raw == b"defer" && p.lexer.token == T::TAsterisk { + // Same scope restriction as `import * as ns from 'path'`: + // ESM import declarations are only valid at module scope + // (or inside a TypeScript `declare namespace`). + if !opts.is_module_scope + && (!opts.is_namespace_scope || !opts.is_typescript_declare) + { + p.lexer.unexpected()?; + return Err(err!("SyntaxError")); + } p.lexer.next()?; p.lexer.expect_contextual_keyword(b"as")?; stmt = S::Import { diff --git a/test/js/bun/resolve/import-defer.test.ts b/test/js/bun/resolve/import-defer.test.ts index e07b3a99ab9..a1e69b123a3 100644 --- a/test/js/bun/resolve/import-defer.test.ts +++ b/test/js/bun/resolve/import-defer.test.ts @@ -324,5 +324,19 @@ describe("import defer", () => { expect(exitCode).not.toBe(0); expect(stderr.toLowerCase()).toContain("error"); }); + + test("import defer inside a TypeScript namespace is a syntax error", async () => { + // ESM import declarations are only valid at module scope; a TypeScript + // `namespace` block only permits `import x = ...`. + const { exitCode, stderr } = await run( + { + "main.ts": `namespace X { import defer * as ns from "./dep.js"; }`, + "dep.js": `export const x = 1;`, + }, + "main.ts", + ); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("error"); + }); }); }); From 810e5697005d5fc523be9cb9cd7a6dbcf7142988 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 07:40:51 +0000 Subject: [PATCH 07/11] Don't strip the namespace binding from `import defer` during unused-import trimming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the deferred namespace was only referenced from a dead branch, scan_imports would clear `star_name_loc` (both in the dead-code trimmer and in the star-to-clause converter). That left the printer with `PHASE_DEFER` but no `CONTAINS_IMPORT_STAR`, producing `import defer"./x";` — a hard SyntaxError in JSC. Keep the `* as ns` binding for phase-defer imports so the module stays linked-but-unevaluated (the intended semantics), and as a belt-and-suspenders measure gate the printed `defer` token on `CONTAINS_IMPORT_STAR` too. Also: make the test suite concurrent; fix a stale '0..=8' comment. --- src/js_parser/scan/scan_imports.rs | 16 +++++++++++-- src/js_printer/lib.rs | 13 +++++++++-- test/js/bun/resolve/import-defer.test.ts | 29 +++++++++++++++++++++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/js_parser/scan/scan_imports.rs b/src/js_parser/scan/scan_imports.rs index 0f392785aa8..1bbe0502d27 100644 --- a/src/js_parser/scan/scan_imports.rs +++ b/src/js_parser/scan/scan_imports.rs @@ -198,8 +198,16 @@ impl<'a> ImportScanner<'a> { is_unused_in_typescript = false; } - // Remove the symbol if it's never used outside a dead code region - if symbol.use_count_estimate == 0 { + // Remove the symbol if it's never used outside a dead code region. + // + // Never strip the namespace binding from an `import defer` + // statement: the grammar requires `* as ns`, so dropping + // it would force the printer to emit a bare side-effect + // import — eagerly evaluating a module the user asked to + // defer. Keeping the binding preserves the intended + // semantics (the module is linked but never evaluated, + // since nothing touches `ns` at runtime). + if symbol.use_count_estimate == 0 && !st.phase_defer { // Make sure we don't remove this if it was used for a property // access while bundling let mut has_any = false; @@ -308,7 +316,11 @@ impl<'a> ImportScanner<'a> { let _ = did_remove_star_loc; let namespace_ref = st.namespace_ref; + // `import defer * as ns` must keep its namespace binding + // (see the matching guard above): converting it to a + // clause import would lose the defer phase entirely. let convert_star_to_clause = !p.options.bundle + && !st.phase_defer && (p.symbols[namespace_ref.inner_index() as usize].use_count_estimate == 0); diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index cb62fdbecb9..50626236e14 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -358,7 +358,7 @@ pub mod analyze_transpiled_module { let record_kinds_len = eat_u32!(); let (rk_off, rk_len) = eat!(record_kinds_len * core::mem::size_of::()); // Validate + decode every record-kind byte into an owned `Box<[RecordKind]>`. - // `RecordKind` is a `#[repr(u8)]` enum, so any byte outside 0..=8 is invalid; + // `RecordKind` is a `#[repr(u8)]` enum, so out-of-range bytes are invalid; // `source` may come from an on-disk cache (`create_from_cached_record`), so it // is untrusted. Decoding once here lets `as_ref()` hand out `&[RecordKind]` // without an `unsafe` reinterpret. @@ -6084,7 +6084,16 @@ pub mod __gated_printer { self.print(b"import"); - let phase_defer = record.flags.contains(ImportRecordFlags::PHASE_DEFER); + // `import defer` grammatically requires `* as ns`; if a + // later pass stripped the star binding (or disabled it on + // the record) the statement can no longer be printed as a + // phase import, so drop the `defer` token rather than emit + // `import defer"./x";`. scan_imports preserves the binding + // for `phase_defer` imports, so this is belt-and-suspenders. + let phase_defer = record.flags.contains(ImportRecordFlags::PHASE_DEFER) + && record + .flags + .contains(ImportRecordFlags::CONTAINS_IMPORT_STAR); if phase_defer { self.print(b" defer"); } diff --git a/test/js/bun/resolve/import-defer.test.ts b/test/js/bun/resolve/import-defer.test.ts index a1e69b123a3..284545c1ba9 100644 --- a/test/js/bun/resolve/import-defer.test.ts +++ b/test/js/bun/resolve/import-defer.test.ts @@ -17,7 +17,7 @@ async function run(files: Record, entry = "main.js") { return { stdout, stderr, exitCode }; } -describe("import defer", () => { +describe.concurrent("import defer", () => { test("defers module evaluation until a property is accessed", async () => { const { stdout, stderr, exitCode } = await run({ "main.js": ` @@ -240,6 +240,33 @@ describe("import defer", () => { expect(exitCode).toBe(0); }); + test("namespace only referenced in dead code keeps the deferred binding", async () => { + // TS unused-import trimming would normally strip the `* as ns` binding + // when the namespace is only referenced in a dead branch, leaving a bare + // side-effect import. For `import defer` that is (a) syntactically + // invalid (`import defer"./x"`) and (b) semantically wrong — it would + // eagerly evaluate a module the user asked to defer. The binding must be + // preserved; since nothing touches it at runtime, the module is linked + // but never evaluated. + const { stdout, stderr, exitCode } = await run( + { + "main.ts": ` + import defer * as ns from "./dep.ts"; + if (false) { console.log(ns.value); } + console.log("main"); + `, + "dep.ts": ` + console.log("dep evaluated"); + export const value = 1; + `, + }, + "main.ts", + ); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean)).toEqual(["main"]); + expect(exitCode).toBe(0); + }); + describe("'defer' remains a valid identifier", () => { test("import defer from '...' (default binding)", async () => { const { stdout, stderr, exitCode } = await run({ From bfdda47a9505e99a32453603260f25ef1767560f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 07:46:02 +0000 Subject: [PATCH 08/11] Reject `export import defer * as` in TypeScript `export import` in TypeScript is the import-equals form (`export import X = Y.Z`); the defer branch was returning early before the `opts.is_export` fall-through to parse_type_script_import_equals_stmt, silently dropping the `export` and parsing it as a plain deferred import. --- src/js_parser/parse/parse_stmt.rs | 8 +++++++- test/js/bun/resolve/import-defer.test.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index e522a00c2fa..e1a5d3ae7fc 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1478,7 +1478,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // `import defer, {x} from 'y'`) it is an ordinary default // binding named `defer`. Compare the raw token so // `def\u0065r` is not treated as the phase keyword. - if default_name_raw == b"defer" && p.lexer.token == T::TAsterisk { + // + // `opts.is_export` rules out `export import defer * as ...` + // (only reachable via the TypeScript `export import foo = bar` + // re-entry) so it falls through to the import-equals handler + // and errors there. + if default_name_raw == b"defer" && p.lexer.token == T::TAsterisk && !opts.is_export + { // Same scope restriction as `import * as ns from 'path'`: // ESM import declarations are only valid at module scope // (or inside a TypeScript `declare namespace`). diff --git a/test/js/bun/resolve/import-defer.test.ts b/test/js/bun/resolve/import-defer.test.ts index 284545c1ba9..e4b767653c6 100644 --- a/test/js/bun/resolve/import-defer.test.ts +++ b/test/js/bun/resolve/import-defer.test.ts @@ -365,5 +365,20 @@ describe.concurrent("import defer", () => { expect(exitCode).not.toBe(0); expect(stderr.toLowerCase()).toContain("error"); }); + + test("'export import defer * as ns' is a syntax error", async () => { + // `export import` in TypeScript is the import-equals form + // (`export import X = ...`); `export import defer * as` matches no + // grammar production in either language. + const { exitCode, stderr } = await run( + { + "main.ts": `export import defer * as ns from "./dep.js"; console.log(ns);`, + "dep.js": `export const x = 1;`, + }, + "main.ts", + ); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("error"); + }); }); }); From c0ddc41069680c76fd31fec1e6f6fde9bbca4e92 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 08:19:35 +0000 Subject: [PATCH 09/11] ci: retrigger From 1a98bc5cb0d9226a201ac8b6b5e27f480e62fbc1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 18 May 2026 12:42:33 +0000 Subject: [PATCH 10/11] Assert the `import defer` shape invariant in process_import_statement phase_defer implies star-only (no default binding, no named clause). The parser guarantees this by construction; the debug assertion makes the invariant explicit at the S::Import -> ImportRecord boundary so any future producer that violates it is caught immediately. --- src/js_parser/p.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index a9da5442b27..9c276e8779d 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -4083,6 +4083,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O None }; + // `import defer` grammatically admits only `* as ns` — no default + // binding, no named clause. The parser guarantees this by + // construction; assert it here so any future S::Import producer + // that sets `phase_defer` without upholding the shape is caught + // immediately rather than surfacing as odd printer output. + debug_assert!( + !stmt.phase_defer + || (stmt.star_name_loc.is_some() + && stmt.default_name.is_none() + && stmt.items.is_empty()) + ); + stmt.import_record_index = self.add_import_record(ImportKind::Stmt, path.loc, path.text); self.import_records.items_mut()[stmt.import_record_index as usize] .flags From 6307ae8a2f22bf02c6d5459c5ff71bbc2758e1ae Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 19 May 2026 09:28:08 +0000 Subject: [PATCH 11/11] Add `--compile --bytecode` tests for `import defer` Verifies that `import defer * as ns from "..."` in the source bytecode-compiles cleanly under both --format=cjs and --format=esm, and that the compiled binary loads from the bytecode cache. The bundler inlines the deferred module into the entry chunk (documented limitation), so the test asserts the inlined-evaluation order. --- test/bundler/bundler_compile.test.ts | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index 80ad6ef6ae3..aef5c9f11fc 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -97,6 +97,40 @@ describe("bundler", () => { }, }, }); + + // `import defer * as ns from "..."` must not break bytecode generation. + // The bundler inlines the deferred module into the entry chunk (documented + // out-of-scope limitation — same as esbuild), so the defer semantics are + // lost in the compiled output; this test verifies that the syntax is + // accepted by the bundler parser, the resulting source bytecode-compiles + // cleanly in JSC, and the compiled binary loads from the bytecode cache. + for (const format of ["cjs", "esm"] as const) { + itBundled(`compile/ImportDeferBytecode+${format}`, { + compile: true, + bytecode: true, + format, + files: { + "/entry.ts": /* js */ ` + import defer * as ns from "./dep.ts"; + console.log("before"); + console.log("value:", ns.value); + `, + "/dep.ts": /* js */ ` + console.log("dep evaluated"); + export const value = 42; + `, + }, + run: { + stdout: "dep evaluated\nbefore\nvalue: 42", + env: { + BUN_JSC_verboseDiskCache: "1", + }, + validate({ stderr }) { + expect(stderr).toContain("[Disk Cache] Cache hit for sourceCode"); + }, + }, + }); + } // ESM bytecode test matrix: each scenario × {default, minified} = 2 tests per scenario. // With --compile, static imports are inlined into one chunk, but dynamic imports // create separate modules in the standalone graph — each with its own bytecode + ModuleInfo.