diff --git a/packages/metro/src/integration_tests/__tests__/build-errors-test.js b/packages/metro/src/integration_tests/__tests__/build-errors-test.js index 9b010f345a..6a39a29b30 100644 --- a/packages/metro/src/integration_tests/__tests__/build-errors-test.js +++ b/packages/metro/src/integration_tests/__tests__/build-errors-test.js @@ -113,6 +113,39 @@ describe('formatting edge cases', () => { await expect(buildPromise).rejects.toMatchSnapshot(); }); + test('reports resolution errors on locations past the end of the source', async () => { + // A Babel plugin appends an unresolvable import whose source location is + // past the end of the file on disk. The resolution error must survive: + // before, building the code frame threw a TypeError that replaced it. + const config = await Metro.loadConfig({ + config: require.resolve('../metro.config.js'), + }); + + const buildPromise = Metro.runBuild( + { + ...config, + transformer: { + ...config.transformer, + babelTransformerPath: + require.resolve('../injectImportPastEndOfFileTransformer'), + }, + }, + {entry: 'build-errors/transform-injected-import.js'}, + ); + + // Deliberately not a snapshot: the resolver's list of candidate paths + // varies between versions, and what matters here is only that the + // resolution error survives with a code frame of the lines that do exist. + const error = await buildPromise.then( + () => null, + (e: Error) => e, + ); + expect(error?.message).toMatch( + /^Unable to resolve module \.\/does-not-exist/, + ); + expect(error?.message).toContain('13 | global.x = 1;'); + }); + test('reports resolution errors with embedded comment after the specifier', async () => { const config = await Metro.loadConfig({ config: require.resolve('../metro.config.js'), diff --git a/packages/metro/src/integration_tests/basic_bundle/build-errors/transform-injected-import.js b/packages/metro/src/integration_tests/basic_bundle/build-errors/transform-injected-import.js new file mode 100644 index 0000000000..0df51fd906 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/build-errors/transform-injected-import.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +// The unresolvable import in this module is added by a Babel plugin, not +// written here - see injectImportPastEndOfFileTransformer.js. +global.x = 1; diff --git a/packages/metro/src/integration_tests/injectImportPastEndOfFileTransformer.js b/packages/metro/src/integration_tests/injectImportPastEndOfFileTransformer.js new file mode 100644 index 0000000000..e75b85391a --- /dev/null +++ b/packages/metro/src/integration_tests/injectImportPastEndOfFileTransformer.js @@ -0,0 +1,67 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +'use strict'; + +import type {PluginObj} from '@babel/core'; +import type { + BabelTransformer, + BabelTransformerArgs, +} from 'metro-babel-transformer'; + +const {parse} = require('@babel/parser'); +const baseTransformer = require('@react-native/metro-babel-transformer'); + +const TARGET_FILE = 'transform-injected-import.js'; +const INJECTED_SPECIFIER = './does-not-exist'; + +/** + * Returns a Babel plugin that appends `import './does-not-exist';` to the + * module, parsed at `startLine` - which is what a plugin that generates and + * appends code does, so that the generated code's locations do not overlap the + * author's. + * + * Given a `startLine` past the end of the file, the dependency Metro collects + * reports a source location that has no counterpart in the file on disk, which + * is what the resolution error's code frame has to tolerate. + */ +function injectImportAt(startLine: number): () => PluginObj<> { + return () => ({ + name: 'metro-test-inject-import-past-end-of-file', + visitor: { + Program(path) { + const generated = parse(`import '${INJECTED_SPECIFIER}';`, { + sourceType: 'module', + startLine, + }); + path.node.body.push(...generated.program.body); + }, + }, + }); +} + +function transform(args: BabelTransformerArgs) { + if (!args.filename.endsWith(TARGET_FILE)) { + return baseTransformer.transform(args); + } + // One line past the last line of the source, so the injected import's + // location does not exist in the file on disk. + const startLine = args.src.split('\n').length + 1; + return baseTransformer.transform({ + ...args, + plugins: [...(args.plugins ?? []), injectImportAt(startLine)], + }); +} + +module.exports = { + transform, + getCacheKey: baseTransformer.getCacheKey, +} as BabelTransformer; diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index fef3b7fa63..d48a00125d 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -373,6 +373,11 @@ function refineDependencyLocation( // Note that module names may not always be found in the source code verbatim, // whether because of escaping or because of exotic dependency APIs. for (let line = loc.end.line - 1; line >= loc.start.line - 1; line--) { + // The error occurred in code that was added by a transform, then it + // won't be present in the original source code. + if (lines[line] == null) { + continue; + } const maxColumn = line === loc.end.line ? loc.end.column + 2 : lines[line].length; const minColumn = line === loc.start.line ? loc.start.column - 1 : 0;