Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/metro-transform-plugins/src/import-export-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export type Options = Readonly<{
importDefault: string,
importAll: string,
resolve: boolean,
out?: {isESModule: boolean, ...},
out?: {isESModule?: boolean, ...},
}>;

type State = {
Expand Down Expand Up @@ -570,11 +570,11 @@ export default function importExportPlugin({
state.exportNamed.length
) {
body.unshift(esModuleExportTemplate());
// Only ever set a positive signal: a definite ES module by
// presence of export syntax.
if (state.opts.out) {
state.opts.out.isESModule = true;
}
} else if (state.opts.out) {
state.opts.out.isESModule = false;
}
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/metro-transform-worker/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type JsOutput = Readonly<{
lineCount: number;
map: VlqMap;
functionMap: null | undefined | FBSourceFunctionMap;
isESModule?: boolean;
}>;
type: JSFileType;
}>;
Expand Down
153 changes: 153 additions & 0 deletions packages/metro-transform-worker/src/__tests__/index-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,159 @@ test('transforms import/export syntax when experimental flag is on', async () =>
]);
});

describe('isESModule', () => {
test('is true for an ES module (positive hint from import-export-plugin)', async () => {
const result = await Transformer.transform(
baseConfig,
'/root',
'local/file.js',
Buffer.from('export default 42;', 'utf8'),
{...baseTransformOptions, experimentalImportSupport: true},
);

expect(result.output[0].data.isESModule).toBe(true);
});

test('is true for ESM already lowered to CJS by Babel (AST fallback)', async () => {
const contents = [
'Object.defineProperty(exports, "__esModule", { value: true });',
'exports.default = 42;',
].join('\n');

const result = await Transformer.transform(
baseConfig,
'/root',
'local/file.js',
Buffer.from(contents, 'utf8'),
baseTransformOptions,
);

expect(result.output[0].data.isESModule).toBe(true);
});

test('is true for the `exports.__esModule = true` assignment form', async () => {
const contents = [
'exports.__esModule = true;',
'exports.default = 42;',
].join('\n');

const result = await Transformer.transform(
baseConfig,
'/root',
'local/file.js',
Buffer.from(contents, 'utf8'),
baseTransformOptions,
);

expect(result.output[0].data.isESModule).toBe(true);
});

test('is true for the sequence-expression assignment form (`@babel/runtime` helpers)', async () => {
// Shape emitted by every helper under `@babel/runtime/helpers/`:
// module.exports = fn, module.exports.__esModule = true,
// module.exports["default"] = module.exports;
const contents = [
'function _interopRequireDefault(e) { return e; }',
'module.exports = _interopRequireDefault,',
' module.exports.__esModule = true,',
' module.exports["default"] = module.exports;',
].join('\n');

const result = await Transformer.transform(
baseConfig,
'/root',
'local/file.js',
Buffer.from(contents, 'utf8'),
baseTransformOptions,
);

expect(result.output[0].data.isESModule).toBe(true);
});

test('is unset (never false) for a module with no ESM marker but WITH dependencies', async () => {
// A module with any require call could resolve to an ES module at
// runtime (e.g. `module.exports = require('./esm-thing')`), so we can't
// rule out ESM interop without whole-graph analysis. The provably-not-ESM
// rule requires zero dependencies.
const result = await Transformer.transform(
baseConfig,
'/root',
'local/file.js',
Buffer.from("var x = require('./other'); module.exports = x;", 'utf8'),
baseTransformOptions,
);

expect(result.output[0].data.isESModule).toBeUndefined();
});

test('is false for a module with no ESM marker and no dependencies', async () => {
// Not ESM: no marker AND no dependencies means the module would have to
// be deliberately obfuscating emission of `__esModule` at runtime, this is
// sufficient proof of non-ESM for our purposes.
const result = await Transformer.transform(
baseConfig,
'/root',
'local/file.js',
Buffer.from('module.exports = 42;', 'utf8'),
baseTransformOptions,
);

expect(result.output[0].data.isESModule).toBe(false);
});

test('is unset (never false) for a CommonJS re-export of an ES module', async () => {
// At runtime this module IS an ES module: it re-exports `./esm`, whose
// `exports.__esModule` is truthy. In isolation, though, the marker isn't
// statically visible here, so we must leave the hint unset rather than
// asserting a misleading `false` (a false negative).
const result = await Transformer.transform(
baseConfig,
'/root',
'local/file.js',
Buffer.from("module.exports = require('./esm');", 'utf8'),
baseTransformOptions,
);

expect(result.output[0].data.isESModule).toBeUndefined();
});

test('is false for a JSON module (trivially never an ES module)', async () => {
const result = await Transformer.transform(
baseConfig,
'/root',
'local/file.json',
Buffer.from('{"foo": 1}', 'utf8'),
baseTransformOptions,
);

expect(result.output[0].data.isESModule).toBe(false);
});

test('is unset (never false) for a module that only imports (no exports)', async () => {
const result = await Transformer.transform(
baseConfig,
'/root',
'local/file.js',
Buffer.from('import "./c";', 'utf8'),
{...baseTransformOptions, experimentalImportSupport: true},
);

expect(result.output[0].data.isESModule).toBeUndefined();
});

test('is unset (never false) for a script', async () => {
const result = await Transformer.transform(
baseConfig,
'/root',
'local/file.js',
Buffer.from('doStuff();', 'utf8'),
{...baseTransformOptions, type: 'script'},
);

expect(result.output[0].data.isESModule).toBeUndefined();
});
});

