Skip to content

react-compiler: keep operands in source order around a statement inside their expression - #42859

Merged
Jarred-Sumner merged 3 commits into
mainfrom
robobun/8179f182/react-compiler-interposed-operand-order
Sep 16, 2026
Merged

Jarred-Sumner merged 3 commits into
mainfrom
robobun/8179f182/react-compiler-interposed-operand-order

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

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

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.

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

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How I reproduced it, on main (0ea0a56388, debug build) and on 1.4.3:

mkdir -p node_modules/react && echo '{"name":"react","main":"index.js"}' > node_modules/react/package.json && : > node_modules/react/index.js
echo 'export const jsxDEV = (type, props) => ({ type, props });' > node_modules/react/jsx-dev-runtime.js
echo 'export const c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));' > node_modules/react/compiler-runtime.js
cat > case.jsx <<'JSX'
class Box { constructor(v) { this.v = v; } }
const list = (...a) => a;
function A(p) { const r = { n: p.n }; let i = 1; const el = <a title={(++r.n && (i += 2))} id={[i, p.n]}>{(p.flag ? r.n : (i += 2))}</a>; return <div>{JSON.stringify([el.props, i, r.n])}</div>; }
function C(p) { const r = { n: p.n }; const a = list(-r.n, new Box(++r.n).v, (--r.n, 2)); return <div>{JSON.stringify([a, r.n])}</div>; }
for (const F of [A, C]) console.log(F.name, F({ n: 2, flag: true }).props.children);
JSX
bun build case.jsx --target=browser --outfile=plain.js && bun plain.js
bun build case.jsx --target=browser --react-compiler --outfile=rc.js && bun rc.js

The build without the compiler prints id: [3,2] for A and [[-2,3,2],2] for C. The build with the compiler prints id: [1,2] and [[-3,3,2],2]. On this branch both builds print the same.

The new tests react-compiler/OperandsKeepSourceOrderAroundAStatement-bun, -browser and react-compiler/MacroOperandStaysInsideTheMacroCall in test/bundler/transpiler/react-compiler.test.ts fail on main and pass on this branch.

@coderabbitai

coderabbitai Bot commented Sep 16, 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: b8d09891-0472-411c-b51c-21cf1595fcb6

📥 Commits

Reviewing files that changed from the base of the PR and between 553523b and cd9090c.

📒 Files selected for processing (4)
  • src/react_compiler/collections.rs
  • src/react_compiler/pipeline.rs
  • src/react_compiler/reactive_scopes/promote_used_temporaries.rs
  • test/bundler/transpiler/react-compiler.test.ts
💤 Files with no reviewable changes (1)
  • src/react_compiler/collections.rs

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


Walkthrough

The compiler now tracks observable effects when promoting temporaries, preserves FBT operands as constants, and models terminal evaluation order. Bundler regressions cover these cases. The mutable IdMap::values_mut method was removed.

Changes

Temporary promotion

Layer / File(s) Summary
Promotion state and entry
src/react_compiler/reactive_scopes/promote_used_temporaries.rs, src/react_compiler/pipeline.rs
promote_used_temporaries receives FBT operands and tracks reads, writes, declarations, effects, and conflicts when promoting temporaries.
Terminal evaluation order
src/react_compiler/reactive_scopes/promote_used_temporaries.rs
Loop, conditional, and switch traversal records terminal effects and combined evaluation positions.
Promotion regressions
test/bundler/transpiler/react-compiler.test.ts
Bundler tests cover evaluation order, temporary promotion, runtime results, cross-target output, and nested FBT macro structure.

IdMap cleanup

Layer / File(s) Summary
Remove mutable values iterator
src/react_compiler/collections.rs
Removed IdMap::values_mut.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to cd909

No actionable behavior or operational issue is established, so the change is ready for normal merge checks.

🚥 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 summarizes the primary change: preserving source-order evaluation when statements occur inside expressions.
Description check ✅ Passed The description explains the problem, implementation, scope, exceptions, limitations, and verification results. It does not use the template headings exactly, but it provides the required content in t…

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

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:14 PM PT - Sep 15th, 2026

⏳ @robobun, your commit a7c5477 is still building in Build #116316, but has 1 failures so far (All Failures):

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

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated
Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated
Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated
…de their expression

Codegen prints an instruction with an unnamed result inside the statement
that uses it, and every other instruction as a statement where it stands.
A statement in the middle of an expression then runs ahead of the earlier
operands that are still inline. PromoteInterposedTemporaries names those
operands, with three gaps:

- It tracked only loads, calls and stores. An expression over a load
  (`-r.n`, `[r.n]`) or a ternary, `&&`, `?.` or comma stayed inline.
- Only a statement with a side effect raised the flag. A statement that
  reads (a memo block that compares its dependencies, an operand with a
  name) did not, so an earlier inline store printed after it.
- A store inside a comma operand raised the flag, although it prints in
  place. That named a later operand ahead of an earlier inline one, or
  named a temporary inside a value block, which codegen rejects.

The pass now gives every instruction a position and every temporary the
effect (none, read, write) of the whole expression it prints as. A use
promotes the temporary when a statement after its definition conflicts
with it. The promoted temporary counts as a statement at its own position,
and the operands of one instruction are visited last-defined first, so the
operands before it follow. A statement inside a value block counts only
for the uses inside that block. A switch case test, a memoization marker
and a macro (fbt) operand are never promoted.
…nd name the values inside them

The set of macro operands went into `consts`, which also decides whether a
load of a local reads state. A `let` captured by a function inside an fbt
call then counted as a constant everywhere in the component. The set is
now separate and only the promotion check reads it.

The set was also every operand of every macro call. It is now the operands
that have to print inside the call (the tagged ones of
MemoizeFbtAndMacroOperandsInSameScope). The value given to `fbt.param()` is
not one of them and keeps its name. When an operand that has to stay inline
conflicts with a statement, the operands inside it get the names.

A destructuring declaration is a write like every other destructuring: an
array pattern runs an iterator.
@robobun
robobun force-pushed the robobun/8179f182/react-compiler-interposed-operand-order branch from cd9090c to 136b90d Compare September 16, 2026 03:58
Comment thread src/react_compiler/inference/memoize_fbt_and_macro_operands_in_same_scope.rs Outdated
Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated
Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated
Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated
Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated
Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated
Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated
Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated
Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated
Comment thread src/react_compiler/reactive_scopes/promote_used_temporaries.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new blocking issues. 1 optional suggestion (a nit or a note on pre-existing code) was found and not posted. Nothing in this review needs a push before merging.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants