Skip to content

react_compiler: preserve typeof unbound-global semantics in codegen - #36743

Closed
robobun wants to merge 3 commits into
mainfrom
farm/e2f54a66/react-compiler-typeof-unbound-global
Closed

react_compiler: preserve typeof unbound-global semantics in codegen#36743
robobun wants to merge 3 commits into
mainfrom
farm/e2f54a66/react-compiler-typeof-unbound-global

Conversation

@robobun

@robobun robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

typeof unboundGlobal inside a react-compiled function prints as typeof (0, unboundGlobal), which throws ReferenceError instead of returning "undefined".

import { useMemo } from "react";
export function useThing() {
  return useMemo(() => typeof SomeUnboundGlobal, []);
}
$ bun build entry.jsx --react-compiler --external='*' --target=browser
...
function useThing() {
  return typeof (0, SomeUnboundGlobal);
}

This breaks the feature-detection idiom (typeof WebSocket !== "undefined" etc.) in any hook or component the compiler rewrites.

Cause

src/react_compiler/codegen.rs at InstructionValue::UnaryExpression emits E::Unary { op: UnTypeof, flags: E::UnaryFlags::empty() }. The printer (src/js_printer/lib.rs:4003) re-wraps any typeof <unbound EIdentifier> that lacks WAS_ORIGINALLY_TYPEOF_IDENTIFIER as typeof (0, ...), to preserve the throwing semantics of e.g. typeof (a ?? b) after folding.

The parser sets that flag for typeof <EIdentifier> (src/js_parser/parse/parse_prefix.rs:369-371), but lower_unary (src/react_compiler/lowering/build_hir/expr.rs:1016-1026) did not capture unary.flags, so the HIR had no way to distinguish typeof x from typeof (0, x) at codegen time. Both collapse to UnaryExpression { TypeOf, <temp-for-LoadGlobal> }, because sequence lowering discards the side-effect-free 0 and inlines the global load.

Upstream's HIR also drops the distinction, but @babel/generator never inserts a defensive (0, ...) wrap, so Babel emits typeof x for both forms.

Fix

Thread the parse-time E::UnaryFlags through InstructionValue::UnaryExpression and emit them back in codegen. typeof x then keeps WAS_ORIGINALLY_TYPEOF_IDENTIFIER so the printer leaves it alone; typeof (0, x) has an empty flag set so the printer still re-wraps the inlined identifier. typeof (sideEffect(), x) is unaffected (the operand stays a sequence, which the printer never wraps).

This is stricter than Babel's reference output (which loses the throw for typeof (0, x)), but it matches the source semantics in every case. Sibling of #36741; that one can set the delete flag unconditionally because PropertyDelete/ComputedDelete are only lowered from delete <EDot|EIndex>, whereas UnaryExpression { TypeOf } is reached from both flagged and unflagged sources.

Also adds Debug to E::UnaryFlags so the HIR #[derive(Debug)] keeps compiling.

Verification

$ bun bd test test/bundler/transpiler/react-compiler.test.ts -t TypeofUnboundGlobal
# TypeofUnboundGlobalReturnsUndefined: fails on main (output contains `typeof (0, SomeUnboundGlobal)` and run throws)
# TypeofSequenceUnboundGlobalStillThrows: passes on both (control)
# both pass with this change

Full react-compiler.test.ts (34 pass) and react-compiler-fixtures.test.ts (3293 pass, 320 skip) pass.


[review] gate passed · iteration 1 · 6 files touched

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

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [672.22ms]
(pass) bundler > react-compiler/ComponentWithHooks [299.48ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [311.71ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [183.13ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [103.88ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [125.64ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [95.64ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [112.12ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [380.78ms]
(pass) bundler > react-compiler/BundledCjsCompilerRuntimeSurvivesTreeShaking [475.61ms]
(pass) bundler > react-compiler/RequireStringPreservesImportRecord [334.92ms]
(pass) bundler > react-compiler/BranchBooleanFeatureFlagPreservesDCE [349.05ms]
(pass) bundle
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (d75001695)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [18.57ms]
(pass) bundler > react-compiler/ComponentWithHooks [7.04ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [7.80ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [5.96ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [2.91ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [4.29ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [2.88ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [2.40ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [7.45ms]
(pass) bundler > react-compiler/BundledCjsCompilerRuntimeSurvivesTreeShaking [12.11ms]
(pass) bundler > react-compiler/RequireStringPreservesImportRecord [7.07ms]
(pass) bundler > react-compiler/BranchBooleanFeatureFlagPreservesDCE [6.29ms]
(pass) bundler > react-compiler/ForwardRefSiblingFn [6.11ms]
(pass) bundler > react-compiler/SelfRefConstArrow [5.89ms]
(pass) bundler > react-compiler/OutlinedFunctionMinify-syntax=false-identifiers=true 
... (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/mechgate.xml" test/bundler/transpiler/react-compiler.test.ts
bun test v1.4.0 (eeac6ea72)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [686.47ms]
(pass) bundler > react-compiler/ComponentWithHooks [316.62ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [328.66ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [199.49ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [99.27ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [112.06ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [107.43ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [93.92ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [331.42ms]
(pass) bundler > react-compiler/BundledCjsCompilerRuntimeSurvivesTreeShaking [427.64ms]
(pass) bundler > react-compiler/RequireStringPreservesImportRecord [330.02ms]
(pass) bundler > react-compiler/BranchBooleanFeatureFlagPreservesDCE [293.72ms]
(pass) bundler
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 711ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap 
... (truncated)
diff hotspot
src/ast/e.rs                                       |  2 +-
 src/react_compiler/codegen.rs                      |  7 ++-
 src/react_compiler/hir/mod.rs                      |  2 +
 src/react_compiler/lowering/build_hir/expr.rs      |  1 +
 .../optimization/constant_propagation.rs           |  1 +
 test/bundler/transpiler/react-compiler.test.ts     | 67 ++++++++++++++++++++++
 6 files changed, 77 insertions(+), 3 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                                     reads  edits  tests
src/ast/e.rs                                                 1      1      0
src/react_compiler/codegen.rs                                5      1      0
src/react_compiler/hir/mod.rs                                6      3      0
src/react_compiler/lowering/build_hir/expr.rs                3      1      0
src/react_compiler/optimization/constant_propagation.rs      1      1      0
test/bundler/transpiler/react-compiler.test.ts               2      2      0

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 447ee950-3b30-4f84-93e2-a966e2029af8

📥 Commits

Reviewing files that changed from the base of the PR and between f91d5c9 and eeac6ea.

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

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

@github-actions github-actions Bot added the claude label Aug 1, 2026

@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 — threads the parse-time UnaryFlags through HIR so typeof unboundGlobal keeps its no-throw semantics after react-compiler codegen.

What was reviewed:

  • Verified lower_unary is the only InstructionValue::UnaryExpression construction site; every other match arm already uses .. so the new field compiles without further edits.
  • Checked the other E::Unary emitters in codegen.rs (delete/update/-Infinity) — none carry WAS_ORIGINALLY_TYPEOF_IDENTIFIER, so leaving them at empty() is correct; the sibling delete-flag case is #36741 as the description notes.
  • Tests exercise both the fix (runtime ["undefined",1] + no (0, x) in output) and the control (typeof (0, x) still re-wraps and throws), and guard against a compiler bailout via the react.memo_cache_sentinel assertion.
Extended reasoning...

Overview

Fixes typeof unboundGlobal inside react-compiled functions printing as typeof (0, unboundGlobal), which evaluates the reference and throws ReferenceError instead of returning "undefined". The fix threads E::UnaryFlags (specifically WAS_ORIGINALLY_TYPEOF_IDENTIFIER) from the parser AST through the HIR InstructionValue::UnaryExpression variant and back out at codegen. Touches: src/ast/e.rs (adds Debug to the bitflags so the HIR #[derive(Debug)] keeps compiling), src/react_compiler/hir/mod.rs (new flags field with a doc comment explaining the Bun-vs-Babel divergence), lowering/build_hir/expr.rs (captures unary.flags), optimization/constant_propagation.rs (adds .. to the destructure), codegen.rs (emits the captured flags instead of empty()), plus two new itBundled tests.

Security risks

None. This is a bundler/transpiler codegen correctness fix — no auth, network, filesystem, or untrusted-input parsing surface is touched. The flag is a 2-bit bitset copied by value.

Level of scrutiny

Medium-low. The change is mechanical (add a field, thread it through one construction site, emit it at one consumption site). I grepped every InstructionValue::UnaryExpression occurrence to confirm lower_unary is the sole constructor and that all other pattern-match sites already use .., so no exhaustiveness break. The other E::Unary emitters in codegen.rs are for delete, ++/--, and -Infinity, none of which carry the typeof flag; the description correctly scopes the delete-flag sibling to #36741.

Other factors

The tests are strong: both include a run: { stdout: ... } check proving runtime semantics, plus output-shape assertions that (a) confirm the hook was actually compiled (not bailed out) via react.memo_cache_sentinel, and (b) assert the specific typeof shape. The control test (TypeofSequenceUnboundGlobalStillThrows) proves the fix doesn't over-apply — typeof (0, x) in source still re-wraps because its parse-time flags are empty. The PR description reports the full react-compiler.test.ts and 3293-fixture suite pass. The HIR field's doc comment clearly records why this deviates from upstream.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review pass came back clean (32 probes, 0 surviving). GitHub fast checks are green (clippy, miri, format, lints, comment-cop) and the evidence gate confirms the test fails on main / passes here under both ASAN and release.

Buildkite #87513 is complete: 184 jobs passed, react-compiler.test.ts green on every lane that ran it. Zero [new] failures; everything red is [pre-existing] on main (setInterval.test.js leak-test timeout on x64-asan, bun-upgrade.test.ts on windows-aarch64), [flaky], or agent-provisioning infra (AWS EC2 FreeBSD + Azure 429 throttling on the Windows lanes). The two earlier builds (#87391, #87420) never got agents at all.

Ready for review.

Comment thread src/react_compiler/hir/mod.rs Outdated

@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 — small, targeted fix that threads parse-time UnaryFlags through the react-compiler HIR so typeof <unbound> doesn't get re-wrapped as typeof (0, x).

What was reviewed:

  • Confirmed lower_unary is the only InstructionValue::UnaryExpression constructor; every other match site already uses .. or was updated here.
  • Checked the remaining E::UnaryFlags::empty() sites in codegen.rs — they're delete (sibling #36741), ++/--, and -Infinity, none of which the printer's typeof-rewrap touches.
  • Both tests execute the bundle (run:) and assert the hook was compiled (not bailed) via the memo-cache sentinel; the control test pins the typeof (0, x) throwing form.
Extended reasoning...

Overview

Threads E::UnaryFlags (specifically WAS_ORIGINALLY_TYPEOF_IDENTIFIER) through the react-compiler HIR so codegen can hand it back to the printer. Five source files touched: adds a flags field to InstructionValue::UnaryExpression, captures unary.flags at the one construction site in lower_unary, emits it in codegen_base_instruction_value, adds .. to the constant-propagation destructure, and derives Debug on UnaryFlags so the HIR's #[derive(Debug)] compiles. Two new itBundled tests.

Security risks

None. Pure transpiler-output correctness; no user input parsing, no I/O, no allocation changes. UnaryFlags is a Copy u8 bitflags — no ownership/lifetime concerns.

Level of scrutiny

Low-moderate. The fix is mechanical plumbing of an existing flag from parse → lower → codegen with no semantic transformation in between. I grepped every InstructionValue::UnaryExpression reference across src/react_compiler/ (12 files): the sole constructor is in lower_unary, and every other match site uses a .. rest pattern (or was updated in this PR). The other E::Unary { flags: empty() } sites in codegen.rs are UnDelete (explicitly deferred to sibling #36741), UnPreInc/UnPostInc/etc., and UnNeg for -Infinity — none reach the printer's typeof-identifier rewrap.

Other factors

  • Tests are strong: both bundle and run the output (so a runtime ReferenceError fails the test), assert the react-compiler actually compiled the hook (via react.memo_cache_sentinel) rather than bailing, and include a control that pins typeof (0, x) semantics so the fix can't over-apply.
  • The evidence block shows the new test fails on main (debug/ASAN) and passes with the fix; react-compiler-fixtures.test.ts (3293 fixtures) also passes.
  • The comment-cop bot's concern about a long doc comment was addressed in eeac6ea (trimmed to one line) and the thread is resolved.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Aug 1st, 2026

@robobun, your commit eeac6ea has 3 failures in Build #87513 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36743

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

bun-36743 --bun

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun conflicts

@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #36741, which was expanded to cover the typeof case as well and merged as 81a68d3 (same mechanism: thread E::UnaryFlags through InstructionValue::UnaryExpression, field named bun_flags). Its TypeofUnboundIdentifierPreservesFlag test covers both the typeof window and the folded-conditional control case. Closing.

@robobun robobun closed this Aug 3, 2026
@robobun
robobun deleted the farm/e2f54a66/react-compiler-typeof-unbound-global branch August 3, 2026 22:10
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