From 26f07b09e65103a5e4b13f92eeab4d7c1a2b8dd1 Mon Sep 17 00:00:00 2001 From: Vitali Zaidman Date: Wed, 19 Aug 2026 02:44:03 -0700 Subject: [PATCH] ESM live bindings 1/n: mirror own-export reassignments into `exports` 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 --- .../__tests__/import-export-plugin-test.js | 245 ++++++++++++++++ .../src/import-export-plugin.js | 277 +++++++++++++++++- 2 files changed, 513 insertions(+), 9 deletions(-) diff --git a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js index 12e46ac6ef..6f1e6abb1c 100644 --- a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js @@ -25,6 +25,12 @@ const opts = { importDefault: '_$$_IMPORT_DEFAULT', }; +const liveOpts = { + importAll: '_$$_IMPORT_ALL', + importDefault: '_$$_IMPORT_DEFAULT', + liveBindings: true, +}; + test('correctly transforms and extracts "import" statements', () => { const code = ` import v from 'foo'; @@ -532,6 +538,245 @@ test('re-export dependencies evaluate before module body at runtime', () => { expect(context.exports.star).toBe('bar star'); }); +describe('unstable_liveBindings', () => { + test('the import side is untouched by this option', () => { + const code = ` + import v from 'foo'; + import {default as w} from 'bar'; + import {x} from 'baz'; + `; + + const expected = ` + var v = _$$_IMPORT_DEFAULT('foo'); + var w = _$$_IMPORT_DEFAULT('bar'); + var x = require('baz').x; + `; + + compare([importExportPlugin], code, expected, liveOpts); + }); + + test('reassigned named exports are mirrored into exports', () => { + const code = ` + export let x = 1; + x = 2; + x += 3; + x++; + ++x; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', { + value: true + }); + let x = 1; + exports.x = x = 2; + exports.x = x += 3; + exports.x = ++x; + exports.x = ++x; + exports.x = x; + `; + + compare([importExportPlugin], code, expected, liveOpts); + }); + + test('postfix update in value position preserves the old value', () => { + const code = ` + export let x = 1; + export const y = x++; + `; + + const expected = ` + var _x; + Object.defineProperty(exports, '__esModule', { + value: true + }); + let x = 1; + const y = (_x = x++, exports.x = x, _x); + exports.x = x; + exports.y = y; + `; + + compare([importExportPlugin], code, expected, liveOpts); + }); + + test('postfix update value and mirrored export agree at runtime', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + ` + export let x = 0; + export function postfix() { return x++; } + export function prefix() { return ++x; } + `, + liveOpts, + ), + ).code; + + const context = { + exports: {} as {[string]: $FlowFixMe}, + require: () => ({}), + }; + + vm.runInNewContext(transformedCode, context); + + // `x++` must evaluate to the pre-increment value while still publishing the + // post-increment value to `exports`. + expect(context.exports.postfix()).toBe(0); + expect(context.exports.x).toBe(1); + expect(context.exports.prefix()).toBe(2); + expect(context.exports.x).toBe(2); + }); + + test('exports aliased under multiple remote names are all mirrored', () => { + const code = ` + let x = 1; + export {x, x as y}; + x = 2; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', { + value: true + }); + let x = 1; + exports.y = exports.x = x = 2; + exports.x = x; + exports.y = x; + `; + + compare([importExportPlugin], code, expected, liveOpts); + }); + + test('destructuring reassignment targets are left untouched (deferred)', () => { + const code = ` + export let x = 1; + ({x} = {x: 2}); + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', { + value: true + }); + let x = 1; + ({ + x + } = { + x: 2 + }); + exports.x = x; + `; + + compare([importExportPlugin], code, expected, liveOpts); + }); + + test('mutable named exports are observable at runtime', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + ` + export let counter = 0; + export function increment() { counter++; } + export function setCounter(v) { counter = v; } + `, + liveOpts, + ), + ).code; + + const context = { + exports: {} as {[string]: $FlowFixMe}, + require: () => ({}), + }; + + vm.runInNewContext(transformedCode, context); + + expect(context.exports.counter).toBe(0); + context.exports.increment(); + expect(context.exports.counter).toBe(1); + context.exports.setCounter(42); + expect(context.exports.counter).toBe(42); + }); + + test('named re-exports forward via live getters', () => { + const code = `export {x} from './foo';`; + const expected = ` + Object.defineProperty(exports, '__esModule', { + value: true + }); + Object.defineProperty(exports, "x", { + enumerable: true, + configurable: true, + get: function () { + return require('./foo').x; + } + }); + `; + compare([importExportPlugin], code, expected, liveOpts); + }); + + test('re-exported named binding is observed live at runtime', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + `export {counter} from './source';`, + liveOpts, + ), + ).code; + + const sourceExports = {counter: 1} as {[string]: $FlowFixMe}; + const context = { + exports: {} as {[string]: $FlowFixMe}, + require: (id: string) => { + if (id !== './source') { + throw new Error(`Unexpected module: ${id}`); + } + return sourceExports; + }, + }; + + vm.runInNewContext(transformedCode, context); + + expect(context.exports.counter).toBe(1); + // Reassignment in the source module is observed through the re-export. + sourceExports.counter = 42; + expect(context.exports.counter).toBe(42); + }); + + test('export * forwards live and respects explicit-export precedence', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + ` + export * from './source'; + export const own = 'own'; + `, + liveOpts, + ), + ).code; + + const sourceExports = { + a: 1, + own: 'star should not win', + default: 'star default', + __esModule: true, + } as {[string]: $FlowFixMe}; + const context = { + exports: {} as {[string]: $FlowFixMe}, + require: (_id: string) => sourceExports, + }; + + vm.runInNewContext(transformedCode, context); + + expect(context.exports.a).toBe(1); + // Explicit export wins over `export *`. + expect(context.exports.own).toBe('own'); + // `export *` never forwards `default` or `__esModule`. + expect(context.exports.default).toBeUndefined(); + // Forwarded names are live. + sourceExports.a = 2; + expect(context.exports.a).toBe(2); + }); +}); + test('enables module exporting when something is exported', () => { const code = ` foo(); diff --git a/packages/metro-transform-plugins/src/import-export-plugin.js b/packages/metro-transform-plugins/src/import-export-plugin.js index e9d1809c46..1ef3f0b199 100644 --- a/packages/metro-transform-plugins/src/import-export-plugin.js +++ b/packages/metro-transform-plugins/src/import-export-plugin.js @@ -35,13 +35,21 @@ import nullthrows from 'nullthrows'; export type Options = Readonly<{ importDefault: string, importAll: string, + liveBindings?: boolean, resolve: boolean, out?: {isESModule: boolean, ...}, }>; type State = { exportAll: Array<{file: string, loc: ?SourceLocation, ...}>, + exportAllLive: Array<{source: Node, loc: ?SourceLocation, ...}>, exportDefault: Array<{local: string, loc: ?SourceLocation, ...}>, + exportGetters: Array<{ + remote: string, + value: Expression, + loc: ?SourceLocation, + ... + }>, exportNamed: Array<{ local: string, remote: string, @@ -101,6 +109,64 @@ const exportTemplate = template.statement(` exports.REMOTE = LOCAL; `); +/** + * Live re-export forwarding ("export {x} from '...'"): defines a getter on + * exports so that reads observe the current value in the source module, which + * may change after this module is evaluated. + */ +const exportGetterTemplate = template.statement(` + Object.defineProperty(exports, REMOTE, { + enumerable: true, + configurable: true, + get: function () { + return VALUE; + }, + }); +`); + +/** + * Reads a named binding from a required module, used inside a live re-export + * getter. + */ +const requireMemberTemplate = template.expression(` + require(FILE).REMOTE +`); + +/** + * Calls an import helper, used inside a live default re-export getter. + */ +const importCallTemplate = template.expression(` + IMPORT(FILE) +`); + +/** + * Live "export all" ("export * from '...'"): defines a getter for each of the + * source module's own enumerable names, except "default"/"__esModule" and names + * already exported by this module (explicit exports take precedence). Reads stay + * live. + */ +const exportAllLiveTemplate = template.statements(` + var REQUIRED = require(FILE); + + Object.keys(REQUIRED).forEach(function (KEY) { + if ( + KEY === "default" || + KEY === "__esModule" || + Object.prototype.hasOwnProperty.call(exports, KEY) + ) { + return; + } + + Object.defineProperty(exports, KEY, { + enumerable: true, + configurable: true, + get: function () { + return REQUIRED[KEY]; + }, + }); + }); +`); + /** * Flags the exported module as a transpiled ES module. Needs to be kept in 1:1 * compatibility with Babel. @@ -179,14 +245,24 @@ export default function importExportPlugin({ loc, }); - withLocation( - exportAllTemplate({ - FILE: resolvePath(t.cloneNode(file), state.opts.resolve), - REQUIRED: path.scope.generateUidIdentifier(file.value), - KEY: path.scope.generateUidIdentifier('key'), - }), - loc, - ).forEach(node => state.imports.push({node})); + if (state.opts.liveBindings === true) { + // Defer emission to Program.exit so explicit exports (which take + // precedence) are already defined on `exports` when the live getters + // are installed. + state.exportAllLive.push({ + source: resolvePath(t.cloneNode(file), state.opts.resolve), + loc, + }); + } else { + withLocation( + exportAllTemplate({ + FILE: resolvePath(t.cloneNode(file), state.opts.resolve), + REQUIRED: path.scope.generateUidIdentifier(file.value), + KEY: path.scope.generateUidIdentifier('key'), + }), + loc, + ).forEach(node => state.imports.push({node})); + } path.remove(); }, @@ -294,6 +370,39 @@ export default function importExportPlugin({ const local = s.local; if (path.node.source) { + const source = nullthrows(path.node.source); + + if (state.opts.liveBindings === true) { + // Re-export forwarding must be live: the source binding can be + // reassigned after this module is evaluated, so we install a + // getter rather than snapshotting the value. + const value: Expression = + // $FlowFixMe[incompatible-use] + local.name === 'default' + ? importCallTemplate({ + IMPORT: t.cloneNode(state.importDefault), + FILE: resolvePath( + t.cloneNode(source), + state.opts.resolve, + ), + }) + : requireMemberTemplate({ + FILE: resolvePath( + t.cloneNode(source), + state.opts.resolve, + ), + // $FlowFixMe[incompatible-call] + REMOTE: t.cloneNode(local), + }); + + state.exportGetters.push({ + remote: remote.name, + value, + loc, + }); + return; + } + // $FlowFixMe[incompatible-use] const temp = path.scope.generateUidIdentifier(local.name); @@ -511,7 +620,9 @@ export default function importExportPlugin({ Program: { enter(path: NodePath, state: State): void { state.exportAll = []; + state.exportAllLive = []; state.exportDefault = []; + state.exportGetters = []; state.exportNamed = []; state.imports = []; @@ -564,10 +675,48 @@ export default function importExportPlugin({ }, ); + // Live re-export forwarding getters (named/default `export … from`). + // Emitted after the explicit data-property exports above so that, by + // the time the live `export *` loops below run, `exports` already owns + // every explicitly-exported name. + state.exportGetters.forEach( + (e: { + remote: string, + value: Expression, + loc: ?SourceLocation, + ... + }) => { + body.push( + withLocation( + exportGetterTemplate({ + REMOTE: t.stringLiteral(e.remote), + VALUE: e.value, + }), + e.loc, + ), + ); + }, + ); + + // Live `export * from` forwarding loops. + state.exportAllLive.forEach( + (e: {source: Node, loc: ?SourceLocation, ...}) => { + withLocation( + exportAllLiveTemplate({ + REQUIRED: path.scope.generateUidIdentifier('exportAll'), + FILE: e.source, + KEY: path.scope.generateUidIdentifier('key'), + }), + e.loc, + ).forEach(node => body.push(node)); + }, + ); + if ( state.exportDefault.length || state.exportAll.length || - state.exportNamed.length + state.exportNamed.length || + state.exportGetters.length ) { body.unshift(esModuleExportTemplate()); if (state.opts.out) { @@ -576,6 +725,116 @@ export default function importExportPlugin({ } else if (state.opts.out) { state.opts.out.isESModule = false; } + + if (state.opts.liveBindings === true) { + // Recompute scope information now that import/export declarations + // have been rewritten, so that `constantViolations` reflect the + // final tree. + path.scope.crawl(); + + // Map each exported local binding to the remote name(s) it is + // exposed as. + const localToRemotes: Map> = new Map(); + const addLocalRemote = (local: string, remote: string): void => { + const remotes = localToRemotes.get(local); + if (remotes != null) { + remotes.push(remote); + } else { + localToRemotes.set(local, [remote]); + } + }; + state.exportNamed.forEach(e => addLocalRemote(e.local, e.remote)); + state.exportDefault.forEach(e => + addLocalRemote(e.local, 'default'), + ); + + const exportsMember = (remote: string) => + t.memberExpression(t.identifier('exports'), t.identifier(remote)); + + // value -> exports.r1 = exports.r2 = ... = value + const mirrorInto = ( + remotes: Array, + value: Expression, + ): Expression => { + let expr: Expression = value; + for (const remote of remotes) { + expr = t.assignmentExpression('=', exportsMember(remote), expr); + } + return expr; + }; + + // True where the update expression's own value cannot be observed, + // so a postfix update may be rewritten without preserving it. + const isValueDiscarded = (violation: NodePath<>): boolean => { + const parent = violation.parentPath; + if (parent == null) { + return false; + } + return ( + parent.isExpressionStatement() || + (parent.isForStatement() && + parent.node.update === violation.node) + ); + }; + + for (const [local, remotes] of localToRemotes) { + const binding = path.scope.getBinding(local); + if (binding == null) { + continue; + } + for (const violation of binding.constantViolations) { + const vnode = violation.node; + if (t.isAssignmentExpression(vnode)) { + if (!t.isIdentifier(vnode.left, {name: local})) { + // Deferred: destructuring / non-identifier assignment + // targets. + continue; + } + // x = v -> exports.r1 = exports.r2 = (x = v) + violation.replaceWith(mirrorInto(remotes, vnode)); + violation.skip(); + } else if (t.isUpdateExpression(vnode)) { + if (!t.isIdentifier(vnode.argument, {name: local})) { + continue; + } + if (vnode.prefix === true || isValueDiscarded(violation)) { + // ++x -> exports.r1 = ++x + // + // Postfix takes this path too where its value is + // unobservable: prefix and postfix have identical side + // effects, so switching form avoids needing a temporary. + violation.replaceWith( + mirrorInto( + remotes, + t.updateExpression( + vnode.operator, + vnode.argument, + true, + ), + ), + ); + violation.skip(); + continue; + } + // x++ -> (t = x++, exports.r1 = x, t) + // + // Postfix evaluates to the *old* value, so it must be held in + // a temporary: mirroring reads the new value, and the outer + // expression has to keep yielding the old one. + const temp = path.scope.generateUidIdentifier(local); + path.scope.push({id: t.cloneNode(temp)}); + violation.replaceWith( + t.sequenceExpression([ + t.assignmentExpression('=', t.cloneNode(temp), vnode), + mirrorInto(remotes, t.identifier(local)), + t.cloneNode(temp), + ]), + ); + violation.skip(); + } + } + } + } }, }, },