From 79b61c480f755072a5bba7ce58a1f33fdcc78229 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 21 May 2026 11:14:08 -0700 Subject: [PATCH] Support source-phase imports (`import source` / `import defer` and dynamic `import.source(...)` / `import.defer(...)`) (#1682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add support for source-phase imports (`import source`, `import.source(...)`) Implements the TC39 source-phase imports proposal (https://github.com/tc39/proposal-source-phase-imports) for both static and dynamic forms: // static import source wasmModule from "./foo.wasm"; import defer * as ns from "./mod.js"; // dynamic const m = await import.source("./foo.wasm"); const m = await import.defer("./mod.js"); Without this, terser dropped the phase keyword on the mozilla-AST round-trip used by tools like Emscripten's acorn-optimizer, silently changing runtime semantics. Static side: * Parser recognises the contextual `source`/`defer` phase keyword after `import`, with peek-based disambiguation so existing patterns like `import source from "x"` keep parsing as default-name imports. * AST_Import gains a `phase` field (null for plain imports). * mozilla-ast and the printer carry phase through. Dynamic side: * New AST_DynamicImport node owns the whole `import.source(...)` / `import.defer(...)` expression, including its args. It is NOT an AST_Call. Plain `import(x)` is still parsed as an AST_Call with a synthetic `import` SymbolRef callee. * mozilla-ast: ImportExpression with `phase` round-trips to/from AST_DynamicImport. * size, equivalent-to and has_side_effects/may_throw updated. * address review feedback: tidy comments and use (x || null) === pattern * Update test/compress/harmony.js * Update test/compress/harmony.js --------- Co-authored-by: Fábio Santos --- lib/ast.js | 39 ++++++++- lib/compress/drop-side-effect-free.js | 7 ++ lib/compress/inference.js | 5 ++ lib/equivalent-to.js | 9 +- lib/mozilla-ast.js | 56 ++++++++---- lib/output.js | 14 +++ lib/parse.js | 35 +++++++- lib/size.js | 6 ++ test/compress/harmony.js | 86 +++++++++++++++++++ test/mocha/source-phase-imports.js | 118 ++++++++++++++++++++++++++ 10 files changed, 356 insertions(+), 19 deletions(-) create mode 100644 test/mocha/source-phase-imports.js diff --git a/lib/ast.js b/lib/ast.js index 3743e3633..745e7a319 100644 --- a/lib/ast.js +++ b/lib/ast.js @@ -1504,9 +1504,10 @@ var AST_NameMapping = DEFNODE("NameMapping", "foreign_name name", function AST_N var AST_Import = DEFNODE( "Import", - "imported_name imported_names module_name assert_clause", + "phase imported_name imported_names module_name assert_clause", function AST_Import(props) { if (props) { + this.phase = props.phase; this.imported_name = props.imported_name; this.imported_names = props.imported_names; this.module_name = props.module_name; @@ -1520,6 +1521,7 @@ var AST_Import = DEFNODE( { $documentation: "An `import` statement", $propdoc: { + phase: "[string?] Phase keyword: 'source', 'defer', or null.", imported_name: "[AST_SymbolImport] The name of the variable holding the module's default export.", imported_names: "[AST_NameMapping*] The names of non-default imported variables", module_name: "[AST_String] String literal describing where this module came from", @@ -1560,6 +1562,40 @@ var AST_ImportMeta = DEFNODE("ImportMeta", null, function AST_ImportMeta(props) $documentation: "A reference to import.meta", }); +var AST_DynamicImport = DEFNODE( + "DynamicImport", + "phase args", + function AST_DynamicImport(props) { + if (props) { + this.phase = props.phase; + this.args = props.args; + this.start = props.start; + this.end = props.end; + } + + this.flags = 0; + }, + { + $documentation: "A phased dynamic import expression: `import.source(specifier [, options])` or `import.defer(specifier [, options])`. Plain `import(x)` continues to be parsed as an AST_Call with a synthetic `import` SymbolRef callee.", + $propdoc: { + phase: "[string] Phase keyword ('source' or 'defer').", + args: "[AST_Node*] specifier followed by optional options argument" + }, + _walk: function(visitor) { + return visitor._visit(this, function() { + var args = this.args; + for (var i = 0, len = args.length; i < len; i++) { + args[i]._walk(visitor); + } + }); + }, + _children_backwards(push) { + let i = this.args.length; + while (i--) push(this.args[i]); + }, + } +); + var AST_Export = DEFNODE( "Export", "exported_definition exported_value is_default exported_names module_name assert_clause", @@ -3264,6 +3300,7 @@ export { AST_Function, AST_Hole, AST_If, + AST_DynamicImport, AST_Import, AST_ImportMeta, AST_Infinity, diff --git a/lib/compress/drop-side-effect-free.js b/lib/compress/drop-side-effect-free.js index 955caa6ff..9c5c9ca9e 100644 --- a/lib/compress/drop-side-effect-free.js +++ b/lib/compress/drop-side-effect-free.js @@ -57,6 +57,7 @@ import { AST_Constant, AST_DefClass, AST_Dot, + AST_DynamicImport, AST_Expansion, AST_Function, AST_Node, @@ -147,6 +148,12 @@ def_drop_side_effect_free(AST_Call, function (compressor, first_in_statement) { return args && make_sequence(this, args); }); +def_drop_side_effect_free(AST_DynamicImport, function (compressor, first_in_statement) { + if (this.phase !== "source") return this; + var args = trim(this.args, compressor, first_in_statement); + return args && make_sequence(this, args); +}); + def_drop_side_effect_free(AST_Accessor, return_null); def_drop_side_effect_free(AST_Function, return_null); diff --git a/lib/compress/inference.js b/lib/compress/inference.js index 0fed66b4e..ffcdfdf49 100644 --- a/lib/compress/inference.js +++ b/lib/compress/inference.js @@ -68,6 +68,7 @@ import { AST_Function, AST_If, AST_Import, + AST_DynamicImport, AST_ImportMeta, AST_Jump, AST_LabeledStatement, @@ -314,6 +315,10 @@ export function is_nullish(node, compressor) { || this.alternative && this.alternative.has_side_effects(compressor); }); def_has_side_effects(AST_ImportMeta, return_false); + def_has_side_effects(AST_DynamicImport, function() { + // `import.source(x)` only compiles the module, which is side-effect free + return this.phase !== "source"; + }); def_has_side_effects(AST_LabeledStatement, function(compressor) { return this.body.has_side_effects(compressor); }); diff --git a/lib/equivalent-to.js b/lib/equivalent-to.js index 33596158b..33fdf4785 100644 --- a/lib/equivalent-to.js +++ b/lib/equivalent-to.js @@ -28,6 +28,7 @@ import { AST_ForOf, AST_If, AST_Import, + AST_DynamicImport, AST_ImportMeta, AST_Jump, AST_LabeledStatement, @@ -190,11 +191,17 @@ AST_VarDef.prototype.shallow_cmp = function(other) { AST_NameMapping.prototype.shallow_cmp = pass_through; AST_Import.prototype.shallow_cmp = function(other) { - return (this.imported_name == null ? other.imported_name == null : this.imported_name === other.imported_name) && (this.imported_names == null ? other.imported_names == null : this.imported_names === other.imported_names); + return (this.imported_name || null) === (other.imported_name || null) + && (this.imported_names || null) === (other.imported_names || null) + && (this.phase || null) === (other.phase || null); }; AST_ImportMeta.prototype.shallow_cmp = pass_through; +AST_DynamicImport.prototype.shallow_cmp = function(other) { + return (this.phase || null) === (other.phase || null) && this.args.length === other.args.length; +}; + AST_Export.prototype.shallow_cmp = function(other) { return (this.exported_definition == null ? other.exported_definition == null : this.exported_definition === other.exported_definition) && (this.exported_value == null ? other.exported_value == null : this.exported_value === other.exported_value) && (this.exported_names == null ? other.exported_names == null : this.exported_names === other.exported_names) && this.module_name === other.module_name && this.is_default === other.is_default; }; diff --git a/lib/mozilla-ast.js b/lib/mozilla-ast.js index 15e7103c9..3387af031 100644 --- a/lib/mozilla-ast.js +++ b/lib/mozilla-ast.js @@ -91,6 +91,7 @@ import { AST_Function, AST_Hole, AST_If, + AST_DynamicImport, AST_Import, AST_ImportMeta, AST_Label, @@ -590,7 +591,8 @@ import { is_basic_identifier_string } from "./parse.js"; imported_name: imported_name, imported_names : imported_names, module_name : from_moz(M.source), - assert_clause: assert_clause_from_moz(M.assertions) + assert_clause: assert_clause_from_moz(M.assertions), + phase: M.phase || null }); }, @@ -616,6 +618,31 @@ import { is_basic_identifier_string } from "./parse.js"; }); }, + ImportExpression: function(M) { + const args = [from_moz(M.source)]; + if (M.options) { + args.push(from_moz(M.options)); + } + if (M.phase) { + return new AST_DynamicImport({ + start: my_start_token(M), + end: my_end_token(M), + phase: M.phase, + args: args + }); + } + return new AST_Call({ + start: my_start_token(M), + end: my_end_token(M), + expression: from_moz({ + type: "Identifier", + name: "import" + }), + optional: false, + args + }); + }, + ExportAllDeclaration: function(M) { var foreign_name = M.exported == null ? new AST_SymbolExportForeign({ name: "*" }) : @@ -1028,19 +1055,6 @@ import { is_basic_identifier_string } from "./parse.js"; }); }, - ImportExpression: function(M) { - let import_token = my_start_token(M); - return new AST_Call({ - start : import_token, - end : my_end_token(M), - expression : new AST_SymbolRef({ - start : import_token, - end : import_token, - name : "import" - }), - args : [from_moz(M.source)] - }); - } }; MOZ_TO_ME.UpdateExpression = @@ -1261,6 +1275,16 @@ import { is_basic_identifier_string } from "./parse.js"; }; }); + def_to_moz(AST_DynamicImport, function To_Moz_ImportExpression(M) { + const [source, options] = M.args.map(to_moz); + return { + type: "ImportExpression", + source, + options: options || null, + phase: M.phase + }; + }); + def_to_moz(AST_Toplevel, function To_Moz_Program(M) { return to_moz_scope("Program", M); }); @@ -1488,12 +1512,14 @@ import { is_basic_identifier_string } from "./parse.js"; }); } } - return { + var moz = { type: "ImportDeclaration", specifiers: specifiers, source: to_moz(M.module_name), assertions: assert_clause_to_moz(M.assert_clause) }; + if (M.phase) moz.phase = M.phase; + return moz; }); def_to_moz(AST_ImportMeta, function To_Moz_MetaProperty() { diff --git a/lib/output.js b/lib/output.js index e7ca1abdc..57ac8efff 100644 --- a/lib/output.js +++ b/lib/output.js @@ -102,6 +102,7 @@ import { AST_Function, AST_Hole, AST_If, + AST_DynamicImport, AST_Import, AST_ImportMeta, AST_Jump, @@ -1697,6 +1698,10 @@ function OutputStream(options) { DEFPRINT(AST_Import, function(self, output) { output.print("import"); output.space(); + if (self.phase) { + output.print(self.phase); + output.space(); + } if (self.imported_name) { self.imported_name.print(output); } @@ -1737,6 +1742,15 @@ function OutputStream(options) { DEFPRINT(AST_ImportMeta, function(self, output) { output.print("import.meta"); }); + DEFPRINT(AST_DynamicImport, function(self, output) { + output.print("import." + self.phase); + output.with_parens(function() { + self.args.forEach(function(arg, i) { + if (i) output.comma(); + arg.print(output); + }); + }); + }); DEFPRINT(AST_NameMapping, function(self, output) { var is_import = output.parent() instanceof AST_Import; diff --git a/lib/parse.js b/lib/parse.js index f393d21bc..46707b9ac 100644 --- a/lib/parse.js +++ b/lib/parse.js @@ -98,6 +98,7 @@ import { AST_Function, AST_Hole, AST_If, + AST_DynamicImport, AST_Import, AST_ImportMeta, AST_IterationStatement, @@ -2311,7 +2312,7 @@ function parse($TEXT, options) { return new_(allow_calls); } if (is("name", "import") && is_token(peek(), "punc", ".")) { - return import_meta(allow_calls); + return parse_import_expr(allow_calls); } var start = S.token; var peeked; @@ -2771,6 +2772,17 @@ function parse($TEXT, options) { function import_statement() { var start = prev(); + // import source x from "..." + // import defer * as x from "..." + var phase = null; + if (is("name", "source") || is("name", "defer")) { + var peeked = peek(); + if (!is_token(peeked, "name", "from") && !is_token(peeked, "punc", ",")) { + phase = S.token.value; + next(); + } + } + var imported_name; var imported_names; if (is("name")) { @@ -2805,14 +2817,33 @@ function parse($TEXT, options) { end: mod_str, }), assert_clause, + phase, end: S.token, }); } - function import_meta(allow_calls) { + // import.meta + // import.source("module") + // import.defer("module") + function parse_import_expr(allow_calls) { var start = S.token; expect_token("name", "import"); expect_token("punc", "."); + if (is("name", "source") || is("name", "defer")) { + var phase = S.token.value; + next(); + if (!is("punc", "(")) { + croak("'import." + phase + "' can only be used in a dynamic import"); + } + next(); + var args = expr_list(")"); + return subscripts(new AST_DynamicImport({ + start: start, + phase: phase, + args: args, + end: prev() + }), allow_calls); + } expect_token("name", "meta"); return subscripts(new AST_ImportMeta({ start: start, diff --git a/lib/size.js b/lib/size.js index b70352619..4b2d9c1b5 100644 --- a/lib/size.js +++ b/lib/size.js @@ -35,6 +35,7 @@ import { AST_Hole, AST_If, AST_Import, + AST_DynamicImport, AST_ImportMeta, AST_Infinity, AST_LabeledStatement, @@ -270,6 +271,11 @@ AST_Import.prototype._size = function () { AST_ImportMeta.prototype._size = () => 11; +AST_DynamicImport.prototype._size = function () { + // `import.` + phase + `()` + arg overhead + return 9 + this.phase.length + list_overhead(this.args); +}; + AST_Export.prototype._size = function () { let size = 7 + (this.is_default ? 8 : 0); diff --git a/test/compress/harmony.js b/test/compress/harmony.js index e7a8ce6ea..8cee25c64 100644 --- a/test/compress/harmony.js +++ b/test/compress/harmony.js @@ -331,6 +331,92 @@ export_named_from_assertions: { expect_exact: 'export{x}from"hello"assert{key:"value"};' } +import_phase_source_default: { + input: { + import source wasmModule from "./foo.wasm"; + use(wasmModule); + } + expect_exact: 'import source wasmModule from"./foo.wasm";use(wasmModule);' + no_mozilla_ast: true +} + +import_phase_defer_namespace: { + input: { + import defer * as ns from "./mod.js"; + use(ns); + } + expect_exact: 'import defer*as ns from"./mod.js";use(ns);' + no_mozilla_ast: true +} + +import_phase_back_compat_default: { + // `source` and `defer` are still valid default-import bindings when + // followed directly by `from` or `,`. + input: { + import source from "./a.js"; + import defer, { x } from "./b.js"; + use(source, defer, x); + } + expect_exact: 'import source from"./a.js";import defer,{x}from"./b.js";use(source,defer,x);' + no_mozilla_ast: true +} + +import_phase_source_mangle: { + mangle = { toplevel: true } + input: { + import source wasmModule from "./foo.wasm"; + use(wasmModule); + } + expect: { + import source o from "./foo.wasm"; + use(o); + } + no_mozilla_ast: true +} + +import_phase_dynamic_defer: { + input: { + import.defer("./mod.js"); + } + expect_exact: 'import.defer("./mod.js");' + no_mozilla_ast: true +} + +import_phase_dynamic_with_options: { + input: { + import.source("./foo.wasm", { with: { type: "webassembly" } }); + } + expect_exact: 'import.source("./foo.wasm",{with:{type:"webassembly"}});' + no_mozilla_ast: true +} + +import_phase_source_no_side_effects: { + options = { + side_effects: true, + } + input: { + import.source("./foo.wasm"); + import.defer("./mod.js"); + } + expect: { + import.defer("./mod.js"); + } + no_mozilla_ast: true +} + +import_phase_source_keeps_arg_side_effects: { + options = { + side_effects: true, + } + input: { + import.source(sideEffect()); + } + expect: { + sideEffect(); + } + no_mozilla_ast: true +} + import_statement_mangling: { mangle = { toplevel: true }; input: { diff --git a/test/mocha/source-phase-imports.js b/test/mocha/source-phase-imports.js new file mode 100644 index 000000000..2bcdfe3ed --- /dev/null +++ b/test/mocha/source-phase-imports.js @@ -0,0 +1,118 @@ +import assert from "assert"; +import * as AST from "../../lib/ast.js"; +import { parse } from "../../lib/parse.js"; +import { minify } from "../../main.js"; + +// Source-phase imports proposal: https://github.com/tc39/proposal-source-phase-imports +// Compress/print coverage lives in test/compress/harmony.js. These tests cover +// the bits that need a Mozilla/spidermonkey AST or specific parser errors. + +function build_moz_static(local_name, source, phase) { + return { + type: "Program", + sourceType: "module", + body: [{ + type: "ImportDeclaration", + phase: phase, + specifiers: [{ + type: "ImportDefaultSpecifier", + local: { type: "Identifier", name: local_name } + }], + source: { type: "Literal", value: source, raw: JSON.stringify(source) }, + attributes: [] + }] + }; +} + +function build_moz_dynamic(specifier, phase) { + return { + type: "Program", + sourceType: "module", + body: [{ + type: "ExpressionStatement", + expression: { + type: "ImportExpression", + source: { type: "Literal", value: specifier, raw: JSON.stringify(specifier) }, + options: null, + ...(phase ? { phase } : {}) + } + }] + }; +} + +describe("Source-phase imports (mozilla AST)", function() { + it("from_mozilla_ast: static `import source`", function() { + const ast = AST.AST_Node.from_mozilla_ast(build_moz_static("wasmModule", "./foo.wasm", "source")); + assert.strictEqual(ast.print_to_string(), 'import source wasmModule from"./foo.wasm";'); + }); + + it("from_mozilla_ast: static `import defer`", function() { + const ast = AST.AST_Node.from_mozilla_ast(build_moz_static("ns", "./mod.js", "defer")); + assert.strictEqual(ast.print_to_string(), 'import defer ns from"./mod.js";'); + }); + + it("from_mozilla_ast: phase-less static import unchanged", function() { + const ast = AST.AST_Node.from_mozilla_ast(build_moz_static("x", "./y.js", undefined)); + assert.strictEqual(ast.print_to_string(), 'import x from"./y.js";'); + }); + + it("to_mozilla_ast: static phase round-trips", function() { + const ast = AST.AST_Node.from_mozilla_ast(build_moz_static("wasmModule", "./foo.wasm", "source")); + const out = ast.to_mozilla_ast(); + assert.strictEqual(out.body[0].type, "ImportDeclaration"); + assert.strictEqual(out.body[0].phase, "source"); + }); + + it("to_mozilla_ast: no `phase` field on plain static imports", function() { + const ast = AST.AST_Node.from_mozilla_ast(build_moz_static("x", "./y.js", undefined)); + assert.strictEqual(ast.to_mozilla_ast().body[0].phase, undefined); + }); + + it("from_mozilla_ast: dynamic `import.source(x)` produces AST_DynamicImport", function() { + const ast = AST.AST_Node.from_mozilla_ast(build_moz_dynamic("./foo.wasm", "source")); + const expr = ast.body[0].body; + assert.ok(expr instanceof AST.AST_DynamicImport); + assert.strictEqual(expr.phase, "source"); + assert.strictEqual(ast.print_to_string(), 'import.source("./foo.wasm");'); + }); + + it("from_mozilla_ast: dynamic `import.defer(x)`", function() { + const ast = AST.AST_Node.from_mozilla_ast(build_moz_dynamic("./mod.js", "defer")); + assert.strictEqual(ast.print_to_string(), 'import.defer("./mod.js");'); + }); + + it("from_mozilla_ast: plain dynamic import is still AST_Call", function() { + const ast = AST.AST_Node.from_mozilla_ast(build_moz_dynamic("./mod.js", undefined)); + const expr = ast.body[0].body; + assert.ok(expr instanceof AST.AST_Call); + assert.strictEqual(ast.print_to_string(), 'import("./mod.js");'); + }); + + it("to_mozilla_ast: phase round-trips on ImportExpression", function() { + const ast = AST.AST_Node.from_mozilla_ast(build_moz_dynamic("./foo.wasm", "source")); + const expr = ast.to_mozilla_ast().body[0].expression; + assert.strictEqual(expr.type, "ImportExpression"); + assert.strictEqual(expr.phase, "source"); + }); + + it("to_mozilla_ast: no `phase` on plain dynamic import", function() { + const ast = AST.AST_Node.from_mozilla_ast(build_moz_dynamic("./mod.js", undefined)); + assert.strictEqual(ast.to_mozilla_ast().body[0].expression.phase, undefined); + }); + + it("minify accepts spidermonkey input with phase", async function() { + const moz = build_moz_static("wasmModule", "./foo.wasm", "source"); + const result = await minify(moz, { parse: { spidermonkey: true }, compress: false, mangle: false }); + assert.strictEqual(result.code, 'import source wasmModule from"./foo.wasm";'); + }); +}); + +describe("Source-phase imports (parse errors)", function() { + it("rejects bare `import.source`", function() { + assert.throws(() => parse('let x = import.source;'), /can only be used in a dynamic import/); + }); + + it("rejects bare `import.defer`", function() { + assert.throws(() => parse('let x = import.defer;'), /can only be used in a dynamic import/); + }); +});