Skip to content

react-compiler: keep x.y++ and x += 1 in place in a nested function - #42630

Open
robobun wants to merge 9 commits into
mainfrom
robobun/74352470/react-compiler-postfix-member-update
Open

robobun wants to merge 9 commits into
mainfrom
robobun/74352470/react-compiler-postfix-member-update

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With --react-compiler, a postfix ++ or -- on a member inside a callback yields the new value: const id = ref.current++ gives old + 1. [x, x += 1] on a local gives [1, 1]. The component body is right. Upstream: facebook/react#35205.
  • lower_update (src/react_compiler/lowering/build_hir/expr.rs) lowers o.p++ to a load, an add and a store, and returns the temporary of the load. Codegen prints an unnamed temporary at each read, so a read after the store prints o.p again.
  • PromoteInterposedTemporaries names it in the component. Nested functions skip that pass.

Fix

  • In a nested function, a used postfix member update lowers to (o.p = (t0 = o.p) + 1, t0), with let t0 at the top of the function. It keeps its place among the other operands, even in a ternary or a loop test. A compound assignment to a local returns the value of its store, as x = y does.
  • An update whose value nothing reads keeps the previous lowering. So does the component body. inline_iifes leaves a function with the new form a function (Notes).
  • Land react-compiler: do not compile a member update whose object or key has side effects #42451 first. Without it counts[i.current++] += 10 prints the update twice.
  • Verified: PostfixMemberUpdateInNestedFunction (29 forms, with and without minify.syntax) and a seeded differential test in test/bundler/transpiler/react-compiler.test.ts. Both fail on 1.4.3. Also react-compiler-fixtures.test.ts.

Background

  • HIR is the flat instruction form. Each instruction writes a temporary. Codegen prints an unnamed temporary inline at its use, a named one as const t0 = ....
  • A value block (a ternary branch, a loop test, a comma expression) prints as one expression, so it cannot hold a declaration.
  • The compiler inlines an IIFE and a useMemo callback into the component. DESIGN.md keeps lowering 1:1 with upstream, so each new branch has an Intentional deviation: comment.
Notes

Reach. while (ref.current++ < 3) ran one turn short, and items[i.current++] read the wrong slot. 20 of the 29 forms in the test are wrong on 1.4.3.

Option taken. Not a bail: const id = nextId.current++ in a handler is common (the antd tabs demo has `newTab${newTabIndex.current++}`), and a bail leaves every such component without memoization. Not the eager promotion from the issue thread (const t0 = o.p; o.p = t0 + 1;): the two statements move ahead of the earlier operands of the same expression, so [ref.current, ref.current++] gives [1, 0].

Why not the existing pass. I tried it first: phase 3 of promote_used_temporaries in codegen_function_expression, with names for the promoted temporaries after RenameVariables. facebook/react#36737 does the same for outlined functions and says that other nested functions need a different fix. The pass tracks only the results of loads and calls. [r.current + 1, r.current++] gave [2, 0] (plain [1, 0]). f(new A(), g(), r.current++) ran g() before new A(), which 1.4.3 runs in order. The component body has these gaps today. That is upstream behavior and this change does not touch it. Remove the deviations when upstream fixes every nested function.

IIFE and useMemo callbacks. The compiler inlines them into the component, where that pass runs. With the new form inside, pair(c.n++, c.n, c.n--) gave [0, 0, 1]: the pass named the load of c.n and moved it ahead of the first update. minify.syntax produces that shape from const first = seq.n++; return [first, seq.n]. So a function whose lowering used the form is not inlined. HirBuilder::build puts the let of the temporary first in the entry block, and inline_iifes looks for a promoted let there (a try puts its catch binding there too, with another kind). Such a callback is then memoized as one call and not statement by statement. A callback with no such update is inlined as before, also under minify.syntax, which joins c.n++; c.n++; into one comma expression. The test checks both.

Compound assignment. lower_compound_assignment_identifier emitted the store, dropped its temporary and returned a load of x. In a nested function it now returns the temporary of the store, like lower_simple_assignment_identifier. The component keeps the upstream shape: with the new one everywhere, the fixture hoisting-invalid-tdz-let goes from _c(2) to _c(1).