test('does not add "use strict" on non-modules', async () => {
const result = await Transformer.transform(
baseConfig,
Expand Down
74 changes: 73 additions & 1 deletion packages/metro-transform-worker/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ import {
} from 'metro-source-map';
import metroTransformPlugins from 'metro-transform-plugins';
import collectDependencies from 'metro/private/ModuleGraph/worker/collectDependencies';
import {
canDefineESModuleInterop,
definesESModuleInterop,
} from 'metro/private/ModuleGraph/worker/esmClassification';
import generateImportNames from 'metro/private/ModuleGraph/worker/generateImportNames';
import {
importLocationsPlugin,
Expand Down Expand Up @@ -173,6 +177,26 @@ export type JsOutput = Readonly<{
lineCount: number,
map: VlqMap,
functionMap: ?FBSourceFunctionMap,
// ESM-interop signal.
//
// `true` - definitely an ES module (a truthy top-level
// `exports.__esModule`, as emitted by Metro's own ESM
// transform or by ESM precompiled to CJS by Babel/tsc, i.e.
// a module with a real `.default`).
// `false` - provably NOT an ES module: either trivially (JSON) or
// because the module has no ESM interop marker AND no
// dependencies at all, so it cannot expose ESM interop at
// runtime (nothing to re-export via `module.exports =
// require('./esm')`). Common at FBiOS scale via generated
// Relay fragments and similar build-generated data modules.
// unset - undetermined. A module with `require(...)` calls but no
// ESM marker could still expose ESM interop at runtime, so
// the classifier stays silent. Consumers must fall back to
// the runtime interop helper.
//
// Serialiser-level rewrites use this tri-state to bypass the runtime
// interop helper for definitively-classified reads.
isESModule?: boolean,
}>,
type: JSFileType,
}>;
Expand Down Expand Up @@ -299,12 +323,18 @@ async function transformJS(
// fold requires and perform constant folding (if in dev).
const plugins: Array<PluginEntry> = [];

// Positive-only ESM hint from the import-export-plugin (set to `true` for a
// definite ES module, left unset otherwise). Forwarded to collectDependencies,
// which falls back to AST detection when it is unset.
const importExportOut: {isESModule?: boolean} = {};

if (options.experimentalImportSupport === true) {
plugins.push([
metroTransformPlugins.importExportPlugin,
{
importAll,
importDefault,
out: importExportOut,
resolve: false,
} as ImportExportPluginOptions,
]);
Expand Down Expand Up @@ -376,6 +406,19 @@ async function transformJS(

let dependencyMapName = '';
let dependencies;
let isESModule = false;
// No ESM marker, no dependencies, and no expression anywhere in the module
// that could define `exports.__esModule` out of view of the top-level scan
// (see `canDefineESModuleInterop`). The dependency check is retained
// separately because a module with dependencies could re-export an ES module
// wholesale - `module.exports = require('./esm.js')` - which is a runtime
// property of the graph rather than of this module's syntax.
//
// Note this establishes the absence of ESM interop, not the presence of
// CommonJS - a script or an empty module qualifies too. Sufficient to cover
// the common FBiOS case: generated Relay fragments and other build-generated
// data modules that literal-export constants and never require anything else.
let hasNoESModuleInterop = false;
let wrappedAst;

// If the module to transform is a script (meaning that is not part of the
Expand Down Expand Up @@ -410,6 +453,14 @@ async function transformJS(
: null,
};
({ast, dependencies, dependencyMapName} = collectDependencies(ast, opts));
// Positive-only hint from the import-export-plugin (a definite ES module),
// otherwise infer from the AST (catches ESM already lowered to CJS by
// Babel/tsc, where the plugin saw no ESM syntax).
isESModule = importExportOut.isESModule ?? definesESModuleInterop(ast);
hasNoESModuleInterop =
!isESModule &&
dependencies.length === 0 &&
!canDefineESModuleInterop(ast);
} catch (error) {
if (error instanceof InternalInvalidRequireCallError) {
throw new InvalidRequireCallError(error, file.filename);
Expand Down Expand Up @@ -513,6 +564,18 @@ async function transformJS(
functionMap: file.functionMap,
lineCount,
map,
// A tri-state signal (see JsOutput.data.isESModule):
// `true` - definitely an ES module (positive ESM check).
// `false` - definitely no ESM interop: no marker, no dependencies,
// and no expression that could define the marker out of
// view. Not a claim that the module is CommonJS.
// unset - undetermined; consumers must fall back to helper
// behaviour.
...(isESModule
? {isESModule: true}
: hasNoESModuleInterop
? {isESModule: false}
: null),
},
type: file.type,
},
Expand Down Expand Up @@ -638,7 +701,16 @@ async function transformJSON(
const outputMap = vlqMapFromTuples(map);
const output: Array<JsOutput> = [
{
data: {code, functionMap: null, lineCount, map: outputMap},
data: {
code,
functionMap: null,
lineCount,
map: outputMap,
// JSON is trivially never an ES module, so we can assert a definite
// `false` here (unlike the JS path, where an undetected runtime ESM
// means we must leave the hint unset rather than emit a false negative).
isESModule: false,
},
type: jsType,
},
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ export default function getSourceMapInfo(
readonly lineCount: number,
readonly isIgnored: boolean,
} {
const data = getJsOutput(module).data;
return {
...getJsOutput(module).data,
code: data.code,
functionMap: data.functionMap,
lineCount: data.lineCount,
map: data.map,
isIgnored: options.shouldAddToIgnoreList(module),
path: options?.getSourceUrl?.(module) ?? module.path,
source: options.excludeSource ? '' : getModuleSource(module),
Expand Down
Loading
Loading