From 3298780df40eae990cd18319923c669853a1c22f Mon Sep 17 00:00:00 2001 From: Emanuele Peruffo Date: Tue, 20 Oct 2026 16:27:00 +0200 Subject: [PATCH 1/2] fix: compose Hermes source maps for every chunk --- .changeset/hermes-source-maps-after-emit.md | 5 + .../HermesBytecodePlugin.ts | 98 ++++++++++--------- .../__tests__/HermesBytecodePlugin.test.ts | 63 +++++++++--- 3 files changed, 110 insertions(+), 56 deletions(-) create mode 100644 .changeset/hermes-source-maps-after-emit.md 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'; From 6eb39e72b27026a9800efb28f39e42ed74d286b8 Mon Sep 17 00:00:00 2001 From: Emanuele Peruffo Date: Mon, 2 Nov 2026 14:28:44 +0100 Subject: [PATCH 2/2] fix: only transform assets emitted in the current compilation --- .../HermesBytecodePlugin.ts | 48 +++++++------ .../__tests__/HermesBytecodePlugin.test.ts | 67 +++++++++++++------ 2 files changed, 76 insertions(+), 39 deletions(-) diff --git a/packages/repack/src/plugins/HermesBytecodePlugin/HermesBytecodePlugin.ts b/packages/repack/src/plugins/HermesBytecodePlugin/HermesBytecodePlugin.ts index 8f7c7fbf4..227069152 100644 --- a/packages/repack/src/plugins/HermesBytecodePlugin/HermesBytecodePlugin.ts +++ b/packages/repack/src/plugins/HermesBytecodePlugin/HermesBytecodePlugin.ts @@ -105,29 +105,39 @@ export class HermesBytecodePlugin { const hermesCLIPath = this.config.hermesCLIPath || getHermesCLIPath(reactNativePath); - // Run once every asset is on disk: a chunk's `.map` can still be unwritten when its own + const emittedAssets = new Map(); + + // Only assets written in this compilation: on rebuilds in the same process, unchanged + // assets are not re-emitted and are already bytecode on disk. + compiler.hooks.assetEmitted.tap( + 'RepackHermesBytecodePlugin', + (file, { outputPath }) => { + const shouldTransformAsset = + compiler.webpack.ModuleFilenameHelpers.matchObject( + { + test: this.config.test, + include: this.config.include, + exclude: this.config.exclude, + }, + file + ); + + if (shouldTransformAsset) { + emittedAssets.set(file, outputPath); + } + } + ); + + // Transform 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; + { name: 'RepackHermesBytecodePlugin', stage: -100 }, + async () => { + const assets = Array.from(emittedAssets); + emittedAssets.clear(); 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; - } - + assets.map(async ([file, outputPath]) => { const bundlePath = path.join(outputPath, file); const sourceMapPath = `${bundlePath}.map`; const useSourceMaps = await fs.promises diff --git a/packages/repack/src/plugins/__tests__/HermesBytecodePlugin.test.ts b/packages/repack/src/plugins/__tests__/HermesBytecodePlugin.test.ts index a89a568ae..d4625da68 100644 --- a/packages/repack/src/plugins/__tests__/HermesBytecodePlugin.test.ts +++ b/packages/repack/src/plugins/__tests__/HermesBytecodePlugin.test.ts @@ -20,11 +20,11 @@ jest.mock('node:fs', () => ({ jest.mock('execa'); -function createCompilation(files: string[]) { - return { - outputOptions: { path: 'output/path/' }, - getAssets: () => files.map((name) => ({ name })), - }; +function emitAssets(files: string[]) { + const [, onAssetEmitted] = compilerMock.hooks.assetEmitted.tap.mock.calls[0]; + for (const file of files) { + onAssetEmitted(file, { outputPath: 'output/path/' }); + } } const compilerMock = { @@ -35,6 +35,9 @@ const compilerMock = { }, }, hooks: { + assetEmitted: { + tap: jest.fn(), + }, afterEmit: { tapPromise: jest.fn(), }, @@ -96,9 +99,8 @@ describe('HermesBytecodePlugin', () => { await new Promise((resolve, reject) => { compilerMock.hooks.afterEmit.tapPromise.mockImplementationOnce( (_, callback) => { - callback(createCompilation(['example.bundle'])) - .then(resolve) - .catch(reject); + emitAssets(['example.bundle']); + callback().then(resolve).catch(reject); } ); pluginInstance.apply(compilerMock as unknown as Compiler); @@ -134,9 +136,8 @@ describe('HermesBytecodePlugin', () => { await new Promise((resolve, reject) => { compilerMock.hooks.afterEmit.tapPromise.mockImplementationOnce( (_, callback) => { - callback(createCompilation(['example.bundle'])) - .then(resolve) - .catch(reject); + emitAssets(['example.bundle']); + callback().then(resolve).catch(reject); } ); pluginInstance.apply(compilerMock as unknown as Compiler); @@ -165,15 +166,8 @@ describe('HermesBytecodePlugin', () => { 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); + emitAssets(['a.chunk.bundle', 'b.chunk.bundle', 'index.bundle']); + callback().then(resolve).catch(reject); } ); pluginInstance.apply(compilerMock as unknown as Compiler); @@ -186,6 +180,39 @@ describe('HermesBytecodePlugin', () => { expect(execaNodeMock).toHaveBeenCalledTimes(2); }); + it('ignores unchanged assets on rebuild', 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(); + + pluginInstance.apply(compilerMock as unknown as Compiler); + const [, afterEmit] = compilerMock.hooks.afterEmit.tapPromise.mock.calls[0]; + + emitAssets(['a.chunk.bundle', 'b.chunk.bundle']); + await afterEmit(); + + emitAssets(['b.chunk.bundle']); + await afterEmit(); + + const execaMock = execa as jest.MockedFunction; + const transformedBundles = execaMock.mock.calls.map(([, args]) => + (args as string[]).at(-1) + ); + + expect(transformedBundles).toEqual([ + path.join('output/path/', 'a.chunk.bundle'), + path.join('output/path/', 'b.chunk.bundle'), + path.join('output/path/', 'b.chunk.bundle'), + ]); + }); + describe('getHermesCLIPath', () => { const reactNativePath = 'path/to/react-native';