From f299e760abdc00aaadc8989eaaab14521af65121 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 15 May 2026 17:57:10 -0700 Subject: [PATCH] Support source-phase imports in the acorn/terser optimizer pipeline When -sSOURCE_PHASE_IMPORTS=1, emcc emits ```js import source wasmModule from './foo.wasm'; ``` in its JS runtime. At -O2/-O3/-Os/-Oz the emitted JS is run through tools/acorn-optimizer.mjs which currently fails with a SyntaxError at parse time because acorn 8.x does not yet understand the source-phase imports proposal (https://github.com/tc39/proposal-source-phase-imports). Wire in the acorn-import-phases plugin so acorn can parse the syntax, and pull in the matching terser support (downstream of emscripten-core/terser#1) so the round-trip through from_mozilla_ast / to_mozilla_ast preserves the `phase` keyword on the way back out. Without the terser side, terser would silently drop the keyword and the host would return the module's exports namespace instead of a WebAssembly.Module, changing runtime semantics. * package.json: add acorn-import-phases dependency. * tools/acorn-optimizer.mjs: extend acorn with the plugin and use the extended parser at the parse site. * third_party/terser/terser.js: rebuilt from the emscripten-core/terser branch with source-phase imports support (PR https://github.com/emscripten-core/terser/pull/1, the v5.18.2 downstream port of upstream terser PR https://github.com/terser/terser/pull/1682). * test/js_optimizer/sourcePhaseImports{,-output}.js: new fixture that feeds two `import source` declarations through the JSDCE pass and checks the keyword survives. * test/test_other.py: register the fixture in test_js_optimizer, and parametrize test_esm_source_phase_imports across no-args and -O2 to exercise the optimizer pipeline. --- package-lock.json | 9 +- package.json | 1 + .../js_optimizer/sourcePhaseImports-output.js | 9 ++ test/js_optimizer/sourcePhaseImports.js | 11 ++ test/test_other.py | 9 +- third_party/terser/terser.js | 144 +++++++++++++++--- tools/acorn-optimizer.mjs | 10 +- 7 files changed, 166 insertions(+), 27 deletions(-) create mode 100644 test/js_optimizer/sourcePhaseImports-output.js create mode 100644 test/js_optimizer/sourcePhaseImports.js diff --git a/package-lock.json b/package-lock.json index e6455c330813a..cf38c3943457c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.4", "google-closure-compiler": "20260429.0.0", "html-minifier-terser": "7.2.0" }, @@ -1478,10 +1479,10 @@ } }, "node_modules/acorn-import-phases": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.3.tgz", - "integrity": "sha512-jtKLnfoOzm28PazuQ4dVBcE9Jeo6ha1GAJvq3N0LlNOszmTfx+wSycBehn+FN0RnyeR77IBxN/qVYMw0Rlj0Xw==", - "dev": true, + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", "engines": { "node": ">=10.13.0" }, diff --git a/package.json b/package.json index c509f348df798..8818318ae3ca9 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.4", "google-closure-compiler": "20260429.0.0", "html-minifier-terser": "7.2.0" }, diff --git a/test/js_optimizer/sourcePhaseImports-output.js b/test/js_optimizer/sourcePhaseImports-output.js new file mode 100644 index 0000000000000..6a86f08230f28 --- /dev/null +++ b/test/js_optimizer/sourcePhaseImports-output.js @@ -0,0 +1,9 @@ +import source wasmModule from "./foo.wasm"; + +import source otherModule from "./bar.wasm"; + +function use() { + return [ wasmModule, otherModule ]; +} + +use(); diff --git a/test/js_optimizer/sourcePhaseImports.js b/test/js_optimizer/sourcePhaseImports.js new file mode 100644 index 0000000000000..f7d69267d6526 --- /dev/null +++ b/test/js_optimizer/sourcePhaseImports.js @@ -0,0 +1,11 @@ +// Source-phase imports (https://github.com/tc39/proposal-source-phase-imports). +// The acorn optimizer must parse these via the acorn-import-phases plugin and +// preserve the `source` phase keyword through the terser from_mozilla_ast -> +// print round-trip used at -O2+. +import source wasmModule from './foo.wasm'; +import source otherModule from './bar.wasm'; + +function use() { + return [wasmModule, otherModule]; +} +use(); diff --git a/test/test_other.py b/test/test_other.py index f77aa3e902129..cf58b33eee99f 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -421,11 +421,15 @@ def test_esm(self, args): self.assertContained('Hello, world!', self.run_js('hello_world.mjs')) @requires_node_25 - def test_esm_source_phase_imports(self): + @parameterized({ + '': ([],), + 'O3': (['-O3'],), + }) + def test_esm_source_phase_imports(self, args): self.node_args += ['--experimental-wasm-modules', '--no-warnings'] self.run_process([EMCC, '-o', 'hello_world.mjs', '-sSOURCE_PHASE_IMPORTS', '--extern-post-js', test_file('modularize_post_js.js'), - test_file('hello_world.c')]) + test_file('hello_world.c')] + args) self.assertContained('import source wasmModule from', read_file('hello_world.mjs')) self.assertContained('Hello, world!', self.run_js('hello_world.mjs')) @@ -3012,6 +3016,7 @@ def test_extern_prepost(self): 'minifyGlobals': (['minifyGlobals'],), 'minifyLocals': (['minifyLocals'],), 'JSDCE': (['JSDCE', '--export-es6'],), + 'sourcePhaseImports': (['JSDCE', '--export-es6'],), 'JSDCE-hasOwnProperty': (['JSDCE'],), 'JSDCE-defaultArg': (['JSDCE'],), 'JSDCE-fors': (['JSDCE'],), diff --git a/third_party/terser/terser.js b/third_party/terser/terser.js index d64f71ea6b4c7..53029c6407b77 100644 --- a/third_party/terser/terser.js +++ b/third_party/terser/terser.js @@ -2404,7 +2404,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; @@ -2864,6 +2864,20 @@ function parse($TEXT, options) { function import_statement() { var start = prev(); + // Source-phase imports proposal: `import source NAME from "..."` and + // `import defer * as NS from "..."`. The phase keyword is a contextual + // identifier; disambiguate from a legitimate default-import named + // `source`/`defer` by peeking past it: if the next token is `from` or + // `,`, the identifier IS the default-imported binding, not a phase. + 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")) { @@ -2898,14 +2912,38 @@ function parse($TEXT, options) { end: mod_str, }), assert_clause, + phase, end: S.token, }); } - function import_meta(allow_calls) { + // Parses an `import.` expression, after `expr_atom` has + // already confirmed the `import .` lookahead. Handles: + // + // import.meta — meta-property + // import.source(specifier [, options]) — source-phase imports proposal + // import.defer(specifier [, options]) — " + // + // The two source-phase forms (https://github.com/tc39/proposal-source-phase-imports) + // share the `import.NAME` shape with `import.meta` but are an entirely + // different proposal: they must be followed by a call and their result + // is a dynamic import, not a meta-property reference. + 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"); + } + return subscripts(new AST_DynamicImport({ + start: start, + phase: phase, + end: prev() + }), allow_calls); + } expect_token("name", "meta"); return subscripts(new AST_ImportMeta({ start: start, @@ -5071,13 +5109,14 @@ var AST_NameMapping = DEFNODE("NameMapping", "foreign_name name", function AST_N var AST_Import = DEFNODE( "Import", - "imported_name imported_names module_name assert_clause", + "imported_name imported_names module_name assert_clause phase", function AST_Import(props) { if (props) { this.imported_name = props.imported_name; this.imported_names = props.imported_names; this.module_name = props.module_name; this.assert_clause = props.assert_clause; + this.phase = props.phase; this.start = props.start; this.end = props.end; } @@ -5090,7 +5129,8 @@ var AST_Import = DEFNODE( 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", - assert_clause: "[AST_Object?] The import assertion" + assert_clause: "[AST_Object?] The import assertion", + phase: "[string?] Phase keyword for source-phase imports ('source' or 'defer'), or null." }, _walk: function(visitor) { return visitor._visit(this, function() { @@ -5127,6 +5167,26 @@ var AST_ImportMeta = DEFNODE("ImportMeta", null, function AST_ImportMeta(props) $documentation: "A reference to import.meta", }); +var AST_DynamicImport = DEFNODE( + "DynamicImport", + "phase", + function AST_DynamicImport(props) { + if (props) { + this.phase = props.phase; + this.start = props.start; + this.end = props.end; + } + + this.flags = 0; + }, + { + $documentation: "The callee of a dynamic `import(...)` / `import.source(...)` / `import.defer(...)` expression. Always appears as the `expression` of an AST_Call.", + $propdoc: { + phase: "[string?] Phase keyword ('source' or 'defer'), or null for a plain dynamic import." + } + } +); + var AST_Export = DEFNODE( "Export", "exported_definition exported_value is_default exported_names module_name assert_clause", @@ -6706,7 +6766,7 @@ const _NOINLINE = 0b00000100; const _KEY = 0b00001000; const _MANGLEPROP = 0b00010000; -// XXX Emscripten: export TreeWalker for walking through AST in acorn-optimizer.mjs. +// XXX Emscripten: export TreeWalker for walking through AST in acorn-optimizer.js. exports.TreeWalker = TreeWalker; /*********************************************************************** @@ -7449,7 +7509,8 @@ def_transform(AST_PrefixedTemplateString, function(self, tw) { 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 }); }, @@ -7475,6 +7536,39 @@ def_transform(AST_PrefixedTemplateString, function(self, tw) { }); }, + ImportExpression: function(M) { + const args = [from_moz(M.source)]; + if (M.options) { + args.push(from_moz(M.options)); + } + // Source-phase imports proposal (https://github.com/tc39/proposal-source-phase-imports): + // `import.source(x)` and `import.defer(x)` arrive as ImportExpression + // nodes with a `phase` field (per acorn-import-phases). Plain + // dynamic imports continue to use the synthetic `import` SymbolRef + // callee for back-compat with downstream code that already groks + // that pattern. + var expression; + if (M.phase) { + expression = new AST_DynamicImport({ + start: my_start_token(M), + end: my_end_token(M), + phase: M.phase + }); + } else { + expression = from_moz({ + type: "Identifier", + name: "import" + }); + } + return new AST_Call({ + start: my_start_token(M), + end: my_end_token(M), + expression: expression, + optional: false, + args + }); + }, + ExportAllDeclaration: function(M) { var foreign_name = M.exported == null ? new AST_SymbolExportForeign({ name: "*" }) : @@ -7887,19 +7981,6 @@ def_transform(AST_PrefixedTemplateString, function(self, tw) { }); }, - 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 = @@ -8112,6 +8193,16 @@ def_transform(AST_PrefixedTemplateString, function(self, tw) { }; }); def_to_moz(AST_Call, function To_Moz_CallExpression(M) { + if (M.expression instanceof AST_DynamicImport) { + const [source, options] = M.args.map(to_moz); + const out = { + type: "ImportExpression", + source, + options: options || null + }; + if (M.expression.phase) out.phase = M.expression.phase; + return out; + } return { type: "CallExpression", callee: to_moz(M.expression), @@ -8347,12 +8438,14 @@ def_transform(AST_PrefixedTemplateString, function(self, tw) { }); } } - 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() { @@ -10398,6 +10491,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); } @@ -10438,6 +10535,13 @@ function OutputStream(options) { DEFPRINT(AST_ImportMeta, function(self, output) { output.print("import.meta"); }); + DEFPRINT(AST_DynamicImport, function(self, output) { + if (self.phase) { + output.print("import." + self.phase); + } else { + output.print("import"); + } + }); DEFPRINT(AST_NameMapping, function(self, output) { var is_import = output.parent() instanceof AST_Import; diff --git a/tools/acorn-optimizer.mjs b/tools/acorn-optimizer.mjs index 5fd4c6c098637..e1b5604981450 100755 --- a/tools/acorn-optimizer.mjs +++ b/tools/acorn-optimizer.mjs @@ -1,11 +1,19 @@ #!/usr/bin/env node import * as acorn from 'acorn'; +import importPhases from 'acorn-import-phases'; import * as terser from '../third_party/terser/terser.js'; import * as fs from 'node:fs'; import assert from 'node:assert'; import {parseArgs} from 'node:util'; +// Extend acorn to understand source-phase import syntax +// (`import source foo from './bar.wasm'`) emitted under -sSOURCE_PHASE_IMPORTS. +// The plugin annotates ImportDeclaration nodes with a `phase` field; the +// bundled terser carries this through from_mozilla_ast / to_mozilla_ast and +// emits it back on output. +const parser = acorn.Parser.extend(importPhases()); + // Utilities function read(x) { @@ -1750,7 +1758,7 @@ const registry = { let ast; try { - ast = acorn.parse(input, params); + ast = parser.parse(input, params); for (let pass of passes) { const resolvedPass = registry[pass]; assert(resolvedPass, `unknown optimizer pass: ${pass}`);