@react-native/babel-preset ยท investigation

Removing regenerator from Hermes dev builds

Whether React Native's Babel preset still needs to lower generators in Hermes dev builds, now that Hermes has fixed the bug that made it necessary. Checked through Hermes's own CDP debugger harness on four Hermes versions, with a breakpoint on every line of 248 lines of fixtures.

RN main c7429b0Hermes 250829098.0.16, 260318099.0.1, .0.2, .0.4Babel 7.29macOSInvestigated 2026-10-21Revision 2, 2026-10-21

Summary

VerdictSafe on main and 0.88The Hermes bug behind the workaround is fixed in 260318099.0.2. Main ships .0.2 and 0.88 ships .0.4.
Not on 0.87250829098.0.17No 250829098.x release has the fix, so 0.87 still needs regenerator in dev.
Breakpoint sweep, .0.2 and .0.447 better, 3 worseNative generators against regenerator, over 248 lines. On the pre-fix 250829098.0.16, native was worse on 22.
Reach todayNarrowThe flag only lowers generators when enableBabelRuntime: false, and stock RN CLI dev builds never use a Hermes profile.

The preset turns on regenerator for Hermes-profile dev builds because "it's not possible to inspect local variables when paused in a generator". That was one Hermes bug, affecting breakpoints on the line of a generator's last yield, and it's fixed. With it fixed, native generators debug better than regenerator output on most lines, and removing the flag makes dev output match release.

Native-generator debugging isn't perfect yet. Four smaller bugs remain on .0.2 and .0.4, from the same kind of root cause as the one that was fixed. Most configs already run native generators in dev, though, so most users hit them today regardless of this flag. They need a Hermes fix, not a Babel workaround.

The original bug

A breakpoint on the line of a generator's last yield pauses when the generator is called, before its body has run. The paused frame shows only the global scope, every local evaluates to undefined, and the breakpoint never fires at the real code afterwards. Breakpoints on other lines, debugger; statements and stepping all work.

The cause was in Hermes's LowerGeneratorFunction pass. It stamped the synthetic resume prologue, which runs on every entry and has no lexical scope, with the source location of the last yield it had processed. A line breakpoint resolves to the lowest bytecode address on its line, so it landed on that prologue.

Michael Leon (@micleo2) fixed it in "Fix debug info source location leakage of generator prologue": c36880fe6 on static_h on 2026-08-03, grafted to the stable branch as d1238c8c6 on 2026-08-10. It first shipped in hermes-v260318099.0.2 on 2026-09-08, and React Native main moved to that release the same day in react/react-native#58397.

HermesLast-yield bugShipped by
260318099.0.4Fixed (tested)0.88-stable
260318099.0.3Fixed (fix in tag)-
260318099.0.2Fixed (tested)RN main since #58397 (2026-09-08)
260318099.0.1Present (tested)RN main from #57647 (2026-07-23)
250829098.0.17-.0.19Present (fix not in tag)0.87-stable pins .0.17
250829098.0.16Present (tested)RN main until #57647

The debugger and generator-lowering code is identical in .0.2 and .0.4, and static_h (at e760a68ab, 2026-10-20) has no further generator-debugging fixes.

What the flag reaches

enableRegenerator is isHermesProfile && dev. It gates two things, and only one of them depends on the Babel runtime setting.

Config, dev: trueGeneratorsLoose for...of, ?., ??, catch binding
hermes-stable, default enableBabelRuntimeNativeLowered
hermes-stable, enableBabelRuntime: falseRegeneratorLowered
default profile (stock RN CLI dev server)NativeNative
  • The Babel runtime decides generator lowering. With the runtime enabled, the flag only sets transform-runtime's regenerator option, which rewrites references to regeneratorRuntime but doesn't lower generators. @babel/plugin-transform-regenerator is added only when enableBabelRuntime === false, as in React Native's own Jest config.
  • Stock RN CLI dev builds never see a Hermes profile. Metro's dev server falls back to the default profile when a bundle URL has no unstable_transformProfile, and React Native's native bundle URLs don't set one. react-native bundle defaults to hermes-stable.
  • The helper lowerings change semantics. Loose for...of never calls iterator.return(). With the flag, for (const x of gen()) break; skips the generator's finally block. Without it, finally runs, as it does in release.

Breakpoint sweep

Main's preset, with dev: true and enableBabelRuntime: false, built five fixtures two ways: as is (regenerator), and with enableRegenerator forced off (native generators). The fixtures cover generators with default and destructured params, try/finally, yield*, loops, closures and class and object methods, plus async functions, async generators, exceptions and Error.stack.

Each output got a breakpoint on every source line, mapped to the lowest generated line for that source line. Every pause was mapped back through the source map, and every local in the file was evaluated on the paused frame. A line counts as native-better or native-worse if native pauses on that source line more or fewer times, or shows more or fewer locals there. Mixed lines are the loop headers described below.

HermesCompilationIdenticalNative betterNative worseMixedDifferent, equivalent
260318099.0.4eager / lazy184 / 18247 / 493 / 33 / 311 / 11
260318099.0.2eager / lazy183 / 18247 / 493 / 33 / 311 / 11
250829098.0.16eager1644922310
  • Where native wins: It pauses on the line the breakpoint was set on, with the right locals. Regenerator often pauses one line early, because consecutive statements merge into one of its case blocks.
  • Stacks and stepping: Native Error.stack frames carry the real function names (gen rather than anonymous), and stepInto from it.next() goes straight into the body instead of through about ten frames of regenerator runtime.
  • The same either way: Pause on exceptions fires the same number of times, and stepping over a yield or await returns to the caller with both, which is how Hermes's stepper treats a suspension.
  • Lazy compilation: Hermes compiles sources over 64 KB lazily, which includes any real dev bundle, so the fixed versions were swept both ways. On .0.16, a breakpoint in a function that hasn't been compiled yet never pauses unless the request carries a column that lands on code. This affects plain functions too and was fixed in 260318099.0.0. The sweep sends line-only requests, which DevTools doesn't (it sends the source-mapped column), so that row is left out.

What native generators still get wrong

Each of these reproduces on plain source, with no Babel involved, against a plain-function control that behaves correctly. All four are present on .0.2 and .0.4.

Stepping
Stepping through try/catch stops on a catch line that never runsStepping over the statement before a try pauses on the catch (e) line first, then on the real code, and can skip the first statement in the try body. In an async function, stepping from before the try went to the catch line and then out of the function, never pausing on the await. The same code as a plain function steps through every line in order. Async functions behave the same way, because Hermes lowers them through the same generator pass. Regenerator steps correctly here.
Breakpoint
A breakpoint on a throw after a resume never firesThe lowest address on the line belongs to a reloaded value placed in a block the normal path doesn't run. The same throw in a plain function pauses as expected.
Breakpoint
A breakpoint on a yield* line never firesThe same mechanism as the throw case. On .0.16 this line hit the old prologue bug instead, when the yield* was the generator's last yield.
Breakpoint
A loop-header breakpoint fires after each resume, never on loop entryGenerator lowering places the resume path before the entry path in bytecode, so the line's lowest address is the rotated loop check after a resume. A plain function fires once, on entry. This is unchanged since .0.16.

The cause is the same class as the fixed bug. LowerGeneratorFunction still gives synthetic and reload instructions borrowed user source locations at three sites the prologue fix didn't touch: the try/catch setup in lowerToSwitch, moveCrossingValuesToOuter and moveInnerPhisToOuter. Stepping also stops at these instructions, and then skips the real statement after them, because they share its statement index.

These bugs don't argue for keeping the flag. Every config with the Babel runtime enabled already runs native generators in dev, so removing the flag only exposes them to setups that disable the runtime.

An unrelated Hermes bug

In a Debug build of Hermes, listing the parameter scope of any function with a default parameter fails an assertion and aborts, on every version tested: VariableScope must be set for lexical scopes in HBC.cpp. That happens when a client calls Runtime.getProperties on that closure scope, which is what expanding it in DevTools does. Plain functions fail the same way, and regenerator output does too, because the outer function keeps its parameters. Destructured-only and simple parameters are fine.

React Native's debug Hermes artifacts are built as Release with the debugger enabled, so the assertion is compiled out in apps. What that build does instead wasn't tested.

The proposed change

A change against main c7429b0 removes enableRegenerator, the five plugins it gated and the destructuring exception that existed only for regenerator. transform-runtime keeps an explicit regenerator: false, which every config other than Hermes dev already gets. The preset drops its five unused plugin dependencies. Only two ranges leave yarn.lock, because other packages still use the other three plugins.

The six hermes-*-dev* output fixtures now keep ?., ??, for...of and catch {}, with no loose helpers. The preset's 130 tests pass and Flow is clean. The full Jest suite fails the same 18 environment-dependent suites with and without the change.

Recommendation

  • Remove enableRegenerator on main. It's also safe for 0.88-stable, which ships .0.4.
  • Keep it on 0.87-stable, unless the Hermes fix is grafted onto a 250829098.x release.
  • Report the four remaining native-generator bugs, and the default-parameter assertion, to Hermes. Repros are below.
  • Reconcile with react/react-native#58255, which proposes the opposite (always lowering generators in dev), and #58410, which cites this flag in its reasoning.

Method

Everything ran through Hermes's CDPAgent unit-test fixture (unittests/API/CDPAgentTest.cpp) with one added test, not a real DevTools session. Hermes was built in Debug with HERMES_ENABLE_DEBUGGER=ON at each tag. The test sets breakpoints with Debugger.setBreakpointByUrl by line, optionally sets pause on exceptions, and runs the script. At each pause it lists every scope with Runtime.getProperties, evaluates watch expressions with Debugger.evaluateOnCallFrame, then resumes or steps a fixed number of times. The runtime has its microtask queue enabled and drained after the script, as in React Native.

Babel helpers were inlined (enableBabelRuntime: false) rather than loaded from @babel/runtime. Breakpoints on transformed output went on the lowest generated line each source line maps to, which approximates what DevTools does with a source map. The Hermes commit history and the lowering-pass analysis come from the static_h and 260318099.0.0-stable branches.

Last-yield repro
// Breakpoint on the `const got = yield v + a;` line (the last yield).
// Hermes <= 260318099.0.1: pauses at the `gen(10)` call, global scope only, every local undefined.
// Hermes >= 260318099.0.2: pauses from `it.next(21)` with a, v, l and c visible.
function plain(a) {
  var v = 1;
  let l = 'two';
  const c = {three: 3};
  return v + l + c.three + a;
}

function* gen(a) {
  var v = 1;
  let l = 'two';
  const c = {three: 3};
  const got = yield v + a;
  var after = got * 2;
  return after + l + c.three;
}

plain(10);
var it = gen(10);
it.next();
it.next(21);
Remaining-bug repros
// Breakpoint on `let state = 'start'`, then step over repeatedly.
// genTry pauses on `} catch (e) {` (never runs), then `const v = yield flag`, then leaves.
// plainTry steps through every line in order.
function plainTry(flag) {
  let state = 'start';
  try {
    const v = flag ? 1 : 0;
    state = 'ok' + v;
  } catch (e) {
    state = 'caught';
  }
  return state;
}
function* genTry(flag) {
  let state = 'start';
  try {
    const v = yield flag;
    state = 'ok' + v;
  } catch (e) {
    state = 'caught';
  }
  return state;
}

// Breakpoint on the throw line in thrower never fires. The one in plainThrower does.
function* thrower(limit) {
  const marker = 'm' + limit;
  const got = yield 1;
  if (got > limit) {
    throw new RangeError(marker + ':' + got);
  }
  return got;
}

// Breakpoint on the yield* line never fires.
function* inner(n) {
  yield n + 1;
  yield n + 2;
}
function* delegating(n) {
  const before = n;
  const got = yield* inner(n);
  return before + 1;
}

// Default-param assertion: pause anywhere in f, then call Runtime.getProperties
// on its closure scope (the parameter environment).
function f(a, b = a * 2) {
  var sum = a + b;
  debugger;
  return sum;
}

Revisions

RevisionDateChange
22026-10-21Clarified why the lazy-compilation sweep on 250829098.0.16 is left out.
12026-10-21First published.

Each revision links to a frozen copy of the report as it was published. The commit history has every edit.