Skip to content

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

Description

@elirangoshen

The problem

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

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:

// 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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions