Skip to content

react-compiler: fix skipped functions and build aborts for dead code in a try body - #43658

Open
robobun wants to merge 1 commit into
mainfrom
robobun/4125682c/react-compiler-prune-maybe-throws-phi
Open

robobun wants to merge 1 commit into
mainfrom
robobun/4125682c/react-compiler-prune-maybe-throws-phi

Conversation

@robobun

@robobun robobun commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Dead code in a try body makes bun build --react-compiler skip the function, abort, or drop code. Skip: Invariant: Expected non-existing phi operand's predecessor to have been mapped to a new terminal for let d = []; try { const max = 3; d = parse(json, max); } catch {} (Babel 1.0.0 compiles it).
  • Aborts: panic: called Option::unwrap() on a None value (infer_reactive_places.rs:410), Found a block with a single predecessor but where a phi has multiple (2) operands. Dropped code: try { const isProd = process.env.NODE_ENV === "production"; if (isProd) track(props); } catch (e) { props.onError?.(e); } return <div /> prints function Comp(props) { ; } in a development build.
  • Cause: the PruneMaybeThrows cleanup (src/react_compiler/optimization/prune_maybe_throws.rs) keeps phi operands of edges and blocks it removed. react-compiler: keep the statements after a dead try body with a branching catch #43603 owns the dropped code.

Fix

  • The pass removes the phi operand with the handler edge, so the first shape compiles. A phi operand of a removed block returns the invariant before the merge: a skip, not an abort. Correct because the merge and later passes assume one phi operand per predecessor.
  • BuildReactiveFunction returns an invariant for a try goto whose target nothing scheduled: a skip, not lost code. The function compiles once react-compiler: keep the statements after a dead try body with a branching catch #43603 lands.
  • Verified: DeadCodeInTryBody, DeadTryBodyKeepsTheCodeAfterIt, DeadTryBodyWithAStalePhiOperandIsSkipped in test/bundler/transpiler/react-compiler.test.ts (compiled variants fail on main). 1807 upstream fixtures and 2372 real-world files print identical output. Also react-compiler-fixtures.test.ts.
  • Self-reviewed: 15 concerns raised, 14 addressed. Rejected: keep one nested shape compiling (Notes).

Background

  • A phi joins the values of a local on the incoming edges of a block, one operand per predecessor.
  • In a try, every instruction ends its block with a MaybeThrow terminal that can go to the catch block (the handler). PruneMaybeThrows nulls the handler of a block that cannot throw. If dead code elimination leaves a whole try body unable to throw, the catch blocks are removed.
  • The try body ends in a goto that codegen ignores, because the try terminal visits the block after the try. Without the terminal, only a merge keeps that block.
Notes

Provenance. Found by fuzzing bun build --react-compiler, then reduced. There is no user report and no issue to close. The first report was the skipped function. The review of the first version of this change found that a fix for that alone lets a function with two of these shapes reach the abort or the dropped code, so this change makes each of them a skipped function at least.

The three defects, with a small input for each.

  1. Skipped function. let d = []; try { const max = 3; d = parse(json, max); } catch {} return <List items={d} />. Constant propagation folds the read of max, and dead code elimination removes its store. The pass nulls the handler of both blocks. The phi of d in the catch block keeps their operands. The phi rewrite looks a stale predecessor up in terminal_mapping, whose keys are continuations of pruned blocks, and returns the invariant. Every instruction that dead code elimination removes from a try body does this: an unused local, a dead store, a folded constant, a guard that --define folds (const dev = process.env.NODE_ENV !== "production"; if (dev) validate(json); in a production build). The catch block has a phi only when a local assigned in the try flows through the catch to a later read.
  2. Aborts. With a phi after a dead try (let ok = true; try { props.store.ready; } catch { ok = false; }), the stale operand of the removed catch block reaches assert_eq! in merge_consecutive_blocks.rs:106. In the nested form (try { try { props.store.ready; } catch { mode = "unsupported"; return null; } props.onLoad(); } catch {}) the removed block is the continuation of a literal block, so the phi rewrite finds a mapping and moves the operand onto another removed block. infer_reactive_places.rs:410 then unwraps the control test of a block that does not exist. Upstream iterates the live Map, visits the operand it moved, and raises the invariant. The port collects the updates first, so it never looked at the moved operand.
  3. Dropped code. The whole try body is dead (the production-only guard above in a development build, or try { window.localStorage; }), so the catch blocks are removed and remove_unnecessary_try_catch turns the try terminal into a goto. With more than one catch block (an if, ?., ?: in the catch body) a removed block stays in the predecessors of the block after the try, and merge_consecutive_blocks does not merge it. The goto to that block is GotoVariant::Try, which build_reactive_function.rs ignores, so nothing visits the block. A try nested in a try needs only a one-statement catch: try { try { props.store.ready; } catch (e) { report(e); } a = props.f(); } catch { a = "caught"; } printed try { ; } catch { a = "caught"; }.

Related open PRs.

Upstream. UPSTREAM_PORTED is 560db51408. Block ids in the errors match babel-plugin-react-compiler, so the port is faithful for 1 and 3.

input (names from the tests) 1.0.0 0.0.0-experimental-a1856f3-20260507 Bun main this PR
UnusedLocalInTry, ConstantLocalInTry, UnusedLocalInNestedTry, TwoLocalsInTry _c(5), _c(6), _c(5), _c(7) invariant skipped the sizes of 1.0.0
UnusedLogicalInTry (the fuzzer input) invariant invariant skipped _c(2)
CatchAssignsLocal invariant invariant abort (assert_eq!) skipped
InnerCatchAssignsLocal invariant invariant abort (unwrap) skipped
BranchingCatch, ProductionOnlyGuard function BranchingCatch(props) { ; } the same the same skipped
InnerTryInOuterTry drops a = props.f() the same the same skipped

1.0.0 rewrote a pruned terminal to a goto, and MergeConsecutiveBlocks merged the continuation into the pruned block, which replaced the stale operand. facebook/react PR 35686 keeps the maybe-throw terminal and nulls its handler, so nothing merges. PruneMaybeThrows.ts and compiler/crates/react_compiler_optimization/src/prune_maybe_throws.rs on facebook/react main (59aff3e1) still have this logic.

Why the predecessors are not recomputed before the merge. An earlier version called mark_predecessors before the merge, as constant_propagation.rs does. With exact predecessors everywhere, the merge also takes the block after a dead do ... while into the block of its break, which main does not do in this pass. That merge is wrong inside a try (see "Existing defects" below), and it turned do { if (p.a) break; try { p.store.ready; return null; } catch (e) { p.g(e); } break; } while (false); inside a try from skipped into wrong code. Now no predecessor set changes, and the upstream rewrite loop stays where it is. It finds nothing after the new check.

Why a dead try body with a stale phi operand is skipped and not compiled. constant_propagation.rs drops every phi operand whose predecessor is gone. That shape here would compile let ok = true; try { window.localStorage; } catch { ok = false; } to a function where ok is always true, also where the load throws. A skip keeps the function as written. #42456 made the same choice, and its test DeadTryBodyWithCatchAssignmentSkipsOnlyThatFunction passes on this branch.

Behavior changes for functions that compiled before.

  • let d = props.initial; try { const k = 3; d = 1; foo(); } catch {}: main moved the operand of the emptied store block onto the literal block before it, so the phi kept props.initial and the element was memoized on d (_c(2)). Now the operand is removed and the output is the _c(1) of 1.0.0. Both are correct: d is 1 wherever the catch can run.
  • The rejected concern. let v = props.initial; try { try { const unused = 1; } catch { v = 1; return null; } props.c(); } catch {} return <span>{v}</span> compiled on main, with a phi operand keyed by a removed block, because the reactive props.initial made InferReactivePlaces stop before the unwrap. With let v = "init" the same function aborted. Now both are skipped, as in Babel. To keep the first one compiling means to compile the whole family above.
  • A function with defect 3 whose lost block held nothing that matters printed correct code on main. Two forms: a dead try that is the last statement of an if or loop body, where the lost block is only the implicit jump that ends that body, and a dead try in front of an empty loop. Such a function is skipped now, and compiles again with react-compiler: keep the statements after a dead try body with a branching catch #43603.
  • All 1807 upstream fixtures print byte-identical output before and after.

Reach. 2372 .jsx/.tsx files that contain a try, from 14 open-source React apps (PostHog, lobe-chat, supabase, grafana, metabase, documenso, twenty, plane, formbricks, bluesky social-app, dub, mattermost, cal.com, excalidraw), each built alone with --react-compiler: no file hits one of the three defects on main, no file hits the new check, and every output is identical before and after, with --target=browser (1298 files have a memo cache) and with --target=bun (ssr mode). So this merges on severity, not on demand.

Differential check. Five generated sets of "use memo" functions (4200 in total) with try/catch, nested try, dead try bodies, loops, loops that leave on every path, switch, labels, break/continue, IIFEs, closures, value blocks, unused locals and constant locals. Each function was rendered 14 times with 6 prop sets on one kept cache, plain build against compiled build, on main and on this branch.

main this PR
builds that abort 256 0
functions that render with a memo cache 2369 3097
output differs from the plain build 200 28

184 of the 200 are right now: they lost code on main and are skipped now. 16 differ on both. 42 functions went from compiled to skipped for defect 3, and 26 of them were wrong on main. Of the 770 functions that render with a memo cache only on this branch, 12 differ from the plain build. Each of the 12 is an existing defect that main shows for the same shape without the dead code.

Existing defects that a newly compiled function can reach. This change does not cause them, but a function that main skipped for the invariant can now meet them.

  • A local assigned only constants, where the catch decides which one, is not reactive: let ok = false; try { const parsed = JSON.parse(json); const version = parsed.version; ok = true; validate(parsed); } catch (e) { report(e); } renders a stale ok on a kept cache. Main does the same without the version line. react-compiler: treat a throw into a catch handler as reactive control flow #42481 is the fix and should land before or with this change (3 of the 12).
  • A let reassigned in a nested memo block is not restored on a cache hit. react_compiler: fix stale values for a let reassigned inside a memo block #42482 is the fix (3 of the 12).
  • A do ... while whose body leaves on every path, inside a try. try { do { if (p.a) break; return null; } while (p.c); p.f(1); } catch {} return <div /> prints try { if (p.a) p.f(1); return null; } catch {} on main, and babel-plugin-react-compiler 1.0.0 prints the same. remove_dead_do_while_statements replaces the DoWhile terminal with a goto, MergeConsecutiveBlocks then merges the block after the loop into the block of break, and the GotoVariant::Try at the end of that chain is ignored in a position that is not the tail of the try block. No PR addresses this yet (6 of the 12, all from the set with such loops).

