Statically detect transformed ESM and set isESModule on transform results - #1862
Open
robhogan wants to merge 1 commit into
Open
Statically detect transformed ESM and set isESModule on transform results#1862robhogan wants to merge 1 commit into
robhogan wants to merge 1 commit into
Conversation
…ults
Summary:
## What
Two pieces of infrastructure that later diffs in the stack build on: AST-based ESM classification in `esmClassification.js`, and a tri-state `isESModule` signal on `JsOutput.data`.
The module exports a positive check and a negative one. They are not complements, and it takes both to say a module has no ESM interop.
## `definesESModuleInterop` - the positive check
Detects a truthy top-level `exports.__esModule` or `module.exports.__esModule`, where truthy means `true`, `1` or `!0`. Four shapes, covering the common ESM->CJS toolchains:
```js
Object.defineProperty(exports, '__esModule', {value: true}); // metro import-export-plugin, babel, tsc, rollup
Object.defineProperty(module.exports, '__esModule', {value: true}); // hand-written interop wrappers
exports.__esModule = true; // babel loose, older tsc, rollup if-default-prop
module.exports = fn, module.exports.__esModule = true, ... // babel/runtime/helpers (sequence expression)
```
AST-based rather than a scan of generated source, so it is robust to whitespace, quoting and property-attribute order.
## `canDefineESModuleInterop` - the negative check
The positive check only inspects top-level statements, so a false result means "no marker here", not "no marker". The marker can be installed from anywhere the exports object is reachable: a self-contained bundle can hand its exports to a helper that sets the key (webpack's `__webpack_require__.r`), needing no dependencies and staying invisible to a statement scan.
`canDefineESModuleInterop` closes that gap. It returns false - nothing in the module can produce the key - only when all three hold:
1. `__esModule` occurs nowhere, as an identifier or as a string.
2. `exports`/`module` are only ever read as the target of an export write we can enumerate. Never aliased, passed to a function, or accessed with a computed key.
3. No construct can define a property under a key that is not literally in the source: computed member assignment, computed object key, spread, `Object.assign`, `Object.defineProperties`.
(1) bars the literal, (3) bars constructing it dynamically, and (2) stops the exports object escaping somewhere the other two cannot see. Anything unrecognised returns true, so the assertion is only ever made on modules whose exports are fully accounted for.
This is what lets a `false` be an assertion about the module rather than a statement about where we happened to look.
## Tri-state signal
The plugin hint is positive-only: `import-export-plugin` sets `out.isESModule = true` when it processes ESM syntax and leaves the field unset otherwise. A definite `false` there would suppress the AST fallback for ESM already lowered to CJS upstream by babel/tsc/rollup.
The worker combines hint and fallback, then widens the result to a tri-state on `JsOutput.data.isESModule`:
```js
const isESModule = importExportOut.isESModule ?? definesESModuleInterop(ast);
const hasNoESModuleInterop =
!isESModule && dependencies.length === 0 && !canDefineESModuleInterop(ast);
```
- `true`: definitely ESM, so it has a real `.default`.
- `false`: no ESM interop. No marker, no dependencies, and no expression that could define the marker out of view. This establishes the absence of ESM interop, not the presence of CommonJS - a script or an empty module qualifies too. Common at FBiOS scale via generated Relay fragments.
- unset: undetermined. A module with `require(...)` calls but no marker can still expose interop via `module.exports = require('./esm')`, so consumers must fall back to the runtime helper.
The dependency clause is kept alongside the new check because re-exporting an ES module wholesale is a property of the graph, not of this module's syntax, so it is not something an AST check can rule out.
Detection lives in the worker rather than `collectDependencies` because the worker owns both the plugin hint and the JsOutput.
`getSourceMapInfo` stops spreading `JsOutput.data` and names its four fields explicitly, so the new optional field cannot leak into source map info:
```js
const data = getJsOutput(module).data;
return {code: data.code, functionMap: data.functionMap, lineCount: data.lineCount, map: data.map, ...};
```
OSS serialisers pick the field up automatically via `module.output[].data`. Metro-Buck bridges it explicitly in a follow-up, since it re-serialises through its own module IR.
## Coverage
Against the release FBiOS RN bundle (17,058 modules): 12,475 (73.1%) detected ESM, 4,581 (26.8%) with no `__esModule` at runtime, 2 (0.01%) missed. Both misses are IIFE-wrapped UMD bundles (one of them `whatwg-fetch.umd.js`) with the marker inside an inner factory; resolving `exports` through the IIFE is not worth it for two modules.
Those numbers measure the positive check, which is unchanged. The negative check does not affect them - it only narrows which of the unmarked modules are asserted `false` rather than left unset. UMD of exactly that shape is now declined rather than asserted, so the two misses cannot become a wrong `false` if such a module has no dependencies.
Reviewed By: huntie
Differential Revision: D111629268
Contributor
|
@robhogan has exported this pull request. If you are a Meta employee, you can view the originating Diff in D111629268. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
What
Two pieces of infrastructure that later diffs in the stack build on: AST-based ESM classification in
esmClassification.js, and a tri-stateisESModulesignal onJsOutput.data.The module exports a positive check and a negative one. They are not complements, and it takes both to say a module has no ESM interop.
definesESModuleInterop- the positive checkDetects a truthy top-level
exports.__esModuleormodule.exports.__esModule, where truthy meanstrue,1or!0. Four shapes, covering the common ESM->CJS toolchains:AST-based rather than a scan of generated source, so it is robust to whitespace, quoting and property-attribute order.
canDefineESModuleInterop- the negative checkThe positive check only inspects top-level statements, so a false result means "no marker here", not "no marker". The marker can be installed from anywhere the exports object is reachable: a self-contained bundle can hand its exports to a helper that sets the key (webpack's
__webpack_require__.r), needing no dependencies and staying invisible to a statement scan.canDefineESModuleInteropcloses that gap. It returns false - nothing in the module can produce the key - only when all three hold:__esModuleoccurs nowhere, as an identifier or as a string.exports/moduleare only ever read as the target of an export write we can enumerate. Never aliased, passed to a function, or accessed with a computed key.Object.assign,Object.defineProperties.(1) bars the literal, (3) bars constructing it dynamically, and (2) stops the exports object escaping somewhere the other two cannot see. Anything unrecognised returns true, so the assertion is only ever made on modules whose exports are fully accounted for.
This is what lets a
falsebe an assertion about the module rather than a statement about where we happened to look.Tri-state signal
The plugin hint is positive-only:
import-export-pluginsetsout.isESModule = truewhen it processes ESM syntax and leaves the field unset otherwise. A definitefalsethere would suppress the AST fallback for ESM already lowered to CJS upstream by babel/tsc/rollup.The worker combines hint and fallback, then widens the result to a tri-state on
JsOutput.data.isESModule:true: definitely ESM, so it has a real.default.false: no ESM interop. No marker, no dependencies, and no expression that could define the marker out of view. This establishes the absence of ESM interop, not the presence of CommonJS - a script or an empty module qualifies too. Common at FBiOS scale via generated Relay fragments.require(...)calls but no marker can still expose interop viamodule.exports = require('./esm'), so consumers must fall back to the runtime helper.The dependency clause is kept alongside the new check because re-exporting an ES module wholesale is a property of the graph, not of this module's syntax, so it is not something an AST check can rule out.
Detection lives in the worker rather than
collectDependenciesbecause the worker owns both the plugin hint and the JsOutput.getSourceMapInfostops spreadingJsOutput.dataand names its four fields explicitly, so the new optional field cannot leak into source map info:OSS serialisers pick the field up automatically via
module.output[].data. Metro-Buck bridges it explicitly in a follow-up, since it re-serialises through its own module IR.Coverage
Against the release FBiOS RN bundle (17,058 modules): 12,475 (73.1%) detected ESM, 4,581 (26.8%) with no
__esModuleat runtime, 2 (0.01%) missed. Both misses are IIFE-wrapped UMD bundles (one of themwhatwg-fetch.umd.js) with the marker inside an inner factory; resolvingexportsthrough the IIFE is not worth it for two modules.Those numbers measure the positive check, which is unchanged. The negative check does not affect them - it only narrows which of the unmarked modules are asserted
falserather than left unset. UMD of exactly that shape is now declined rather than asserted, so the two misses cannot become a wrongfalseif such a module has no dependencies.Reviewed By: huntie
Differential Revision: D111629268