ESM live bindings 1/n: mirror own-export reassignments into exports - #1868
ESM live bindings 1/n: mirror own-export reassignments into exports#1868vzaidman wants to merge 1 commit into
exports#1868Conversation
Summary:
# Context - live bindings
Metro's `experimentalImportSupport` transform gives ES module imports snapshot semantics. An imported binding is read once, so a later reassignment in the source module is never observed:
```js
// counter.js
export let count = 0;
export function bump() { count++; }
// consumer.js
import {count, bump} from './counter';
bump();
console.log(count); // 0 under Metro today, 1 per spec
```
This is wrong per spec, and it's observable in two scenarios - mutation of an export (as above), which isn't common, and in cycles (below).
## Example - dependency cycle
Cycles are valid in ESM and the following should work as it reads.
```
// a.js
import { B } from './b.js';
export const A = 1;
export function readB() { return B; }
```
```
// b.js
import { A } from './a.js';
export const B = 2;
export function readA() { return A; }
```
```
// main.js
import { readB } from './a.js';
import { readA } from './b.js';
console.log(readA(), readB()); // ESM: 1 2
```
**But with Metro's snapshotting transform**, `a.js` is still evaluating (requiring `b.js`, before it assigns `exports.A`) when `b.js` snapshots its (empty) exports.
```
// a.js
const { B } = require('./b.js'); // b.js completes, 2 is assigned to B
exports.A = 1;
exports.readB = () => B;
```
```
// b.js
const { A } = require('./a.js'); // a.js in progress, its module.exports is still empty {}
exports.B = 2;
exports.readA = () => A; // snapshotted as `undefined`
```
This is an insidious class of bugs that escapes detection by type checkers.
## Performance
Making imports live means a read has to go back to the source on every access rather than binding a value once, so it costs bytes and potentially time. This stack implements liveness first, and then builds on that with optimisations to bring us back to neutral or better.
Liveness stays behind `unstable_liveBindings`, off by default through this stack.
# This diff
The producer half: makes a module's own exports live, so a reassignment after initialisation is visible to importers.
Babel does this by defining an accessor on `exports` for every exported name. Instead this walks each exported binding's `constantViolations` and mirrors the reassignment back as a plain data-property write:
| source | emitted |
| --- | --- |
| `x = v` | `exports.x = (x = v)` |
| `x += v` | `exports.x = (x += v)` |
| `++x` | `exports.x = ++x` |
| `x++` (value unused) | `exports.x = ++x` |
| `x++` (value used) | `(_x = x++, exports.x = x, _x)` |
Reads stay on the plain-property fast path and the cost lands only where a
reassignment actually happens. Only 7 modules in the Wilde graph ever reassign
an exported binding, so this is close to free in practice.
## Why mirroring rather than accessors
Costs land in different places. Accessors pay per exported *name* at init and make every read a getter call. Mirroring pays per *reassignment* and leaves reads as plain property loads.
Measured on the stable SH compiler with the production invocation, and on the release (opt) VM:
| | mirroring | accessors |
| --- | --- | --- |
| HBC per exported name | 26.29 B | 86.03 B |
| HBC per reassignment | 14.59 B | 0 B |
| read | 7.45 ns | 40.80 ns |
| write | 26.80 ns | 6.00 ns |
Mirroring wins on size while a module averages fewer than **4.09 reassignments per exported name**. Across FB-app graph only **7 modules of 44,621** reassign an exported binding at all, so essentially every module pays the 26 B seed and nothing more.
It also puts the CPU cost on the rare operation. Reads outnumber reassignments heavily, and accessors make every one of them 5.5x more expensive.
## Re-exports use getters, not mirroring
Re-exports (`export {x} from './y'`, `export {default as D} from './y'`, `export * from './y'`) require liveness but cannot use mirroring: the reassignment happens in the source module, not this one, so there is no local binding site to hook onto. Snapshotting at re-export time (`exports.x = require('./y').x`) would freeze the value at first read.
The plugin installs a getter:
```js
Object.defineProperty(exports, 'x', {
enumerable: true,
configurable: true,
get: function () { return require('./y').x; },
});
```
## Correctness tradeoff
The cost is that `exports.x` remains an ordinary, writable data property (incorrect, but not a regression vs Metro's current output).
Because this throws under real ESM, it's a pattern that should not exist in the wild. Flow and TS already error on it.
Reviewed By: huntie
Differential Revision: D111529091
|
@vzaidman has exported this pull request. If you are a Meta employee, you can view the originating Diff in D111529091. |
| }, | ||
| }); | ||
| }); | ||
| `); |
There was a problem hiding this comment.
Precedence for duplicate names across multiple export * from differs between live and non-live modes. The non-live template exports[KEY] = REQUIRED[KEY] overwrites on each iteration, so the last star wins. The live template short-circuits via Object.prototype.hasOwnProperty.call(exports, KEY), so the first star that installs a getter wins and any subsequent star silently loses. If two export * sources share a name, importers will get different values depending on whether unstable_liveBindings is on. Worth intentionally choosing (and probably matching the spec/no-export behavior, or at least matching the existing Metro behavior) rather than letting the mode flip it.
| e.loc, | ||
| ).forEach(node => body.push(node)); | ||
| }, | ||
| ); |
There was a problem hiding this comment.
Under liveBindings, the var REQUIRED = require(FILE); for each export * from is pushed onto the tail of the program body inside Program.exit, whereas the non-live path unshifts it via state.imports so it runs before the module's own body. This changes the module-init side-effect order for export * sources: they're now required after (rather than before) this module's own body executes. Combined with the fact that named/default re-export getters call require(FILE) lazily on every read, a module that previously ran its star source's side effects at load time may now defer them. This is likely a semantic change worth confirming, especially with cycles.
Summary:
Context - live bindings
Metro's
experimentalImportSupporttransform gives ES module imports snapshot semantics. An imported binding is read once, so a later reassignment in the source module is never observed:This is wrong per spec, and it's observable in two scenarios - mutation of an export (as above), which isn't common, and in cycles (below).
Example - dependency cycle
Cycles are valid in ESM and the following should work as it reads.
But with Metro's snapshotting transform,
a.jsis still evaluating (requiringb.js, before it assignsexports.A) whenb.jssnapshots its (empty) exports.This is an insidious class of bugs that escapes detection by type checkers.
Performance
Making imports live means a read has to go back to the source on every access rather than binding a value once, so it costs bytes and potentially time. This stack implements liveness first, and then builds on that with optimisations to bring us back to neutral or better.
Liveness stays behind
unstable_liveBindings, off by default through this stack.This diff
The producer half: makes a module's own exports live, so a reassignment after initialisation is visible to importers.
Babel does this by defining an accessor on
exportsfor every exported name. Instead this walks each exported binding'sconstantViolationsand mirrors the reassignment back as a plain data-property write:x = vexports.x = (x = v)x += vexports.x = (x += v)++xexports.x = ++xx++(value unused)exports.x = ++xx++(value used)(_x = x++, exports.x = x, _x)Reads stay on the plain-property fast path and the cost lands only where a
reassignment actually happens. Only 7 modules in the Wilde graph ever reassign
an exported binding, so this is close to free in practice.
Why mirroring rather than accessors
Costs land in different places. Accessors pay per exported name at init and make every read a getter call. Mirroring pays per reassignment and leaves reads as plain property loads.
Measured on the stable SH compiler with the production invocation, and on the release (opt) VM:
Mirroring wins on size while a module averages fewer than 4.09 reassignments per exported name. Across FB-app graph only 7 modules of 44,621 reassign an exported binding at all, so essentially every module pays the 26 B seed and nothing more.
It also puts the CPU cost on the rare operation. Reads outnumber reassignments heavily, and accessors make every one of them 5.5x more expensive.
Re-exports use getters, not mirroring
Re-exports (
export {x} from './y',export {default as D} from './y',export * from './y') require liveness but cannot use mirroring: the reassignment happens in the source module, not this one, so there is no local binding site to hook onto. Snapshotting at re-export time (exports.x = require('./y').x) would freeze the value at first read.The plugin installs a getter:
Correctness tradeoff
The cost is that
exports.xremains an ordinary, writable data property (incorrect, but not a regression vs Metro's current output).Because this throws under real ESM, it's a pattern that should not exist in the wild. Flow and TS already error on it.
Reviewed By: huntie
Differential Revision: D111529091