Skip to content

ESM live bindings 3/n: Accept mode-1 helper shape in dependency analysis - #1866

Open
vzaidman wants to merge 3 commits into
mainfrom
export-D115860462
Open

ESM live bindings 3/n: Accept mode-1 helper shape in dependency analysis#1866
vzaidman wants to merge 3 commits into
mainfrom
export-D115860462

Conversation

@vzaidman

Copy link
Copy Markdown
Contributor

Summary:
Two related relaxations in the dependency-analysis pipeline that
together let it accept a mode-selecting second argument on Metro's
inlineable helper calls, and observe the underlying usage through the
namespace-shaped wrapper the mode-1 form returns.

1. collectDependencies.getModuleNameFromCallArgs arity

Previously rejected any inlineable-helper call with != 1 argument.
Now accepts 1 or 2 args. The dependency name is always the first arg;
the optional second arg is metadata for the runtime helper (a mode
selector - see the sibling change in
metro-runtime/src/polyfills/require.js). Behaviour unchanged for
existing 1-arg callers. 3+ args still rejected.

2. visitDependencyUses.walkReferences walks through .default on

mode-1 helper results

The mode-1 form of _$$_IMPORT_DEFAULT(id, 1) returns a
namespace-shaped wrapper {default: <value>}. Under
unstable_liveBindings, downstream code accesses this via
_$$_IMPORT_DEFAULT(id, 1).default. The .default accessor is a
shape convention, not a semantic operation - analysers built on
visitDependencyUses (for example extractSoundResources, which
extracts sound-file names from sx(name) call sites) need to observe
the OUTER usage, not the intermediate accessor.

walkReferences now walks through .default accessors on mode-1
results, propagating mode-1 provenance through constant var _foo = helper(id, 1) bindings. This means:

  • _$$_IMPORT_DEFAULT(id, 1).default(arg) - the visitor sees the
    outer call and dispatches visitCall.
  • var _foo = _$$_IMPORT_DEFAULT(id, 1); _foo.default(arg) - same,
    via the hoisted binding.
  • require('X').default (arbitrary CJS namespace access, NOT mode-1)
    • unchanged, .default is preserved as the observable operation.

The mode-1 detection is a static structural check: the helper call
carries a numeric literal 1 as its second argument.

Why one diff, before the plugin change

These two fixes are prerequisites for D115038400 (the plugin change
that starts emitting the mode-1 shape under unstable_liveBindings).
Together they let the dep-analysis pipeline handle the shape end to
end - relaxing this once, in isolation from the plugin change, means
each subsequent diff stands alone and builds cleanly.

Reviewed By: huntie

Differential Revision: D115860462

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
… runtime helper for namespace-shaped return

Summary:
Adds an optional `experimentalMode` argument to the `importDefault` runtime helper, selecting the return shape.

## Usage

Existing, unchanged - helper returns the value of the default export

```js
const x = importDefault(id); // exports.default for ESM, exports for CJS
```

`1` — returns a namespace, not memoised:

```js
const ns = importDefault(id, 1); // exports for ESM, {default: exports} for CJS
ns.default;
```

## Why this is important for liveness

The namespace shape lets a default import bind once and read `.default` per use, which is what the `unstable_liveBindings` emission needs to stay live under inline-requires.

Mode 1 skips memoisation because its CJS wrapper is allocated per call — caching it would pin `.default` to the exports object seen on the first call and hide a later `module.exports = X`.

## Why two modes?

*The primary purpose of supporting both is temporary experimentation* - I'm hoping the live stack proves end-to-end perf neutral while being more correct, and we can drop the 2-arg form.

## Why not a new helper, or separate function (`require.importDefaultNamespace`, etc)
 - Adding another helper arg to the module wrapper is a lot of churn and inflated HBC on the production (control) path.
 - Switching the behaviour of the existing module helper at runtime is viable, but it'd have to be a check inside `require.js`, with `unstable_liveBindings` passed through as a global via prelude or similar. It works but it's intrusive.
 - Exposing this functionality other than by a direct module wrapper arg call muddles any perf or HBC size comparison with non-live baseline - `require.<function>()` is slower and bigger than `<function>()`, and resolving a variable from a higher scope is slower than a local scope. In the proposed design, we keep it *almost* equivalent, bar the extra arg we expect to disappear.

Reviewed By: huntie

Differential Revision: D115566590
Summary:
Two related relaxations in the dependency-analysis pipeline that
together let it accept a mode-selecting second argument on Metro's
inlineable helper calls, and observe the underlying usage through the
namespace-shaped wrapper the mode-1 form returns.

## 1. `collectDependencies.getModuleNameFromCallArgs` arity

Previously rejected any inlineable-helper call with != 1 argument.
Now accepts 1 or 2 args. The dependency name is always the first arg;
the optional second arg is metadata for the runtime helper (a mode
selector - see the sibling change in
`metro-runtime/src/polyfills/require.js`). Behaviour unchanged for
existing 1-arg callers. 3+ args still rejected.

## 2. `visitDependencyUses.walkReferences` walks through `.default` on
##    mode-1 helper results

The mode-1 form of `_$$_IMPORT_DEFAULT(id, 1)` returns a
namespace-shaped wrapper `{default: <value>}`. Under
`unstable_liveBindings`, downstream code accesses this via
`_$$_IMPORT_DEFAULT(id, 1).default`. The `.default` accessor is a
shape convention, not a semantic operation - analysers built on
`visitDependencyUses` (for example `extractSoundResources`, which
extracts sound-file names from `sx(name)` call sites) need to observe
the OUTER usage, not the intermediate accessor.

`walkReferences` now walks through `.default` accessors on mode-1
results, propagating mode-1 provenance through constant `var _foo =
helper(id, 1)` bindings. This means:

- `_$$_IMPORT_DEFAULT(id, 1).default(arg)` - the visitor sees the
  outer call and dispatches `visitCall`.
- `var _foo = _$$_IMPORT_DEFAULT(id, 1); _foo.default(arg)` - same,
  via the hoisted binding.
- `require('X').default` (arbitrary CJS namespace access, NOT mode-1)
  - unchanged, `.default` is preserved as the observable operation.

The mode-1 detection is a static structural check: the helper call
carries a numeric literal `1` as its second argument.

## Why one diff, before the plugin change

These two fixes are prerequisites for D115038400 (the plugin change
that starts emitting the mode-1 shape under `unstable_liveBindings`).
Together they let the dep-analysis pipeline handle the shape end to
end - relaxing this once, in isolation from the plugin change, means
each subsequent diff stands alone and builds cleanly.

Reviewed By: huntie

Differential Revision: D115860462
@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 D115860462.

depMapParamBinding,
} = bindModuleIRElements(file);
// `importDefaultParamBinding` is a getter that redoes a scope lookup on each
// access, and this is read once per reference below - so resolve it once.

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.

modeArgHelperName is derived from importDefaultParamBinding.identifier.name unconditionally at the top of the function. If bindModuleIRElements can return a nullish binding for this element (as it does for depMapParamBinding, typed ?Binding), this eager dereference would throw earlier than the previous code path did. Worth confirming the binding is always present.

@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