[human-review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/react-compiler.test.ts
bun test v1.4.3 (367d939d9)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [666.70ms]
(pass) bundler > react-compiler/ComponentWithHooks [259.09ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [255.16ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [274.16ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [137.85ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [102.61ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [348.40ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [95.57ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [643.53ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [91.52ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [104.53ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [288.20ms]
(pass) bundler > react-
... (truncated)

release without fix: 3 failed, 1 skipped
bun test v1.4.3-canary.1 (367d939d9)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [15.87ms]
(pass) bundler > react-compiler/ComponentWithHooks [8.97ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [6.90ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [7.45ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [6.87ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [3.69ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [10.63ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [4.36ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [15.95ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [5.66ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [3.81ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [8.23ms]
(pass) bundler > react-compiler/BundledCjsCompilerRuntimeSurvivesTreeShaking [11.26ms]
(pass) bundler > react-compiler/RequireStringPreservesImportRecord [7.06ms]
(pass) bundler > react-compiler/BranchBooleanFeat
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/react-compiler.test.ts
bun test v1.4.3 (367d939d9)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [579.64ms]
(pass) bundler > react-compiler/ComponentWithHooks [285.52ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [246.58ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [244.58ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [141.11ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [98.77ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [322.41ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [110.46ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [620.51ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [90.90ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [99.30ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [342.46ms]
(pass) bundler > react-c
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     7750323f3d
  features     lto, baseline

23 deps, 131 codegen, 1176 objects in 772ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1250] install /workspace/bun
bun install v1.4.3-canary.1 (367d939d9)

Checked 22 installs across 61 packages (no changes) [7.00ms]
[2/1250] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939d9)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1250] gen ErrorCode+*.h
[4/1250] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (367d939d9)

Checked 111 installs across 104 packages (no changes) [7.00ms]
[5/1250] gen bindgenv2
[6/1250] gen node-fallbacks/react-refresh.js
Bundled 1 module in 4ms

  react-refresh.js  4.81 KB  (entry point)

[7/1250] gen .bind.ts → GeneratedBindings.cpp
[8/1250] fetch zlib
[zlib] up to date
[9/1250] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[10/1223] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstan
... (truncated)
diff hotspot
.../optimization/prune_maybe_throws.rs             |  58 +++-
 .../reactive_scopes/build_reactive_function.rs     |  13 +
 test/bundler/transpiler/react-compiler.test.ts     | 324 +++++++++++++++++++++
 3 files changed, 383 insertions(+), 12 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/react_compiler/optimization/prune_maybe_throws.rs         6      8     39
…act_compiler/reactive_scopes/build_reactive_function.rs      2      1     39
test/bundler/transpiler/react-compiler.test.ts                4      5     39

PruneMaybeThrows nulls the handler of a MaybeThrow block that cannot
throw and then cleans up the graph. After SSA the cleanup left stale
data behind:

- The catch block kept a phi operand for the pruned block. The phi
  rewrite raised "Expected non-existing phi operand's predecessor to
  have been mapped to a new terminal" and the function was not compiled.
- A phi after a dead try kept the operand of a removed catch block. The
  merge asserted on it, or the phi rewrite moved it onto another removed
  block and InferReactivePlaces panicked.
- When a whole try body was dead and its catch body had more than one
  block, the block after the try was not merged into the try body.
  BuildReactiveFunction ignores the goto to it, so the rest of the
  function was lost.

Remove the operand when the handler edge is removed. Raise the invariant
of the phi rewrite before the merge for an operand of a removed block,
so that function is skipped. In BuildReactiveFunction, raise an
invariant for a try goto whose target nothing scheduled, so that
function is skipped too.
@robobun

robobun commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The gate passed: the three -compiled tests fail without the fix and pass with it.

CI (build 119075): 179 of 181 jobs passed, and test/bundler/transpiler/react-compiler.test.ts passed on every lane. The two red jobs are tests that this diff does not touch and that also fail on main: test/bake/deinitialization.test.ts (alpine 3.23 aarch64) and test/js/bun/spawn/spawn.test.ts (debian 13 x64-asan). Both are reported for main-break triage. A new CI run would fail on them again, so I did not start one.

How I reproduced it (main at 26e7a4b and the 1.4.3-canary.1 release binary, linux x64):

// skip.jsx: skipped function
export function Skip({ json }) {
  let d = [];
  try {
    const max = 3;
    d = parse(json, max);
  } catch {}
  return <List items={d} />;
}
// abort.jsx: build abort
export function Abort(props) {
  let ok = true;
  try {
    props.store.ready;
  } catch {
    ok = false;
  }
  return <div>{ok ? props.a : null}</div>;
}
  • bun build skip.jsx --react-compiler --target=browser --external "*" prints the function as written, with no _c(. A debug build with BUN_DEBUG_react_compiler=1 logs Invariant: Expected non-existing phi operand's predecessor to have been mapped to a new terminal.
  • bun build abort.jsx --react-compiler --target=browser --external "*" exits 134: panic: assertion left == right failed: Found a block with a single predecessor but where a phi has multiple (2) operands. The nested form in the PR body gives panic: called Option::unwrap() on a None value.
  • With this branch the first file compiles to _c(5) and the second is left as written.

Tests: bun bd test test/bundler/transpiler/react-compiler.test.ts -t "DeadCodeInTryBody|DeadTryBody". The three -compiled variants fail with the release binary and pass with this branch.

@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 9f0a296c-60d6-402d-adb5-31c5ce5c5524

📥 Commits

Reviewing files that changed from the base of the PR and between 18fe86e and 7750323.

📒 Files selected for processing (3)
  • src/react_compiler/optimization/prune_maybe_throws.rs
  • src/react_compiler/reactive_scopes/build_reactive_function.rs
  • test/bundler/transpiler/react-compiler.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


Walkthrough

The compiler now validates try/catch control-flow cleanup, removes stale handler phi operands, and reports unscheduled try fallthrough blocks. Regression tests cover dead try bodies, nested catches, preserved reachability, cache sizes, and stale phi operands.

Changes

Try/catch pruning

Layer / File(s) Summary
Prune handler edges and phi operands
src/react_compiler/optimization/prune_maybe_throws.rs
Validates phi predecessors after CFG cleanup, removes operands for pruned handler edges, and shares the unmapped-predecessor invariant diagnostic.
Validate try fallthrough scheduling
src/react_compiler/reactive_scopes/build_reactive_function.rs
Reports an invariant error when a GotoVariant::Try target fallthrough block is not scheduled.
Add try/catch regression coverage
test/bundler/transpiler/react-compiler.test.ts
Adds plain-versus-compiled tests for dead try bodies, nested and branching catches, preserved reachability, cache sizes, and stale phi operands.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: fixing skipped functions and build aborts caused by dead code in try bodies.
Description check ✅ Passed The description explains the problem, cause, fix, and verification results. It covers the template requirements, even though it uses Problem and Fix headings instead of the exact template headings.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:02 PM PT - Sep 20th, 2026

❌ @robobun, your commit 7750323 has 2 failures in Build #119075 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 43658

That installs a local version of the PR into your bun-43658 executable, so you can run:

bun-43658 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/react_compiler/reactive_scopes/build_reactive_function.rs

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant