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(); + } + } + } + } }, }, },