diff --git a/.claude/skills/sync-react-compiler.md b/.claude/skills/sync-react-compiler.md
index 75d6e2c1653f..7cb1275c6ebc 100644
--- a/.claude/skills/sync-react-compiler.md
+++ b/.claude/skills/sync-react-compiler.md
@@ -44,7 +44,10 @@ the porting reference for the AST-boundary files.
2. **Apply whole-crate diffs mechanically.** For each hunk under the
whole-crate section, apply it to the corresponding `src/react_compiler/
/`
file. The only systematic edit is import paths (`react_compiler_hir::` →
- `crate::hir::`, etc.); everything else lands verbatim.
+ `crate::hir::`, etc.); everything else lands verbatim. A few sites fix
+ miscompiles that upstream still has. They carry a "Not in upstream" comment,
+ and the script lists them at the end of its output. DESIGN.md says why each
+ exists, under "Behaviour that differs from upstream". Keep them.
3. **Re-port AST-boundary diffs by hand.** For each hunk under the
AST-boundary section, re-port it into the named Bun file using the
@@ -54,7 +57,8 @@ the porting reference for the AST-boundary files.
`react_compiler_ast::statements::Statement::Foo { … }`, the Bun port calls
`Stmt::alloc(S::Foo { … }, loc)`. Keep control flow, pass ordering,
variable names, and comments 1:1 with upstream — only the AST reads/writes
- change.
+ change. The "Not in upstream" rule from step 2 applies here too: `codegen.rs`
+ has such a site (`codegen_reactive_scope`). Keep it.
For large diffs, fan out one agent per file with the upstream diff + the Bun
port + DESIGN.md as context, then adversarially review each port.
diff --git a/scripts/sync-react-compiler.sh b/scripts/sync-react-compiler.sh
index cb0e4e9a6125..7eb03768f7f5 100755
--- a/scripts/sync-react-compiler.sh
+++ b/scripts/sync-react-compiler.sh
@@ -129,4 +129,10 @@ git -C "$tmp" grep -l '\breact_compiler_ast\b' -- 'compiler/crates/*/src/*.rs' \
|| echo "(none)"
echo
+# See "Behaviour that differs from upstream" in src/react_compiler/DESIGN.md.
+echo "### sites that fix what upstream still gets wrong (keep them) ###"
+git -C "$repo_root" grep -n 'Not in upstream' -- src/react_compiler ':!src/react_compiler/DESIGN.md' \
+ || echo "(none)"
+echo
+
echo "When porting is done: echo $new > $ported_file"
diff --git a/src/react_compiler/DESIGN.md b/src/react_compiler/DESIGN.md
index 285c66f33d32..1beda0c0e52b 100644
--- a/src/react_compiler/DESIGN.md
+++ b/src/react_compiler/DESIGN.md
@@ -66,6 +66,75 @@ Keep the diff between upstream and the port as small as the type substitution
allows — the `/sync-react-compiler` skill re-ports upstream changes hunk by
hunk, so gratuitous restructuring makes that harder.
+### Behaviour that differs from upstream
+
+A few sites fix miscompiles that upstream still has. Each one carries a
+one-line comment that starts with "Not in upstream":
+
+```sh
+git grep -n "Not in upstream" src/react_compiler
+```
+
+Keep them on a sync. `react-compiler-fixtures.test.ts` cannot tell whether they
+survived: it compiles the upstream fixtures in `infer` mode, which skips most
+of their functions, and none of these sites changes what Bun emits for one. The
+tests in `test/bundler/transpiler/react-compiler.test.ts` that run compiled
+components can. When upstream fixes the same case, run Bun's test for it
+against upstream's fix before you drop Bun's.
+
+The code comments are short on purpose. The reasons for a group of sites go
+here.
+
+#### A `let` declared before a reactive scope and reassigned inside it
+
+facebook/react#37224. The scope stores the variable after the computation and
+restores it when the cache hits, so the variable is an output of the scope.
+`react-compiler/ReassignedLocalDeclaredBeforeMemoBlock` runs a component for
+each point below.
+
+- `visit_phi` (`propagate_scope_dependencies_hir.rs`). A path that does not
+ reassign the variable leaves it with the value it had on entry. Only a phi
+ reads that value, and upstream visits instruction operands only. So the value
+ is not a dependency, and a cache hit restores the value of an older render.
+ `visit_phi` runs in the block loop that already walks the phis. It makes an
+ operand a dependency when `reassignments` dates it before the current scope.
+ An operand that `reassignments` does not hold yet comes from a loop back
+ edge, so its definition is later in the scope.
+- `visit_phi` also records the phi itself in `reassignments`, dated just before
+ its block, with an empty scope stack (nothing reads the scope stack of a
+ `reassignments` entry). Upstream never records a phi, so
+ `check_valid_dependency` dates a read of one by the first declaration of its
+ variable, and a read of a phi inside the scope becomes a dependency. Codegen
+ reads a dependency by name before the scope, where the variable still holds
+ the value on entry. That is the wrong value, and it throws when the read is
+ `u.profile.name` and the value on entry is null.
+- `apply_reactive_flags_replay` (`infer_reactive_places.rs`) flags every phi
+ operand whose identifier is reactive. Upstream stops at the first reactive
+ operand of a phi, so the flag of a later operand can be unset. `visit_phi`
+ copies that flag into the dependency.
+- `prune_non_reactive_dependencies.rs`, `|| dep.reactive`. The value on entry
+ can be a parameter, or a phi that only another phi reads. No place in the
+ reactive function carries that identifier, so the set the pass builds never
+ holds it.
+- `exit_scope`. Upstream records a reassignment on the innermost scope. When the
+ cache of an enclosing scope hits, the inner scope does not run, so the
+ enclosing scope has to restore the variable too.
+- `handle_instruction`, `PrefixUpdate` and `PostfixUpdate`. Upstream visits only
+ `value`, so `x++` is not a reassignment and the scope does not restore `x`.
+- `codegen_reactive_scope` (`codegen.rs`). The cache stores run after the
+ computation, so a dependency on a variable the scope reassigns stored the new
+ value, and the next comparison tested the value on entry against it. The
+ dependency is copied to a `const` before the scope, and the comparison and
+ the store use the copy. `rename_variables` has run by then, so
+ `fresh_temporary_name` picks the first unused `t`.
+
+The open upstream fix, facebook/react#37273, adds the same dependency. It
+differs in three ways. It tests `operand.reactive` without the change to
+`apply_reactive_flags_replay`. It stores the dependency before the computation,
+which breaks the rule that the cache never holds inputs without outputs (React
+shares the cache between render attempts). It does not pass a reassignment to
+the enclosing scope.
+
### Type mapping (input: lowering)
| upstream `react_compiler_ast` | `bun_ast` |
diff --git a/src/react_compiler/codegen.rs b/src/react_compiler/codegen.rs
index 473b14c9d2ed..7ed1f1de8a56 100644
--- a/src/react_compiler/codegen.rs
+++ b/src/react_compiler/codegen.rs
@@ -452,6 +452,22 @@ impl<'a, 'h> Context<'a, 'h> {
validated
}
+ /// The first `t` that no identifier uses, for a temporary that only codegen creates.
+ fn fresh_temporary_name(&mut self) -> String {
+ let mut itoa = bun_core::fmt::ItoaBuf::new();
+ let mut name = String::new();
+ for index in 0u32.. {
+ name.clear();
+ name.push('t');
+ name.push_str(itoa.format(index));
+ if !self.unique_identifiers.contains(&name) {
+ break;
+ }
+ }
+ self.unique_identifiers.insert(name.clone());
+ name
+ }
+
fn record_error(&mut self, detail: CompilerErrorDetail) -> Result<(), CompilerError> {
self.env.record_error(detail)
}
@@ -680,17 +696,49 @@ fn codegen_reactive_scope(
for dep in &deps {
let index = cx.alloc_cache_index();
+ let dep_decl = cx.env.identifiers[dep.identifier.0 as usize].declaration_id;
+ // Not in upstream: the stores run last, so a dependency the scope reassigns is copied first.
+ let value_on_entry = if scope_reassignments
+ .iter()
+ .any(|id| cx.env.identifiers[id.0 as usize].declaration_id == dep_decl)
+ {
+ let name = store_str(cx.fresh_temporary_name().as_bytes());
+ let copy_ref = cx.cg.ref_for_local(name);
+ statements.push(Stmt::alloc(
+ S::Local {
+ kind: S::Kind::KConst,
+ decls: decl_list([G::Decl {
+ binding: Binding::alloc(
+ cx.cg.arena,
+ b::Identifier { r#ref: copy_ref },
+ loc,
+ ),
+ value: Some(codegen_dependency(cx, dep)?),
+ }]),
+ ..Default::default()
+ },
+ loc,
+ ));
+ Some(name)
+ } else {
+ None
+ };
+ let dep_expr = |cx: &mut Context| match value_on_entry {
+ Some(name) => Ok(Expr::init_identifier(cx.cg.ref_for_local(name), loc)),
+ None => codegen_dependency(cx, dep),
+ };
+
let comparison = Expr::init(
E::Binary {
op: OpCode::BinStrictNe,
left: cache_slot(index),
- right: codegen_dependency(cx, dep)?,
+ right: dep_expr(cx)?,
},
loc,
);
change_exprs.push(comparison);
- let dep_value = codegen_dependency(cx, dep)?;
+ let dep_value = dep_expr(cx)?;
cache_store_exprs.push(Expr::init(
E::Binary {
op: OpCode::BinAssign,
diff --git a/src/react_compiler/inference/infer_reactive_places.rs b/src/react_compiler/inference/infer_reactive_places.rs
index 3ea70884de56..b4c2c3f77d10 100644
--- a/src/react_compiler/inference/infer_reactive_places.rs
+++ b/src/react_compiler/inference/infer_reactive_places.rs
@@ -581,6 +581,10 @@ fn apply_reactive_flags_replay(
operand.reactive = true;
}
}
+ // Not in upstream: the fixpoint stops at the first reactive operand. Flag the rest.
+ if reactive_ids[operand.identifier.0 as usize] {
+ operand.reactive = true;
+ }
}
}
diff --git a/src/react_compiler/inference/propagate_scope_dependencies_hir.rs b/src/react_compiler/inference/propagate_scope_dependencies_hir.rs
index 08116abb4acd..699361710809 100644
--- a/src/react_compiler/inference/propagate_scope_dependencies_hir.rs
+++ b/src/react_compiler/inference/propagate_scope_dependencies_hir.rs
@@ -1781,11 +1781,72 @@ impl<'a> DependencyCollectionContext<'a> {
}
}
+ // Not in upstream: the enclosing scope has to restore what this scope reassigns.
+ if let Some(parent) = self.current_scope() {
+ let parent_start = env.scopes[parent.0 as usize].range.start;
+ for index in 0..env.scopes[scope_id.0 as usize].reassignments.len() {
+ let id = env.scopes[scope_id.0 as usize].reassignments[index];
+ let decl_id = env.identifiers[id.0 as usize].declaration_id;
+ let declared_before_parent = self
+ .declarations
+ .get(decl_id)
+ .is_some_and(|decl| decl.id < parent_start);
+ let already = env.scopes[parent.0 as usize]
+ .reassignments
+ .iter()
+ .any(|other| env.identifiers[other.0 as usize].declaration_id == decl_id);
+ if declared_before_parent && !already {
+ env.scopes[parent.0 as usize].reassignments.push(id);
+ }
+ }
+ }
+
if !pruned {
self.deps.insert(scope_id, scoped_deps);
}
}
+ /// Not in upstream: a value from before the scope that reaches a phi in it is a dependency.
+ fn visit_phi(
+ &mut self,
+ phi: &crate::hir::Phi,
+ before_block: EvaluationOrder,
+ env: &mut Environment,
+ ) {
+ for (_pred_id, operand) in &phi.operands {
+ if let Some(maybe_optional_chain) = self.temporaries.get(operand.identifier) {
+ self.visit_dependency(maybe_optional_chain.clone(), env);
+ continue;
+ }
+ // An operand that is not declared yet comes from a loop back edge, later in the scope.
+ let (Some(scope), Some(decl)) = (
+ self.current_scope(),
+ self.reassignments.get(operand.identifier),
+ ) else {
+ continue;
+ };
+ if decl.id < env.scopes[scope.0 as usize].range.start {
+ self.visit_dependency(
+ ReactiveScopeDependency {
+ identifier: operand.identifier,
+ reactive: operand.reactive,
+ path: hir_vec![],
+ loc: operand.loc,
+ },
+ env,
+ );
+ }
+ }
+ // A read of this phi inside the scope it sits in is then not a dependency of that scope.
+ self.reassignments.insert(
+ phi.place.identifier,
+ Decl {
+ id: before_block,
+ scope_stack: Vec::new(),
+ },
+ );
+ }
+
fn current_scope(&self) -> Option {
self.scope_stack.last().copied()
}
@@ -2134,6 +2195,25 @@ fn handle_instruction(
ctx.visit_operand(&lvalue.place, env);
ctx.visit_operand(val, env);
}
+ // Not in upstream: `x++` reassigns `x`, so the scope has to restore it on a cache hit.
+ InstructionValue::PrefixUpdate {
+ lvalue, value: val, ..
+ }
+ | InstructionValue::PostfixUpdate {
+ lvalue, value: val, ..
+ } => {
+ ctx.visit_operand(val, env);
+ ctx.visit_reassignment(lvalue, env);
+ let scope_stack_copy = ctx.scope_stack.clone();
+ ctx.declare(
+ lvalue.identifier,
+ Decl {
+ id,
+ scope_stack: scope_stack_copy,
+ },
+ env,
+ );
+ }
_ => {
// Visit all value operands
let operands = visitors::each_instruction_value_operand(&instr.value, env);
@@ -2207,11 +2287,13 @@ fn handle_function_deps(
}
// Record phi operands
- for phi in &block.phis {
- for (_pred_id, operand) in &phi.operands {
- if let Some(maybe_optional_chain) = ctx.temporaries.get(operand.identifier) {
- ctx.visit_dependency(maybe_optional_chain.clone(), env);
- }
+ if !block.phis.is_empty() {
+ let first = match block.instructions.first() {
+ Some(instr_id) => func.instructions[instr_id.0 as usize].id,
+ None => block.terminal.evaluation_order(),
+ };
+ for phi in &block.phis {
+ ctx.visit_phi(phi, EvaluationOrder(first.0.saturating_sub(1)), env);
}
}
diff --git a/src/react_compiler/reactive_scopes/prune_non_reactive_dependencies.rs b/src/react_compiler/reactive_scopes/prune_non_reactive_dependencies.rs
index 3f3678f1588b..aff3c41da6a1 100644
--- a/src/react_compiler/reactive_scopes/prune_non_reactive_dependencies.rs
+++ b/src/react_compiler/reactive_scopes/prune_non_reactive_dependencies.rs
@@ -214,9 +214,10 @@ impl<'a> ReactiveFunctionTransform for PruneVisitor<'a> {
let scope_data = &mut self.env.scopes[scope_id.0 as usize];
// Remove non-reactive dependencies
+ // Not in upstream: `|| dep.reactive`, for a parameter or a phi that only a phi reads.
scope_data
.dependencies
- .retain(|dep| state.contains(&dep.identifier));
+ .retain(|dep| state.contains(&dep.identifier) || dep.reactive);
// If any deps remain, mark all declarations and reassignments as reactive
if !scope_data.dependencies.is_empty() {
diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts
index a53221bd3769..96e49b895b0c 100644
--- a/test/bundler/transpiler/react-compiler.test.ts
+++ b/test/bundler/transpiler/react-compiler.test.ts
@@ -2790,6 +2790,622 @@ describe("bundler", () => {
},
});
}
+
+ // A `let` that is declared before a memo block and reassigned inside it is an
+ // output of the block: the block stores it after the computation and restores
+ // it when the cache hits. Every component below renders several times against
+ // one memo cache, and has to render what the uncompiled component renders.
+ // `*` marks a render that got the same `list` array as the render before it,
+ // so each line also shows that the block still hits its cache.
+ const reassignedLocalOutput = `
+ ConditionalReassign [1,["x"]] [2,["x"]] [2,["x"]]* ["flagged",[]]
+ ReadThenReassign [2,["x"]] [3,["x"]] [3,["x"]]*
+ UpdateExpression [2,["x"]] [3,["x"]] [3,["x"]]*
+ CounterInLoop [1,[3]] [1,[3]]* [0,[1,2]]
+ NestedScope ["flagged",[[1],"x"]] ["flagged",[[1],"x"]]* ["none",[[1],"x"]]
+ PrunedNestedScope [true,[{"v":3,"w":1}]] [true,[{"v":3,"w":1}]] [true,[{"v":3,"w":1}]]* ["k",[{"v":1,"w":1},{"v":2,"w":1}]]
+ LoopReassign ["i",[1,2]] ["j",[1,2]] ["j",[1,2]]* [3,[3]]
+ JoinBeforeScope ["a1",["x"]] ["a2",["x"]] ["base",["x"]] ["base",["x"]]*
+ ChainedScopes ["1two",[[0],[]]] ["2two",[[0],[]]] ["2two",[[0],[]]]* ["onetwo",[[],[]]]
+ DependencyPath ["b",["a","b"]] ["b",["b","b"]] ["b",["b","b"]]*
+ DestructuringSwap ["2:1",[0]] ["1:2",[0]] ["1:2",[0]]*
+ ForOfOnly ["5,1,2",[5,1,2]] ["6,1,2",[6,1,2]] ["6,1,2",[6,1,2]]* ["none",null]
+ ForWithContinue ["5,1,2",[5,1,2]] ["6,1,2",[6,1,2]] ["6,1,2",[6,1,2]]*
+ useFormErrors [false,[[],[]]] [true,[["That name is already taken"],[]]] [true,[["That name is already taken"],[]]]* [true,[[],["Pick an X variable"]]] [true,[["That name is already taken"],["Pick an X variable"]]]
+ useBareParam [1,["b"]] [2,["b"]] [2,["b"]]* ["X",[]]
+ NullableEntryThenProp ["anon",["anon"]] ["ann",["ann"]] ["ann",["ann"]]*
+ BothArmsThenProp ["b",["b"]] ["b",["b"]]* ["c",["c"]]
+ TwoArrays ["v1",[3]] ["v1",[3]]* ["v2",[3]]
+ ThreeArrays ["v1",[3]] ["v1",[3]]* ["v2",[3]]
+ SameDependencies ["v1",[1]] ["v1",[1]]* ["v2",[2]]
+ DestructuringInNestedScope ["1:2",[3]] ["1:2",[3]]* ["2:2",[3]]
+ PrefixUpdateInNestedScope [-1,[3]] [-1,[3]]* [0,[3]]
+ NestedScopeWithEntryFromProp ["a",[3]] ["b",[3]] ["b",[3]]* ["off",[3]] ["off",[3]]*
+ DoWhileInsideScopeOfSibling ["v1",{}] ["v1",{}]* ["v2",{}]
+ SpreadInsideLoopsInsideScopeOfSibling [{"q":1},{}] [{"q":1},{}]* ["init",{}]
+ LoopInsideScopeOfSibling ["a",{}] ["b",{}] ["b",{}]* ["off",{}] ["off",{}]*
+ ClosureReassigns [1,["x"]] [2,["x"]] [2,["x"]]* ["flagged",[]] ["flagged",[]] ["flagged",[]]*
+ CompoundInTernaryTest [2,[1,2,2]] [4,[2,4,4]] [4,[2,4,4]]* [2,[3,2,2]] [4,[2,4,4]]
+ CompoundInArray [5,[6,3,3,5,5]] [4,[5,2,2,4,4]] [4,[5,2,2,4,4]]*
+ CompoundInIfTest [3,["big",3]] [2,["small",2]] [2,["small",2]]*
+ CompoundInJsxAttribute [2,{"title":8,"id":2}] [0,{"title":2,"id":0}] [0,{"title":2,"id":0}]*
+ CompoundInMapEntries [2,[[8,2],["after",2]]] [0,[[2,0],["after",0]]] [0,[[2,0],["after",0]]]*
+ PlainAssignmentInArray [2,[4,2,2]] [1,[2,1,1]] [1,[2,1,1]]*
+ CompoundStatement [2,[2]] [4,[4]] [4,[4]]*
+ UpdateInTernaryTest [2,[[1]]] [3,[[1]]] [3,[[1]]]
+ CopyNamedLikeAnExport [1,["imported"]] [2,["imported"]] [2,["imported"]]*
+ `;
+ for (const reactCompiler of [false, true]) {
+ itBundled(`react-compiler/ReassignedLocalDeclaredBeforeMemoBlock-${reactCompiler ? "compiled" : "plain"}`, {
+ files: {
+ "/entry.jsx": /* jsx */ `
+ import { render } from "react";
+ import { t1 as imported } from "./names";
+
+ // The else path leaves label with the value it had on entry, so that
+ // value is an input of the block although no instruction in it reads it.
+ function ConditionalReassign(p) {
+ let label = p.count;
+ const list = [];
+ if (p.flag) {
+ label = "flagged";
+ } else {
+ list.push(p.b);
+ }
+ return ;
+ }
+
+ // The block reads label, so it is a dependency. Its cache slot has to
+ // hold the value on entry, not the value after the reassignment.
+ function ReadThenReassign(p) {
+ let label = p.count;
+ const list = [];
+ if (p.flag) label = label + 1;
+ list.push(p.b);
+ return ;
+ }
+
+ function UpdateExpression(p) {
+ let n = p.count;
+ const list = [];
+ if (p.flag) n++;
+ list.push(p.b);
+ return ;
+ }
+
+ function CounterInLoop(p) {
+ let count = 0;
+ const list = [];
+ for (const item of p.items) {
+ if (item.ok) count++;
+ list.push(item.v);
+ }
+ return ;
+ }
+
+ // inner has its own memo block inside the block of outer. When the
+ // outer cache hits, the inner block does not run.
+ function NestedScope(p) {
+ let label = "none";
+ const outer = [];
+ const inner = [];
+ if (p.flag) label = "flagged";
+ inner.push(p.a);
+ outer.push(inner);
+ outer.push(p.b);
+ return ;
+ }
+
+ // The block of copy is in a loop, so the compiler prunes it.
+ function PrunedNestedScope(p) {
+ let hasOk = p.initial;
+ const list = [];
+ for (const item of p.items) {
+ const copy = { v: item.v };
+ if (item.ok) hasOk = true;
+ copy.w = p.w;
+ list.push(copy);
+ }
+ return ;
+ }
+
+ function LoopReassign(p) {
+ let found = p.initial;
+ const list = [];
+ for (const item of p.items) {
+ if (item.ok) found = item.v;
+ list.push(item.v);
+ }
+ return ;
+ }
+
+ // The value on entry is itself the join of two paths, and nothing reads
+ // it between that join and the block.
+ function JoinBeforeScope(p) {
+ let label = "base";
+ if (p.primary) label = p.a;
+ const list = [];
+ if (p.flag) label = "flagged";
+ else list.push(p.b);
+ return ;
+ }
+
+ function ChainedScopes(p) {
+ let label = p.count;
+ const first = [];
+ if (p.f1) label = "one";
+ else first.push(p.x);
+ const second = [];
+ if (p.f2) label = label + "two";
+ else second.push(p.y);
+ return ;
+ }
+
+ function DependencyPath(p) {
+ let object = p.o;
+ const list = [];
+ list.push(object.x);
+ if (p.flag) object = p.o2;
+ list.push(object.x);
+ return ;
+ }
+
+ function DestructuringSwap(p) {
+ let a = p.a;
+ let b = p.b;
+ const list = [];
+ if (p.flag) [a, b] = [b, a];
+ list.push(p.x);
+ return ;
+ }
+
+ // The memo block belongs to the loop, not to another value.
+ function ForOfOnly(p) {
+ let derived = [p.value, ...p.items];
+ for (const item of p.items) {
+ if (item < 0) derived = null;
+ }
+ return ;
+ }
+
+ const check = () => false;
+ function ForWithContinue(p) {
+ let derived = [p.value, ...p.items];
+ for (let i = 0; i < 2; i++) {
+ if (i === 0) continue;
+ if (check()) derived = null;
+ }
+ return ;
+ }
+
+ // facebook/react#37224. Each check has a memo block of its own, and both
+ // blocks restore hasErrors. "use memo" because the compiler only infers a
+ // hook from a call to another hook.
+ function useFormErrors(name, existingNames, settings) {
+ "use memo";
+ let hasErrors = false;
+
+ const nameErrors = [];
+ if (existingNames.includes(name)) {
+ hasErrors = true;
+ nameErrors.push("That name is already taken");
+ }
+
+ const settingsErrors = [];
+ if (settings.chartView === "histogram" && !settings.xVariableId) {
+ hasErrors = true;
+ settingsErrors.push("Pick an X variable");
+ }
+
+ return { label: hasErrors, list: [nameErrors, settingsErrors] };
+ }
+
+ // The value on entry is a parameter that nothing but the join reads.
+ function useBareParam(p, label) {
+ "use memo";
+ const list = [];
+ if (p.flag) label = p.x;
+ else list.push(p.b);
+ return { label, list };
+ }
+
+ // user.profile.name is read from the value after the join. The value on
+ // entry can be null, so nothing may read that path before the block.
+ function NullableEntryThenProp({ user, fallbackUser }) {
+ let u = user;
+ const list = [];
+ if (!u) u = fallbackUser;
+ list.push(u.profile.name);
+ return ;
+ }
+
+ // Both arms assign, so the value on entry does not reach the read at all.
+ function BothArmsThenProp(p) {
+ let object = p.a;
+ const list = [];
+ if (p.kind) object = p.b;
+ else object = p.c;
+ list.push(object.v.w);
+ return ;
+ }
+
+ // The components from here on have a memo block inside another one,
+ // and the assignment is in the inner block. When the cache of the
+ // outer block hits, the inner block does not run, so the outer block
+ // has to restore the variable too. In the first two, every block
+ // survives to the output.
+ function TwoArrays(p) {
+ let v = "init";
+ const outer = [];
+ const inner = [];
+ v = "v" + p.a;
+ inner.push(p.b);
+ outer.push(p.c);
+ return ;
+ }
+
+ function ThreeArrays(p) {
+ let v = "init";
+ const outer = [];
+ const middle = [];
+ const inner = [];
+ v = "v" + p.a;
+ inner.push(p.a);
+ middle.push(p.b);
+ outer.push(p.c);
+ return ;
+ }
+
+ // Both blocks depend on p.a only, so the compiler merges the inner one
+ // into the outer one.
+ function SameDependencies(p) {
+ let v = "init";
+ const outer = [];
+ const inner = [];
+ v = "v" + p.a;
+ inner.push(p.a);
+ outer.push(p.a);
+ return ;
+ }
+
+ function DestructuringInNestedScope(p) {
+ let v = "init", u = "init";
+ const outer = [];
+ const inner = [];
+ [v, u] = [p.a, p.b];
+ inner.push(p.b);
+ outer.push(p.c);
+ return ;
+ }
+
+ function PrefixUpdateInNestedScope(p) {
+ let v = 0;
+ const outer = [];
+ const inner = [];
+ if (p.a === 1) --v;
+ inner.push(p.b);
+ outer.push(p.c);
+ return ;
+ }
+
+ // The value on entry is a prop, and the outer block is the one that
+ // has to depend on it.
+ function NestedScopeWithEntryFromProp(p) {
+ let v = p.v;
+ const outer = [];
+ const inner = [];
+ if (p.off) v = "off";
+ inner.push(p.b);
+ outer.push(p.c);
+ return ;
+ }
+
+ // The block of w runs from its declaration to the end of the for-of.
+ // The do-while counter j has a block of its own inside it. The
+ // compiler prunes that block later, because nothing it computes needs
+ // memoization.
+ function DoWhileInsideScopeOfSibling(p) {
+ let v = "init";
+ let w = "w";
+ let j = 0;
+ do { j++; v = "v" + p.a; } while (j < p.n);
+ for (const it of p.items) { w = {}; }
+ return ;
+ }
+
+ // The block of the object spread is inside loops, so the compiler
+ // flattens it.
+ function SpreadInsideLoopsInsideScopeOfSibling(p) {
+ let v = "init";
+ let w = "w";
+ for (const k in p.obj) {
+ for (let i = 0; i < p.n; i++) {
+ switch (p.a) {
+ case 1: v = { ...p.obj }; break;
+ default: break;
+ }
+ }
+ }
+ for (const it of p.items) { w = {}; }
+ return ;
+ }
+
+ function LoopInsideScopeOfSibling(p) {
+ let v = p.v;
+ let w = "w";
+ for (const it of p.items) { if (p.off) v = "off"; }
+ for (const it of p.items) { w = {}; }
+ return ;
+ }
+
+ // Right on 1.4.3 too. A local that a closure assigns is not in SSA form.
+ // Its declaration is part of the same memo block as the call.
+ function ClosureReassigns(p) {
+ let label = p.count;
+ const set = () => {
+ label = "flagged";
+ };
+ const list = [];
+ if (p.flag) set();
+ else list.push(p.b);
+ return ;
+ }
+
+ // From here on the block reads i and then assigns it, in one expression.
+ // Each run renders a value of n that equals the i the render before
+ // ended with, which is what the cache slot of i held. The compiler
+ // moves the assignments of CompoundInArray, CompoundInJsxAttribute,
+ // CompoundInMapEntries and PlainAssignmentInArray out of the block, so
+ // those four were right before. They guard the copy of i.
+ function CompoundInTernaryTest(p) {
+ let i = p.n;
+ const list = (i *= 1) > 2 ? [i, (i -= 1), i] : [i, (i *= 2), i];
+ return ;
+ }
+
+ function CompoundInArray(p) {
+ let i = p.n;
+ const list = [i, (i >>= 1), i, (i += 2), i];
+ return ;
+ }
+
+ function CompoundInIfTest(p) {
+ let i = p.n;
+ const list = [];
+ if ((i -= 1) > 2) list.push("big", i);
+ else list.push("small", i);
+ return ;
+ }
+
+ function CompoundInJsxAttribute(p) {
+ let i = p.n;
+ const list = >= 2)} />;
+ return ;
+ }
+
+ function CompoundInMapEntries(p) {
+ let i = p.n;
+ const list = [...new Map([[i, (i >>= 2)], ["after", i]])];
+ return ;
+ }
+
+ function PlainAssignmentInArray(p) {
+ let i = p.n;
+ const list = [i, (i = i >> 1), i];
+ return ;
+ }
+
+ function CompoundStatement(p) {
+ let i = p.n;
+ const list = [];
+ i *= 2;
+ list.push(i);
+ return ;
+ }
+
+ // The block that assigns n has all of its instructions in the test of
+ // the conditional. The compiler leaves this function as written, so no
+ // render hits a cache.
+ const yes = () => true;
+ function UpdateInTernaryTest(p) {
+ let n = p.count;
+ const outer = [];
+ const inner = [];
+ const r = (n++, p.f()) ? 1 : 0;
+ inner.push(r);
+ outer.push(inner);
+ return ;
+ }
+
+ // The copy of label is the first free t, which is t1 here. The bundler
+ // prints the import under the name of its export, which is t1 too.
+ function CopyNamedLikeAnExport(p) {
+ let label = p.count;
+ const list = [];
+ if (p.flag) label = "flagged";
+ else list.push(imported);
+ return ;
+ }
+
+ function run(Component, ...renders) {
+ let previous;
+ const results = renders.map(args => {
+ try {
+ const { label, list } = render(Component, ...(Array.isArray(args) ? args : [args]));
+ const hit = list === previous ? "*" : "";
+ previous = list;
+ return JSON.stringify([label, list]) + hit;
+ } catch (error) {
+ return "THROW(" + error.message + ")";
+ }
+ });
+ console.log(Component.name + " " + results.join(" "));
+ }
+
+ const none = [{ ok: false, v: 1 }, { ok: false, v: 2 }];
+ const some = [{ ok: true, v: 3 }];
+ const o1 = { x: "a" }, o2 = { x: "b" }, o3 = { x: "b" };
+
+ run(
+ ConditionalReassign,
+ { count: 1, flag: false, b: "x" },
+ { count: 2, flag: false, b: "x" },
+ { count: 2, flag: false, b: "x" },
+ { count: 3, flag: true, b: "x" },
+ );
+ run(
+ ReadThenReassign,
+ { count: 1, flag: true, b: "x" },
+ { count: 2, flag: true, b: "x" },
+ { count: 2, flag: true, b: "x" },
+ );
+ run(
+ UpdateExpression,
+ { count: 1, flag: true, b: "x" },
+ { count: 2, flag: true, b: "x" },
+ { count: 2, flag: true, b: "x" },
+ );
+ run(CounterInLoop, { items: some }, { items: some }, { items: none });
+ run(
+ NestedScope,
+ { flag: true, a: 1, b: "x" },
+ { flag: true, a: 1, b: "x" },
+ { flag: false, a: 1, b: "x" },
+ );
+ run(
+ PrunedNestedScope,
+ { initial: "i", items: some, w: 1 },
+ { initial: "j", items: some, w: 1 },
+ { initial: "j", items: some, w: 1 },
+ { initial: "k", items: none, w: 1 },
+ );
+ run(
+ LoopReassign,
+ { initial: "i", items: none },
+ { initial: "j", items: none },
+ { initial: "j", items: none },
+ { initial: "k", items: some },
+ );
+ run(
+ JoinBeforeScope,
+ { primary: true, a: "a1", flag: false, b: "x" },
+ { primary: true, a: "a2", flag: false, b: "x" },
+ { primary: false, a: "a2", flag: false, b: "x" },
+ { primary: false, a: "a3", flag: false, b: "x" },
+ );
+ run(
+ ChainedScopes,
+ { count: 1, f1: false, f2: true, x: 0, y: 0 },
+ { count: 2, f1: false, f2: true, x: 0, y: 0 },
+ { count: 2, f1: false, f2: true, x: 0, y: 0 },
+ { count: 3, f1: true, f2: true, x: 0, y: 0 },
+ );
+ run(DependencyPath, { o: o1, o2, flag: true }, { o: o3, o2, flag: true }, { o: o3, o2, flag: true });
+ run(
+ DestructuringSwap,
+ { a: 1, b: 2, flag: true, x: 0 },
+ { a: 2, b: 1, flag: true, x: 0 },
+ { a: 2, b: 1, flag: true, x: 0 },
+ );
+ const positive = [1, 2];
+ run(
+ ForOfOnly,
+ { value: 5, items: positive },
+ { value: 6, items: positive },
+ { value: 6, items: positive },
+ { value: 7, items: [1, -2] },
+ );
+ run(
+ ForWithContinue,
+ { value: 5, items: positive },
+ { value: 6, items: positive },
+ { value: 6, items: positive },
+ );
+ const names = ["taken"];
+ const bar = { chartView: "bar" };
+ const histogram = { chartView: "histogram" };
+ run(
+ useFormErrors,
+ ["free", names, bar],
+ ["taken", names, bar],
+ ["taken", names, bar],
+ ["free", names, histogram],
+ ["taken", names, histogram],
+ );
+ const flagOff = { flag: false, x: "X", b: "b" };
+ run(useBareParam, [flagOff, 1], [flagOff, 2], [flagOff, 2], [{ flag: true, x: "X", b: "b" }, 3]);
+ const ann = { profile: { name: "ann" } };
+ run(
+ NullableEntryThenProp,
+ { user: null, fallbackUser: { profile: { name: "anon" } } },
+ { user: ann, fallbackUser: null },
+ { user: ann, fallbackUser: null },
+ );
+ const w = value => ({ v: { w: value } });
+ const b1 = w("b"), c1 = w("c");
+ run(
+ BothArmsThenProp,
+ { a: null, kind: 1, b: b1, c: null },
+ { a: null, kind: 1, b: b1, c: null },
+ { a: null, kind: 0, b: null, c: c1 },
+ );
+ const nested1 = { a: 1, b: 2, c: 3, n: 1, items: [1], obj: { q: 1 } };
+ const nested2 = { ...nested1, a: 2 };
+ run(TwoArrays, nested1, nested1, nested2);
+ run(ThreeArrays, nested1, nested1, nested2);
+ run(SameDependencies, nested1, nested1, nested2);
+ run(DestructuringInNestedScope, nested1, nested1, nested2);
+ run(PrefixUpdateInNestedScope, nested1, nested1, nested2);
+ const entryA = { v: "a", off: false, b: 2, c: 3, items: nested1.items };
+ const entryB = { ...entryA, v: "b" };
+ const entryOff = { ...entryB, off: true };
+ run(NestedScopeWithEntryFromProp, entryA, entryB, entryB, entryOff, entryOff);
+ run(DoWhileInsideScopeOfSibling, nested1, nested1, nested2);
+ run(SpreadInsideLoopsInsideScopeOfSibling, nested1, nested1, nested2);
+ run(LoopInsideScopeOfSibling, entryA, entryB, entryB, entryOff, entryOff);
+ run(
+ ClosureReassigns,
+ { count: 1, flag: false, b: "x" },
+ { count: 2, flag: false, b: "x" },
+ { count: 2, flag: false, b: "x" },
+ { count: 3, flag: true, b: "x" },
+ { count: 4, flag: true, b: "x" },
+ { count: 4, flag: true, b: "x" },
+ );
+ run(CompoundInTernaryTest, { n: 1 }, { n: 2 }, { n: 2 }, { n: 3 }, { n: 2 });
+ run(CompoundInArray, { n: 6 }, { n: 5 }, { n: 5 });
+ run(CompoundInIfTest, { n: 4 }, { n: 3 }, { n: 3 });
+ run(CompoundInJsxAttribute, { n: 8 }, { n: 2 }, { n: 2 });
+ run(CompoundInMapEntries, { n: 8 }, { n: 2 }, { n: 2 });
+ run(PlainAssignmentInArray, { n: 4 }, { n: 2 }, { n: 2 });
+ run(CompoundStatement, { n: 1 }, { n: 2 }, { n: 2 });
+ run(UpdateInTernaryTest, { count: 1, f: yes }, { count: 2, f: yes }, { count: 2, f: yes });
+ run(CopyNamedLikeAnExport, { count: 1, flag: false }, { count: 2, flag: false }, { count: 2, flag: false });
+ `,
+ "/names.js": `export const t1 = "imported";`,
+ // One memo cache per component, kept between renders, as a fiber keeps it.
+ "/node_modules/react/index.js": /* js */ `
+ const fibers = new Map();
+ let current;
+ exports.render = (Component, ...args) => {
+ current = fibers.get(Component);
+ if (!current) fibers.set(Component, (current = { cache: null }));
+ return Component(...args);
+ };
+ exports.memoCache = size =>
+ (current.cache ??= new Array(size).fill(Symbol.for("react.memo_cache_sentinel")));
+ `,
+ "/node_modules/react/compiler-runtime.js": `exports.c = size => require("./index.js").memoCache(size);`,
+ "/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => props;`,
+ "/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => props;`,
+ "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
+ },
+ reactCompiler,
+ target: "browser",
+ backend: "cli",
+ run: { stdout: reactCompiler ? reassignedLocalOutput : reassignedLocalOutput.replaceAll("*", "") },
+ });
+ }
});
// Three passes kept one copy of their work per basic block or per nesting