Skip to content

ESM live bindings 1/n: mirror own-export reassignments into exports - #1868

Open
vzaidman wants to merge 1 commit into
mainfrom
export-D111529091
Open

ESM live bindings 1/n: mirror own-export reassignments into exports#1868
vzaidman wants to merge 1 commit into
mainfrom
export-D111529091

Conversation

@vzaidman

Copy link
Copy Markdown
Contributor

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:

// 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:

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

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
@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 19, 2026
@meta-codesync

meta-codesync Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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

},
});
});
`);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

e.loc,
).forEach(node => body.push(node));
},
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@robhogan

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