Skip to content

fix: compose Hermes source maps for every chunk - #1516

Open
emanueleperuffo wants to merge 2 commits into
callstack:mainfrom
emanueleperuffo:fix/hermes-source-maps-after-emit
Open

emanueleperuffo wants to merge 2 commits into
callstack:mainfrom
emanueleperuffo:fix/hermes-source-maps-after-emit

Conversation

@emanueleperuffo

Copy link
Copy Markdown

Summary

HermesBytecodePlugin runs hermesc in the assetEmitted hooks and checks whether the chunk's .map exists. A chunk's .map may not have been written yet at that moment, so hermesc runs without source maps:

  • debug info is then embedded in the bytecode (.bundle), making the chunk bigger
  • composeSourceMaps is skipped, so stack traces for that chunk can't be symbolicated back to the original source

This change calls the hermesc from the afterEmit hook instead, once all assets are guaranteed to be written.

Our app has many lazy chunks, and most of them hit the race. With this fix, the debug info is moved from the chunks into the source maps:

Platform Before After Diff
iOS 115.5 MB 111.0 MB −4.45 MB (−3.85%)
Android 133.8 MB 128.9 MB −4.88 MB (−3.65%)

Lazy chunks now ship with a composed source map, and frames resolve to the correct source file and line. The main bundle is not affected, since React Native compiles it with its own hermesc step.

Test plan

  • I bundled our apps and tester-app for Android with the plugin enabled. All chunks were compiled and had their source maps composed.
  • We shipped the fix in our app (webpack) and measured the sizes above, and confirmed that production errors thrown inside lazy chunks are symbolicated to the right source lines.
  • The tester app is too small to reproduce the race with both the old and new code, since it depends on timing and chunk count, so it needs to be tested with bigger apps.

@vercel

vercel Bot commented Oct 30, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6eb39e7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@callstack/repack Patch
@callstack/repack-plugin-expo-modules Patch
@callstack/repack-plugin-nativewind Patch
@callstack/repack-plugin-reanimated Patch
@callstack/repack-dev-server Patch
@callstack/repack-init Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@dannyhw dannyhw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this! I can confirm the race, on Rspack too. On the super-app-showcase trading app, the current plugin composed source maps for only 36/66 chunks, and this PR composes all 66 (4.53 MB → 3.98 MB).

Nit (non-blocking): compilation.getAssets() also returns assets that weren't written in this compilation. When rebuilding in the same process (e.g. bundle --watch with caching enabled), unchanged chunks are skipped and are still bytecode on disk, so hermesc fails on them. Normal release builds aren't affected. Recording files in assetEmitted and transforming them in afterEmit keeps the old "only files written this build" behaviour:

let emittedAssets = new Map<string, string>();

compiler.hooks.assetEmitted.tap('RepackHermesBytecodePlugin', (file, { outputPath }) => {
  emittedAssets.set(file, outputPath);
});

compiler.hooks.afterEmit.tapPromise(
  { name: 'RepackHermesBytecodePlugin', stage: -100 },
  async () => {
    const assets = emittedAssets;
    emittedAssets = new Map();
    await Promise.all(
      Array.from(assets, async ([file, outputPath]) => {
        // …same body as now
      })
    );
  }
);

(Rspack doesn't expose webpack's compilation.emittedAssets, hence the Map.) Happy to push this if you'd like.

@emanueleperuffo

emanueleperuffo commented Oct 31, 2026 •

Copy link
Copy Markdown
Author

Thanks for this! I can confirm the race, on Rspack too. On the super-app-showcase trading app, the current plugin composed source maps for only 36/66 chunks, and this PR composes all 66 (4.53 MB → 3.98 MB).

Nit (non-blocking): compilation.getAssets() also returns assets that weren't written in this compilation. When rebuilding in the same process (e.g. bundle --watch with caching enabled), unchanged chunks are skipped and are still bytecode on disk, so hermesc fails on them. Normal release builds aren't affected. Recording files in assetEmitted and transforming them in afterEmit keeps the old "only files written this build" behaviour:

let emittedAssets = new Map<string, string>();

compiler.hooks.assetEmitted.tap('RepackHermesBytecodePlugin', (file, { outputPath }) => {
  emittedAssets.set(file, outputPath);
});

compiler.hooks.afterEmit.tapPromise(
  { name: 'RepackHermesBytecodePlugin', stage: -100 },
  async () => {
    const assets = emittedAssets;
    emittedAssets = new Map();
    await Promise.all(
      Array.from(assets, async ([file, outputPath]) => {
        // …same body as now
      })
    );
  }
);

(Rspack doesn't expose webpack's compilation.emittedAssets, hence the Map.) Happy to push this if you'd like.

Sure! I'll take a look at it next Monday if it's ok.

@emanueleperuffo

Copy link
Copy Markdown
Author

Hi @dannyhw I have pushed a new commit with your suggestions. I used a emittedAssets.clear() and a const instead of a let just out of my preference for immutable variables. Let me know if it's ok.

I have tried changing our patch with these changes and it works correctly, tests pass and also rebuilds work.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants