callstack / callstack/repack

babelLoader always uses hermes-parser, unlike Metro — 90s on one prebuilt file

Open
#1,447 3 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
1.9k
Forks
165
Avg merge
11d 16h
Merged PRs (30d)
9

Description

### The problem

`babelLoader` parses every non-TypeScript file with hermes-parser (`packages/repack/src/loaders/babelLoader/babelLoader.ts:89-96`):

```ts
const sourceAst = isTS || isTSX
? parseSync(src, babelConfig)
: hermesParser.parse(src, { babel: true, ... });
```

Metro does not. `@react-native/babel-preset` passes `parseLangTypes: 'flow'` to `babel-plugin-syntax-hermes-parser`, which skips hermes-parser when a file has no `@flow` pragma and lets `@babel/parser` handle it:

```js
// babel-plugin-syntax-hermes-parser/dist/index.js:56
if (parseLangTypes === 'flow' && !/@flow/.test(code)) { return; }
```

So Re.Pack ignores the preset's own `parseLangTypes`, and a dependency that is free under Metro can cost minutes here.

### What it cost us

We bundle the Expensify app with Re.Pack. Our cold production iOS build was ~114s, against Metro's ~83s. One file accounted for 94s of that:

`@lottiefiles/dotlottie-react/dist/browser/index.js` — 502 KB, 22 lines, prebuilt minified ESM, no `@flow` pragma.

| parser | time |
|---|---|
| hermes-parser | **90.1s** |
| @babel/parser | **0.1s** |

That is not the parse itself. `parse(src, {babel: false})` takes 0.1s; all 88s is in hermes-parser's Hermes-AST to Babel-AST conversion, which is quadratic in the number of sibling nodes: every node replacement copies the whole sibling array. This file has one `ArrayExpression` with 128,834 elements. Reported upstream with a profile and a minimal repro: facebook/hermes#2158.

Because it is one serial task, it set a minimum time for the whole build no matter how many workers we gave it.

It was also hard to spot: no warning, and nothing showed up in per-loader timings. We only found it by bisecting.

Across our whole babel lane (3522 files), 2779 have no pragma. Those parse in 1.1s with `@babel/parser` versus 4.7s with hermes-parser — and hermes-parser fails on 2 of them.

### Why not simply switch parsers

hermes-parser is still needed for the other files. `@babel/parser` fails on 241 files in react-native 0.86, which use Flow syntax it cannot read (`as` casts, `component(...)` types). The pragma check is the fix, not a swap.

### Suggested fix

```ts
const sourceAst = isTS || isTSX
? parseSync(src, babelConfig)
: /@flow/.test(src)
? hermesParser.parse(src, { babel: true, ... })
: parseSync(src, babelConfig);
```

Better still, read the preset's `parseLangTypes` instead of hard-coding the check, so `parseLangTypes: 'all'` still sends everything to hermes-parser.

### We have tested this

We run it in our app through the existing `hermesParserPath` option, which `babelSwcLoader` passes through. Our replacement parser checks for `@flow` and picks hermes-parser only then.

- bundle output is **byte-identical**, apart from the sourcemap filename
- build time is flat, because we had already moved that package to another loader
- the file above: **90.1s → 0.1s**

Identical output on a 35 MB bundle shows the check does not change what gets built.

Happy to open a PR.

### Related

#1422 covers Flow in the SWC-native lane (`getJsTransformRules`). This report is about the babel lane that the templates use, so they do not overlap, but both come down to how Flow is detected.

### Environment

`@callstack/repack@5.2.5`, Rspack 2.1.3, `hermes-parser@0.36.1`, `@babel/parser@7.29.7`, react-native 0.86, Node 26.5.0, macOS arm64.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.