diff --git a/.changeset/hermes-source-maps-after-emit.md b/.changeset/hermes-source-maps-after-emit.md new file mode 100644 index 000000000..025168ee8 --- /dev/null +++ b/.changeset/hermes-source-maps-after-emit.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": patch +--- + +Fix `HermesBytecodePlugin` skipping source map composition for many chunks. The plugin ran in `assetEmitted`, where a chunk's `.map` file might not be written yet, so hermesc ran without source maps, embedded debug info in the bytecode and skipped source map composition. It now runs in `afterEmit`, once all assets are on disk. diff --git a/packages/repack/src/plugins/HermesBytecodePlugin/HermesBytecodePlugin.ts b/packages/repack/src/plugins/HermesBytecodePlugin/HermesBytecodePlugin.ts index 2a8159066..8f7c7fbf4 100644 --- a/packages/repack/src/plugins/HermesBytecodePlugin/HermesBytecodePlugin.ts +++ b/packages/repack/src/plugins/HermesBytecodePlugin/HermesBytecodePlugin.ts @@ -105,50 +105,60 @@ export class HermesBytecodePlugin { const hermesCLIPath = this.config.hermesCLIPath || getHermesCLIPath(reactNativePath); - compiler.hooks.assetEmitted.tapPromise( - { name: 'RepackHermesBytecodePlugin', stage: 10 }, - async (file, { outputPath }) => { - const shouldTransformAsset = - compiler.webpack.ModuleFilenameHelpers.matchObject( - { - test: this.config.test, - include: this.config.include, - exclude: this.config.exclude, - }, - file - ); - - if (!shouldTransformAsset) { - return; - } - - const bundlePath = path.join(outputPath, file); - const sourceMapPath = `${bundlePath}.map`; - const useSourceMaps = await fs.promises - .access(sourceMapPath) - .then(() => true) - .catch(() => false); - - logger.debug(`Starting hermes compilation for asset: ${bundlePath}`); - - const { sourceMap: hermesSourceMapPath } = - await transformBundleToHermesBytecode({ - hermesCLIPath, - useSourceMaps, - bundlePath, - }); - - logger.info(`Asset transformed: ${file}`); - - if (useSourceMaps) { - await composeSourceMaps({ - reactNativePath, - packagerMapPath: sourceMapPath, - compilerMapPath: hermesSourceMapPath, - }); - - logger.info(`Asset sourceMap transformed: ${file}.map`); - } + // Run once every asset is on disk: a chunk's `.map` can still be unwritten when its own + // `assetEmitted` fires, which made hermesc run without source maps and skipped composition. + compiler.hooks.afterEmit.tapPromise( + 'RepackHermesBytecodePlugin', + async (compilation) => { + const outputPath = compilation.outputOptions.path as string; + + await Promise.all( + compilation.getAssets().map(async ({ name: file }) => { + const shouldTransformAsset = + compiler.webpack.ModuleFilenameHelpers.matchObject( + { + test: this.config.test, + include: this.config.include, + exclude: this.config.exclude, + }, + file + ); + + if (!shouldTransformAsset) { + return; + } + + const bundlePath = path.join(outputPath, file); + const sourceMapPath = `${bundlePath}.map`; + const useSourceMaps = await fs.promises + .access(sourceMapPath) + .then(() => true) + .catch(() => false); + + logger.debug( + `Starting hermes compilation for asset: ${bundlePath}` + ); + + const { sourceMap: hermesSourceMapPath } = + await transformBundleToHermesBytecode({ + hermesCLIPath, + useSourceMaps, + bundlePath, + }); + + logger.info(`Asset transformed: ${file}`); + + if (useSourceMaps) { + await composeSourceMaps({ + reactNativePath, + packagerMapPath: sourceMapPath, + compilerMapPath: hermesSourceMapPath, + }); + + logger.info(`Asset sourceMap transformed: ${file}.map`); + } + }) + ); } ); } diff --git a/packages/repack/src/plugins/__tests__/HermesBytecodePlugin.test.ts b/packages/repack/src/plugins/__tests__/HermesBytecodePlugin.test.ts index 3e33e7841..a89a568ae 100644 --- a/packages/repack/src/plugins/__tests__/HermesBytecodePlugin.test.ts +++ b/packages/repack/src/plugins/__tests__/HermesBytecodePlugin.test.ts @@ -20,6 +20,13 @@ jest.mock('node:fs', () => ({ jest.mock('execa'); +function createCompilation(files: string[]) { + return { + outputOptions: { path: 'output/path/' }, + getAssets: () => files.map((name) => ({ name })), + }; +} + const compilerMock = { context: __dirname, options: { @@ -28,7 +35,7 @@ const compilerMock = { }, }, hooks: { - assetEmitted: { + afterEmit: { tapPromise: jest.fn(), }, }, @@ -56,7 +63,7 @@ describe('HermesBytecodePlugin', () => { pluginInstance.apply(compilerMock as unknown as Compiler); - expect(compilerMock.hooks.assetEmitted.tapPromise).not.toHaveBeenCalled(); + expect(compilerMock.hooks.afterEmit.tapPromise).not.toHaveBeenCalled(); }); it('overrides compareBeforeEmit webpack option by default', () => { @@ -87,9 +94,9 @@ describe('HermesBytecodePlugin', () => { fsMock.access.mockResolvedValueOnce(); await new Promise((resolve, reject) => { - compilerMock.hooks.assetEmitted.tapPromise.mockImplementationOnce( + compilerMock.hooks.afterEmit.tapPromise.mockImplementationOnce( (_, callback) => { - callback('example.bundle', { outputPath: 'output/path/' }) + callback(createCompilation(['example.bundle'])) .then(resolve) .catch(reject); } @@ -101,9 +108,7 @@ describe('HermesBytecodePlugin', () => { typeof execa.node >; - expect(compilerMock.hooks.assetEmitted.tapPromise).toHaveBeenCalledTimes( - 1 - ); + expect(compilerMock.hooks.afterEmit.tapPromise).toHaveBeenCalledTimes(1); expect(execaMock).toHaveBeenCalledTimes(1); expect(execaMock.mock.calls[0][0]).toEqual('path/to/hermesc'); @@ -127,9 +132,9 @@ describe('HermesBytecodePlugin', () => { fsMock.access.mockRejectedValueOnce(new Error('File not found')); await new Promise((resolve, reject) => { - compilerMock.hooks.assetEmitted.tapPromise.mockImplementationOnce( + compilerMock.hooks.afterEmit.tapPromise.mockImplementationOnce( (_, callback) => { - callback('example.bundle', { outputPath: 'output/path/' }) + callback(createCompilation(['example.bundle'])) .then(resolve) .catch(reject); } @@ -139,14 +144,48 @@ describe('HermesBytecodePlugin', () => { const execaMock = execa as jest.MockedFunction; - expect(compilerMock.hooks.assetEmitted.tapPromise).toHaveBeenCalledTimes( - 1 - ); + expect(compilerMock.hooks.afterEmit.tapPromise).toHaveBeenCalledTimes(1); expect(execaMock).toHaveBeenCalledTimes(1); expect(execaMock.mock.calls[0][0]).toEqual('path/to/hermesc'); }); }); + it('transforms every matching asset and skips excluded ones', async () => { + const pluginInstance = new HermesBytecodePlugin({ + enabled: true, + test: /\.(js)?bundle$/, + exclude: /index.bundle$/, + reactNativePath: 'path/to/react-native', + hermesCLIPath: 'path/to/hermesc', + }); + + const fsMock = fs.promises as jest.Mocked; + fsMock.access.mockResolvedValue(); + + await new Promise((resolve, reject) => { + compilerMock.hooks.afterEmit.tapPromise.mockImplementationOnce( + (_, callback) => { + callback( + createCompilation([ + 'a.chunk.bundle', + 'b.chunk.bundle', + 'index.bundle', + ]) + ) + .then(resolve) + .catch(reject); + } + ); + pluginInstance.apply(compilerMock as unknown as Compiler); + }); + + const execaMock = execa as jest.MockedFunction; + const execaNodeMock = execa.node as jest.MockedFunction; + + expect(execaMock).toHaveBeenCalledTimes(2); + expect(execaNodeMock).toHaveBeenCalledTimes(2); + }); + describe('getHermesCLIPath', () => { const reactNativePath = 'path/to/react-native';