From 32acb2e87cfe651e464d277b646de9d307c2c4cc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:02:56 +0000 Subject: [PATCH 1/3] react-compiler: a compound assignment to a local has the value of its 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. --- src/react_compiler/lowering/build_hir/expr.rs | 19 ++- .../react-compiler-fixtures.test.ts | 8 +- .../bundler/transpiler/react-compiler.test.ts | 151 ++++++++++++++++++ 3 files changed, 171 insertions(+), 7 deletions(-) diff --git a/src/react_compiler/lowering/build_hir/expr.rs b/src/react_compiler/lowering/build_hir/expr.rs index 1948e498b42f..7f1f2be98016 100644 --- a/src/react_compiler/lowering/build_hir/expr.rs +++ b/src/react_compiler/lowering/build_hir/expr.rs @@ -929,33 +929,40 @@ fn lower_compound_assignment_identifier( effect: Effect::Unknown, loc: ident_loc, }; + // Unlike upstream: the value is the store itself, as for `x = y`, so it prints in place. if builder.is_context_identifier(ref_) { - lower_value_to_temporary( + let temp = lower_value_to_temporary( builder, InstructionValue::StoreContext { lvalue: LValue { kind: InstructionKind::Reassign, - place: place.clone(), + place, }, value: binary_place, loc, }, )?; - Ok(InstructionValue::LoadContext { place, loc }) + Ok(InstructionValue::LoadLocal { + loc: temp.loc, + place: temp, + }) } else { - lower_value_to_temporary( + let temp = lower_value_to_temporary( builder, InstructionValue::StoreLocal { lvalue: LValue { kind: InstructionKind::Reassign, - place: place.clone(), + place, }, value: binary_place, type_annotation: None, loc, }, )?; - Ok(InstructionValue::LoadLocal { place, loc }) + Ok(InstructionValue::LoadLocal { + loc: temp.loc, + place: temp, + }) } } _ => { diff --git a/test/bundler/transpiler/react-compiler-fixtures.test.ts b/test/bundler/transpiler/react-compiler-fixtures.test.ts index 4b20304bbba1..5950f6e5e532 100644 --- a/test/bundler/transpiler/react-compiler-fixtures.test.ts +++ b/test/bundler/transpiler/react-compiler-fixtures.test.ts @@ -205,7 +205,13 @@ function shouldSkip(relPath: string, pragmas: Pragmas): string | null { // Known divergences from upstream — Bun produces a different (or no) result. // Grow this from CI; each entry must say why. // `__proto__: null`: a fixture named "constructor" exists. -const TODO: Record = Object.assign(Object.create(null), {}); +const TODO: Record = Object.assign(Object.create(null), { + // Upstream lowers `x += 5` to a store and then a new read of `x`, and prints + // that read as a stray `x;` after the memo block that declares `x`. The read + // splits `getX` and the JSX into two memo blocks: `_c(2)`. Bun keeps the value + // of the store, so there is no stray read and one block holds both: `_c(1)`. + "hoisting-invalid-tdz-let": "a compound assignment has the value of its store, so no stray read splits the scope", +}); // `minify: { syntax: true }` runs the parser's visit-phase folding and the // nested-block statement mangler before the React Compiler sees the function diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 429cb7d8cf99..a69e41b60468 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -2629,6 +2629,157 @@ describe("bundler", () => { }, }); } + + // The value of `i += 2` is the value it stores. When it is lowered to a + // store and then a new read of `i`, the store prints as a statement ahead of + // the expression it is part of, and an earlier operand of that expression + // that reads `i` prints after it. + for (const target of ["bun", "browser"] as const) { + itBundled(`react-compiler/CompoundAssignmentToALocalStaysInPlace-${target}`, { + files: { + "/entry.ts": /* ts */ ` + import * as forms from "./forms"; + const lines: string[] = []; + for (const [name, Form] of Object.entries(forms)) { + try { + lines.push(name + "=" + Form({ n: 1, flag: true, items: [5, 6, 7, 8] }).props.children); + } catch (e) { + lines.push(name + " threw " + e); + } + } + console.log(lines.join("\\n")); + `, + "/forms.jsx": /* jsx */ ` + const list = (...values) => values; + + export function ComputedKey(p) { + const o = {}; + let i = p.n; + o["k" + i] = i += 2; + return
{JSON.stringify(o)}
; + } + export function TemplateKey(p) { + const o = {}; + let i = p.n; + o[\`k\${i}\`] = i += 2; + return
{JSON.stringify(o)}
; + } + export function Arguments(p) { + let i = p.n; + const r = list(\`k\${i}\`, i++, "k" + i + "z", (i += 2), typeof i + i); + return
{JSON.stringify(r)}
; + } + export function ArrayAndObjectLiterals(p) { + let i = p.n; + const r = [[i, -i], { a: "k" + i }, (i += 2)]; + return
{JSON.stringify(r)}
; + } + export function JsxAttribute(p) { + let i = p.n; + const el = ; + return
{JSON.stringify(el.props)}
; + } + export function InLoop(p) { + let i = p.n; + const out = []; + for (const x of [1, 2]) { + out.push(list("k" + i, (i += x))); + } + return
{JSON.stringify(out)}
; + } + export function InTernaryBranch(p) { + let i = p.n; + const r = p.flag ? list("k" + i, (i += 2)) : null; + return
{JSON.stringify(r)}
; + } + export function InCallback(p) { + const run = n => { + const o = {}; + let i = n; + o["k" + i] = i += 2; + return list(o, "k" + i, (i *= 2)); + }; + return
{JSON.stringify(run(p.n))}
; + } + export function CapturedLocal(p) { + let i = p.n; + const read = () => i; + const r = list("k" + i, (i += 2), read()); + return
{JSON.stringify(r)}
; + } + export function OperandIsATernary(p) { + let i = p.n; + const r = list(p.flag ? i : 0, (i += 2)); + return
{JSON.stringify(r)}
; + } + export function OperandIsALogical(p) { + let i = p.n; + const r = list(p.flag && i, (i += 2)); + return
{JSON.stringify(r)}
; + } + export function OperandIsAnOptionalChain(p) { + let i = p.n; + const r = list(p.items?.[i], (i += 2)); + return
{JSON.stringify(r)}
; + } + export function OperandIsAComma(p) { + let i = p.n; + const r = list((0, "k" + i), (i += 2)); + return
{JSON.stringify(r)}
; + } + // An operand that updates \`i\` stays ahead of the operand that reads it. + export function UpdateInAnEarlierTernary(p) { + let i = p.n; + let j = p.n; + const r = list(p.flag ? i++ : 0, "k" + i, i, (j += 2)); + return
{JSON.stringify(r)}
; + } + export function Statements(p) { + let i = p.n; + i += 2; + for (let j = 0; j < 2; j += 1) i *= 2; + return
{i}
; + } + `, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + "/node_modules/react/index.js": ``, + "/node_modules/react/jsx-runtime.js": /* js */ ` + export const jsx = (type, props) => ({ type, props }); + export const jsxs = jsx; + `, + "/node_modules/react/jsx-dev-runtime.js": /* js */ ` + export const jsxDEV = (type, props) => ({ type, props }); + `, + "/node_modules/react/compiler-runtime.js": /* js */ ` + export function c(size) { + return new Array(size).fill(Symbol.for("react.memo_cache_sentinel")); + } + `, + }, + reactCompiler: true, + backend: "cli", + target, + run: { + stdout: ` + Arguments=["k1",1,"k2z",4,"number4"] + ArrayAndObjectLiterals=[[1,-1],{"a":"k1"},3] + CapturedLocal=["k1",3,3] + ComputedKey={"k1":3} + InCallback=[{"k1":3},"k3",6] + InLoop=[["k1",2],["k2",4]] + InTernaryBranch=["k1",3] + JsxAttribute={"title":"k1","id":3} + OperandIsAComma=["k1",3] + OperandIsALogical=[1,3] + OperandIsATernary=[1,3] + OperandIsAnOptionalChain=[6,3] + Statements=12 + TemplateKey={"k1":3} + UpdateInAnEarlierTernary=[1,"k2",2,3] + `, + }, + }); + } }); // Three passes kept one copy of their work per basic block or per nesting From c9dc1ae169313953118427ea9c9c35f9ab76e651 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:14:14 +0000 Subject: [PATCH 2/3] test: record the slot count of hoisting-invalid-tdz-let and keep the fixture checked --- .../transpiler/react-compiler-fixtures.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/bundler/transpiler/react-compiler-fixtures.test.ts b/test/bundler/transpiler/react-compiler-fixtures.test.ts index 5950f6e5e532..765929d45852 100644 --- a/test/bundler/transpiler/react-compiler-fixtures.test.ts +++ b/test/bundler/transpiler/react-compiler-fixtures.test.ts @@ -205,12 +205,17 @@ function shouldSkip(relPath: string, pragmas: Pragmas): string | null { // Known divergences from upstream — Bun produces a different (or no) result. // Grow this from CI; each entry must say why. // `__proto__: null`: a fixture named "constructor" exists. -const TODO: Record = Object.assign(Object.create(null), { +const TODO: Record = Object.assign(Object.create(null), {}); + +// Fixtures where Bun's memo slot counts differ from upstream's on purpose, with +// the counts Bun produces. The fixture still has to produce them, so a compiler +// change that alters one fails the test. Each entry must say why. +const SLOT_COUNT_DIVERGENCE: Record = Object.assign(Object.create(null), { // Upstream lowers `x += 5` to a store and then a new read of `x`, and prints // that read as a stray `x;` after the memo block that declares `x`. The read // splits `getX` and the JSX into two memo blocks: `_c(2)`. Bun keeps the value - // of the store, so there is no stray read and one block holds both: `_c(1)`. - "hoisting-invalid-tdz-let": "a compound assignment has the value of its store, so no stray read splits the scope", + // of the store, so there is no stray read and one block holds both. + "hoisting-invalid-tdz-let": [1], }); // `minify: { syntax: true }` runs the parser's visit-phase folding and the @@ -452,7 +457,7 @@ describe("react-compiler upstream fixtures", () => { // Success fixture. expect({ fixture: name, buildError }).toEqual({ fixture: name, buildError: null }); - const want = slotCounts(expected.code ?? ""); + const want = SLOT_COUNT_DIVERGENCE[name]?.slice() ?? slotCounts(expected.code ?? ""); const got = slotCounts(output); // A component that `@enableJsxOutlining` outlines has memo slots of its // own. Bun declares an outlined function ahead of the module's other From d73c2c3e546521efdbaa68883d0b079bacc5a132 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:56:09 +0000 Subject: [PATCH 3/3] test: a compound assignment statement leaves no read of the local after its memo block --- test/bundler/transpiler/react-compiler.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index a69e41b60468..a53221bd3769 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -2633,7 +2633,9 @@ describe("bundler", () => { // The value of `i += 2` is the value it stores. When it is lowered to a // store and then a new read of `i`, the store prints as a statement ahead of // the expression it is part of, and an earlier operand of that expression - // that reads `i` prints after it. + // that reads `i` prints after it. As a statement, `i += 2` leaves that read + // behind as a stray `i;`, which can land after the memo block that declares + // `i`: "ReferenceError: i is not defined". for (const target of ["bun", "browser"] as const) { itBundled(`react-compiler/CompoundAssignmentToALocalStaysInPlace-${target}`, { files: { @@ -2642,7 +2644,8 @@ describe("bundler", () => { const lines: string[] = []; for (const [name, Form] of Object.entries(forms)) { try { - lines.push(name + "=" + Form({ n: 1, flag: true, items: [5, 6, 7, 8] }).props.children); + const children = Form({ n: 1, flag: true, items: [5, 6, 7, 8] }).props.children; + lines.push(name + "=" + (typeof children === "function" ? children() : children)); } catch (e) { lines.push(name + " threw " + e); } @@ -2707,6 +2710,12 @@ describe("bundler", () => { const r = list("k" + i, (i += 2), read()); return
{JSON.stringify(r)}
; } + export function CapturedBeforeItsDeclaration(p) { + const read = () => i; + let i = p.n; + i *= 3; + return
{read}
; + } export function OperandIsATernary(p) { let i = p.n; const r = list(p.flag ? i : 0, (i += 2)); @@ -2763,6 +2772,7 @@ describe("bundler", () => { stdout: ` Arguments=["k1",1,"k2z",4,"number4"] ArrayAndObjectLiterals=[[1,-1],{"a":"k1"},3] + CapturedBeforeItsDeclaration=3 CapturedLocal=["k1",3,3] ComputedKey={"k1":3} InCallback=[{"k1":3},"k3",6]