diff --git a/src/bun.zig b/src/bun.zig index 5a3132ed8c7..9833ba26966 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -1438,6 +1438,7 @@ pub fn sliceTo(pointer: anytype, comptime end: std.meta.Elem(@TypeOf(pointer))) pub const Semver = @import("./semver/semver.zig"); pub const ImportRecord = @import("./options_types/import_record.zig").ImportRecord; pub const ImportKind = @import("./options_types/import_record.zig").ImportKind; +pub const ImportPhase = @import("./options_types/import_record.zig").ImportPhase; pub const Watcher = @import("./watcher/Watcher.zig"); diff --git a/src/bundler/analyze_transpiled_module.zig b/src/bundler/analyze_transpiled_module.zig index 3cd6ed6e0b6..bbeab9a24f0 100644 --- a/src/bundler/analyze_transpiled_module.zig +++ b/src/bundler/analyze_transpiled_module.zig @@ -17,6 +17,10 @@ pub const RecordKind = enum(u8) { export_info_namespace, /// module_name export_info_star, + /// module_name, import_name = '*', local_name (import defer * as x from "...") + import_info_namespace_defer, + /// module_name (import defer requested-module entry) + requested_module_defer, _, pub fn len(record: RecordKind) !usize { @@ -25,10 +29,12 @@ pub const RecordKind = enum(u8) { .import_info_single => 3, .import_info_single_type_script => 3, .import_info_namespace => 3, + .import_info_namespace_defer => 3, .export_info_indirect => 3, .export_info_local => 3, .export_info_namespace => 2, .export_info_star => 1, + .requested_module_defer => 1, else => return error.InvalidRecordKind, }; } @@ -174,6 +180,7 @@ pub const ModuleInfo = struct { strings_buf: std.ArrayListUnmanaged(u8), strings_lens: std.ArrayListUnmanaged(u32), requested_modules: std.AutoArrayHashMap(StringID, FetchParameters), + requested_modules_defer: std.AutoArrayHashMapUnmanaged(StringID, void), buffer: std.ArrayListUnmanaged(StringID), record_kinds: std.ArrayListUnmanaged(RecordKind), flags: Flags, @@ -225,6 +232,17 @@ pub const ModuleInfo = struct { pub fn addImportInfoNamespace(self: *ModuleInfo, module_name: StringID, local_name: StringID) !void { try self._addRecord(.import_info_namespace, &.{ module_name, .star_namespace, local_name }); } + pub fn addImportInfoNamespaceDefer(self: *ModuleInfo, module_name: StringID, local_name: StringID) !void { + try self._addRecord(.import_info_namespace_defer, &.{ module_name, .star_namespace, local_name }); + } + pub fn addRequestedModuleDefer(self: *ModuleInfo, module_name: StringID) !void { + // JSC's ModuleAnalyzer dedups per (specifier, phase); mirror that here + // so the debug fallbackParse() diff in BunAnalyzeTranspiledModule.cpp + // agrees when the same specifier is deferred more than once. + const gop = try self.requested_modules_defer.getOrPut(self.gpa, module_name); + if (gop.found_existing) return; + try self._addRecord(.requested_module_defer, &.{module_name}); + } pub fn addExportInfoIndirect(self: *ModuleInfo, export_name: StringID, import_name: StringID, module_name: StringID) !void { if (try self._hasOrAddExportedName(export_name)) return; // a syntax error will be emitted later in this case try self._addRecord(.export_info_indirect, &.{ export_name, import_name, module_name }); @@ -259,6 +277,7 @@ pub const ModuleInfo = struct { .strings_lens = .{}, .exported_names = .{}, .requested_modules = std.AutoArrayHashMap(StringID, FetchParameters).init(allocator), + .requested_modules_defer = .{}, .buffer = .empty, .record_kinds = .empty, .flags = .{ .contains_import_meta = false, .is_typescript = is_typescript }, @@ -271,6 +290,7 @@ pub const ModuleInfo = struct { self.strings_lens.deinit(self.gpa); self.exported_names.deinit(self.gpa); self.requested_modules.deinit(); + self.requested_modules_defer.deinit(self.gpa); self.buffer.deinit(self.gpa); self.record_kinds.deinit(self.gpa); } @@ -326,6 +346,10 @@ pub const ModuleInfo = struct { if (k == .import_info_single or k == .import_info_single_type_script) { try local_name_to_module_name.put(self.buffer.items[i + 2], .{ .module_name = self.buffer.items[i], .import_name = self.buffer.items[i + 1], .record_kinds_idx = idx, .is_namespace = false }); } else if (k == .import_info_namespace) { + // Deliberately excludes .import_info_namespace_defer: a deferred + // namespace object lives in THIS module's environment, so + // `export { ns }` must stay a Local export (proposal ParseModule + // 11.a.ii). JSC's ModuleAnalyzer::exportVariable does the same. try local_name_to_module_name.put(self.buffer.items[i + 2], .{ .module_name = self.buffer.items[i], .import_name = .star_namespace, .record_kinds_idx = idx, .is_namespace = true }); } i += k.len() catch unreachable; diff --git a/src/bundler_jsc/analyze_jsc.zig b/src/bundler_jsc/analyze_jsc.zig index 566c4e0098b..1fdebc082d2 100644 --- a/src/bundler_jsc/analyze_jsc.zig +++ b/src/bundler_jsc/analyze_jsc.zig @@ -42,7 +42,7 @@ export fn zig__ModuleInfoDeserialized__toJSModuleRecord( switch (k) { .declared_variable => declared_variables.add(vm, identifiers, res.buffer[i]), .lexical_variable => lexical_variables.add(vm, identifiers, res.buffer[i]), - .import_info_single, .import_info_single_type_script, .import_info_namespace, .export_info_indirect, .export_info_local, .export_info_namespace, .export_info_star => {}, + .import_info_single, .import_info_single_type_script, .import_info_namespace, .import_info_namespace_defer, .requested_module_defer, .export_info_indirect, .export_info_local, .export_info_namespace, .export_info_star => {}, else => return null, } i += k.len() catch unreachable; // handled above @@ -70,6 +70,8 @@ export fn zig__ModuleInfoDeserialized__toJSModuleRecord( .import_info_single => module_record.addImportEntrySingle(identifiers, res.buffer[i + 1], res.buffer[i + 2], res.buffer[i]), .import_info_single_type_script => module_record.addImportEntrySingleTypeScript(identifiers, res.buffer[i + 1], res.buffer[i + 2], res.buffer[i]), .import_info_namespace => module_record.addImportEntryNamespace(identifiers, res.buffer[i + 1], res.buffer[i + 2], res.buffer[i]), + .import_info_namespace_defer => module_record.addImportEntryNamespaceDefer(identifiers, res.buffer[i + 1], res.buffer[i + 2], res.buffer[i]), + .requested_module_defer => module_record.addRequestedModuleDefer(identifiers, res.buffer[i]), .export_info_indirect => if (res.buffer[i + 1] == .star_namespace) module_record.addNamespaceExport(identifiers, res.buffer[i + 0], res.buffer[i + 2]) else @@ -138,6 +140,10 @@ const JSModuleRecord = opaque { pub const addImportEntrySingleTypeScript = JSC_JSModuleRecord__addImportEntrySingleTypeScript; extern fn JSC_JSModuleRecord__addImportEntryNamespace(module_record: *JSModuleRecord, identifier_array: *IdentifierArray, import_name: StringID, local_name: StringID, module_name: StringID) void; pub const addImportEntryNamespace = JSC_JSModuleRecord__addImportEntryNamespace; + extern fn JSC_JSModuleRecord__addImportEntryNamespaceDefer(module_record: *JSModuleRecord, identifier_array: *IdentifierArray, import_name: StringID, local_name: StringID, module_name: StringID) void; + pub const addImportEntryNamespaceDefer = JSC_JSModuleRecord__addImportEntryNamespaceDefer; + extern fn JSC_JSModuleRecord__addRequestedModuleDefer(module_record: *JSModuleRecord, identifier_array: *IdentifierArray, module_name: StringID) void; + pub const addRequestedModuleDefer = JSC_JSModuleRecord__addRequestedModuleDefer; }; const bun = @import("bun"); diff --git a/src/js_parser/ast/E.zig b/src/js_parser/ast/E.zig index ba20dd7e2ba..45508ff82f8 100644 --- a/src/js_parser/ast/E.zig +++ b/src/js_parser/ast/E.zig @@ -1399,6 +1399,9 @@ pub const Import = struct { expr: ExprNodeIndex, options: ExprNodeIndex = Expr.empty, import_record_index: u32, + // https://github.com/tc39/proposal-defer-import-eval + // https://github.com/tc39/proposal-source-phase-imports + phase: bun.ImportPhase = .evaluation, /// TODO: /// Comments inside "import()" expressions have special meaning for Webpack. diff --git a/src/js_parser/ast/Expr.zig b/src/js_parser/ast/Expr.zig index e952fe0dc14..2059ae8641b 100644 --- a/src/js_parser/ast/Expr.zig +++ b/src/js_parser/ast/Expr.zig @@ -2488,6 +2488,7 @@ pub const Data = union(Tag) { .expr = try el.expr.deepClone(allocator), .options = try el.options.deepClone(allocator), .import_record_index = el.import_record_index, + .phase = el.phase, }); return .{ .e_import = item }; }, diff --git a/src/js_parser/ast/ImportScanner.zig b/src/js_parser/ast/ImportScanner.zig index 3eba0e05a03..240ba659747 100644 --- a/src/js_parser/ast/ImportScanner.zig +++ b/src/js_parser/ast/ImportScanner.zig @@ -93,7 +93,12 @@ pub fn scan( // system and used for type-only imports). if (!keep_unused_imports) { var found_imports = false; - var is_unused_in_typescript = true; + // Non-evaluation-phase imports (`import defer * as ns` / + // `import source x`) are never considered unused: the + // deferred subgraph is still loaded/linked and TLA deps + // eagerly evaluate, so the statement has observable effects + // independent of whether the binding is referenced. + var is_unused_in_typescript = record.phase == .evaluation; if (st.default_name) |default_name| { found_imports = true; @@ -104,8 +109,11 @@ pub fn scan( 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. + // Non-evaluation phases (`import source x from ...`) keep the + // binding: the statement is pass-through and dropping it would + // change load semantics. + if (symbol.use_count_estimate == 0 and record.phase == .evaluation) { st.default_name = null; } } @@ -121,7 +129,7 @@ pub fn scan( } // Remove the symbol if it's never used outside a dead code region - if (symbol.use_count_estimate == 0) { + if (symbol.use_count_estimate == 0 and record.phase == .evaluation) { // Make sure we don't remove this if it was used for a property // access while bundling var has_any = false; @@ -198,7 +206,10 @@ pub fn scan( } const namespace_ref = st.namespace_ref; - const convert_star_to_clause = !p.options.bundle and (p.symbols.items[namespace_ref.innerIndex()].use_count_estimate == 0); + // Deferred imports always keep the namespace binding even if it + // appears unused; evaluating the module is a side effect that the + // user may be relying on via later property access. + const convert_star_to_clause = !p.options.bundle and record.phase == .evaluation and (p.symbols.items[namespace_ref.innerIndex()].use_count_estimate == 0); if (convert_star_to_clause and !keep_unused_imports) { st.star_name_loc = null; @@ -210,7 +221,10 @@ pub fn scan( ImportItemForNamespaceMap.init(allocator); if (p.options.bundle) { - if (st.star_name_loc != null and existing_items.count() > 0) { + // Lowering `ns.foo` to a direct named import bypasses the + // deferred namespace's evaluate-on-access gate, so only do + // this for evaluation-phase imports. + if (st.star_name_loc != null and existing_items.count() > 0 and record.phase == .evaluation) { const sorted = try allocator.alloc(string, existing_items.count()); defer allocator.free(sorted); for (sorted, existing_items.keys()) |*result, alias| { diff --git a/src/js_parser/ast/P.zig b/src/js_parser/ast/P.zig index 6c8ea313a7c..583d3c05afd 100644 --- a/src/js_parser/ast/P.zig +++ b/src/js_parser/ast/P.zig @@ -599,12 +599,14 @@ pub fn NewParser_( } p.import_records.items[import_record_index].flags.handles_import_errors = (state.is_await_target and p.fn_or_arrow_data_visit.try_body_count != 0) or state.is_then_catch_target; + p.import_records.items[import_record_index].phase = state.phase; p.import_records_for_current_part.append(p.allocator, import_record_index) catch unreachable; return p.newExpr(E.Import{ .expr = arg, .import_record_index = @intCast(import_record_index), .options = state.import_options, + .phase = state.phase, }, state.loc); } @@ -620,6 +622,7 @@ pub fn NewParser_( .expr = arg, .options = state.import_options, .import_record_index = std.math.maxInt(u32), + .phase = state.phase, }, state.loc); } @@ -2707,7 +2710,7 @@ pub fn NewParser_( _ = children.pop(); } - pub fn processImportStatement(p: *P, stmt_: S.Import, path: ParsedPath, loc: logger.Loc, was_originally_bare_import: bool) anyerror!Stmt { + pub fn processImportStatement(p: *P, stmt_: S.Import, path: ParsedPath, loc: logger.Loc, was_originally_bare_import: bool, phase: bun.ImportPhase) anyerror!Stmt { const is_macro = FeatureFlags.is_macro_enabled and (path.is_macro or js_ast.Macro.isMacroPath(path.text)); var stmt = stmt_; if (is_macro) { @@ -2780,6 +2783,7 @@ pub fn NewParser_( stmt.import_record_index = p.addImportRecord(.stmt, path.loc, path.text); p.import_records.items[stmt.import_record_index].flags.was_originally_bare_import = was_originally_bare_import; + p.import_records.items[stmt.import_record_index].phase = phase; if (stmt.star_name_loc) |star| { const name = p.loadNameFromRef(stmt.namespace_ref); @@ -2945,8 +2949,15 @@ pub fn NewParser_( try p.validateAndSetImportType(&path, &stmt); } - // Track the items for this namespace - try p.import_items_for_namespace.put(p.allocator, stmt.namespace_ref, item_refs); + // Track the items for this namespace so maybeRewritePropertyAccess + // can later rewrite `ns.foo` to a direct import identifier under + // bundling. Skip non-evaluation phases: a deferred namespace must + // keep its property accesses intact so evaluation is gated on + // first access; registering it here would let the visit phase + // strip the E.Dot before ImportScanner ever runs. + if (phase == .evaluation) { + try p.import_items_for_namespace.put(p.allocator, stmt.namespace_ref, item_refs); + } return p.s(stmt, loc); } diff --git a/src/js_parser/ast/parseImportExport.zig b/src/js_parser/ast/parseImportExport.zig index 862d04677ab..cae21b34f65 100644 --- a/src/js_parser/ast/parseImportExport.zig +++ b/src/js_parser/ast/parseImportExport.zig @@ -11,15 +11,30 @@ pub fn ParseImportExport( /// Note: The caller has already parsed the "import" keyword pub fn parseImportExpr(noalias p: *P, loc: logger.Loc, level: Level) anyerror!Expr { // Parse an "import.meta" expression + var phase: bun.ImportPhase = .evaluation; if (p.lexer.token == .t_dot) { - p.esm_import_keyword = js_lexer.rangeOfIdentifier(p.source, loc); try p.lexer.next(); if (p.lexer.isContextualKeyword("meta")) { + // `import.meta` is module-only syntax; mark the file as + // having ESM syntax. `import.defer()` / `import.source()` + // extend ImportCall and are valid in Script context (like + // plain `import()`), so they must not set this. + p.esm_import_keyword = js_lexer.rangeOfIdentifier(p.source, loc); try p.lexer.next(); p.has_import_meta = true; return p.newExpr(E.ImportMeta{}, loc); + } else if (p.lexer.isContextualKeyword("defer")) { + // "import.defer('path')" + // https://github.com/tc39/proposal-defer-import-eval + phase = .defer_; + try p.lexer.next(); + } else if (p.lexer.isContextualKeyword("source")) { + // "import.source('path')" + // https://github.com/tc39/proposal-source-phase-imports + phase = .source; + try p.lexer.next(); } else { - try p.lexer.expectedString("\"meta\""); + try p.lexer.expectedString("\"meta\", \"defer\", or \"source\""); } } @@ -65,12 +80,14 @@ pub fn ParseImportExport( if (comptime only_scan_imports_and_do_not_visit) { if (value.data == .e_string and value.data.e_string.isUTF8() and value.data.e_string.isPresent()) { const import_record_index = p.addImportRecord(.dynamic, value.loc, value.data.e_string.slice(p.allocator)); + p.import_records.items[import_record_index].phase = phase; return p.newExpr(E.Import{ .expr = value, // .leading_interior_comments = comments, .import_record_index = import_record_index, .options = import_options, + .phase = phase, }, loc); } } @@ -82,6 +99,7 @@ pub fn ParseImportExport( // .leading_interior_comments = comments, .import_record_index = std.math.maxInt(u32), .options = import_options, + .phase = phase, }, loc); } diff --git a/src/js_parser/ast/parseStmt.zig b/src/js_parser/ast/parseStmt.zig index d6f4e85d1a1..0e9c88ed505 100644 --- a/src/js_parser/ast/parseStmt.zig +++ b/src/js_parser/ast/parseStmt.zig @@ -1002,6 +1002,44 @@ pub fn ParseStmt( } }; try p.lexer.next(); + // "import defer * as ns from 'path'" + // "import source ns from 'path'" + // https://github.com/tc39/proposal-defer-import-eval + // https://github.com/tc39/proposal-source-phase-imports + // + // Note: "import defer from 'path'" is a default import with the + // binding "defer". Only "import defer *" triggers the phase syntax. + // + // Mirror the non-token terms of the TypeScript import-equals gate + // below so `namespace Foo { import defer * as ns ... }` and + // `export import defer * as ns ...` fall through to it and error + // like every other import form here, rather than slipping through + // as a real S.Import. + const can_be_esm_phase_import = !opts.is_export and (!opts.is_namespace_scope or opts.is_typescript_declare); + if (can_be_esm_phase_import and p.lexer.token == .t_asterisk and strings.eqlComptime(default_name, "defer")) { + stmt.default_name = null; + try p.lexer.next(); + try p.lexer.expectContextualKeyword("as"); + stmt.namespace_ref = try p.storeNameInRef(p.lexer.identifier); + stmt.star_name_loc = p.lexer.loc(); + try p.lexer.expect(.t_identifier); + try p.lexer.expectContextualKeyword("from"); + const path = try p.parsePath(); + try p.lexer.expectOrInsertSemicolon(); + return try p.processImportStatement(stmt, path, loc, false, .defer_); + } + if (can_be_esm_phase_import and p.lexer.token == .t_identifier and strings.eqlComptime(default_name, "source") and !p.lexer.isContextualKeyword("from")) { + stmt.default_name = .{ + .loc = p.lexer.loc(), + .ref = try p.storeNameInRef(p.lexer.identifier), + }; + try p.lexer.expect(.t_identifier); + try p.lexer.expectContextualKeyword("from"); + const path = try p.parsePath(); + try p.lexer.expectOrInsertSemicolon(); + return try p.processImportStatement(stmt, path, loc, false, .source); + } + if (comptime is_typescript_enabled) { // Skip over type-only imports if (strings.eqlComptime(default_name, "type")) { @@ -1093,7 +1131,7 @@ pub fn ParseStmt( const path = try p.parsePath(); try p.lexer.expectOrInsertSemicolon(); - return try p.processImportStatement(stmt, path, loc, was_originally_bare_import); + return try p.processImportStatement(stmt, path, loc, was_originally_bare_import, .evaluation); } fn t_break(p: *P, _: *ParseStatementOptions, loc: logger.Loc) anyerror!Stmt { try p.lexer.next(); diff --git a/src/js_parser/ast/repl_transforms.zig b/src/js_parser/ast/repl_transforms.zig index 22d2be74e2e..02355e50d53 100644 --- a/src/js_parser/ast/repl_transforms.zig +++ b/src/js_parser/ast/repl_transforms.zig @@ -168,10 +168,11 @@ pub fn ReplTransforms(comptime P: type) type { // import { a, b } from 'mod' -> var {a, b} = await import('mod') // import * as X from 'mod' -> var X = await import('mod') // import 'mod' -> await import('mod') - const path_str = p.import_records.items[import_data.import_record_index].path.text; + const import_record = &p.import_records.items[import_data.import_record_index]; const import_expr = p.newExpr(E.Import{ - .expr = p.newExpr(E.String{ .data = path_str }, stmt.loc), + .expr = p.newExpr(E.String{ .data = import_record.path.text }, stmt.loc), .import_record_index = std.math.maxInt(u32), + .phase = import_record.phase, }, stmt.loc); const await_expr = p.newExpr(E.Await{ .value = import_expr }, stmt.loc); diff --git a/src/js_parser/ast/visitExpr.zig b/src/js_parser/ast/visitExpr.zig index 2bf2f6532ed..15c4487f01c 100644 --- a/src/js_parser/ast/visitExpr.zig +++ b/src/js_parser/ast/visitExpr.zig @@ -1176,6 +1176,7 @@ pub fn VisitExpr( .loc = e_.expr.loc, .import_loader = e_.importRecordLoader(), + .phase = e_.phase, }; return p.import_transposer.maybeTransposeIf(e_.expr, &state); diff --git a/src/js_parser/parser.zig b/src/js_parser/parser.zig index 5d58762d2e3..8530ca4435b 100644 --- a/src/js_parser/parser.zig +++ b/src/js_parser/parser.zig @@ -217,6 +217,7 @@ pub const TransposeState = struct { import_record_tag: ?ImportRecord.Tag = null, import_loader: ?bun.options.Loader = null, import_options: Expr = Expr.empty, + phase: bun.ImportPhase = .evaluation, }; pub const JSXTag = struct { diff --git a/src/js_printer/js_printer.zig b/src/js_printer/js_printer.zig index 9e31478401d..a6969351d08 100644 --- a/src/js_printer/js_printer.zig +++ b/src/js_printer/js_printer.zig @@ -1857,7 +1857,11 @@ fn NewPrinter( // Allow it to fail at runtime, if it should if (module_type != .internal_bake_dev) { - p.print("import("); + switch (record.phase) { + .evaluation => p.print("import("), + .defer_ => p.print("import.defer("), + .source => p.print("import.source("), + } p.printImportRecordPath(record); } else { p.printSymbol(p.options.hmr_ref); @@ -2425,7 +2429,11 @@ fn NewPrinter( p.printSymbol(p.options.hmr_ref); p.print(".dynamicImport("); } else { - p.print("import("); + switch (e.phase) { + .evaluation => p.print("import("), + .defer_ => p.print("import.defer("), + .source => p.print("import.source("), + } } // TODO: // if (e.leading_interior_comments.len > 0) { @@ -4588,6 +4596,11 @@ fn NewPrinter( } p.print("import"); + switch (record.phase) { + .evaluation => {}, + .defer_ => p.print(" defer"), + .source => p.print(" source"), + } var item_count: usize = 0; @@ -4711,7 +4724,15 @@ fn NewPrinter( .json5 => analyze_transpiled_module.ModuleInfo.FetchParameters.hostDefined(bun.handleOom(mi.str("json5"))), .md => analyze_transpiled_module.ModuleInfo.FetchParameters.hostDefined(bun.handleOom(mi.str("md"))), } else .none) else .none; - bun.handleOom(mi.requestModule(irp_id, fetch_parameters)); + if (record.phase == .defer_) { + // Deferred imports are only namespace imports and carry no + // loader attribute today. Keep the requested-module list in + // sync with JSC's ModuleAnalyzer, which records a separate + // defer-phase request. + bun.handleOom(mi.addRequestedModuleDefer(irp_id)); + } else { + bun.handleOom(mi.requestModule(irp_id, fetch_parameters)); + } if (s.default_name) |name| { const local_name = p.renamer.nameForSymbol(name.ref.?); @@ -4733,7 +4754,11 @@ fn NewPrinter( if (record.flags.contains_import_star) { const local_name = p.renamer.nameForSymbol(s.namespace_ref); bun.handleOom(mi.addVar(bun.handleOom(mi.str(local_name)), .lexical)); - bun.handleOom(mi.addImportInfoNamespace(irp_id, bun.handleOom(mi.str(local_name)))); + if (record.phase == .defer_) { + bun.handleOom(mi.addImportInfoNamespaceDefer(irp_id, bun.handleOom(mi.str(local_name)))); + } else { + bun.handleOom(mi.addImportInfoNamespace(irp_id, bun.handleOom(mi.str(local_name)))); + } } } } diff --git a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp index f33f9469222..9e4c72a93ff 100644 --- a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp +++ b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp @@ -146,6 +146,32 @@ 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) +{ + // JSC_HAS_IMPORT_DEFER is defined by the WebKit revision that adds + // ModulePhase. Until the prebuilt WebKit is bumped to include it, fall + // back to an evaluation-phase namespace entry so this translation unit + // still compiles; the printer will still emit "import defer * as ..." + // and JSC's own parser will set the phase when re-analyzing. + moduleRecord->addImportEntry(JSModuleRecord::ImportEntry { + .type = JSModuleRecord::ImportEntryType::Namespace, + .moduleRequest = getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), + .importName = getFromIdentifierArray(moduleRecord->vm(), identifierArray, importName), + .localName = getFromIdentifierArray(moduleRecord->vm(), identifierArray, localName), +#ifdef JSC_HAS_IMPORT_DEFER + .phase = JSModuleRecord::ModulePhase::Defer, +#endif + }); +} +extern "C" void JSC_JSModuleRecord__addRequestedModuleDefer(JSModuleRecord* moduleRecord, Identifier* identifierArray, uint32_t moduleName) +{ + RefPtr attributes = RefPtr {}; +#ifdef JSC_HAS_IMPORT_DEFER + moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes), JSModuleRecord::ModulePhase::Defer); +#else + moduleRecord->appendRequestedModule(getFromIdentifierArray(moduleRecord->vm(), identifierArray, moduleName), std::move(attributes)); +#endif +} 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) @@ -272,10 +298,15 @@ String dumpRecordInfo(JSModuleRecord* moduleRecord) Vector sortedDeps; for (const auto& request : moduleRecord->requestedModules()) { WTF::StringPrintStream line; +#ifdef JSC_HAS_IMPORT_DEFER + const char* phase = request.m_phase == AbstractModuleRecord::ModulePhase::Defer ? "defer" : "evaluation"; +#else + const char* phase = "evaluation"; +#endif if (request.m_attributes == nullptr) - line.print(" module(", request.m_specifier, ")\n"); + line.print(" module(", request.m_specifier, "),phase(", phase, ")\n"); 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, "),phase(", phase, "),attributes(", (uint8_t)request.m_attributes->type(), ", ", request.m_attributes->hostDefinedImportType(), ")\n"); sortedDeps.append(line.toString()); } std::sort(sortedDeps.begin(), sortedDeps.end(), [](const String& a, const String& b) { @@ -291,7 +322,12 @@ 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"); +#ifdef JSC_HAS_IMPORT_DEFER + const char* phase = importEntry.phase == AbstractModuleRecord::ModulePhase::Defer ? "defer" : "evaluation"; +#else + const char* phase = "evaluation"; +#endif + line.print(" import(", importEntry.importName, "), local(", importEntry.localName, "), module(", importEntry.moduleRequest, "), phase(", phase, ")\n"); sortedImports.append(line.toString()); } std::sort(sortedImports.begin(), sortedImports.end(), [](const String& a, const String& b) { diff --git a/src/options_types/import_record.zig b/src/options_types/import_record.zig index bc7c6121874..b80f5b857b6 100644 --- a/src/options_types/import_record.zig +++ b/src/options_types/import_record.zig @@ -97,6 +97,22 @@ pub const ImportKind = enum(u8) { } }; +/// https://github.com/tc39/proposal-defer-import-eval +/// https://github.com/tc39/proposal-source-phase-imports +pub const ImportPhase = enum(u2) { + evaluation = 0, + defer_ = 1, + source = 2, + + pub fn keyword(this: ImportPhase) []const u8 { + return switch (this) { + .evaluation => "", + .defer_ => "defer", + .source => "source", + }; + } +}; + pub const ImportRecord = struct { pub const Index = bun.GenericIndex(u32, ImportRecord); @@ -104,6 +120,7 @@ pub const ImportRecord = struct { path: fs.Path, kind: ImportKind, tag: Tag = .none, + phase: ImportPhase = .evaluation, loader: ?bun.options.Loader = null, source_index: bun.ast.Index = .invalid, diff --git a/test/js/bun/import-defer/import-defer.test.ts b/test/js/bun/import-defer/import-defer.test.ts new file mode 100644 index 00000000000..7d797e4e78e --- /dev/null +++ b/test/js/bun/import-defer/import-defer.test.ts @@ -0,0 +1,405 @@ +// Tests for the TC39 Stage 3 "import defer" proposal. +// https://github.com/tc39/proposal-defer-import-eval +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +async function run(files: Record, entry = "main.mjs") { + 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: stdout.trim(), stderr: stderr.trim(), exitCode }; +} + +// The runtime semantics live in JavaScriptCore. Until the oven-sh/WebKit +// revision that adds ModulePhase (oven-sh/WebKit#206) is picked up by the +// prebuilt WebKit used in CI, probe for it by running a minimal deferred +// import and skip the runtime suites if it doesn't parse. The +// transpiler-only tests below are unconditional. +const runtimeSupported = await (async () => { + const { exitCode } = await run({ + "main.mjs": `import defer * as x from "./empty.mjs"; void x;`, + "empty.mjs": `export {};`, + }); + return exitCode === 0; +})(); +const describeRuntime = runtimeSupported ? describe.concurrent : describe.skip; + +describeRuntime("import defer (static)", () => { + test("module is not evaluated until a property is accessed", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.mjs": ` + globalThis.order = []; + import defer * as ns from "./dep.mjs"; + order.push("main"); + console.log(order.join(",")); + // trigger evaluation + void ns.value; + console.log(order.join(",")); + // second access does not re-evaluate + void ns.value; + console.log(order.join(",")); + `, + "dep.mjs": ` + globalThis.order.push("dep"); + export const value = 42; + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe(["main", "main,dep", "main,dep"].join("\n")); + expect(exitCode).toBe(0); + }); + + test("transitive sync dependencies are also deferred", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.mjs": ` + globalThis.order = []; + import defer * as ns from "./a.mjs"; + order.push("main"); + console.log(order.join(",")); + void ns.value; + console.log(order.join(",")); + `, + "a.mjs": ` + import "./b.mjs"; + globalThis.order.push("a"); + export const value = 1; + `, + "b.mjs": ` + globalThis.order.push("b"); + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe(["main", "main,b,a"].join("\n")); + expect(exitCode).toBe(0); + }); + + test("module already evaluated via another import is not re-evaluated", async () => { + const { stdout, stderr, exitCode } = await run({ + "setup.mjs": `globalThis.order = [];`, + "main.mjs": ` + import "./setup.mjs"; + import "./dep.mjs"; + import defer * as ns from "./dep.mjs"; + order.push("main"); + void ns.value; + console.log(order.join(",")); + `, + "dep.mjs": ` + globalThis.order.push("dep"); + export const value = 42; + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe("dep,main"); + expect(exitCode).toBe(0); + }); + + test("same specifier deferred twice produces a single requested-module entry", async () => { + // ModuleInfo must dedup defer-phase requests per specifier to match + // JSC's ModuleAnalyzer; in debug builds a mismatch fails the + // fallbackParse() diff in BunAnalyzeTranspiledModule.cpp. + const { stdout, stderr, exitCode } = await run({ + "main.mjs": ` + globalThis.order = []; + import defer * as a from "./dep.mjs"; + import defer * as b from "./dep.mjs"; + order.push("main"); + console.log(a === b); + console.log(order.join(",")); + void a.value; + console.log(order.join(",")); + `, + "dep.mjs": ` + globalThis.order.push("dep"); + export const value = 42; + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe(["true", "main", "main,dep"].join("\n")); + expect(exitCode).toBe(0); + }); + + test("deferred namespace has @@toStringTag 'Deferred Module' and hides 'then'", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.mjs": ` + import defer * as ns from "./dep.mjs"; + console.log(ns[Symbol.toStringTag]); + console.log(Object.prototype.toString.call(ns)); + console.log(typeof ns.then); + console.log(Object.keys(ns).includes("then")); + `, + "dep.mjs": ` + export const value = 1; + export function then(resolve) { resolve("bad"); } + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe(["Deferred Module", "[object Deferred Module]", "undefined", "false"].join("\n")); + expect(exitCode).toBe(0); + }); + + test("deferred namespace is distinct from regular namespace", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.mjs": ` + import defer * as deferred from "./dep.mjs"; + import * as regular from "./dep.mjs"; + console.log(deferred === regular); + console.log(regular[Symbol.toStringTag]); + console.log(deferred[Symbol.toStringTag]); + `, + "dep.mjs": ` + export const value = 1; + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe(["false", "Module", "Deferred Module"].join("\n")); + expect(exitCode).toBe(0); + }); + + test("throwing module rethrows on every access", async () => { + const { stdout, exitCode } = await run({ + "main.mjs": ` + import defer * as ns from "./dep.mjs"; + for (let i = 0; i < 2; i++) { + try { + void ns.value; + console.log("no throw"); + } catch (e) { + console.log("caught", e.message); + } + } + `, + "dep.mjs": ` + throw new Error("boom"); + export const value = 1; + `, + }); + expect(stdout).toBe(["caught boom", "caught boom"].join("\n")); + expect(exitCode).toBe(0); + }); + + test("async transitive dependency is eagerly evaluated", async () => { + // Per spec: TLA modules in a deferred subgraph are evaluated eagerly + // (so that property-access evaluation can be synchronous). + const { stdout, stderr, exitCode } = await run({ + "setup.mjs": `globalThis.order = [];`, + "main.mjs": ` + import "./setup.mjs"; + import defer * as ns from "./a.mjs"; + order.push("main"); + console.log(order.join(",")); + void ns.value; + console.log(order.join(",")); + `, + "a.mjs": ` + import "./b.mjs"; + globalThis.order.push("a"); + export const value = 1; + `, + "b.mjs": ` + await 0; + globalThis.order.push("b"); + `, + }); + expect(stderr).toBe(""); + // b has TLA so it is eagerly evaluated; a is deferred. + expect(stdout).toBe(["b,main", "b,main,a"].join("\n")); + expect(exitCode).toBe(0); + }); + + test("re-exporting a deferred namespace preserves deferred semantics", async () => { + // `import defer * as ns; export { ns }` must export the DEFERRED namespace + // object (a Local export per proposal ParseModule 11.a.ii), not a regular + // namespace re-export of the target module. + const { stdout, stderr, exitCode } = await run({ + "setup.mjs": `globalThis.order = [];`, + "main.mjs": ` + import "./setup.mjs"; + import { ns } from "./reexport.mjs"; + order.push("main"); + console.log(ns[Symbol.toStringTag]); + console.log(order.join(",")); + void ns.value; + console.log(order.join(",")); + `, + "reexport.mjs": ` + import "./setup.mjs"; + import defer * as ns from "./dep.mjs"; + globalThis.order.push("reexport"); + export { ns }; + `, + "dep.mjs": ` + globalThis.order.push("dep"); + export const value = 42; + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe(["Deferred Module", "reexport,main", "reexport,main,dep"].join("\n")); + expect(exitCode).toBe(0); + }); +}); + +// These exercise Bun's parser disambiguation only and do not require the +// WebKit defer-phase patch: `import defer from` stays evaluation-phase, and +// `import defer {x}` fails in Bun's parser before reaching JSC. +describe("syntax disambiguation", () => { + test("'import defer from' is a default import named 'defer' (back-compat)", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.mjs": ` + import defer from "./dep.mjs"; + console.log(defer); + `, + "dep.mjs": ` + export default "hello"; + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe("hello"); + expect(exitCode).toBe(0); + }); + + test("'import defer {x}' is a syntax error", async () => { + const { exitCode, stderr } = await run({ + "main.mjs": ` + import defer { x } from "./dep.mjs"; + `, + "dep.mjs": `export const x = 1;`, + }); + expect(stderr).toContain("error"); + expect(exitCode).not.toBe(0); + }); +}); + +describe("import.defer() (dynamic)", () => { + test("transpiler preserves import.defer()", async () => { + const transpiler = new Bun.Transpiler({ loader: "js" }); + const out = await transpiler.transform(`const ns = import.defer("./x.js");`); + expect(out).toContain("import.defer("); + }); + + test.skipIf(!runtimeSupported)("returns a deferred namespace object", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.mjs": ` + const ns = await import.defer("./dep.mjs"); + console.log(ns[Symbol.toStringTag]); + console.log(Object.prototype.toString.call(ns)); + console.log(typeof ns.then); + console.log(ns.value); + `, + "dep.mjs": ` + export const value = 42; + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe(["Deferred Module", "[object Deferred Module]", "undefined", "42"].join("\n")); + expect(exitCode).toBe(0); + }); + + // The dynamic form currently evaluates the module body eagerly; per spec + // only async transitive dependencies should run during the import.defer() + // call. Full lazy evaluation requires threading ModulePhase through + // ContinueDynamicImport and the moduleLoaderImportModule embedder hook. + test.todo("module is not evaluated until a property is accessed (dynamic)", async () => { + const { stdout, stderr, exitCode } = await run({ + "main.mjs": ` + globalThis.order = []; + const ns = await import.defer("./dep.mjs"); + order.push("main"); + console.log(order.join(",")); + void ns.value; + console.log(order.join(",")); + `, + "dep.mjs": ` + globalThis.order.push("dep"); + export const value = 42; + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe(["main", "main,dep"].join("\n")); + expect(exitCode).toBe(0); + }); +}); + +describe("transpiler", () => { + test("static import defer round-trips", async () => { + const transpiler = new Bun.Transpiler({ loader: "js" }); + const out = await transpiler.transform(`import defer * as ns from "./x.js"; console.log(ns.value);`); + expect(out).toContain("import defer"); + expect(out).toContain("* as ns"); + }); + + test("import.source round-trips", async () => { + const transpiler = new Bun.Transpiler({ loader: "js" }); + const out = await transpiler.transform(`const m = import.source("./x.wasm");`); + expect(out).toContain("import.source("); + }); + + test("unused 'import defer * as ns' is not stripped (TypeScript)", async () => { + const transpiler = new Bun.Transpiler({ loader: "ts" }); + const out = await transpiler.transform(`import defer * as ns from "./x.js";\nexport const y = 1;`); + expect(out).toContain("import defer"); + }); + + test("unused 'import source x' is not stripped (TypeScript)", async () => { + const transpiler = new Bun.Transpiler({ loader: "ts" }); + const out = await transpiler.transform(`import source x from "./x.wasm";\nexport const y = 1;`); + expect(out).toContain("import source"); + }); + + test("phase imports reject inside TS namespace / after export like other import forms", async () => { + // Line ~1084 in parseStmt.zig routes `import ...` inside a + // non-declare TS namespace (or after `export import`) to + // parseTypeScriptImportEqualsStmt, which expects `=` and errors. The + // defer/source early-return branches must not bypass that gate. + const ts = new Bun.Transpiler({ loader: "ts" }); + // Baseline: plain `import ns from "x"` inside a namespace is rejected. + await expect(ts.transform(`namespace Foo { import bar from "./x"; }`)).rejects.toThrow(); + for (const src of [ + `namespace Foo { import defer * as ns from "./x"; }`, + `namespace Foo { import source x from "./x"; }`, + `export import defer * as ns from "./x";`, + `export import source x from "./x";`, + ]) { + await expect(ts.transform(src)).rejects.toThrow(); + } + // But inside a `declare namespace` (ambient), the import-equals gate + // is skipped, so phase imports are accepted (mirroring the baseline). + expect(await ts.transform(`declare namespace Foo { import defer * as ns from "./x"; }`)).toBeDefined(); + }); + + test("REPL transform preserves phase when lowering static import to dynamic", async () => { + // repl_transforms.zig rewrites `import * as X from "m"` -> `var X = await import("m")`. + // It must thread the import record's phase through to the synthesized + // E.Import so `import defer * as ns` lowers to `await import.defer(...)`. + const repl = new Bun.Transpiler({ loader: "ts", replMode: true }); + const out = await repl.transform(`import defer * as ns from "./x.js"; ns.value;`); + expect(out).toContain("import.defer("); + }); + + test("import.defer()/import.source() do not force ESM detection", async () => { + // import.defer() and import.source() extend ImportCall, which is valid in + // Script context (like plain `import()`). Unlike `import.meta`, their + // presence must not mark the file as having ESM syntax. Observable via + // the top-level `this` rewrite: in an otherwise-ambiguous .js input, + // ESM detection rewrites top-level `this` away from the CJS `exports` + // binding. + const transpiler = new Bun.Transpiler({ loader: "js" }); + const plain = await transpiler.transform(`console.log(this);\nconst m = import("./x");`); + const meta = await transpiler.transform(`console.log(this);\nconst m = import.meta;`); + // Baseline: plain import() keeps CJS `this`, import.meta flips to ESM. + expect(plain).toContain("console.log(exports)"); + expect(meta).not.toContain("console.log(exports)"); + // Phase calls must behave like plain import(), not like import.meta. + for (const phase of ["defer", "source"]) { + const out = await transpiler.transform(`console.log(this);\nconst m = import.${phase}("./x");`); + expect(out).toContain("console.log(exports)"); + expect(out).toContain(`import.${phase}(`); + } + }); +});