Skip to content

react-compiler: fix memo blocks around an expression that reads a local and then assigns it - #42940

Open
robobun wants to merge 1 commit into
mainfrom
robobun/c2bba2c0/react-compiler-interposed-temporary-dependency
Open

robobun wants to merge 1 commit into
mainfrom
robobun/c2bba2c0/react-compiler-interposed-temporary-dependency

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With --react-compiler --target=browser, let i = p.n; const list = [i, (i = i >> 1), i]; returns the array of the previous render when p.n goes from 4 to 5. The memo block tests $[0] !== i || $[1] !== t1, the values after the assignment, and not the first read (const t0 = i). With a property path a valid component throws TypeError: undefined is not an object (evaluating 'o.y.z').
  • The temporaries sidemap (collect_temporaries_sidemap_impl, src/react_compiler/inference/propagate_scope_dependencies_hir.rs:260) maps the result of LoadLocal i to i, also when i is reassigned before the use. Upstream does the same.
  • An assignment without a name prints at its use, inside the memo block ([i, (i = G.v)]). A cache hit skips it, and a later read of i is wrong.

Fix

  • find_temporaries_used_outside_declaring_scope now also keeps out of the sidemap a temporary that is used after a reassignment of its local. The block then depends on the temporary. A block that contains the load depends on the local by name.
  • PromoteInterposedTemporaries names an assignment to a local that is pending when a memo block starts. It then prints ahead of the block.
  • No new pass. Two maps stay empty unless a local is reassigned. Time and peak RSS do not change (Notes).
  • Verified: test/bundler/transpiler/react-compiler.test.ts (24 new components, 22 wrong on main), react-compiler-fixtures.test.ts, bundler_jsx.test.ts. All upstream fixture outputs are byte-identical to main.

Background

  • A memo block (if ($[0] !== dep) {...}) runs again only when a dependency changed. Only client mode has them.
  • The HIR loads a variable into a temporary (t = LoadLocal i). Codegen prints a temporary without a name at its use. The sidemap lets the dependency pass see through it.
  • PromoteUsedTemporaries names a temporary (const t0 = i). It then prints as a statement.
Notes

Origin. A differential fuzz run found this. There is no user report. Only client mode (--target=browser) is affected, because ssr mode has no memo blocks. #42859 made it more visible: it names the assignment ahead of the block, so the wrong dependency always compares equal.

Repro. A react stub with one memo cache per component (the test has it), then:

function PlainAssignment(p) {
  let i = p.n;
  const list = [i, (i = i >> 1), i];
  return <div label={i} list={list} />;
}
// renders with n = 4, 5, 5
// plain:    [4,2,2] [5,2,2] [5,2,2]
// compiled: [4,2,2] [4,2,2] [4,2,2]   (main c6b7fcb5bb)

Main prints this for the component. t0 holds the value the block needs, and the block does not test it:

let i = p.n;
const t0 = i;
const t1 = i = i >> 1;
let t2;
if ($[0] !== i || $[1] !== t1)
  t2 = [t0, t1, i], $[0] = i, $[1] = t1, $[2] = t2;
else
  t2 = $[2];

This branch prints if ($[0] !== i || $[1] !== t0 || $[2] !== t1).

What counts as a reassignment. StoreLocal and Destructure with kind Reassign, PrefixUpdate and PostfixUpdate. The test is conservative: a reassignment with an instruction id between the load and the use, on any path. A false positive costs one named temporary, which holds the right value. A local that a closure assigns is a context variable and is not in SSA form. LoadContext has its own guard (is_load_context_mutable), and those shapes are right on main.

Two uses that are not checked.

  • A switch case test. Lowering puts it ahead of the discriminant, and it prints behind it, where it runs. switch ((i = p.m)) { case i: } has to read the new i by name. PromoteInterposedTemporaries has the same exemption.
  • The property of a method call (xs.join in xs.join(((xs = []), "-"))). Codegen prints it through the receiver, and the receiver is checked. With a name of its own, codegen rejects the function (MethodCall::property must be an unpromoted + unmemoized MemberExpression) and it stays uncompiled.

The test has a component for each (SwitchCaseTest, MethodOfReassignedReceiver), and a line that lists every component that did not compile.

How the walk stays cheap. rewrite_instruction_kinds_based_on_reassignment leaves kind Let only on a declaration that a later instruction reassigns. The walk records such a declaration, and from then on it records the loads of that local (the load id and the local). Nothing declares a parameter. So when the walk meets the first reassignment of a local it has no record of, it collects the loads of the instructions before it once. ReassignedParameter in the test takes that path. Correctness does not depend on the Let kind: without it the same fallback runs. A function with no recorded load skips the check of the operands.

Measured. Debug builds (ASAN), bun build --react-compiler of one file, median of 5 runs, main then this branch. The time differences are below the run-to-run noise (about 10%).

input time peak RSS
400 components, 10 statements each, no local is reassigned 76.4 s, 79.1 s 2,106 MB, 2,107 MB
the same, every statement reads and reassigns two locals (about 100,000 recorded loads) 87.4 s, 84.7 s 2,128 MB, 2,145 MB
40 hooks with 40 reassigned parameters each (1,600 walks of the earlier instructions) 50.8 s, 52.1 s 1,390 MB, 1,397 MB

The 17 MB of the second row include the larger output: each statement has two more named temporaries and two more cache slots.

Why not at the use. Swapping the dependency for the temporary in visit_operand is not enough with nested blocks. In NestedBlocks the load is inside the block of outer, and the use is inside the block of the inner array. The temporary is not a valid dependency of the outer block (it is declared inside it), and because the load is a sidemap entry nothing visits it at the load. The outer block would lose its dependency on i. With the temporary out of the sidemap, the load is visited as a normal instruction.

The assignment inside the block. The value of i = G.v is not reactive, so no block depends on it and nothing names it. On main the block of [i, (i = G.v)] depends on i by name, which makes the block entry a read for PromoteInterposedTemporaries, and that names the pending assignment. With the dependency on t0 that no longer happens. Main has the same defect wherever the block does not read the local by name: make((i = G.v)) (a block with no dependency, i is undefined from the second render on), [i + 1, (i = G.v)] (the block depends on the temporary of i + 1), [(i = G.v), k] with a const k, and [((i = p.m), 0)]. The new effect Store conflicts with a memo block that starts after the temporary. StoreLocal, StoreContext and Destructure with kind Reassign have it, and the two updates. A call that is pending at a block still prints inside it, as before.

The 24 components of the test. Wrong on main: plain and compound assignment, JSX attribute, Map entries, call arguments, object literal with a computed key, i++, --i, destructuring assignment, a property path (throws on main), a reassigned parameter, two locals, nested blocks, an assignment in a ?: branch and in an && operand, and seven with an assignment that main prints inside the block. Right on main, as guards: the switch case test and the method call.

Other checks.

  • Every upstream fixture compiles to the same bytes as on main: 1,586 outputs in infer mode, and 1,556 outputs with @compilationMode:"all" added to each fixture (973 of them memoized).
  • Differential check, plain build against compiled build, one memo cache per component. 125 hand-written components: 77 wrong on main, 0 here. 3,600 generated components that read and reassign i, j and o inside arrays, objects, calls, JSX, ?:, && and sequences, with reactive and non-reactive values: 122 wrong on main, 51 here, none newly wrong. The 51 are right with react_compiler: fix stale values for a let reassigned inside a memo block #42482 applied on top (it applies cleanly).
  • Three more corpora (about 1,180 files and 28,000 functions: loops, switch, try, closures, hooks, parameters, optional chains, every expression kind) found the two unchecked uses above. With those in place they give the groups below and nothing else.

Right on main by luck. #42482 fixes a different defect: a block that reassigns one of its own dependencies stores the new value in the cache slot, so the next comparison is wrong. In make(j, ident((i = i - 1)), (j = i % 2), j) both i and j are such dependencies on main. The slot of j never compares equal, so the block always runs again and the result is right. With the dependency on t0 the block can hit, and the slot of i then matches when p.n goes from 8 to 7. 8 of about 10,800 generated components in those corpora are like this. All of them are right with #42482 applied on top, so #42482 is best merged first. NestedBlocks in the test renders the right values, but its outer block never hits its cache until #42482.

Not fixed here. These have other causes:

  • The JSX key. Lowering puts it ahead of the other attributes, and the runtime call evaluates it last. <b key={i} a={(i = p.m)} /> and <b key={i++} b={i} /> are wrong on main. For a non-reactive i the props of the element alone were right on main, because everything printed inline. The named assignment now prints ahead, as it already does for a reactive i. The key, and a later read of i, were wrong on main in every variant.
  • A bare read of a parameter ahead of its reassignment, outside any memo block: p + ((p = p.n >> 1) + [p].length). PromoteInterposedTemporaries takes every parameter for a constant.
  • A getter with a side effect on a module-level object: [GL.one, (i = GL.two)] runs two first. Main does the same for [GL.one, (i = p.m)]: a property load on a global has no effect in that pass.

[human-review] gate passed · iteration 0 · 3 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/pr_gate.xml" test/bundler/transpiler/react-compiler.test.ts
bun test v1.4.3 (09bb54630)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [570.97ms]
(pass) bundler > react-compiler/ComponentWithHooks [223.63ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [228.99ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [232.51ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [135.34ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [105.66ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [332.93ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [161.65ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [538.23ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [92.15ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [85.53ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [245.49ms]
(pass) bundler > react-
... (truncated)

release without fix: 13 failed, 1 skipped
bun test v1.4.3-canary.1 (09bb54630)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [15.26ms]
(pass) bundler > react-compiler/ComponentWithHooks [7.26ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [5.95ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [5.67ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [5.95ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [3.17ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [7.40ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [4.07ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [12.38ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [4.96ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [3.19ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [6.32ms]
(pass) bundler > react-compiler/BundledCjsCompilerRuntimeSurvivesTreeShaking [10.49ms]
(pass) bundler > react-compiler/RequireStringPreservesImportRecord [6.04ms]
(pass) bundler > react-compiler/BranchBooleanFeatu
... (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 (09bb54630)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [559.56ms]
(pass) bundler > react-compiler/ComponentWithHooks [222.60ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [285.84ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [226.54ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [116.35ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [94.70ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [294.84ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [92.90ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [516.78ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [101.76ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [114.28ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [263.95ms]
(pass) bundler > react-
... (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     1a37a82d13
  features     lto, baseline

23 deps, 131 codegen, 1176 objects in 672ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1254] mkdir stamps
[2/1254] mkdir codegen
[3/1254] mkdir pch
[4/1254] mkdir obj
[5/1254] install /workspace/bun
bun install v1.4.3-canary.1 (09bb54630)

Checked 22 installs across 61 packages (no changes) [26.00ms]
[6/1254] gen ErrorCode+*.h
[7/1254] gen bindgenv2
[8/1254] fetch tinycc
[tinycc] up to date
[9/1254] fetch picohttpparser
[picohttpparser] up to date
[10/1254] gen .bind.ts → GeneratedBindings.cpp
[11/1254] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[12/1254] fetch zlib
[zlib] up to date
[13/1254] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (09bb54630)

Checked 1 install across 2 packages (no changes) [6.00ms]
[14/1254] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[15/1254] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (09bb54630)

Checked 
... (truncated)
diff hotspot
.../inference/propagate_scope_dependencies_hir.rs  | 152 ++++++++++
 .../reactive_scopes/promote_used_temporaries.rs    |  36 ++-
 test/bundler/transpiler/react-compiler.test.ts     | 326 +++++++++++++++++++++
 3 files changed, 507 insertions(+), 7 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
…_compiler/inference/propagate_scope_dependencies_hir.rs      8      8     30
…ct_compiler/reactive_scopes/promote_used_temporaries.rs      4      6     30
test/bundler/transpiler/react-compiler.test.ts                6      5     30

…al and then assigns it

`[i, (i = i >> 1), i]` reads `i`, assigns it, and uses the first read
after the assignment. Two things went wrong in the memo block that
builds the array.

The block depended on `i` by name. At the block `i` already holds the
new value, so the block hit its cache when only the old value changed.
With a property path the dependency (`o.y.z`) was read from the new `o`
and could throw. The temporaries sidemap maps the result of `LoadLocal i`
to `i`, because codegen normally prints such a temporary as `i` at its
use. That is only valid when `i` is not reassigned between the load and
the use. Such a temporary is now left out of the sidemap, like a
temporary that is used outside the scope that declares it. The block
then depends on the temporary, and an enclosing block that contains the
load still depends on `i` by name. A switch case test and the property
of a method call are not checked: both print where they are used.

The assignment could print inside the block. When the value it stores is
not reactive (`i = G.v`) it gets no name from a dependency, and codegen
prints a temporary without a name at its use. A cache hit then skips the
assignment, and a later read of `i` gets the old value. An assignment to
a local that is pending when a memo block starts now gets a name, so it
prints as a statement ahead of the block.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 4 days. After that, they cost $0.25 per reviewed file.

Or wait 6 seconds for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: e851356a-0998-4f4a-88b0-6396ba50eb5a

📥 Commits

Reviewing files that changed from the base of the PR and between c6b7fcb and 1a37a82.

📒 Files selected for processing (3)
  • src/react_compiler/inference/propagate_scope_dependencies_hir.rs
  • src/react_compiler/reactive_scopes/promote_used_temporaries.rs
  • test/bundler/transpiler/react-compiler.test.ts

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

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status

How I reproduced it: let i = p.n; const list = [i, (i = i >> 1), i]; bundled with bun build --react-compiler --target=browser against a react stub that keeps one memo cache per component. Renders with n 4, 5, 5 print [4,2,2] [4,2,2] [4,2,2]. The plain build prints [4,2,2] [5,2,2] [5,2,2]. With the src/ of main c6b7fcb, react-compiler/LocalReassignedBetweenReadAndUse-compiled is wrong on 22 of 24 components. bun bd test test/bundler/transpiler/react-compiler.test.ts passes on this branch (75 pass), and so do react-compiler-fixtures.test.ts (3293 pass) and bundler_jsx.test.ts (57 pass).

Self-reviewed: 8 concerns raised, 8 addressed. The largest one: with only the dependency fix, a cache hit skipped an assignment that printed inside the memo block, so the second part of the fix is in this PR. A second round of adversarial tests then found 2 regressions (a switch case test, and the property of a method call). Both are fixed, and the test has a guard for each.

Merge order: #42482 is best merged first. The PR body says why under "Right on main by luck".

@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 PR and didn't find any bugs, but since it adds ~150 lines of non-upstream dependency-tracking logic to a crate documented as a byte-for-byte port (plus a new Effect::Store variant that changes when assignments are hoisted ahead of memo blocks), a human look at the design choices would still be worthwhile.

What was reviewed:

  • LoadsOfReassignedLocals in propagate_scope_dependencies_hir.rs: the Let/Reassign store recording, the visit_loads_before fallback for reassigned parameters, and the Switch test / MethodCall property exemptions — each traced against the corresponding test component.
  • Effect::Store in promote_used_temporaries.rs: checked that it only tightens the existing Write conflict rule (adds the memo-block entry position) and that memo_block is restored on exit from nested scopes.
  • Ruled out as pre-existing rather than regressions: optional-chain reads (o?.y) before a reassignment are not tracked, matching main's behavior; the 51 remaining fuzz cases cited in the description are attributed to a separate PR.
  • Test coverage: 24 component fixtures rendered multiple times against a one-cache-per-component react stub, with compiled vs. plain output compared and a "not compiled" line asserting every component was memoized.
Extended reasoning...

Overview

The PR touches two passes in Bun's Rust port of React Compiler (src/react_compiler/inference/propagate_scope_dependencies_hir.rs, src/react_compiler/reactive_scopes/promote_used_temporaries.rs) and adds a parameterized test to test/bundler/transpiler/react-compiler.test.ts. The first change adds a LoadsOfReassignedLocals tracker that keeps a temporary out of the temporaries sidemap when it is used after its source local was reassigned, so the memo block depends on the temporary rather than the (already-mutated) local by name. The second introduces an Effect::Store variant so an unnamed assignment pending at a memo block entry is promoted and printed ahead of the block instead of inside it, where a cache hit would skip it.

Security risks

None. This is compile-time transform logic operating on the compiler's own HIR; it does not parse untrusted bytes, allocate based on external sizes, or touch I/O. The indexing style (env.identifiers[...], func.instructions[...]) matches the crate's existing invariant-based convention.

Level of scrutiny

High. The crate's own design doc treats inference/ and reactive_scopes/ as minimal-diff upstream ports so they can be re-synced hunk by hunk; this change adds a sizable non-upstream block and alters the semantics of an existing pass. The logic has several deliberate carve-outs (switch case tests, method-call properties, the Let-kind reliance with a fallback scan for parameters) whose correctness depends on lowering/codegen details a maintainer familiar with the port should confirm. The bug hunt ran dry without findings and the ruled-out candidates (optional chains, conditional-branch loads, Store ordering in the promote pass, parameter rescan coverage) all landed on either pre-existing behavior or behavior consistent with main, but the design question of whether this belongs in these passes vs. lowering/codegen is one a human should weigh.

Other factors

The test is substantive: 24 fixtures, both plain and compiled variants, multiple renders against a persistent memo cache, and a trailing "not compiled:" line that would surface any component the compiler bailed on. The PR description claims all existing upstream fixtures produce byte-identical output; I did not independently re-run that corpus. No CODEOWNERS entry covers these paths, and there are no outstanding third-party review objections in the timeline.

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

On the design question in the review: why these two passes, and not lowering or codegen.

The dependency. For the repro, main already prints const t0 = i ahead of the assignment. It still returns the old array, because the memo block does not test t0. So the fix has to be where the dependencies are chosen.

  • Codegen cannot do it. propagate_scope_dependencies_hir merges the SSA versions of i by declaration, and codegen prints a dependency by name. After that, the read of the old i and a read of the new i are one dependency.
  • Lowering cannot do it. The HIR is right: the load is ahead of the store.
  • The sidemap is the step that moves a read from its load to its use. Upstream documents that as valid only when nothing changes the value in between, and find_temporaries_used_outside_declaring_scope is the function that decides it. This PR adds one case to that decision.

The assignment. In the HIR the assignment is outside the memo block. It ends up inside only because codegen prints a temporary without a name at its use. PromoteInterposedTemporaries is the pass that decides which temporaries need a name for that reason. Since #42859 it already counts the entry of a memo block as a statement. Effect::Store adds one rule to it.

Cost for a sync with upstream. The upstream part of find_temporaries_used_outside_declaring_scope (handle_place and the loop) is unchanged apart from two calls. The new code is one struct below it, with a "Not in upstream" comment. Phase 3 of promote_used_temporaries.rs has been Bun-only since #42859.

On the optional chains that the review lists as not tracked: 12 hand-written shapes (o?.x, o?.y.z, o.y?.z, o?.y?.[k], o?.f?.(), with o reassigned before the use) are right on main and on this branch. The result of an optional chain is a temporary of its own, so the block depends on that temporary.

The claude-find-issues check failed before it did any work (is_error: true after 1 s). Buildkite is green.

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