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
5 changes: 5 additions & 0 deletions .changeset/hermes-source-maps-after-emit.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,13 @@ export class HermesBytecodePlugin {
const hermesCLIPath =
this.config.hermesCLIPath || getHermesCLIPath(reactNativePath);

compiler.hooks.assetEmitted.tapPromise(
{ name: 'RepackHermesBytecodePlugin', stage: 10 },
async (file, { outputPath }) => {
const emittedAssets = new Map<string, string>();

// 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(
{
Expand All @@ -118,37 +122,53 @@ export class HermesBytecodePlugin {
file
);

if (!shouldTransformAsset) {
return;
if (shouldTransformAsset) {
emittedAssets.set(file, outputPath);
}
}
);

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`);
}
// 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(
{ name: 'RepackHermesBytecodePlugin', stage: -100 },
async () => {
const assets = Array.from(emittedAssets);
emittedAssets.clear();

await Promise.all(
assets.map(async ([file, outputPath]) => {
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`);
}
})
);
}
);
}
Expand Down
96 changes: 81 additions & 15 deletions packages/repack/src/plugins/__tests__/HermesBytecodePlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ jest.mock('node:fs', () => ({

jest.mock('execa');

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 = {
context: __dirname,
options: {
Expand All @@ -29,6 +36,9 @@ const compilerMock = {
},
hooks: {
assetEmitted: {
tap: jest.fn(),
},
afterEmit: {
tapPromise: jest.fn(),
},
},
Expand Down Expand Up @@ -56,7 +66,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', () => {
Expand Down Expand Up @@ -87,11 +97,10 @@ describe('HermesBytecodePlugin', () => {
fsMock.access.mockResolvedValueOnce();

await new Promise<void>((resolve, reject) => {
compilerMock.hooks.assetEmitted.tapPromise.mockImplementationOnce(
compilerMock.hooks.afterEmit.tapPromise.mockImplementationOnce(
(_, callback) => {
callback('example.bundle', { outputPath: 'output/path/' })
.then(resolve)
.catch(reject);
emitAssets(['example.bundle']);
callback().then(resolve).catch(reject);
}
);
pluginInstance.apply(compilerMock as unknown as Compiler);
Expand All @@ -101,9 +110,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');

Expand All @@ -127,26 +134,85 @@ describe('HermesBytecodePlugin', () => {
fsMock.access.mockRejectedValueOnce(new Error('File not found'));

await new Promise<void>((resolve, reject) => {
compilerMock.hooks.assetEmitted.tapPromise.mockImplementationOnce(
compilerMock.hooks.afterEmit.tapPromise.mockImplementationOnce(
(_, callback) => {
callback('example.bundle', { outputPath: 'output/path/' })
.then(resolve)
.catch(reject);
emitAssets(['example.bundle']);
callback().then(resolve).catch(reject);
}
);
pluginInstance.apply(compilerMock as unknown as Compiler);
});

const execaMock = execa as jest.MockedFunction<typeof execa>;

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<typeof fs.promises>;
fsMock.access.mockResolvedValue();

await new Promise<void>((resolve, reject) => {
compilerMock.hooks.afterEmit.tapPromise.mockImplementationOnce(
(_, callback) => {
emitAssets(['a.chunk.bundle', 'b.chunk.bundle', 'index.bundle']);
callback().then(resolve).catch(reject);
}
);
pluginInstance.apply(compilerMock as unknown as Compiler);
});

const execaMock = execa as jest.MockedFunction<typeof execa>;
const execaNodeMock = execa.node as jest.MockedFunction<typeof execa.node>;

expect(execaMock).toHaveBeenCalledTimes(2);
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<typeof fs.promises>;
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<typeof execa>;
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';

Expand Down
Loading