Skip to content

react_compiler: fix panics on a dead try body and on a useMemo call nested in another - #42456

Open
robobun wants to merge 1 commit into
mainfrom
robobun/49fd58f5/react-compiler-invariant-errors
Open

robobun wants to merge 1 commit into
mainfrom
robobun/49fd58f5/react-compiler-invariant-errors

Conversation

@robobun

@robobun robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build --react-compiler aborts on try { window.localStorage; } catch { ok = false; } when ok is read later: panic: assertion `left == right` failed: Found a block with a single predecessor but where a phi has multiple (2) operands (src/react_compiler/optimization/merge_consecutive_blocks.rs:106). Dead code elimination empties the try body, prune_maybe_throws drops the edge to the catch block, and the phi for ok keeps the operand of the removed block.
  • useMemo(() => a + b, [a, useMemo(() => b * 2, [b])]) aborts with panic: Unexpected nested StartMemoize instructions (src/react_compiler/validation/validate_no_set_state_in_render.rs:96). drop_manual_memoization nests the marker pairs of the two calls.
  • Both are upstream invariants. Babel catches them and leaves that function uncompiled. The port has assert!, and Bun builds with panic = "abort".

Fix

  • merge_consecutive_blocks returns the invariant as an error. Its four callers propagate it with ?.
  • The three asserts on StartMemoize / FinishMemoize pairing (validate_no_set_state_in_render, validate_exhaustive_dependencies) become cold_invariant errors.
  • Correct because program.rs leaves a function that returns an error uncompiled, as babel-plugin-react-compiler 1.0.0 does for the same inputs. Compiled output does not change.
  • Verified: test/bundler/transpiler/react-compiler.test.ts (two new tests, SIGABRT on 1.4.3 canary). Also react-compiler-fixtures.test.ts.

Background

  • The compiler lowers a component to a control flow graph in SSA form. A phi joins the values a local has on each incoming edge of a block.
  • prune_maybe_throws removes the edge from a try body to its catch block when nothing left in the body can throw.
  • StartMemoize and FinishMemoize mark the range of a source useMemo / useCallback call for later validations.
  • compile_fn returns Result. program.rs skips that one function for each Err.
Notes

Provenance. Found by fuzzing. There is no user report and no issue to close. Same family as #42375 (merged) and #42378 (open): an upstream CompilerError.invariant that the port has as a panic.

Repro (any directory, 1.4.3 canary 6a92015; main b993710 still has both asserts):

// a.jsx
export function Banner() {
  let supported = true;
  try {
    window.localStorage;
  } catch {
    supported = false;
  }
  return <span>{supported ? "on" : "off"}</span>;
}
// c.jsx
import { useMemo } from "react";
export function Sum({ a, b }) {
  const x = useMemo(() => a + b, [a, useMemo(() => b * 2, [b])]);
  return <div>{x}</div>;
}
$ bun build --react-compiler a.jsx --external react --external 'react/*'
panic: assertion `left == right` failed: Found a block with a single predecessor but where a phi has multiple (2) operands
Crashed while visiting a.jsx
$ bun build --react-compiler c.jsx --external react --external 'react/*'
panic: Unexpected nested StartMemoize instructions
Crashed while visiting c.jsx

Every --target aborts, and Bun.build({ reactCompiler: true }) aborts the calling process. With this change both builds exit 0, BUN_DEBUG_react_compiler=1 on a debug build logs compile_fn err: ... Invariant ... for that function, and the function is printed as written. A Bun.build with a.jsx, c.jsx and a third entry point returns success: true with the third file memoized.

First site. Frames: merge_consecutive_blocks (merge_consecutive_blocks.rs:106), prune_maybe_throws (the second run, after SSA and DeadCodeElimination), run_hir_passes, compile_fn (pipeline.rs:172). The try body must be fully dead after dead code elimination (property, global and index loads, destructuring, typeof, literals, unused declarations; a call or a value that is read later keeps it alive), and the catch block must assign a local that is read after the try. const unused = 1 in the try body is enough (#42432 lists that form as not fixed there). The same body in the callback of a useMemo aborts too, because the callback is inlined. A variant builds in development and aborts only with --production or --define process.env.NODE_ENV='"production"': try { if (process.env.NODE_ENV !== "production") { validate(props); } const label = props.label; } catch { ok = false; }.

The cause is upstream's. PruneMaybeThrows.ts calls mergeConsecutiveBlocks before it rewrites phi operands, and it has no step that drops the operand of a predecessor that became unreachable. ConstantPropagation.ts has that step. facebook/react main (019019be) still has this order in TS and in compiler/crates/react_compiler_optimization, and both still have the invariant (assert_eq! in the Rust crate).

Alternative not taken for the first site. Drop the phi operands of removed predecessors in prune_maybe_throws before the merge, as constant_propagation does. The function then compiles, which upstream does not do today. The compiled output also has no try/catch any more, because dead code elimination removed the load. try { window.localStorage } catch { supported = false } would always render on, also where the load throws. pipeline.rs says the pass sequence stays identical with upstream, so this PR only changes how the invariant is reported.

Why constant_propagation.rs and inline_iifes.rs change. They call merge_consecutive_blocks. Neither can reach the invariant today: inline_iifes and the standalone pipeline pass run before SSA, so no block has a phi, and constant_propagation removes the operands of unreachable predecessors right before it merges. They return Result so that the helper has no caller that has to panic or drop the error. evaluate_instruction recurses into nested functions, so its return type changes from Option<Constant> to Result<Option<Constant>, _>. The edits there are mechanical (return Some(x) to return Ok(Some(x))).

Second site. Frames: validate_impl (validate_no_set_state_in_render.rs:96), validate_no_set_state_in_render (:26), run_hir_passes (pipeline.rs:467). drop_manual_memoization puts StartMemoize after the load of useMemo / useCallback and FinishMemoize after the call, so a manual memo call anywhere in the arguments of another one nests the pairs. For a call in the dependency list, the port also records Expected the dependency list to be an array of simple expressions, as upstream main does (env.recordError), and continues. This validation runs before the pipeline checks the recorded errors. useMemo(() => a, [a], useMemo(() => b, [b])) nests the pairs with no recorded error. A useMemo in the callback of another useMemo does not nest them, because the pass does not visit nested functions.

Comparison with babel-plugin-react-compiler 1.0.0. I ran it on the 11 functions of the two new tests. It leaves the same 8 as written and compiles the same 3. Its errors: Found a block with a single predecessor but where a phi has multiple (2) operands for the four try forms, Unexpected nested StartMemoize instructions for the third-argument form, and Expected the dependency list to be an array of simple expressions for the three dependency-list forms (1.0.0 throws from DropManualMemoization, one pass earlier than main).

The two FinishMemoize checks. No known input reaches Expected FinishMemoize to align with previous StartMemoize instruction (validate_no_set_state_in_render.rs:103) or Found FinishMemoize without corresponding StartMemoize (validate_exhaustive_dependencies.rs:787). A nested input always fails the StartMemoize check first, and validate_exhaustive_dependencies tolerates nesting. They are the same pairing invariant, so they change with it. To exercise the new paths I forced each comparison to fail in a local build (selected by an environment variable, not committed). Each time bun build exited 0, the debug log showed the invariant for the function with the useMemo calls, and the other function in the file still compiled. validate_preserved_manual_memoization.rs already returns early on both conditions.

Not changed here. The other assert! / .expect() / unreachable! sites in src/react_compiler stay as they are. No known input reaches them, and most need Result plumbing through a pass that returns (). In validation/ these are validate_exhaustive_dependencies.rs:187 and :326 and validate_locals_not_reassigned_after_render.rs:262. #42378 lists the ones in reactive_scopes/ and inference/. A stack overflow or an allocation failure still ends the process.

What the tests check. Each test bundles one file with --target=browser through the CLI and runs the output against a fake react/compiler-runtime whose c counts its calls. A compiled function calls c once per render, so the count is 1 for a compiled function and 0 for a skipped one. The try test also defines process.env.NODE_ENV as "production" for the production-only form. window is not defined when the output runs, so a function that is left as written takes its catch path (off, ReferenceError). Each of the 8 skipped functions aborts 1.4.3 canary on its own. The controls (a call in the try body, a plain component, two sequential useMemo calls) make 1 call each, so the skip is per function and not per file.

Sweep. Built all 1806 source files under test/bundler/transpiler/react-compiler-fixtures/ with --target=browser and --target=bun on 1.4.3 canary. None aborts with either message, so no upstream fixture has these shapes.

Suites run with the debug build: test/bundler/transpiler/react-compiler.test.ts (60 pass), test/bundler/transpiler/react-compiler-fixtures.test.ts (3293 pass, 320 skip). cargo clippy -p bun_react_compiler and cargo fmt --check are clean. cargo check -p bun_react_compiler --release passes.

Self-reviewed: the review raised no code concern. It asked for the provenance line and the "Not changed here" paragraph above.


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

fails on main (without fix)
ASAN without fix: 2 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 (6a92015fc)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [756.88ms]
(pass) bundler > react-compiler/ComponentWithHooks [296.42ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [242.88ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [273.37ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [136.87ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [109.58ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [335.54ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [105.36ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [667.91ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [117.72ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [102.23ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [416.81ms]
(pass) bundler > reac
... (truncated)

release without fix: 19 failed, 1 skipped
bun test v1.4.3-canary.1 (6a92015fc)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [21.56ms]
(pass) bundler > react-compiler/ComponentWithHooks [8.98ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [9.04ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [17.23ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [9.27ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [5.62ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [13.75ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [5.15ms]
1031 |       ]);
1032 |       const stdout = Buffer.from(stdoutBytes);
1033 |       const stderr = Buffer.from(stderrBytes);
1034 |       const success = exitCode === 0;
1035 |       if (buildProc.signalCode) {
1036 |         throw new Error(
                         ^
error: [react-compiler/SsrObjectMethodShorthand] 'bun build' subprocess killed by SIGABRT
cmd: /workspace/bun/build/release/bun build /tmp/bun-build-tests/bun-BPbkFv/react-compiler/SsrObjectMethodShorthand/entry.jsx --outfile=/tmp/bun-build-tests/bun
... (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 (6a92015fc)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [987.51ms]
(pass) bundler > react-compiler/ComponentWithHooks [387.22ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [305.29ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [262.03ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [171.58ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [143.95ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [457.10ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [138.57ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [965.24ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [139.29ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [157.04ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [392.76ms]
(pass) bundler > reac
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 783ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/4] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_react_compiler v0.0.0 (/workspace/bun/src/react_compiler)
�[1m�[92m   Compiling�[0m bun_js_parser v0.0.0 (/workspace/bun/src/js_parser)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
�[1m�[92m   Compiling�[0m bun_css_jsc v0.0.0 (/works
... (truncated)
diff hotspot
.../optimization/constant_propagation.rs           |  72 ++++----
 src/react_compiler/optimization/inline_iifes.rs    |   7 +-
 .../optimization/merge_consecutive_blocks.rs       |  39 +++--
 .../optimization/prune_maybe_throws.rs             |   2 +-
 src/react_compiler/pipeline.rs                     |   6 +-
 .../validation/validate_exhaustive_dependencies.rs |  15 +-
 .../validation/validate_no_set_state_in_render.rs  |  40 +++--
 test/bundler/transpiler/react-compiler.test.ts     | 185 +++++++++++++++++++++
 8 files changed, 301 insertions(+), 65 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/react_compiler/optimization/constant_propagation.rs       2      0     13
src/react_compiler/optimization/inline_iifes.rs               1      0     13
…react_compiler/optimization/merge_consecutive_blocks.rs      1      4     13
src/react_compiler/optimization/prune_maybe_throws.rs         1      1     13
src/react_compiler/pipeline.rs                                2      0     13
…compiler/validation/validate_exhaustive_dependencies.rs      1      0     13
…_compiler/validation/validate_no_set_state_in_render.rs      1      0     13
test/bundler/transpiler/react-compiler.test.ts                3      3     13

…king

merge_consecutive_blocks returns the 'Found a block with a single
predecessor but where a phi has multiple operands' invariant as an
error. Its callers (prune_maybe_throws, constant_propagation,
inline_iifes and the pipeline) propagate it.

validate_no_set_state_in_render returns 'Unexpected nested StartMemoize
instructions' and 'Expected FinishMemoize to align with previous
StartMemoize instruction' as invariant errors.
validate_exhaustive_dependencies does the same for 'Found FinishMemoize
without corresponding StartMemoize'.

program.rs leaves a function that fails with an error uncompiled, so
one such function no longer aborts the build.
@robobun

robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:55 AM PT - Sep 12th, 2026

✅ @robobun, your commit a002e9c9d45ace98b3192ff6ae16241019d06a8c passed in Build #114673! 🎉


🧪   To try this PR locally:

bunx bun-pr 42456

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

bun-42456 --bun

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: open, waiting for CI. Head a002e9c on main b993710.

Reproduction: on 1.4.3 canary (6a92015), each of these aborts with exit 134. Main b993710 still has both asserts.

// a.jsx
export function Banner() {
  let supported = true;
  try {
    window.localStorage;
  } catch {
    supported = false;
  }
  return <span>{supported ? "on" : "off"}</span>;
}
// c.jsx
import { useMemo } from "react";
export function Sum({ a, b }) {
  const x = useMemo(() => a + b, [a, useMemo(() => b * 2, [b])]);
  return <div>{x}</div>;
}
$ bun build --react-compiler a.jsx --external react --external 'react/*'
panic: assertion `left == right` failed: Found a block with a single predecessor but where a phi has multiple (2) operands
Crashed while visiting a.jsx
$ bun build --react-compiler c.jsx --external react --external 'react/*'
panic: Unexpected nested StartMemoize instructions
Crashed while visiting c.jsx

With this branch both commands exit 0 and print the function as written, for --target=browser, bun and node. babel-plugin-react-compiler 1.0.0 also leaves both functions as written. USE_SYSTEM_BUN=1 bun test test/bundler/transpiler/react-compiler.test.ts -t SkipsOnlyThatFunction fails with SIGABRT on both new tests. bun bd test on the same file passes (60 tests).

@coderabbitai

coderabbitai Bot commented Sep 12, 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 1b310aab-9cf0-4448-ad67-353987c3477e

📥 Commits

Reviewing files that changed from the base of the PR and between b993710 and a002e9c.

📒 Files selected for processing (8)
  • src/react_compiler/optimization/constant_propagation.rs
  • src/react_compiler/optimization/inline_iifes.rs
  • src/react_compiler/optimization/merge_consecutive_blocks.rs
  • src/react_compiler/optimization/prune_maybe_throws.rs
  • src/react_compiler/pipeline.rs
  • src/react_compiler/validation/validate_exhaustive_dependencies.rs
  • src/react_compiler/validation/validate_no_set_state_in_render.rs
  • test/bundler/transpiler/react-compiler.test.ts

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


Walkthrough

The compiler optimization and validation passes now return and propagate CompilerDiagnostic errors. Assertion-based invariant failures for phi nodes and memoization markers now produce diagnostics. Bundler tests cover isolated compiler bailouts and memoization behavior.

Changes

Compiler diagnostic propagation

Layer / File(s) Summary
Fallible constant propagation
src/react_compiler/optimization/constant_propagation.rs
Constant propagation and instruction evaluation now return Result values. Nested-function processing and template-literal paths propagate diagnostics or return successful fallback values.
Optimization error propagation
src/react_compiler/optimization/inline_iifes.rs, src/react_compiler/optimization/merge_consecutive_blocks.rs, src/react_compiler/optimization/prune_maybe_throws.rs, src/react_compiler/pipeline.rs
Optimization passes and pipeline calls now propagate diagnostics. Invalid phi operand counts return invariant errors instead of panicking.
Validation invariant diagnostics
src/react_compiler/validation/validate_exhaustive_dependencies.rs, src/react_compiler/validation/validate_no_set_state_in_render.rs
Memoization marker mismatches and nesting errors now return location-aware invariant diagnostics.
Compiler bailout regression coverage
test/bundler/transpiler/react-compiler.test.ts
Tests cover isolated function bailouts, dead try/catch transformations, nested manual memo calls, and compiler-runtime call counts.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to a002e

Invariant failures now bail out only the affected function while preserving compilation of unaffected functions.

🚥 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 identifies the two main fixes: preventing React compiler panics for dead try bodies and nested useMemo calls.
Description check ✅ Passed The description explains the problem, implementation, expected behavior, verification steps, test results, and scope. It does not use the template headings exactly, but it provides the required inform…

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

@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.

LGTM — mechanical assert→Result conversion that matches the existing cold_invariant / mem::replace+? pattern already used elsewhere in this crate.

What was reviewed:

  • The new ? between mem::replace(&mut env.functions[...], placeholder_function()) and the restore in process_inner_function and merge_consecutive_blocks — env is created fresh per compile_fn (pipeline.rs:132) and dropped on Err, and the identical pattern already exists at enter_ssa.rs:449, infer_types.rs:473, and align_object_method_scopes.rs:106.
  • The evaluate_instruction Option<Constant> → Result<Option<Constant>, _> rewrite — every early return Some/None is now return Ok(Some/None) and the trailing match value is wrapped once; no branch changed semantics.
  • The (Some(operand), None) iterator match in merge_consecutive_blocks — correctly errors on both 0 and >1 operands, preserving the original assert_eq!(len, 1) contract with the same message text as upstream babel.
  • Tests: itBundled with local stub react/compiler-runtime counting c() calls; each test has control functions asserting compilation still happens (call count 1) alongside the skipped ones (call count 0), so a regression that stops compiling everything would fail.
Extended reasoning...

Overview

This PR converts four user-reachable assert!/assert_eq! panics in the React Compiler HIR passes into recoverable CompilerDiagnostic errors, so that inputs which trip these upstream invariants (a dead try body leaving a stale phi operand, or nested useMemo/useCallback producing nested StartMemoize/FinishMemoize markers) skip only the affected function instead of aborting the process. merge_consecutive_blocks now returns Result<(), CompilerDiagnostic>; its four callers (constant_propagation, inline_iifes, prune_maybe_throws, and the standalone pipeline pass) thread the ? through, and evaluate_instruction is mechanically rewritten from Option<Constant> to Result<Option<Constant>, _>. Two itBundled tests exercise both bug classes with multiple variants plus control functions that must still compile.

Security risks

None. This is compiler-internal error propagation; no parsing of untrusted headers, no auth, no crypto, no filesystem or network. The change strictly reduces attack surface by turning a user-input-reachable panic = "abort" (a DoS vector per REVIEW.md) into a per-function skip that already existed for every other CompilerDiagnostic.

Level of scrutiny

Moderate, focused on the REVIEW.md rule "new early returns → re-audit acquisitions above them." The only acquisition above a new ? is the mem::replace(..., placeholder_function()) swap in process_inner_function and the recursive merge_consecutive_blocks loop. I confirmed Environment is constructed per compile_fn call (pipeline.rs:132) and discarded on Err (program.rs:1385-1391), so a placeholder left in env.functions on the error path is never observed. This is not a new pattern: enter_ssa.rs:449, infer_types.rs:473, and align_object_method_scopes.rs:106 already ? between the same replace/restore pair. The #[cold] #[inline(never)] diagnostic constructor mirrors the existing cold_invariant/cold_todo helpers in diagnostics/mod.rs.

Other factors

The tests are well-designed: they use the itBundled harness, stub react/react/compiler-runtime locally (no network), and assert both the rendered output and the c() call count for each function — so they distinguish "left uncompiled" from "compiled" from "crashed," and the control functions (CallInTryBody, Plain, SequentialMemos) would catch a regression that over-skips. The PR description documents that both tests SIGABRT on the base commit, satisfying the fails-on-USE_SYSTEM_BUN=1 requirement. No CODEOWNERS entry covers src/react_compiler/ or test/bundler/. The bug hunt ran to a dry streak with no findings and no candidates ruled out. The PR timeline has no outstanding reviewer objections.

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

I had the same abort from a hand-off and found this PR when my change was ready. I will not open a second PR for it. This PR covers the crash.

For reference, the branch robobun/fb0912db/react-compiler-prune-stale-phi-operands has the approach listed here under "Alternative not taken". In one line: prune_maybe_throws calls mark_predecessors, prunes the phi operands of removed predecessors, and runs eliminate_redundant_phi before merge_consecutive_blocks, so these functions compile and are not skipped.

Three facts from that work, in case someone takes it up after this PR:

  • eliminate_redundant_phi then runs after InferMutationAliasingEffects, and in the Rust port an effect holds clones of its instruction's places. The pass has to rewrite the places inside instr.effects, the MaybeThrow / Return terminal effects and the aliasing_effects of nested functions too. Without that, const base = [props.value]; let target = base; try { const unused = 1 } catch { target = [] } target.push("pushed"); return base pushes to the cached array on each render.
  • The other invariant of the same pass, Expected non-existing phi operand's predecessor to have been mapped to a new terminal, leaves two more shapes uncompiled today with no message: a dead try body whose catch has an if / else or a nested try, and a try where only the first statements are eliminated and a later one still throws (the handler keeps phi operands for blocks that no longer have an edge to it). The re-key loop that raises it dates from when the pass rewrote MaybeThrow to Goto ([compiler] Fix for loops in try/catch react/react#35686 changed that). The prune covers both shapes.
  • I agree with the caveat in the notes. try { window.localStorage } catch { supported = false } prints on with that branch. The cause is that dead code elimination removes a read that can throw inside a try. That is already visible on main (and in babel-plugin-react-compiler 1.0.0) without any phi: try { window.localStorage } catch { return "off" } return "on" compiles to return "on". So compile in place of skip is only safe after dead code elimination keeps such reads inside a try.

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.

2 participants