diff --git a/src/ast/S.zig b/src/ast/S.zig index e47c3f223a..47b4f346fa 100644 --- a/src/ast/S.zig +++ b/src/ast/S.zig @@ -163,6 +163,7 @@ pub const Import = struct { star_name_loc: ?logger.Loc = null, import_record_index: u32, is_single_line: bool = false, + is_deferred: bool = false, }; pub const Return = struct { value: ?ExprNodeIndex = null }; diff --git a/src/import_record.zig b/src/import_record.zig index f5c5b43195..70ea8dfbf6 100644 --- a/src/import_record.zig +++ b/src/import_record.zig @@ -134,6 +134,9 @@ pub const ImportRecord = struct { /// out to be type-only imports after analyzing the whole file. is_unused: bool = false, + /// True if this import uses the "defer" keyword + is_deferred: bool = false, + /// If this is true, the import contains syntax like "* as ns". This is used /// to determine whether modules that have no exports need to be wrapped in a /// CommonJS wrapper or not. diff --git a/src/js_parser.zig b/src/js_parser.zig index e55e9227f2..f2a15a8c79 100644 --- a/src/js_parser.zig +++ b/src/js_parser.zig @@ -8932,6 +8932,7 @@ fn NewParser_( stmt.import_record_index = p.addImportRecord(.stmt, path.loc, path.text); p.import_records.items[stmt.import_record_index].was_originally_bare_import = was_originally_bare_import; + p.import_records.items[stmt.import_record_index].is_deferred = stmt.is_deferred; if (stmt.star_name_loc) |star| { const name = p.loadNameFromRef(stmt.namespace_ref); @@ -8993,6 +8994,7 @@ fn NewParser_( p.import_records.items[new_import_id].path.namespace = js_ast.Macro.namespace; p.import_records.items[new_import_id].is_unused = true; + p.import_records.items[new_import_id].is_deferred = stmt.is_deferred; if (comptime only_scan_imports_and_do_not_visit) { p.import_records.items[new_import_id].is_internal = true; p.import_records.items[new_import_id].path.is_disabled = true; @@ -9052,6 +9054,7 @@ fn NewParser_( p.import_records.items[new_import_id].path.namespace = js_ast.Macro.namespace; p.import_records.items[new_import_id].is_unused = true; + p.import_records.items[new_import_id].is_deferred = stmt.is_deferred; if (comptime only_scan_imports_and_do_not_visit) { p.import_records.items[new_import_id].is_internal = true; p.import_records.items[new_import_id].path.is_disabled = true; @@ -10318,6 +10321,12 @@ fn NewParser_( }; var was_originally_bare_import = false; + // Check for "import defer" syntax + if (p.lexer.isContextualKeyword("defer")) { + stmt.is_deferred = true; + try p.lexer.next(); + } + // "export import foo = bar" if ((opts.is_export or (opts.is_namespace_scope and !opts.is_typescript_declare)) and p.lexer.token != .t_identifier) { try p.lexer.expected(.t_identifier); @@ -10343,7 +10352,7 @@ fn NewParser_( was_originally_bare_import = true; }, .t_asterisk => { - // "import * as ns from 'path'" + // "import * as ns from 'path'" or "import defer * as ns from 'path'" if (!opts.is_module_scope and (!opts.is_namespace_scope or !opts.is_typescript_declare)) { try p.lexer.unexpected(); return error.SyntaxError; @@ -10355,6 +10364,7 @@ fn NewParser_( .namespace_ref = try p.storeNameInRef(p.lexer.identifier), .star_name_loc = p.lexer.loc(), .import_record_index = std.math.maxInt(u32), + .is_deferred = stmt.is_deferred, }; try p.lexer.expect(.t_identifier); try p.lexer.expectContextualKeyword("from"); @@ -10486,6 +10496,12 @@ fn NewParser_( }, } + // Validate that deferred imports are only allowed with star imports + if (stmt.is_deferred and stmt.star_name_loc == null) { + try p.log.addError(p.source, loc, "The 'defer' keyword can only be used with star imports: import defer * as ns from 'module'"); + return error.SyntaxError; + } + const path = try p.parsePath(); try p.lexer.expectOrInsertSemicolon(); diff --git a/test/js/bun/import-defer.test.ts b/test/js/bun/import-defer.test.ts new file mode 100644 index 0000000000..77980fc8de --- /dev/null +++ b/test/js/bun/import-defer.test.ts @@ -0,0 +1,210 @@ +import { test, expect, describe } from "bun:test"; +import { tempDirWithFiles, bunExe, bunEnv } from "harness"; + +describe("import defer", () => { + test("should parse import defer syntax", async () => { + const dir = tempDirWithFiles("import-defer", { + "utils.js": ` + export const helper = () => "hello"; + export const counter = { value: 0 }; + `, + "main.js": ` + import defer * as utils from "./utils.js"; + + // Module should be loaded but not evaluated yet + export function getUtils() { + return utils; // This should trigger evaluation + } + + export function checkCounter() { + return utils.counter.value; + } + `, + }); + + const proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const error = await proc.stderr.text(); + const exitCode = await proc.exited; + + expect(exitCode).toBe(0); + expect(error).toBe(""); + }); + + test("should only allow defer with star imports", async () => { + const dir = tempDirWithFiles("import-defer-error", { + "utils.js": `export const helper = () => "hello";`, + "main.js": ` + import defer { helper } from "./utils.js"; // This should be an error + console.log(helper()); + `, + }); + + const proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const error = await proc.stderr.text(); + const exitCode = await proc.exited; + + expect(exitCode).toBe(1); + expect(error).toContain("The 'defer' keyword can only be used with star imports"); + }); + + test("should defer module evaluation until property access", async () => { + const dir = tempDirWithFiles("import-defer-eval", { + "side-effect.js": ` + console.log("Module evaluated!"); + export const value = 42; + `, + "main.js": ` + import defer * as sideEffect from "./side-effect.js"; + + console.log("Before access"); + const result = sideEffect.value; // Should trigger evaluation here + console.log("After access:", result); + `, + }); + + const proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const output = await proc.stdout.text(); + const error = await proc.stderr.text(); + const exitCode = await proc.exited; + + expect(exitCode).toBe(0); + expect(error).toBe(""); + + // The order should be: "Before access", then "Module evaluated!", then "After access: 42" + const lines = output.trim().split("\n"); + expect(lines[0]).toBe("Before access"); + expect(lines[1]).toBe("Module evaluated!"); + expect(lines[2]).toBe("After access: 42"); + }); + + test("should not defer modules with top-level await", async () => { + const dir = tempDirWithFiles("import-defer-await", { + "async-module.js": ` + await new Promise(resolve => setTimeout(resolve, 1)); + export const value = "async"; + `, + "main.js": ` + import defer * as asyncModule from "./async-module.js"; // Should this be an error? + console.log(asyncModule.value); + `, + }); + + const proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const error = await proc.stderr.text(); + const exitCode = await proc.exited; + + // According to the TC39 spec, modules with top-level await cannot be deferred + // The implementation should either reject this or handle it appropriately + expect(exitCode).toBe(1); + }); + + test("should handle re-exports from deferred modules", async () => { + const dir = tempDirWithFiles("import-defer-reexport", { + "base.js": ` + export const baseValue = "base"; + `, + "reexport.js": ` + export { baseValue } from "./base.js"; + export const reexportValue = "reexport"; + `, + "main.js": ` + import defer * as reexport from "./reexport.js"; + + console.log(reexport.baseValue); + console.log(reexport.reexportValue); + `, + }); + + const proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const output = await proc.stdout.text(); + const error = await proc.stderr.text(); + const exitCode = await proc.exited; + + expect(exitCode).toBe(0); + expect(error).toBe(""); + expect(output).toContain("base"); + expect(output).toContain("reexport"); + }); + + test("should handle circular dependencies with deferred imports", async () => { + const dir = tempDirWithFiles("import-defer-circular", { + "a.js": ` + import defer * as b from "./b.js"; + export const aValue = "a"; + export function getB() { + return b.bValue; + } + `, + "b.js": ` + import defer * as a from "./a.js"; + export const bValue = "b"; + export function getA() { + return a.aValue; + } + `, + "main.js": ` + import * as a from "./a.js"; + import * as b from "./b.js"; + + console.log(a.aValue); + console.log(b.bValue); + console.log(a.getB()); + console.log(b.getA()); + `, + }); + + const proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const output = await proc.stdout.text(); + const error = await proc.stderr.text(); + const exitCode = await proc.exited; + + expect(exitCode).toBe(0); + expect(error).toBe(""); + + const lines = output.trim().split("\n"); + expect(lines).toContain("a"); + expect(lines).toContain("b"); + }); +});