Skip to content

Support ESM export names that are not valid JS identifiers#27236

Open
guybedford wants to merge 12 commits into
emscripten-core:mainfrom
guybedford:esm-reserved-export-names
Open

Support ESM export names that are not valid JS identifiers#27236
guybedford wants to merge 12 commits into
emscripten-core:mainfrom
guybedford:esm-reserved-export-names

Conversation

@guybedford

@guybedford guybedford commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Based on #27218 (the metadce ESM support), which must land first.

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 }, which is a syntax error.

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:

var $default = ...;
export { $default as default };

Combined with AUTO_INIT (which frees the default export slot) this allows a program to expose its own default export.

  • new toValidIdentifier / isValidIdentifier / quoteExportName helpers in utility.mjs
  • jsifier.mjs and modules.mjs bind JS library / runtime symbols to the legalized identifier and emit an aliased export { binding as name } when a rename was required
  • names with illegal characters use the string-literal export form

Tested by test_modularize_instance_reserved_export in test_other.py, which exports both a reserved-word runtime method (default) and function (delete) and imports them back.

Additionally this fixes a metadce interaction with -sWASM_ESM_INTEGRATION at -O2+: the aliased export { $default as default } was misclassified as a JS function exported to wasm (a wasm import edge). default was recorded as a wasm import, nothing in wasm used 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 default is now excluded from the import-edge classification in emitDCEGraph and from the specifier removal in applyDCEGraphRemovals. Covered by the extended emitDCEGraph-esm / applyDCEGraphRemovals-esm fixtures.

Resolves #27217.

Made with AI assistance under my review

@sbc100 sbc100 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this relate to the existing js_manipulation.isidentifier that generates the export name is not a valid JS symbol warning?

i.e. what happens if I do -sEXPORTED_RUNTIME_METHODS=delete in a non-ESM build? I assume we should be disallowing keywords like delete in js_manipulation.isidentifier?

@guybedford

Copy link
Copy Markdown
Collaborator Author

How does this relate to the existing js_manipulation.isidentifier that generates the export name is not a valid JS symbol warning?

It complements it - that is only called for _ prefixed names, so they are never reserved words. So they effectively have the same identical semantics.

i.e. what happens if I do -sEXPORTED_RUNTIME_METHODS=delete in a non-ESM build?

It is now supported and tested in this PR.

I assume we should be disallowing keywords like delete in js_manipulation.isidentifier?

As above, isidentifier names have a $ / _ prefix so can never be keywords.

guybedford added 12 commits July 7, 2026 12:08
Building with -sWASM_ESM_INTEGRATION at -O2 or above failed in the JS
optimizer's metadce pass with 'could not find the assignment to
"wasmImports"'. In this mode the wasm<->JS boundary is expressed with
native ES import/export syntax rather than the wasmImports object and
wasmExports['x'] member uses that metadce pattern-matches.

Teach the three metadce-related acorn-optimizer passes about the ES form:
- emitDCEGraph reads 'import {..} from "./x.wasm"' as wasm export nodes
  (collapsing aliases such as memory/wasmMemory to one node) and
  'export { js as wasmName }' as wasm import edges, leaving re-exports
  (export { _main }) to root naturally.
- applyDCEGraphRemovals prunes unused specifiers from those statements.
- applyImportAndExportNameChanges applies minified names to the
  wasm-facing side of each specifier.

building.py also drops dropped exports (including internal ones like the
indirect function table) from the JS import to keep the two interfaces
in sync, and link.py keeps import/export name minification on for ESM
while disabling the now-pointless import module name minification.
Under SOURCE_PHASE_IMPORTS the glue emits `import source wasmModule from
'./x.wasm'`, whose lone specifier is an ImportDefaultSpecifier with no
`imported`. isImportFromWasm matched it (source ends in .wasm) and
emitDCEGraph then threw reading `spec.imported.name`.

Gate isImportFromWasm on `!node.phase` so only value-phase wasm imports
(the wasm export bindings) are treated as export edges.
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.
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.
@guybedford

Copy link
Copy Markdown
Collaborator Author

This PR also needed to update the metadce side to support the new output format. To properly layer this work, this PR is now based to #27218 and should land after that one.

@guybedford guybedford force-pushed the esm-reserved-export-names branch from bb11f8b to cd89a79 Compare July 10, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

-sWASM_ESM_INTEGRATION incompatibility with metadce

2 participants