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,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`);
}
})
);
}
);
}
Expand Down
63 changes: 51 additions & 12 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 createCompilation(files: string[]) {
return {
outputOptions: { path: 'output/path/' },
getAssets: () => files.map((name) => ({ name })),
};
}

const compilerMock = {
context: __dirname,
options: {
Expand All @@ -28,7 +35,7 @@ const compilerMock = {
},
},
hooks: {
assetEmitted: {
afterEmit: {
tapPromise: jest.fn(),
},
},
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -87,9 +94,9 @@ 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/' })
callback(createCompilation(['example.bundle']))
.then(resolve)
.catch(reject);
}
Expand All @@ -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');

Expand All @@ -127,9 +132,9 @@ 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/' })
callback(createCompilation(['example.bundle']))
.then(resolve)
.catch(reject);
}
Expand All @@ -139,14 +144,48 @@ describe('HermesBytecodePlugin', () => {

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) => {
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<typeof execa>;
const execaNodeMock = execa.node as jest.MockedFunction<typeof execa.node>;

expect(execaMock).toHaveBeenCalledTimes(2);
expect(execaNodeMock).toHaveBeenCalledTimes(2);
});

describe('getHermesCLIPath', () => {
const reactNativePath = 'path/to/react-native';

Expand Down