From 0cf6f6a1002cfb1c1a07bb88f937f9ac10cf203f Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 30 Jun 2026 18:33:17 -0700 Subject: [PATCH 1/2] Support ESM export names that are not valid JS identifiers Export names that are JS reserved words (e.g. `default`) or otherwise not valid binding identifiers previously produced broken output such as `var default = ...` / `export { default }`. Such names are now bound to a legal identifier internally (memoized, with a numeric suffix to disambiguate collisions) and exported under their original name via an alias, e.g. `var $default = ...; export { $default as default }`. Combined with SELF_INIT (which frees the `default` export slot) this allows a program to expose its own `default` export. --- src/jsifier.mjs | 34 +++++++++++++++++++---------- src/modules.mjs | 7 ++++-- src/utility.mjs | 53 ++++++++++++++++++++++++++++++++++++++++++++++ test/test_other.py | 28 ++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 13 deletions(-) diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 84dc251827b75..85ccf42a60ad7 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -40,6 +40,8 @@ import { warningOccured, localFile, timer, + toValidIdentifier, + quoteExportName, } from './utility.mjs'; import {LibraryManager, librarySymbols, nativeAliases} from './modules.mjs'; @@ -604,6 +606,11 @@ function(${args}) { let isStub = false; const mangled = mangleCSymbolName(symbol); + // The JS binding identifier for this symbol. This is the same as + // `mangled` for the common case, but differs when `mangled` is not a + // valid JS identifier (e.g. a reserved word), in which case we bind it to + // a legal name and export it under its original name via an alias. + const binding = toValidIdentifier(mangled); if (!LibraryManager.library.hasOwnProperty(symbol)) { const isWeakImport = WEAK_IMPORTS.has(symbol); @@ -761,20 +768,20 @@ function(${args}) { // modifyJSFunction which could have changed or removed the name. if (contentText.match(/^\s*([^}]*)\s*=>/s)) { // Handle arrow functions - contentText = `var ${mangled} = ` + contentText + ';'; + contentText = `var ${binding} = ` + contentText + ';'; } else if (contentText.startsWith('class ')) { // Handle class declarations (which also have typeof == 'function'.) - contentText = contentText.replace(/^class(?:\s+(?!extends\b)[^{\s]+)?/, `class ${mangled}`); + contentText = contentText.replace(/^class(?:\s+(?!extends\b)[^{\s]+)?/, `class ${binding}`); } else { // Handle regular (non-arrow) functions - contentText = contentText.replace(/function(?:\s+([^(]+))?\s*\(/, `function ${mangled}(`); + contentText = contentText.replace(/function(?:\s+([^(]+))?\s*\(/, `function ${binding}(`); } } else if (typeof snippet == 'string' && snippet.startsWith(';')) { // In JS libraries // foo: ';[code here verbatim]' // emits // 'var foo;[code here verbatim];' - contentText = 'var ' + mangled + snippet; + contentText = 'var ' + binding + snippet; if (snippet[snippet.length - 1] != ';' && snippet[snippet.length - 1] != '}') { contentText += ';'; } @@ -786,7 +793,7 @@ function(${args}) { if (isNativeAlias) { contentText = ''; } else { - contentText = `var ${mangled};`; + contentText = `var ${binding};`; } } else { // In JS libraries @@ -796,13 +803,18 @@ function(${args}) { if (typeof snippet == 'string' && snippet[0] == '=') { snippet = snippet.slice(1); } - contentText = `var ${mangled} = ${snippet};`; + contentText = `var ${binding} = ${snippet};`; } if (contentText && MODULARIZE == 'instance' && (EXPORT_ALL || EXPORTED_FUNCTIONS.has(mangled)) && !isStub) { // In MODULARIZE=instance mode mark JS library symbols are exported at - // the point of declaration. - contentText = 'export ' + contentText; + // the point of declaration. When the binding had to be renamed to a + // legal identifier, export it under its original name via an alias. + if (binding == mangled) { + contentText = 'export ' + contentText; + } else { + contentText += `\nexport { ${binding} as ${quoteExportName(mangled)} };`; + } } // Dynamic linking needs signatures to create proper wrappers. @@ -810,14 +822,14 @@ function(${args}) { if (!WASM_BIGINT) { sig = sig[0].replace('j', 'i') + sig.slice(1).replace(/j/g, 'ii'); } - contentText += `\n${mangled}.sig = '${sig}';`; + contentText += `\n${binding}.sig = '${sig}';`; } if (ASYNCIFY && isAsyncFunction) { assert(isFunction); - contentText += `\n${mangled}.isAsync = true;`; + contentText += `\n${binding}.isAsync = true;`; } if (isStub) { - contentText += `\n${mangled}.stub = true;`; + contentText += `\n${binding}.stub = true;`; if (ASYNCIFY && MAIN_MODULE) { contentText += `\nasyncifyStubs['${symbol}'] = undefined;`; } diff --git a/src/modules.mjs b/src/modules.mjs index bc6aa5295f5fa..bd4d01a37536f 100644 --- a/src/modules.mjs +++ b/src/modules.mjs @@ -23,6 +23,8 @@ import { mergeInto, localFile, timer, + toValidIdentifier, + quoteExportName, } from './utility.mjs'; import {preprocess, processMacros} from './parseTools.mjs'; @@ -463,12 +465,13 @@ function addMissingLibraryStubs(unusedLibSymbols) { } function exportSymbol(name) { + const binding = toValidIdentifier(name); // In MODULARIZE=instance mode symbols are exported by being included in // an export { foo, bar } list so we build up the simple list of names if (MODULARIZE === 'instance') { - return name; + return binding == name ? name : `${binding} as ${quoteExportName(name)}`; } - return `Module['${name}'] = ${name};`; + return `Module['${name}'] = ${binding};`; } // export parts of the JS runtime that the user asked for diff --git a/src/utility.mjs b/src/utility.mjs index 7d185e0c89fa5..f531a544217a7 100644 --- a/src/utility.mjs +++ b/src/utility.mjs @@ -17,6 +17,59 @@ export function safeQuote(x) { return x.replace(/"/g, '\\"').replace(/'/g, "\\'"); } +// JS reserved words that are otherwise valid identifiers but cannot be used as +// binding names (e.g. `var default = ...` is a syntax error). +const JS_RESERVED_WORDS = new Set([ + 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', + 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', + 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', + 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', + 'typeof', 'var', 'void', 'while', 'with', 'yield', 'let', 'static', + 'implements', 'interface', 'package', 'private', 'protected', 'public', + 'await', +]); + +// Whether `name` can be used verbatim as a JS binding identifier (e.g. after +// `var` or in an `export { x }` clause). +export function isValidIdentifier(name) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !JS_RESERVED_WORDS.has(name); +} + +// Format `name` for the exported-name position of an ESM export/import clause +// (the part after `as`). Reserved words are legal there verbatim, but names +// with illegal characters must use the string-literal form. +export function quoteExportName(name) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name); +} + +// Maps a desired name to the JS identifier we use as its internal binding. +// Names that are already valid identifiers map to themselves. Others (reserved +// words, or names with illegal characters) are rewritten to a legal identifier, +// with a numeric suffix appended to disambiguate any collisions. The mapping is +// memoized so that a given name always resolves to the same binding. +const legalizedIdentifiers = new Map(); +const usedIdentifiers = new Set(); + +export function toValidIdentifier(name) { + let ident = legalizedIdentifiers.get(name); + if (ident !== undefined) return ident; + if (isValidIdentifier(name)) { + ident = name; + } else { + let base = name.replace(/[^A-Za-z0-9_$]/g, '_'); + if (!/^[A-Za-z_$]/.test(base) || JS_RESERVED_WORDS.has(base)) { + base = '$' + base; + } + ident = base; + for (let i = 0; usedIdentifiers.has(ident); i++) { + ident = base + '_' + i; + } + } + legalizedIdentifiers.set(name, ident); + usedIdentifiers.add(ident); + return ident; +} + export function dump(item) { let funcData; try { diff --git a/test/test_other.py b/test/test_other.py index 39a7d5d2efa56..c7b5a5cec9d0d 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -6273,6 +6273,34 @@ def test_modularize_instance_auto_init(self): self.assertContained('main1\nmain2\nfoo\nbar\nbaz\n', self.run_js('runner.mjs')) + def test_modularize_instance_reserved_export(self): + # Export names that are JS reserved words (and so not valid binding + # identifiers) are bound to a legal name internally and exported under their + # original name via an alias. With AUTO_INIT the `default` slot is free. + create_file('library.js', '''\ + addToLibrary({ + $default: () => { console.log('default-export'); return 42; }, + $delete: () => console.log('delete-export'), + });''') + self.run_process([EMCC, test_file('modularize_instance.c'), + '-sMODULARIZE=instance', '-sAUTO_INIT', + '-Wno-experimental', + '-sEXPORTED_RUNTIME_METHODS=default', + '-sEXPORTED_FUNCTIONS=_bar,_main,delete', + '--js-library', 'library.js', + '-o', 'modularize_instance.mjs'] + self.get_cflags()) + + create_file('runner.mjs', ''' + import { strict as assert } from 'assert'; + import theDefault, { delete as del, _foo as foo } from "./modularize_instance.mjs"; + assert(typeof theDefault === 'function'); + assert(theDefault() === 42); // reserved-word export via EXPORTED_RUNTIME_METHODS + del(); // reserved-word export via EXPORTED_FUNCTIONS + foo(); + ''') + + self.assertContained('main1\nmain2\ndefault-export\ndelete-export\nfoo\n', self.run_js('runner.mjs')) + @also_with_pthreads @requires_node_25 def test_esm_integration_auto_init(self): From fd0ac6e9b970c2222203c71a5b72bc2b0eeac5cd Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 10 Jul 2026 13:27:50 -0700 Subject: [PATCH 2/2] Fix metadce treating the reserved `default` export as a wasm import Under MODULARIZE=instance the glue emits `export { \$default as default }` for a reserved-word runtime export. emitDCEGraph classified any aliased `export { x as y }` as a JS function exported to wasm (a wasm import edge), so `default` was recorded as a wasm import; nothing in wasm uses it, so it landed in unusedImports and applyDCEGraphRemovals dropped the specifier, leaving a bare `export` that fails to parse in the next pass. A wasm import name is a C symbol and can never be `default`, so exclude `default` from the import-edge classification in emitDCEGraph and from the specifier removal in applyDCEGraphRemovals. --- .../applyDCEGraphRemovals-esm-output.js | 4 ++++ test/js_optimizer/applyDCEGraphRemovals-esm.js | 5 +++++ test/js_optimizer/emitDCEGraph-esm.js | 7 +++++++ tools/acorn-optimizer.mjs | 13 ++++++++++--- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/test/js_optimizer/applyDCEGraphRemovals-esm-output.js b/test/js_optimizer/applyDCEGraphRemovals-esm-output.js index e094a183cd44d..4530f155da741 100644 --- a/test/js_optimizer/applyDCEGraphRemovals-esm-output.js +++ b/test/js_optimizer/applyDCEGraphRemovals-esm-output.js @@ -13,3 +13,7 @@ export { _main }; var HEAP32; export { HEAP32 }; + +var $default = {}; + +export { $default as default }; diff --git a/test/js_optimizer/applyDCEGraphRemovals-esm.js b/test/js_optimizer/applyDCEGraphRemovals-esm.js index be702e6a9320a..02dbdba64e9b2 100644 --- a/test/js_optimizer/applyDCEGraphRemovals-esm.js +++ b/test/js_optimizer/applyDCEGraphRemovals-esm.js @@ -31,4 +31,9 @@ export { _main }; var HEAP32; export { HEAP32 }; +// A reserved-word runtime export (aliased `$default as default`) must never be +// treated as a wasm import and dropped, even though it is aliased. +var $default = {}; +export { $default as default }; + // EXTRA_INFO: { "unusedImports": ["unused_import"], "unusedExports": ["unused_export", "__indirect_function_table"] } diff --git a/test/js_optimizer/emitDCEGraph-esm.js b/test/js_optimizer/emitDCEGraph-esm.js index 8be0d43a379f9..853f97e0f3110 100644 --- a/test/js_optimizer/emitDCEGraph-esm.js +++ b/test/js_optimizer/emitDCEGraph-esm.js @@ -44,6 +44,13 @@ var HEAP32; var baz = () => {}; export { HEAP32, baz }; +// A reserved-word runtime export (from the reserved-export-names support): the +// local is legalized to `$default` and aliased back to `default`. Though +// aliased like a wasm import edge, a wasm import name can never be `default`, +// so this must be treated as a plain runtime export, not a wasm import. +var $default = {}; +export { $default as default }; + // Top-level uses: root the memory export (via the alias) and one wasm export. wasmMemory.buffer; _used_export(); diff --git a/tools/acorn-optimizer.mjs b/tools/acorn-optimizer.mjs index 5662cb0d54ff0..a43cfa4d15c4b 100755 --- a/tools/acorn-optimizer.mjs +++ b/tools/acorn-optimizer.mjs @@ -727,14 +727,19 @@ function emitDCEGraph(ast) { // (a) JS functions sent to wasm: export { _fd_write as fd_write }; // (b) re-exports of wasm exports: export { _main }; // (c) runtime/library exports: export { HEAP32, baz }; + // (d) reserved-name runtime export: export { $default as default }; // Only (a) are wasm import edges (recorded and removed here). (b) and // (c) are ordinary top-level uses left in place to root in the second // pass. (a) is the only form written with an alias (`x as y`), which // acorn represents with distinct local/exported nodes; bare specifiers - // reuse a single node for both sides. + // reuse a single node for both sides. (d) is also aliased but is a JS + // runtime export, not a wasm import: a wasm import name is a C symbol + // and can never be a JS reserved word like `default`. const importEdges = node.specifiers.filter( (spec) => - spec.local !== spec.exported && !wasmExportLocals.has(spec.local.name), + spec.local !== spec.exported && + spec.exported.name !== 'default' && + !wasmExportLocals.has(spec.local.name), ); if (importEdges.length) { // (a) `export { jsName as nativeName }` - jsName implements the import. @@ -1000,8 +1005,10 @@ function applyDCEGraphRemovals(ast) { } else if (isExportSpecifierList(node)) { // WASM_ESM_INTEGRATION: drop unused wasm imports from // export { _fd_write as fd_write, .. }; + // Never drop `export { $default as default }`: it is a JS runtime export, + // not a wasm import (a wasm import name can never be `default`). node.specifiers = node.specifiers.filter((spec) => { - if (unusedImports.has(spec.exported.name)) { + if (spec.exported.name !== 'default' && unusedImports.has(spec.exported.name)) { foundUnusedImports.add(spec.exported.name); return false; }