Skip to content

Statically detect transformed ESM and set isESModule on transform results - #1862

Open
robhogan wants to merge 1 commit into
mainfrom
export-D111629268
Open

Statically detect transformed ESM and set isESModule on transform results#1862
robhogan wants to merge 1 commit into
mainfrom
export-D111629268

Conversation

@robhogan

Copy link
Copy Markdown
Contributor

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:

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:

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:

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

…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
@meta-codesync

meta-codesync Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

@robhogan has exported this pull request. If you are a Meta employee, you can view the originating Diff in D111629268.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant