Skip to content

react-compiler: a compound assignment to a local has the value of its store - #42809

Merged
dylan-conway merged 3 commits into
mainfrom
robobun/ef94d6c1/react-compiler-compound-assignment-value
Sep 15, 2026
Merged

dylan-conway merged 3 commits into
mainfrom
robobun/ef94d6c1/react-compiler-compound-assignment-value

Conversation

@robobun

@robobun robobun commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With bun build --react-compiler, o["k" + i] = i += 2 writes the key k3, not k1: the output is i = i + 2; o["k" + i] = i;. Any operand that reads i ahead of i += 2 in one expression has this. 1.4.2 and main, both output modes.
  • As a statement, x += 5 leaves a stray x; behind. In client mode it can land after the memo block that declares x: ReferenceError: x is not defined in a valid component. main and 1.4.3-canary only: 1.4.2 leaves such a component uncompiled.
  • lower_compound_assignment_identifier (src/react_compiler/lowering/build_hir/expr.rs:904) drops the result of the store and returns a new read of the local. Codegen prints a store that nothing reads as a statement, ahead of its expression. Upstream does the same.

Fix

  • Return the result of the store, as x = y and the global arm already do. The store prints in place: o["k" + i] = i = i + 2. The extra read is gone.
  • Correct because the value of x op= y is the value that it stores.
  • The fixture hoisting-invalid-tdz-let goes from _c(2) to _c(1). _c(2) is the count of the output with the stray x;. main already prints _c(1) for the same source with x = x + 5 (Notes). The fixtures test records the new count.
  • Verified: test/bundler/transpiler/react-compiler.test.ts (2 new tests, 16 forms each, 14 fail on main in each mode). Also react-compiler-fixtures.test.ts, bundler_jsx.test.ts.

Background

  • Codegen prints the result of an HIR instruction inline at its use. It prints an instruction whose result nothing reads as a statement.
  • In client mode, memo blocks (if ($[0] === sentinel) {...}) wrap groups of statements. _c(N) allocates their N cache slots. Adjacent blocks with the same dependencies merge.
  • react-compiler: keep x.y++ and x += 1 in place in a nested function #42630 has this change for callbacks only (behind is_nested_function()), to keep that fixture at _c(2). It has to drop that gate when it rebases.
Notes

The slot count of hoisting-invalid-tdz-let. The fixture is const getX = () => x; console.log(getX()); let x = 4; x += 5; return <Stringify getX={getX} shouldInvokeFns={true} />. It calls getX() before let x on purpose, so it always throws (upstream's eval output: Cannot access 'x' before initialization). Upstream and main print this:

let $ = _c(2);
let getX;
if ($[0] === sentinel) {
  getX = () => x;
  console.log(getX());
  let x = 4;
  x = x + 5;
  $[0] = getX;
} else getX = $[0];
x; // the read that `x += 5` leaves behind. `x` is not in scope here.
let t0;
if ($[1] === sentinel) { t0 = <Stringify getX={getX} ... />; $[1] = t0; } else t0 = $[1];

The stray x; never runs in the fixture, because getX() throws first. Remove the console.log(getX()) line and the component is valid. Then, in client mode (--target=browser), main and 1.4.3-canary throw ReferenceError: x is not defined on every render, where the uncompiled component gives getX() === 9. This branch gives 9. 1.4.2 does not compile a component whose closure reads a let ahead of its declaration, so no release has this error yet. The new form CapturedBeforeItsDeclaration in the test is this case.

The stray read is also the only thing that keeps the two memo blocks apart. Both have no dependencies, so without a statement between them they merge: one block, _c(1). main already prints exactly that for the same fixture with x = x + 5 in place of x += 5, and this branch prints the same text for both spellings. Memoization does not get coarser: with <Stringify getX={getX} value={p.value} /> the second block depends on p.value, the blocks stay apart, and the count is _c(3) before and after.

Forms. Each form is built without the compiler, in ssr mode (--target=bun) and in client mode (--target=browser), with i = 1. "main" is a debug build of 7e56b40 (it has #42386 and #42644). 1.4.2 prints the same as main for the first form.

form expected main
o["k" + i] = i += 2, and with a template literal as the key {"k1":3} {"k3":3}
list(`k${i}`, i++, "k" + i + "z", (i += 2), typeof i + i) ["k1",1,"k2z",4,"number4"] ["k4",1,"k4z",4,"number4"]
[[i, -i], { a: "k" + i }, (i += 2)] [[1,-1],{"a":"k1"},3] ssr [[3,-3],{"a":"k3"},3], client [[3,-1],{"a":"k3"},3]
<a title={"k" + i} id={(i += 2)} /> title: "k1" ssr title: "k3", client right
out.push(list("k" + i, (i += x))) in for (const x of [1, 2]) [["k1",2],["k2",4]] [["k2",2],["k4",4]]
p.flag ? list("k" + i, (i += 2)) : null ["k1",3] ["k3",3]
in a callback: o["k" + i] = i += 2; return list(o, "k" + i, (i *= 2)) [{"k1":3},"k3",6] [{"k3":3},"k6",6]
i captured by a closure: list("k" + i, (i += 2), read()) ["k1",3,3] ["k3",3,3]
const read = () => i; let i = p.n; i *= 3; return <div>{read}</div>, then read() 3 ssr right, client ReferenceError: i is not defined
list(p.flag ? i : 0, (i += 2)), list(p.flag && i, (i += 2)) [1,3] [3,3]
list(p.items?.[i], (i += 2)) with items = [5,6,7,8] [6,3] [8,3]
list((0, "k" + i), (i += 2)) ["k1",3] ["k3",3]
list(p.flag ? i++ : 0, "k" + i, i, (j += 2)) [1,"k2",2,3] [1,"k2",1,3]
i += 2; for (let j = 0; j < 2; j += 1) i *= 2; (control) 12 12

All of them match the uncompiled output on this branch, in both modes.

The last wrong row. list(p.flag ? i++ : 0, "k" + i, i, (j += 2)) has no operand that reads j. On main the statement j = j + 2 makes PromoteInterposedTemporaries give the plain load of i a name, and the const t0 = i prints ahead of the ternary that updates i. With the store in place there is no statement, so nothing is promoted.

Why not the pass. PromoteInterposedTemporaries (src/react_compiler/reactive_scopes/promote_used_temporaries.rs) is the upstream answer to a statement in the middle of an expression: it names the operands that come before the statement. It tracks only the results of loads, calls and stores, which is why o[i] = i += 2 was right and o["k" + i] = i += 2 was not. I first widened the pass to track an expression over a load. That fixed these forms, but it made forms wrong that main gets right, for example list(p.flag ? i++ : 0, "k" + i, (j += 2)) gave "k1" and list(p.bump?.(r), r.n + 1, (j += 2)) gave [11,2,3]. A name moves an operand ahead of an earlier operand that is a ternary, &&, ?. or comma, because the pass never names those. Here the statement does not have to exist, so this PR removes it and leaves the pass alone.

Not fixed here. A postfix update of a member prints its store as a statement, because the value of the expression is the old value. So [r.n + 1, r.n++] gives [3,1] in ssr mode, list(o.n + 1, o.n += 2, o.n * 2, o.n++) gives [5,3,8,3], and list(new A(), call(), r.n++) runs call() first. These need either the form #42630 uses in callbacks or a pass that names every earlier operand.

Fixture text. I compiled every upstream fixture that builds (1,585 in client mode, 1,622 in ssr mode) with main and with this branch and compared the files. Seven differ, the same seven in both modes:

  • component, primitive-reassigned-loop-force-scopes-enabled: for (...; i = i + 1, i) becomes for (...; i = i + 1).
  • hoisting-reassigned-let-declaration, hoisting-reassigned-twice-let-declaration, repro-scope-missing-mutable-range and its copy under propagate-scope-deps-hir-fork/: a stray x; or count; statement after the assignment is gone.
  • hoisting-invalid-tdz-let: the source is const getX = () => x; console.log(getX()); let x = 4; x += 5; return <Stringify getX={getX} />. Upstream and main memoize getX in one block, then print the stray x; outside it, where x is not in scope, then memoize the JSX in a second block. Without the stray read one block holds both.

… store

`i += 2` on a local was lowered to a store whose result is dropped, followed by
a new read of `i`. Nothing read the result of the store, so codegen printed the
store as a statement, ahead of the expression it is part of. An earlier operand
of that expression that reads `i` then printed after it: `o["k" + i] = i += 2`
became `i = i + 2; o["k" + i] = i;` and wrote the key "k3".

Return the result of the store, as a simple assignment and a compound
assignment to a global already do. The store prints in place.

The upstream fixture hoisting-invalid-tdz-let goes from _c(2) to _c(1): the
stray read of `x` after `x += 5` no longer splits the scope.
@robobun

robobun commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:56 PM PT - Sep 15th, 2026

@robobun, your commit d73c2c3 is building: #116083

@robobun

robobun commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed, waiting for CI.

Reproduction, with a stub react package in node_modules.

  1. Wrong key, ssr and client mode. 1.4.2, 1.4.3-canary, and a debug build of main 7e56b40:
// k.jsx
function App(p) {
  const o = {};
  let i = p.n;
  o["k" + i] = i += 2;
  return <div>{JSON.stringify(o)}</div>;
}
console.log(App({ n: 1 }).props.children);
$ bun build k.jsx --target=bun --outfile=u.js && bun u.js
{"k1":3}
$ bun build k.jsx --react-compiler --target=bun --outfile=c.js && bun c.js
{"k3":3}
  1. ReferenceError, client mode (--target=browser). 1.4.3-canary and main only: 1.4.2 leaves this component uncompiled. This is the fixture hoisting-invalid-tdz-let without its console.log(getX()) line:
function Foo() {
  const getX = () => x;
  let x = 4;
  x += 5;
  return <Stringify getX={getX} shouldInvokeFns={true} />;
}
console.log(Foo().props.getX());

The uncompiled build prints 9. With --react-compiler main and 1.4.3-canary throw ReferenceError: x is not defined, because the output has a stray x; after the memo block that declares let x.

With this branch the first prints {"k1":3} and the second prints 9. The two new tests (-t CompoundAssignmentToALocalStaysInPlace) fail 2 of 2 with the src/ of main and pass 2 of 2 with this branch.

@coderabbitai

coderabbitai Bot commented Sep 15, 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: 9ecb621c-62c1-4e99-bccb-e1bd2910fca6

📥 Commits

Reviewing files that changed from the base of the PR and between c9dc1ae and d73c2c3.

📒 Files selected for processing (1)
  • test/bundler/transpiler/react-compiler.test.ts

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


Walkthrough

Changes

The compiler now returns the stored value for compound assignments to context and local identifiers. Regression tests cover evaluation order across targets and expression contexts. Fixture validation supports a known memo-slot count divergence.

Compound assignment lowering

Layer / File(s) Summary
Return the stored compound-assignment value
src/react_compiler/lowering/build_hir/expr.rs
lower_compound_assignment_identifier returns the store temporary for context and local bindings instead of reloading the original place.
Validate local compound assignments
test/bundler/transpiler/react-compiler.test.ts, test/bundler/transpiler/react-compiler-fixtures.test.ts
Bundler tests cover compound-assignment ordering across Bun and browser targets. Fixture validation uses the hoisting-invalid-tdz-let slot-count override when present.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to d73c2

The compound-assignment regression is covered across the affected output targets, with no unresolved actionable issue 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 identifies the main change: compound assignments to local variables now use the value of their store.
Description check ✅ Passed The description clearly explains the problem, fix, expected behavior, verification coverage, fixture changes, and known limitations. It does not use the template headings exactly, but it provides the …

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.

Code review found no issues

No high-confidence issues detected in this change.

@dylan-conway

Copy link
Copy Markdown
Member

@robobun so this pr changes the test from _c(2) to _c(1), then the other pr changes it back to _c(2). is _c(2) the correct result? we cannot merge this if it introduces a bug like this. combine the prs?

@robobun

robobun commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

@dylan-conway No. _c(2) is the count of an output that has a bug, and _c(1) is what main already prints for the same code written as x = x + 5. I checked both, details below. I also added the failing case to the test in this PR (d73c2c3).

What _c(2) is. For the fixture, upstream and main print two memo blocks with a stray x; between them. That x; is the extra read that x += 5 leaves behind, and it is outside the block that declares let x:

if ($[0] === sentinel) { getX = () => x; console.log(getX()); let x = 4; x = x + 5; $[0] = getX; } else getX = $[0];
x;   // not in scope here
if ($[1] === sentinel) { t0 = <Stringify getX={getX} .../>; $[1] = t0; } else t0 = $[1];

The fixture never reaches that line. It calls getX() before let x on purpose and always throws (upstream's eval output for it is Cannot access 'x' before initialization). Remove that one call and the component is valid:

function Foo() {
  const getX = () => x;
  let x = 4;
  x += 5;
  return <Stringify getX={getX} shouldInvokeFns={true} />;
}
build, --react-compiler --target=browser Foo().props.getX(), two renders
compiler off 9, 9
main and 1.4.3-canary (_c(2)) ReferenceError: x is not defined, both renders
this PR (_c(1)) 9, 9

1.4.2 leaves this component uncompiled, so no release has this error yet. It is in 1.4.3-canary.

Why it becomes _c(1). The stray read is the only thing that keeps the two blocks apart. Both blocks have no dependencies, so with nothing between them the compiler merges them. With x = x + 5 in place of x += 5, main prints _c(1) today, and this PR prints the same text for both spellings. Memoization does not get coarser: with <Stringify getX={getX} value={p.value} /> the second block depends on p.value, the blocks stay apart, and the count is _c(3) before and after.

The other PR. #42630 does not change the count back. It has the same hunk, but only for callbacks (is_nested_function()), and its notes say the gate is there to keep this fixture at _c(2). So the gate keeps the bug above. Once this PR is in, #42630 has to drop the gate when it rebases (it conflicts with main now anyway). It cannot flip back unnoticed: with the gate, react-compiler-fixtures.test.ts here fails on the recorded count, and CompoundAssignmentToALocalStaysInPlace fails 14 of its 16 forms, one of them the ReferenceError above.

Combine? I would keep them apart and remove the overlap. This PR is the whole compound assignment change (about 10 lines of src/). The rest of #42630 is the postfix member update in callbacks, which is larger and needs #42451 first, so one combined PR would wait on that. I left the exact change on #42630. If you prefer one PR, tell me and I will fold it in.

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

@dylan-conway
dylan-conway merged commit c3a107e into main Sep 15, 2026
9 of 10 checks passed
@dylan-conway
dylan-conway deleted the robobun/ef94d6c1/react-compiler-compound-assignment-value branch September 15, 2026 22:15
springmin pushed a commit to springmin/bun that referenced this pull request Sep 15, 2026
Upstream:
- WebKit 65513e295c73: DFG LiveCatchVariablePreservationPhase flushes the
  right handler's live locals when two try ranges are adjacent
  (oven-sh/WebKit#629)
- deps: mimalloc to the head of bun-dev3-v2 (oven-sh/mimalloc#47/49/50/51:
  recently used large pages are left alone for a while)
- Bun.serve: send the completed responses of a pipelined read before
  JavaScript runs again (oven-sh#42796)
- Remove dead code from the SQL and node:http native classes, 13
  internal-field cells, Http2Response and the WebView ObjC wrappers
  (oven-sh#42816); .typos.toml and the bun-error images go with it
- ast: keep a class with a static accessor initializer in place (oven-sh#40887)
- react-compiler: a compound assignment to a local has the value of its
  store (oven-sh#42809)

Conflicts: none. The 4 files both sides changed (webkit.ts, the two test
files above plus serve.test.ts) auto-merged.

Impact assessment (complete, not sampled):
- all 48 files the merge touches checked for lost `ohos` references: none
- every line the OHOS side added since 7e56b40 is still present in the
  4 files both sides changed
- docs/ohos-adaptation-checklist.md markers all verified present
- no deleted file is referenced by the OHOS build or tests
- upstream moved the WebKit and mimalloc pins; the OHOS build was rebuilt
  from both and smoked (see the build log)
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.

3 participants