#42451. A value that is lowered once and printed twice: counts.current[i.current++] += 10 becomes counts.current[(...)] = counts.current[(...)] + 10 and advances i twice. Before this change it advanced i once and used the wrong slot. An IIFE that stays a function and is the object of another update prints twice the same way. #42451 leaves such a function uncompiled. The two changes merge cleanly in src/. Both append to the test file.

Differential runs. A generator of random expressions around member updates (arrays, calls, new, templates, ternaries, &&, ??, optional calls, comma, try, switch, while), compared with the same file built without the compiler. The counts are cases whose output differs.

1.4.3 main this branch
in a callback, first generator, 2000 cases 1192 1305 2
in a callback, second generator, 800 cases 300 318 0
in an IIFE or useMemo callback in the component, 800 cases 1 5 1

In these runs no case that compiles on main bails on this branch. The 2 cases are #42628: a later (x = x + 5) on a local prints as a statement ahead of an earlier one. The 1 case is #42629: ReferenceError: x is not defined, with a prefix update only. Both reproduce on main. The test generator leaves out assignments to locals for that reason.

Upstream fixtures. I built all 1733 fixture inputs before and after, with and without minify.syntax. Two outputs change and no slot count does. bug-ref-prefix-postfix-operator:

// before
count.current = count.current + 1;
let id = count.current;
// after
let t1;
let id = (count.current = (t1 = count.current) + 1, t1);

repro-scope-missing-mutable-range loses a stray count; after count = count + ... in a forEach callback.

Not changed. o.p = o.p + 1 for ++ is the desugaring of upstream. It is wrong for a string ("5"++ stores "51") and for a BigInt, in every position. A computed key with a side effect (o[key()]++) calls key() twice, which is #42451. for (;; a.b++, c.d++) does not compile before or after (a comma as the whole update of a for).


