Skip to content

react-compiler: keep require("x") and require.resolve("x") calls in a compiled function - #42432

Open
robobun wants to merge 3 commits into
mainfrom
robobun/4c5376c8/react-compiler-require-is-a-call
Open

robobun wants to merge 3 commits into
mainfrom
robobun/4c5376c8/react-compiler-require-is-a-call

Conversation

@robobun

@robobun robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With bun build --react-compiler, an unused require("./side") in a compiled component or hook is deleted, so ./side never runs. The plain bundler keeps init_side().
  • A used require() moves to each read of its local, so const a = require("a"); const { b } = require("b") evaluates b first. An unused require.resolve("x") is deleted too.
  • Cause: lower_expression (src/react_compiler/lowering/build_hir/expr.rs:318) lowered E::RequireString to a pure LoadGlobal { BunOpaque } constant. Dead code elimination prunes an unused one. Constant propagation forwards a used one to every read.

Fix

  • Lower E::RequireString and E::RequireResolveString as a CallExpression whose callee loads the node, the shape import() already has. No pass prunes, forwards or merges a call. Upstream sees this source as a call too.
  • Codegen finds such a call by its callee temporary (Context::require_call_nodes). The printed callee is not enough: require("./fn")(arg) would read as require("./fn").
  • Cost: in client mode a used require() result takes one memo slot, like any call result.
  • Verified: test/bundler/transpiler/react-compiler.test.ts (2 new tests, 13 forms each, 12 fail on main). Also react-compiler-fixtures.test.ts, bundler_dynamic_import_dce.test.ts, bundler_jsx.test.ts.

Background

  • The React Compiler runs per function, after the parser. By then require("x") is one E::RequireString node that holds its import record index.
  • A LoadGlobal instruction reads a global. The compiler treats it as a constant with no side effects.
  • BunOpaque is a LoadGlobal that carries a whole Bun AST node. Codegen prints it back unchanged, so the bundler keeps its import record.
  • react-compiler: do not fold a local that holds a different module on each path #42376 fixes how constant propagation compares two such constants. Both PRs are needed, see Notes.
Notes

Forms in the new tests. Each form requires its own module, which records that it ran. "main" is a debug build of 4b5862f. The expected column is also what the plain bundler prints.

form expected main
require("./s"); s runs does not run
the same inside a returned arrow runs when called does not run
the same inside a useEffect callback (client mode) runs does not run
if (yes) require("./s") runs does not run
void require("./s") runs does not run
const one = (require("./s"), 1) runs does not run
const unused = require("./s") runs does not run
no ? require("./a") : require("./b") reads b, runs b reads a, runs b then a
if (no) m = require("./a"); else m = require("./b") reads b reads a
const first = require("./1"); const { name } = require("./2"); first.name + name runs 1, 2 runs 2, 1
let m = require("./1"); try { m = require("./2") } catch {} runs 1, 2 runs 2 only
try { require.resolve("./not-there"); return "found" } catch { return "missing" } missing found
require("./function")(yes) (control for the codegen change) calls the export calls the export

The ssr output mode (--target=bun) removes effects by design, so the useEffect form expects nothing there.

Relation to #42376. That PR makes evaluate_phi compare two BunOpaque constants by the node they carry, not by name(). It fixes the wrong-module rows above and also import.meta.main against !import.meta.main and import items, which this PR does not touch. It does not fix a dropped or a moved require() (its body lists the dropped one as not fixed). After this PR a require("x") / require.resolve("x") result is not a constant, so those two arms of loads_same_value can no longer be reached. Whichever PR lands second can delete them, and the duplicated ternary and if/else test forms. A trial merge of the two branches is clean in both orders.

What the output looks like. In ssr mode the compiled body mirrors the source (init_side();, let mod = no ? require_a() : require_b();). In client mode the result of require() is memoized like the result of any call to an unknown global: if ($[0] === sentinel) { t0 = require_other(); $[0] = t0 } else t0 = $[0]. Before, a used require() was printed inline at each read with no slot. The module system caches a module after its first evaluation, so the memo block does not change when a module runs.

Differential. 52 more shapes print the same lines with and without --react-compiler, for --target=bun and --target=browser: property reads in JSX, destructure with defaults, new (require(x))(), ?? / ||, typeof, optional chains, template literals, spreads, asset-like requires in JSX attributes, useMemo / lazy useState initializers, loops, switch, early return, nested ternary, for..in / for..of / for init, IIFEs, labeled blocks, try / catch around a module that throws, require(require(x)), require(require.resolve(x)), require(x)(), require(x)?.(), import(). 35 of them were also run with --format=cjs, --target=node, --minify, --minify-syntax and --splitting. No shape that compiled before stops compiling. Four shapes that mutate a required module (m.extra = 1, delete m.name, require(x).count++, require(x).value = 5) are left uncompiled by 1.4.3-canary.1 (6a92015), and compile now.

Why require.resolve("x") too. It can throw (module not found), the plain bundler does not treat it as removable (expr_can_be_removed_if_unused), and upstream sees a method call on require.

The codegen lookup. The first version of this change matched the call by its printed callee, as the import() path does (if let ExprData::EImport(orig) = callee_expr.data). require("./fn")(kind) then printed as require("./fn"): the inner call prints as the bare node, which is also what the outer call's callee prints as. Context::object_methods is the existing pattern for an instruction that a later instruction consumes by identity, so require_call_nodes follows it. The import() path is unchanged.

Unrelated bugs found on the way, not fixed here (each reproduces without require):

  1. try { const unused = 1 } catch { found = "missing" } in a compiled function panics in merge_consecutive_blocks.rs:106 (Found a block with a single predecessor but where a phi has multiple (2) operands).
  2. const o = { get() { return 1 } } in a compiled function panicked with --target=bun in align_object_method_scopes.rs:52. react_compiler: fix panic on an object method shorthand in ssr output mode #42375 fixed it.
  3. Without the compiler, --target=node prints !import.meta.main as !require.main == module.

[human-review] gate passed · iteration 0 · 4 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 (6a92015fc)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [1208.74ms]
(pass) bundler > react-compiler/ComponentWithHooks [513.09ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [511.03ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [508.73ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [402.06ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [380.54ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [709.99ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [269.00ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [1030.35ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [274.11ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [387.67ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [664.18ms]
(pass) bundler > re
... (truncated)

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

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [28.20ms]
(pass) bundler > react-compiler/ComponentWithHooks [13.50ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [10.06ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [14.05ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [16.48ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [12.22ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [89.38ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [9.94ms]
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-q0TUYQ/react-compiler/SsrObjectMethodShorthand/entry.jsx --outfile=/tmp/bun-build-tests
... (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 [1690.22ms]
(pass) bundler > react-compiler/ComponentWithHooks [494.28ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [499.07ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [586.56ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [310.00ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [281.44ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [560.13ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [377.70ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [1120.59ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [318.22ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [223.63ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [394.04ms]
(pass) bundler > re
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1120ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/124] gen cpp.rs (cppbind)
[2/124] gen JS modules (bundle-modules)
Preprocess modules (11235ms)
Bundle modules (147ms)
Postprocesss modules (165ms)
Bundle Functions (540ms)
Generate Code (53ms)

[12.15s] Bundled "src/js" for production
  2600 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[2/123] 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   Comp
... (truncated)
diff hotspot
src/react_compiler/codegen.rs                  |  11 ++
 src/react_compiler/hir/mod.rs                  |  16 +++
 src/react_compiler/lowering/build_hir/expr.rs  |  20 ++-
 test/bundler/transpiler/react-compiler.test.ts | 171 +++++++++++++++++++++++++
 4 files changed, 216 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                            reads  edits  tests
src/react_compiler/codegen.rs                       6      6     18
src/react_compiler/hir/mod.rs                       3      2     18
src/react_compiler/lowering/build_hir/expr.rs       4      2     18
test/bundler/transpiler/react-compiler.test.ts      5      4     18

Inside a function the React Compiler compiles, `require("x")` was lowered
to a pure `LoadGlobal` constant. Dead code elimination dropped an unused
one, so the module was never evaluated. Constant propagation moved a used
one to each of its reads, which changed the evaluation order. And a local
that gets a different module on each path read the first module, because
constant propagation compares such constants by a name that is `require`
for all of them.

Lower both nodes the way `import()` is lowered: a `CallExpression` whose
callee loads the original node. Codegen finds such a call by its callee
temporary, so `require("x")()` is not read as `require("x")`.
@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: 74d2afc5-5d67-4281-8f44-f58fa35153f0

📥 Commits

Reviewing files that changed from the base of the PR and between a749e0a and 9dea2a4.

📒 Files selected for processing (4)
  • src/react_compiler/codegen.rs
  • src/react_compiler/hir/mod.rs
  • src/react_compiler/lowering/build_hir/expr.rs
  • test/bundler/transpiler/react-compiler.test.ts

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


Walkthrough

The React Compiler now lowers require() and require.resolve() as calls, preserves their original AST expressions during code generation, and validates runtime behavior across browser and Bun bundler targets.

Changes

Require call preservation

Layer / File(s) Summary
Lower require expressions
src/react_compiler/lowering/build_hir/expr.rs, src/react_compiler/hir/mod.rs
Require expressions now lower to zero-argument calls with Bun-opaque callees. NonLocalBinding exposes the original require-call expression.
Reuse require calls in codegen
src/react_compiler/codegen.rs
Code generation records require-call expressions by temporary identifier and reuses them for matching CallExpression instructions.
Validate compiled require behavior
test/bundler/transpiler/react-compiler.test.ts
Tests cover browser and Bun targets, evaluation order, conditional behavior, reassignment, missing modules, nested callbacks, exported calls, and SSR effect removal.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 9dea2

The require-call preservation change is covered across lowering, code generation, and bundler behavior, with no unresolved merge-blocking risk identified.

🚥 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 and concisely states the main change: preserving require("x") and require.resolve("x") calls in compiled React Compiler functions.
Description check ✅ Passed The description explains the problem, fix, design details, compatibility considerations, and verification results. It does not use the template headings exactly, but it provides the required informati…

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

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed, waiting for CI.

How I reproduced it (bun 1.4.3-canary.1 6a92015, and a debug build of main 4b5862f), in a directory with react installed:

// side.js
console.log("side evaluated");
// c.jsx
export function App() {
  require("./side");
  return <div />;
}
App();
console.log("done");
$ bun build --target=bun c.jsx --outfile=u.js && bun u.js
side evaluated
done
$ bun build --react-compiler --target=bun c.jsx --outfile=c.js && bun c.js
done

With this branch the second command prints side evaluated and done. The new tests in test/bundler/transpiler/react-compiler.test.ts (react-compiler/RequireIsACall-bun, react-compiler/RequireIsACall-browser) fail on main and pass with the fix.

Self-reviewed: 5 concerns raised, 5 addressed (rebased on main, left the import() codegen path unchanged, named #42376 and the split between the two PRs, stated the memo slot cost, removed claims the evidence did not support).

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

I reviewed this and didn't find any bugs. The change closely mirrors two existing patterns in the same files (the EImport lowering shape and the object_methods identifier-keyed map), and the test matrix is thorough. A human look is still worthwhile for the HIR modeling choice and the stated coordination with #42376 (which arms of loads_same_value become dead, and landing order).

What was reviewed:

  • Lowering in expr.rs — matches the Data::EImport arm directly above it; ERequireCallTarget/ERequireResolveCallTarget remain in the opaque-constant arm.
  • Codegen lookup — keyed on the callee temporary's IdentifierId, not the printed expr, so require("./fn")(arg) (covered by CallsTheExport) still emits the outer call; falls through to normal LoadGlobal codegen for every other binding.
  • Test — exact-stdout assertion over 13 forms × 2 targets, plus an onAfterBundle guard that no useEffect(() => survived, so a form that silently bailed out of compilation would fail.
Extended reasoning...

Overview

The PR changes how Bun's React Compiler pass models require("x") and require.resolve("x") in HIR. Previously both were lowered as a pure LoadGlobal(BunOpaque) constant, which let DCE drop unused ones, constant propagation forward them to reads (reordering evaluation), and phi merging pick the wrong branch. The fix lowers them as a CallExpression whose callee is the opaque node — the exact shape the adjacent Data::EImport arm already uses — so HIR passes treat them as effectful calls. Codegen gains a require_call_nodes: IdMap<IdentifierId, Expr> (following the existing object_methods pattern) that records the original bun_ast::Expr at LoadGlobal time and returns it verbatim when the matching CallExpression is emitted, preserving the bundler's import_record_index. A new itBundled test in react-compiler.test.ts exercises 13 syntactic positions across both bun and browser targets with exact-stdout assertions and an onAfterBundle check that every function actually compiled.

Security risks

None. This is a bundler/compiler-pass correctness fix over the internal HIR; no auth, crypto, network, filesystem, or untrusted-input parsing is touched. The worst-case failure mode is incorrect bundled output, which the test asserts against directly.

Level of scrutiny

Moderate. The diff is small and each piece mirrors an established pattern in the same file (EImport lowering at expr.rs:253, object_methods identifier-keyed map at codegen.rs:1418), which substantially lowers the risk of a novel interaction. I checked that the new LoadGlobal match arm in codegen_instruction_nullable falls through (no return) so non-require globals still reach normal codegen, and that the CallExpression short-circuit is keyed on the callee temporary rather than the printed callee — the CallsTheExport form covers the require("./fn")(arg) ambiguity the doc comment describes. The remaining ERequireCallTarget / ERequireResolveCallTarget / EImportMetaMain / ERequireMain variants stay in the constant arm, which is correct since those are the bare identifiers, not calls.

Other factors

The test coverage is strong by REVIEW.md's standards: it asserts exact stdout (not "no panic"), covers both targets, includes a negative control (CallsTheExport) for the codegen lookup, and guards against silent bail-out via the useEffect(() => absence check. No CODEOWNERS entry covers src/react_compiler/ or test/bundler/. I'm deferring rather than approving because the PR explicitly describes coordination with #42376 (dead loads_same_value arms and duplicated test forms to delete depending on landing order) and because the choice to model these as a zero-arg call on an opaque callee — while it matches import() — is a HIR modeling decision a react_compiler maintainer should sign off on.

@robobun

robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:30 AM PT - Sep 12th, 2026

✅ @robobun, your commit 9dea2a47b23e2e78f3273f758b5944f00e26b2e0 passed in Build #114614! 🎉


🧪   To try this PR locally:

bunx bun-pr 42432

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

bun-42432 --bun

Comment thread src/react_compiler/codegen.rs Outdated
Comment thread src/react_compiler/hir/mod.rs Outdated
Comment thread src/react_compiler/hir/mod.rs Outdated
Comment thread src/react_compiler/lowering/build_hir/expr.rs Outdated
@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

I worked on the same report and came to the same change: robobun/bbbad706/react-compiler-keep-unused-require. The lowering is the same. The codegen lookup there also takes the import() path by callee temporary. I do not open a second PR. Two results from that branch that this PR does not have yet, both checked on a debug build of 9dea2a4:

1. A hook that is read off a require() result stops the compile of its function. The Notes say that no shape that compiled before stops compiling. These two shapes compile on main (4b5862f) and do not compile with this change:

export function InlineHook() {
  const v = require("./hooks.cjs").useThing();
  const [s] = useState(1);
  return <div>{v + s}</div>;
}
export function LocalHook() {
  const m = require("./hooks.cjs");
  const v = m.useThing();
  const [s] = useState(1);
  return <div>{v + s}</div>;
}

BUN_DEBUG_react_compiler=1 shows compile_fn err: ... "Hooks must be the same function on every render, but this value may change over time to a different function." for both. The result of require() is now a call result, which validate_hooks_usage classes as a local, so .useThing off it is a PotentialHook. On main it was a global, so .useThing was a KnownHook. The functions are left as written, so the output is still correct. babel-plugin-react-compiler@1.0.0 reports the same error for the same two shapes, and for const { useThing } = require("./hooks.cjs"), which main also rejects (with a different message). So this matches upstream. I think the Notes must say it.

2. This PR also removes a build crash. On main, an unused require("x") or require.resolve("x") in a try block aborts bun build when the catch block assigns a local that is read later:

export function RequireInATryBlock() {
  useEffect(() => {});
  let found = "found";
  try {
    require("not-installed");
  } catch {
    found = "not found";
  }
  return found;
}
panic: assertion `left == right` failed: Found a block with a single predecessor but where a phi has multiple (2) operands

Dead code elimination removed the LoadGlobal, the try block was empty, and prune_maybe_throws removed the edge to the catch block. That is item 1 of the unrelated bugs in the Notes, with require as the trigger. With this change the call stays in the block and the function prints not found. The assertion itself (merge_consecutive_blocks.rs:106) is still there for a try body that has no call, as the Notes say. My branch has an itBundled test for the two shapes (react-compiler/UnusedRequireInATryBlock-*). It fails on main and passes on this branch, if you want to take it.

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

Code review found no issues

No high-confidence issues detected in this change.

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