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.
Summary
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.
| Hermes | Last-yield bug | Shipped by |
|---|---|---|
| 260318099.0.4 | Fixed (tested) | 0.88-stable |
| 260318099.0.3 | Fixed (fix in tag) | - |
| 260318099.0.2 | Fixed (tested) | RN main since #58397 (2026-09-08) |
| 260318099.0.1 | Present (tested) | RN main from #57647 (2026-07-23) |
| 250829098.0.17-.0.19 | Present (fix not in tag) | 0.87-stable pins .0.17 |
| 250829098.0.16 | Present (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: true | Generators | Loose for...of, ?., ??, catch binding |
|---|---|---|
hermes-stable, default enableBabelRuntime | Native | Lowered |
hermes-stable, enableBabelRuntime: false | Regenerator | Lowered |
default profile (stock RN CLI dev server) | Native | Native |
- The Babel runtime decides generator lowering. With the runtime enabled, the flag only sets transform-runtime's
regeneratoroption, which rewrites references toregeneratorRuntimebut doesn't lower generators.@babel/plugin-transform-regeneratoris added only whenenableBabelRuntime === 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
defaultprofile when a bundle URL has nounstable_transformProfile, and React Native's native bundle URLs don't set one.react-native bundledefaults tohermes-stable. - The helper lowerings change semantics. Loose
for...ofnever callsiterator.return(). With the flag,for (const x of gen()) break;skips the generator'sfinallyblock. Without it,finallyruns, 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.
| Hermes | Compilation | Identical | Native better | Native worse | Mixed | Different, equivalent |
|---|---|---|---|---|---|---|
| 260318099.0.4 | eager / lazy | 184 / 182 | 47 / 49 | 3 / 3 | 3 / 3 | 11 / 11 |
| 260318099.0.2 | eager / lazy | 183 / 182 | 47 / 49 | 3 / 3 | 3 / 3 | 11 / 11 |
| 250829098.0.16 | eager | 164 | 49 | 22 | 3 | 10 |
- 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
caseblocks. - Stacks and stepping: Native
Error.stackframes carry the real function names (genrather thananonymous), andstepIntofromit.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
yieldorawaitreturns 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, about half of all breakpoints in lazily compiled functions never pause in either variant, from a separate bug fixed in 260318099.0.0, 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.
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.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.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.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
enableRegeneratoron 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;
}