[human-review] gate passed · iteration 1 · 7 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 [590.56ms]
(pass) bundler > react-compiler/ComponentWithHooks [229.28ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [225.88ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [248.06ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [126.91ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [92.26ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [281.83ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [93.14ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [546.40ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [102.65ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [94.22ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [246.92ms]
(pass) bundler > react-c
... (truncated)

release without fix: 1 skipped
bun test v1.4.3-canary.1 (b2dc9cf97)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [17.04ms]
(pass) bundler > react-compiler/ComponentWithHooks [6.86ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [7.40ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [7.95ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [6.45ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [3.61ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [8.23ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [3.61ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [13.34ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [4.09ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [3.83ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [7.75ms]
(pass) bundler > react-compiler/BundledCjsCompilerRuntimeSurvivesTreeShaking [10.66ms]
(pass) bundler > react-compiler/RequireStringPreservesImportRecord [7.03ms]
(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 (6a92015fc)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [829.74ms]
(pass) bundler > react-compiler/ComponentWithHooks [312.52ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [384.00ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [285.93ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [150.33ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [110.78ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [458.58ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [144.51ms]
(pass) bundler > react-compiler/SsrObjectMethodShorthand [787.76ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [131.36ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [131.85ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [292.52ms]
(pass) bundler > reac
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 758ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/23] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[2/23] gen cpp.rs (cppbind)
[3/23] gen JS modules (bundle-modules)
Preprocess modules (10652ms)
Bundle modules (53ms)
Postprocesss modules (179ms)
Bundle Functions (653ms)
Generate Code (52ms)

[11.60s] Bundled "src/js" for production
  2600 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[3/7] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_threading v0.0.0 (/workspace/bun/src/threading)
�[1m�[92m   Compiling�[0m bun_react_compiler v0.0.0 (/workspace/bun/src/react_compiler)
�[1m�[92m   Compiling�[0m bun_io v0.0.0 (/workspace/bun/src/io)
�[1m�[92m   Compiling�[0m bun_watcher v0.0.0 (/workspace/bun/src/watcher)
�[1m�[92m   Compiling�[0m bun_crash_handler v0.0.0 (/workspace/bun/src/crash_handler)
�[1m�[92m   Compiling�[0m bun_event_loop v0.0.0 (/workspace/bun/src/event_loop)
�[1m�[92m   Compiling�[0m bun_sour
... (truncated)
diff hotspot
.claude/skills/sync-react-compiler.md           |   3 +
 src/react_compiler/lowering/build_hir/expr.rs   | 234 +++++++----
 src/react_compiler/lowering/build_hir/mod.rs    |   1 +
 src/react_compiler/lowering/build_hir/stmt.rs   |   6 +-
 src/react_compiler/lowering/hir_builder.rs      |  69 +++
 src/react_compiler/optimization/inline_iifes.rs |  25 +-
 test/bundler/transpiler/react-compiler.test.ts  | 534 ++++++++++++++++++++++++
 7 files changed, 794 insertions(+), 78 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                             reads  edits  tests
.claude/skills/sync-react-compiler.md                0      0     66
src/react_compiler/lowering/build_hir/expr.rs        3      0     66
src/react_compiler/lowering/build_hir/mod.rs         0      0     66
src/react_compiler/lowering/build_hir/stmt.rs        3      0     66
src/react_compiler/lowering/hir_builder.rs           3      0     66
src/react_compiler/optimization/inline_iifes.rs      0      0     66
test/bundler/transpiler/react-compiler.test.ts       5      1     66

In a function inside a component, `const id = ref.current++` gave `id`
the new value. The update lowers to a load, an add and a store, and its
value is the temporary that holds the load. Codegen prints an unnamed
temporary where it is read, and the store is a statement, so a read
after the store printed `ref.current` again. PromoteInterposedTemporaries
names that temporary for the component, but upstream does not run it on
a nested function (react/react#35205).

When the value of a postfix member update is used in a nested function,
lower it to `(old = o.p, o.p = old + 1, old)`. `old` is a temporary that
the builder declares with `let` at the top of the function, so the
sequence can sit in a value block. The expression statement and the
update of a `for` keep the previous lowering.
…t to the statement

`(o.p = (old = o.p) + 1, old)` has no statement between the load and the
store, so PromoteInterposedTemporaries finds nothing to promote inside the
sequence when the compiler inlines the callback into the component (an IIFE
or a `useMemo` callback). The `let` goes ahead of the current statement and
only a value block defers it to the top of the function, so an inlined
callback keeps its memo slot count.
…ated expressions

Add an IIFE, a `useMemo` callback and a callback inside a `useMemo` callback
to the forms. Add a seeded differential test: 48 generated expressions around
member updates give the same values and the same order of side effects with
and without the compiler.
…update

The compiler inlines an IIFE and a `useMemo` callback into the component. There
PromoteInterposedTemporaries names only some operands of an expression, so
`pair(c.n++, c.n, c.n--)` gave `[0, 0, 1]`. A function whose lowering used the
in-place form stays a function. The temporary goes back to the top of the
function in every case.

Run the forms with and without minify.syntax. The seeded test now also
generates functions that capture nothing and functions that the compiler
would inline.
A `try` at the start of a `useMemo` callback declares its catch binding first,
also with a promoted temporary, so the callback of the upstream fixture
repro-nested-try-catch-in-usememo stopped being inlined. Only a `let`
declaration marks an in-place member update.
@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on 1.4.3 and on main with the entry below and a stub react package in node_modules.

import { useRef } from "react";
const Stub = () => null;
function Tabs() {
  const newTabIndex = useRef(0);
  const add = () => {
    const id = newTabIndex.current++;
    const key = `newTab${newTabIndex.current++}`;
    return id + ":" + key + ":" + newTabIndex.current;
  };
  return <Stub run={add} />;
}
console.log(Tabs().p.run());
bun build k15.jsx --outfile=plain.js && bun plain.js              # 0:newTab1:2
bun build k15.jsx --react-compiler --outfile=rc.js && bun rc.js   # 1:newTab2:2 before, 0:newTab1:2 with this PR

Tests: bun bd test test/bundler/transpiler/react-compiler.test.ts -t PostfixMemberUpdateInNestedFunction and -t "keeps the value and the order". Both fail with USE_SYSTEM_BUN=1.

Self-reviewed in two rounds. Round one raised 4 blocking concerns, all addressed: a callback that the compiler inlines (an IIFE, a useMemo callback) stays a function when it has the new form, the forms run under minify.syntax too, and the seeded test also generates inlined and outlined functions. Round two raised 11 smaller ones. Addressed: x += 1 on a local (the same shape), comma operands that nothing reads, a test for each new clause (each one fails when I remove the clause), the claims in the description, and a note for the sync procedure. Not in this PR: a general check in codegen for a temporary that prints late or twice. The review called it a follow-up.

CI: every react-compiler test passes on all lanes (Linux, macOS, Windows, ASAN) in builds 115371 and 115380. Both builds are red for one test that this diff does not touch: test/js/bun/spawn/spawn-pipe-leak.test.ts on ubuntu 25.04 x64 (peak RSS of Bun.spawn pipes, 181 of 182 jobs pass). The other failures passed on retry. This needs a maintainer to judge the merge. Land #42451 first.

@coderabbitai

coderabbitai Bot commented Sep 13, 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: f819e156-7592-47be-ab83-a73e32471cd5

📥 Commits

Reviewing files that changed from the base of the PR and between 369798b and b2dc9cf.

📒 Files selected for processing (3)
  • src/react_compiler/lowering/build_hir/expr.rs
  • src/react_compiler/lowering/hir_builder.rs
  • src/react_compiler/optimization/inline_iifes.rs

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


Walkthrough

The compiler separates effect-only updates from value-producing updates. Nested-function postfix member updates use entry-declared temporaries. IIFE inlining detects this pattern, and tests cover update semantics across callbacks, loops, modules, and syntax minification.

Changes

Postfix member update lowering

Layer / File(s) Summary
Entry temporary support
src/react_compiler/lowering/hir_builder.rs, src/react_compiler/lowering/build_hir/mod.rs
HirBuilder tracks nested functions, promotes temporaries, and prepends their declarations to the entry block.
Update and sequence lowering
src/react_compiler/lowering/build_hir/expr.rs
Comma expressions use shared sequence-block lowering. Compound assignments retain store temporaries for nested functions. Update lowering distinguishes used results from effect-only results and preserves old member values for nested-function postfix updates.
Effect-only statement wiring
src/react_compiler/lowering/build_hir/stmt.rs
Expression statements and for-loop updates use lower_expression_for_effect.
IIFE handling and regression coverage
src/react_compiler/optimization/inline_iifes.rs, test/bundler/transpiler/react-compiler.test.ts, .claude/skills/sync-react-compiler.md
IIFE inlining skips the promoted temporary pattern. Tests cover member updates, evaluation order, memoization, split modules, and syntax minification. Verification instructions include intentional-deviation checks.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 7e213

No current merge-blocking risk was identified in the reviewed lowering and IIFE compatibility paths.

🚥 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 identifies the main fix: preserving postfix member-update values in nested functions.
Description check ✅ Passed The description explains the problem, implementation, scope, dependency, and verification results. It does not use the template headings exactly, but it provides the required information in equivalent…

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.

I reviewed this PR and didn't find any bugs. Because it's an intentional deviation from upstream React Compiler lowering with cross-pass coupling (the entry-block let shape doubles as a signal to inline_iifes) and a stated ordering dependency on #42451, a human look at the design tradeoffs would still be worthwhile.

What was reviewed:

  • lower_member_update extraction — behavior of the non-nested / prefix / unused-value paths is unchanged from the original inlined code.
  • push_entry_declarations — only runs when entry_declarations is non-empty, so top-level components and nested functions without a used postfix member update produce identical HIR.
  • has_in_place_member_update — the Let + Promoted-name check on the entry block's first instruction matches only what declare_temporary_at_entry prepends; I found no other lowering path that puts a promoted let at index 0 of a nested function's entry block.
  • Test matrix covers both minifySyntax states, prefix/statement/for-update cheap paths, name collisions with t0/t1, IIFE/useMemo inlining, and evaluation-order against new/calls; the seeded differential test asserts every generated hook still compiles (no new bailouts).
Extended reasoning...

Overview

This PR fixes a React Compiler miscompilation (react/react#35205): a postfix member update o.p++ inside a nested function returned the post-update value because codegen re-printed o.p after the store. The fix threads a value_is_used flag into lower_update, and in a nested function lowers a used postfix member update to the sequence (o.p = (t0 = o.p) + 1, t0) with let t0 prepended to the function's entry block. Supporting machinery: lower_in_sequence_block (extracted from lower_sequence), lower_member_update (extracted so both paths share the load+add+store), lower_expression_for_effect (routes SExpr and for-update through the cheap path), and HirBuilder gains is_nested_function, entry_declarations, declare_temporary_at_entry, and push_entry_declarations. inline_iifes.rs gains has_in_place_member_update, which detects the prepended let and skips inlining that function so PromoteInterposedTemporaries in the outer body cannot reorder its operands. Tests add a 24-scenario itBundled matrix (× minifySyntax) plus a 48-case seeded differential test comparing compiled vs plain output.

Security risks

None. This is pure compile-time HIR lowering in the React Compiler pass; no untrusted input parsing, network, filesystem, auth, or crypto is touched. The added test fixtures use in-tree stub react/compiler-runtime modules — no external network.

Level of scrutiny

High. This is compiler correctness code that affects generated JS semantics for every user who enables --react-compiler, and it is an explicit deviation from upstream behavior (flagged with Intentional deviation: comments per what the PR says DESIGN.md requires). The fix couples two passes through a structural shape: HirBuilder::build guarantees the promoted let is the first instruction of the entry block, and inline_iifes reads exactly that position. That coupling is documented at both ends, but a maintainer should confirm no other path can produce a promoted let at entry-block index 0 (which would silently disable IIFE inlining for unrelated code) and that the memoization-granularity tradeoff — a useMemo/IIFE body containing such an update is now memoized as one call rather than statement-by-statement — is acceptable. The PR also states it should land after #42451 and that two known-remaining issues (#42628, #42629) reproduce on main; a human should confirm the merge order.

Other factors

Test coverage is thorough: the matrix asserts compiled: true for every case (guarding against silent bailouts), covers the cheap paths (Statement, ForUpdate, LogicalStatement), the ordering-sensitive shapes (EarlierOperands, EarlierSum, EarlierNewAndCall), the outlined-function path, and the IIFE/useMemo inlining interaction. The seeded differential test compares against a build without the compiler and asserts every hook took a memo cache. The Rust changes follow local conventions (arena HirVec/AstAlloc::vec, CompilerError propagation, no .unwrap() on the entry-block lookup). The refactored lower_sequence/lower_member_update are behavior-preserving on the pre-existing paths. No CODEOWNERS entry covers src/react_compiler/.

…ds for effect

`[x, x += 1]` in a nested function gave `[1, 1]`: the store to `x` was a
statement ahead of the load of the first operand. In a nested function the
value of a compound assignment to a local is now the value of the store, as
for `x = y`.

An operand of a comma that is not the last one has no reader, so it takes the
previous lowering of `o.p++`. `minify.syntax` joins `o.p++; o.q++;` into one
comma expression.

The tests check which callbacks are still inlined, the body of the component,
and each generated hook on its own. The seeded test moves to the end of the
file, after the tests that measure the memory of a child process.
Comment thread src/react_compiler/lowering/build_hir/expr.rs Outdated
Comment thread src/react_compiler/lowering/build_hir/expr.rs Outdated
Comment thread src/react_compiler/lowering/build_hir/expr.rs Outdated
Comment thread src/react_compiler/lowering/build_hir/expr.rs Outdated
Comment thread src/react_compiler/lowering/build_hir/expr.rs Outdated
@robobun

robobun commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:09 PM PT - Sep 13th, 2026

❌ @robobun, your commit 7e21328 has 1 failures in Build #115380 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42630

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

bun-42630 --bun

Comment thread src/react_compiler/lowering/hir_builder.rs Outdated
Comment thread src/react_compiler/lowering/hir_builder.rs Outdated
Comment thread src/react_compiler/optimization/inline_iifes.rs Outdated
Comment thread src/react_compiler/optimization/inline_iifes.rs Outdated
@robobun robobun changed the title react-compiler: keep the old value of x.y++ in a nested function react-compiler: keep x.y++ and x += 1 in place in a nested function Sep 14, 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.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

A note on the compound assignment hunk here (lower_compound_assignment_identifier returns the result of the store when builder.is_nested_function()).

The same forms are also wrong in the component body on main, and this PR leaves them so by design. For example o["k" + i] = i += 2 compiles to i = i + 2; o["k" + i] = i; and writes the key k3. I built this branch merged into main (6a6a1e3) and checked: inside a callback the form is fixed, in the component body it still prints {"k3":3}. PromoteInterposedTemporaries only covers a plain load there (o[i] = i += 2 is right), not "k" + i, a template, -i, an array or object literal, or an operand that is a ternary, &&, ?. or comma.

#42809 makes that hunk unconditional, as a separate small change. It fixes those forms in the component body and in callbacks. The cost is the one you found: hoisting-invalid-tdz-let goes from _c(2) to _c(1), which #42809 records in react-compiler-fixtures.test.ts with the new count, so the fixture stays checked (the stray x; that splits the scope upstream is outside the block that declares x). Six more fixtures only lose a stray x; or , i.

The two PRs conflict in that one hunk. If #42809 lands first, the gate on the compound assignment goes away in the rebase. The member update part of this PR is not affected.

I also tried to close the component body gaps in the pass, by tracking an expression over a load. That makes forms wrong that main gets right, for example id(p.flag ? i++ : 0, "k" + i, j += 2) gives "k1" where "k2" is right: a named operand moves ahead of an earlier ternary, &&, ?. or comma operand that the pass never names. So I dropped that. It matches what the notes here say about phase 3.

@robobun

robobun commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up to my note above, after a maintainer question on #42809 about the fixture count.

The gate on the compound assignment hunk here (builder.is_nested_function() in lower_compound_assignment_identifier) keeps hoisting-invalid-tdz-let at _c(2). That count is the count of a buggy output. The x; that x += 5 leaves behind prints after the memo block that declares let x. The fixture throws before it gets there (it calls getX() before let x on purpose). Without that call the component is valid, and main throws ReferenceError: x is not defined on every render in client mode:

function Foo() {
  const getX = () => x;
  let x = 4;
  x += 5;
  return <Stringify getX={getX} shouldInvokeFns={true} />;
}

main already prints _c(1) for the same source with x = x + 5. The evidence is in #42809 (comment).

So the change needed here is small:

With the gate kept, two tests from #42809 fail: the recorded slot count in react-compiler-fixtures.test.ts, and 14 of the 16 forms of react-compiler/CompoundAssignmentToALocalStaysInPlace. The member update part of this PR is not affected.

dylan-conway pushed a commit that referenced this pull request Sep 15, 2026
… store (#42809)

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

<details><summary>Notes</summary>

**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:

```js
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.

</details>
Jarred-Sumner pushed a commit that referenced this pull request Sep 16, 2026
…de their expression (#42859)

### Problem

- With `bun build --react-compiler`, one expression can run its operands
out of source order. The value is silently wrong: `list(-r.n, (() => {
r.n = 10; return r.n; })())` gives `[-10,10]`, not `[-2,10]`. main and
1.4.3.
- Codegen prints an instruction with an unnamed result inside the
statement that uses it, and every other instruction as a statement at
once (`codegen_instruction`, `src/react_compiler/codegen.rs:1630`). A
statement in the middle of an expression then runs ahead of the earlier
inline operands.
- `PromoteInterposedTemporaries`
(`src/react_compiler/reactive_scopes/promote_used_temporaries.rs`) names
those operands. It knows only loads, calls and stores, and only
statements with a side effect. It takes a store inside a comma operand
for a statement. Upstream has the same gaps.

### Fix

- Each temporary gets a position and the effect (read or write) of the
whole expression it prints as. A use names it when a later statement
conflicts: one writes, the other reads or writes.
- A named temporary counts as a statement at its own position. Operands
are checked last-defined first, so earlier operands get a name too.
- A memo block that compares dependencies is a read. A statement inside
a value block counts only inside it. A switch case test, and an operand
that fbt wants inside its call, never get a name (Notes).
- Verified: `test/bundler/transpiler/react-compiler.test.ts` (3 new
tests fail on main), `react-compiler-fixtures.test.ts`,
`bundler_jsx.test.ts`.

### Background

- HIR is the flat instruction form. Each instruction writes a temporary.
- A value block is a ternary, `&&`, `?.` or comma. It prints as one
expression, so it cannot hold a declaration.
- A memo block is `if ($[0] !== dep) {...} else {...}`. Only client mode
has them.

<details><summary>Notes</summary>

**The three gaps, with the repro from the report** (`p = { n: 2, flag:
true }`, `r = { n: p.n }`):

| source | expected | main |
| --- | --- | --- |
| `<a title={++r.n && (i += 2)} id={[i, p.n]}>{p.flag ? r.n : (i +=
2)}</a>` (client) | `id: [3,2]` | `id: [1,2]`: the memo block of the
array reads `i` ahead of the inline `&&` |
| ``<a title={[list(r.n), ++r.n]} id={i}>{new Box(`${p.flag ? r.n :
r.n}:${m[i++ % 3]}`).v}</a>`` (client) | `children: "3:6"` | `"2:6"`:
the template has a name (a memo block depends on it) and reads `r.n`
ahead of the inline store |
| `list(-r.n, new Box(++r.n).v, (--r.n, 2))` (both modes) | `[-2,3,2]` |
`[-3,3,2]`: the store in the comma operand names `new Box(++r.n).v`,
which moves ahead of `-r.n` |

The third one now prints fully inline. The first two print `const t1 =
(r.n = r.n + 1) && (i = 3);` and `const t1 = [list(r.n), r.n = r.n +
1];` ahead of the statement.

**Why positions and not the upstream flag.** Upstream keeps one
needs-promotion flag per tracked temporary and sets all of them at a
statement with a side effect. A use by another temporary (`-r.n` uses
the load of `r.n`) happens before the flag, so the load stays inline,
and the unary was never tracked. With every kind tracked, the temporary
that is still unused when the statement prints is the one that gets the
name, together with the expression inside it. The position compare also
drops the loop over all tracked temporaries at each statement, which was
quadratic in the size of the function.

**Effects.** Write: calls, `new`, tagged templates, `await`, stores,
deletes, updates, every destructuring (an array pattern runs an
iterator), iterator steps. Read: a load of a local that is reassigned, a
property load that is not on a global, a spread, and an operator (`+`,
unary minus, a template literal, `in`) over an operand whose inferred
type is not primitive, because the conversion reads the object. A
declaration writes a variable that nothing earlier can read. A temporary
inherits the effects of the unnamed operands inside it. The model has no
aliasing: any write conflicts with any read. That is what upstream does
for the kinds it tracks.

**Memo block entry.** It counts as a read only when a dependency has a
property path or is a local that is reassigned. `$[0] !== lastname` on a
constant cannot change, so a pending call is not named for it.

**Value blocks.** A store with no result inside `(--r.n, 2)` prints in
place inside that expression. It is a statement for the uses inside the
same value block, where a name is a declaration that codegen rejects
("Cannot declare variables in a value block", the function stays
uncompiled, as before). It is no longer a statement for the operands
around the block. In 2,000 generated components with control flow, 94
that main leaves uncompiled for that reason now compile, for example
`for (let k = 0; k < 2; k += (--r.n, 1))`. No generated component that
compiles on main bails on this branch.

**Not named on purpose.**
- A switch case test. Lowering evaluates every case test ahead of the
discriminant (upstream requires a "reorderable" expression there), and
codegen prints it in place, behind the discriminant, which is the source
order. A name would run `case bump(r):` ahead of the discriminant. The
test has this form as a control.
- `StartMemoize` and `FinishMemoize`. Codegen prints nothing for them,
so they do not evaluate their operand. Treating `FinishMemoize` as a use
named every `useMemo` result.
- The `init` and `test` of a `for..of` print the collection once, so
they count as one statement.
- An operand that fbt wants inside its call. The fbt transform rejects a
variable in place of a nested `fbt.param(...)` call (upstream fixture
`fbt/repro-fbt-param-nested-fbt-jsx`). Upstream never names one there,
because a JSX statement does not raise its flag. Here the first
`fbt.param()` call is pending when the memo block of the second element
reads `props.lastname`, and without an exemption the output is `const t2
= fbt.param("firstname", t1); ... fbt(["Name: ", t2, ...])`.
`MemoizeFbtAndMacroOperandsInSameScope` now returns, next to
`macroValues`, the operands it tags: the ones that have to print inside
the macro call. Only the promotion check reads that set. A value given
to `fbt.param()` is not in it and gets a name as before. When a tagged
operand conflicts with a statement, the operands inside it get the
names: `fbt(["a", fbt.param("n", r.n), fbt.param("m", (() => { r.n = r.n
* 10; return r.n; })())], "d")` prints `const t1 = r.n; r.n = r.n * 10;
fbt(["a", fbt.param("n", t1), ...])`. main hoists the whole
`fbt.param("n", r.n)` call there. The pass tags nothing in ssr mode (it
needs reactive scopes), so ssr output is as on main.

**Differential runs.** A seeded generator of components whose
expressions mix reads and writes of the same state (a `let`, a captured
`let`, a member, an array): unary, binary, template, array, object,
`new`, JSX, spread, `in`, ternary, `&&`, `||`, `??`, `?.`, comma, calls,
prefix and postfix updates, assignments, destructuring assignment,
`delete`, inside a declaration, JSX attributes, `if`, loops, `switch`,
`try`, a labeled block, an early return, an inlined IIFE and a `useMemo`
callback. Each component is built without the compiler and for both
targets, and rendered four times with a fresh memo cache and four times
with a kept one. The counts are results that differ from the build
without the compiler.

| cases | main | this branch |
| --- | --- | --- |
| 3,000, mixed | 73 | 1 |
| 2,000, control flow | 21 | 0 |
| 1,500, with `r.n++` | 44 | 0 |
| 1,000, deeper expressions | 34 | 0 |

The 1 is a `let` that is reassigned inside a memo block and is stale on
a cache hit. main has it too, and #42482 is open for it.

**Upstream fixtures.** I built every fixture that builds (1,585 in
client mode, 1,622 in ssr mode), with and without `minify.syntax`, on
main and on this branch, and compared the files. One differs, in ssr
mode: `jsx-tag-evaluation-order-non-global` prints `jsxDEV(Tag, {
children: [(Tag = props.alternateComponent, maybeMutate(maybeMutable)),
jsxDEV(Tag, {})] })` where main prints `const T0 = Tag;` first. The
store in the comma operand named the tag on main. The tag is the first
argument, so it still reads the old `Tag`. No slot count changes.

**Not fixed here.**
- `{ ...r, a: (delete r.k) }` when the later operand has a name. HIR
reads a spread when the object is created, after all operands. That
needs a change in lowering.
- Nested functions do not run this pass (#42630).
- `++` on a string is lowered to `x + 1` (upstream desugaring).

`IdMap::values_mut` had no other caller, and the crate denies dead code.

</details>
@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

A note for the rebase. #42859 is on main now, and this PR conflicts with it.

The branch robobun/9a60b295/react-compiler-member-postfix-in-place (one commit on main 55c1106) fixes the same bug in a different way:

  • PropertyStore and ComputedStore get a field that records the source expression (x.y++, --x.y, x.y += v). Codegen prints that expression in place, and the store is the value of a postfix update too.
  • The HIR instructions stay as upstream lowers them (load, add, store), so no analysis pass changes. There is no hoisted let, and inline_iifes does not change.

I ran the tests of this PR against that branch. All 29 forms of PostfixMemberUpdateInNestedFunction give the expected results, with and without minify.syntax, and the seeded differential test passes. One assertion differs: in onAfterBundle, UseMemoBody and IifeBody stay inlined into the component on that branch (their results are right). On main 55c1106 all three tests fail.

What else the branch does:

  • It prints get().n++ and m[i++] += 10 with the object and the key once. The 4 test variants of react-compiler: do not compile a member update whose object or key has side effects #42451 pass on it, and the functions stay compiled.
  • o.b++ on a BigInt member no longer throws TypeError: Invalid mix of BigInt and other type in addition., and o.s++ on a string member no longer concatenates.
  • A used postfix update inside a ternary, && or a loop test now compiles. On main the component stays uncompiled (Cannot declare variables in a value block).
  • react-compiler-fixtures.test.ts: 3293 pass. One fixture output changes in infer mode: bug-ref-prefix-postfix-operator now prints let id = count.current++.
  • A seeded differential run (6000 random components around member updates and compound member assignments, plain build against --react-compiler, both targets): 0 cases differ on the branch. On main 462 differ: 461 run the object or the key twice, and 1 is a postfix update of a string member.

I did not open a PR for the branch, because this PR and #42451 already own these two bugs. Take the branch if it helps, or ask me to open it.

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