diff --git a/packages/metro/src/Bundler.js b/packages/metro/src/Bundler.js index 947c9b969f..e344e93729 100644 --- a/packages/metro/src/Bundler.js +++ b/packages/metro/src/Bundler.js @@ -46,7 +46,12 @@ export default class Bundler { type: 'transformer_load_failed', error, }); + throw error; }); + + // Observe initialization failures immediately so callers can await the + // original promise later without triggering an unhandled rejection. + this._initializedPromise.catch(() => {}); } getWatcher(): EventEmitter { diff --git a/packages/metro/src/__tests__/Bundler-test.js b/packages/metro/src/__tests__/Bundler-test.js new file mode 100644 index 0000000000..8515b01c5f --- /dev/null +++ b/packages/metro/src/__tests__/Bundler-test.js @@ -0,0 +1,99 @@ +/** + * 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 + * @oncall react_native + */ + +import type {ConfigT} from 'metro-config'; + +import Bundler from '../Bundler'; +import Transformer from '../DeltaBundler/Transformer'; +import DependencyGraph from '../node-haste/DependencyGraph'; +import {getDefaultConfig} from 'metro-config'; + +jest.mock('../DeltaBundler/Transformer'); +jest.mock('../node-haste/DependencyGraph'); + +type ClassMock = JestMockFn, unknown>; + +// $FlowFixMe[incompatible-type] Jest automocks the default export +const MockTransformer: ClassMock = Transformer; +// $FlowFixMe[incompatible-type] Jest automocks the default export +const MockDependencyGraph: ClassMock = DependencyGraph; + +describe('Bundler', () => { + let config: ConfigT; + let reporter; + + beforeEach(() => { + reporter = {update: jest.fn()}; + config = {...getDefaultConfig.getDefaultValues('/'), reporter}; + + MockDependencyGraph.mockImplementation(() => ({ + ready: jest.fn().mockResolvedValue(), + })); + + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test.each([ + ['ready', (bundler: Bundler) => bundler.ready()], + [ + 'transformFile', + // $FlowFixMe[incompatible-type] Transform options are unused before initialization fails + (bundler: Bundler) => bundler.transformFile('/entry.js', {}), + ], + ])( + 'propagates Transformer initialization errors from %s', + async (_method, invoke) => { + const error = new Error('Transformer initialization failed'); + MockTransformer.mockImplementation(() => { + throw error; + }); + + const bundler = new Bundler(config); + + await expect(invoke(bundler)).rejects.toBe(error); + expect(reporter.update).toHaveBeenCalledWith({ + type: 'transformer_load_failed', + error, + }); + }, + ); + + test('does not emit an unhandled rejection before ready is called', async () => { + jest.useRealTimers(); + + const error = new Error('Transformer initialization failed'); + const unhandledRejections: Array = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + + MockTransformer.mockImplementation(() => { + throw error; + }); + process.on('unhandledRejection', onUnhandledRejection); + + try { + const bundler = new Bundler(config); + + await new Promise(resolve => setImmediate(resolve)); + + await expect(bundler.ready()).rejects.toBe(error); + expect(unhandledRejections).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + jest.useFakeTimers(); + } + }); +});