From 52add2e6183ccd46d653afe04725ebea073db083 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:17:39 +0000 Subject: [PATCH 01/13] react-compiler: read the method of a member call ahead of a statement that can mutate the receiver Codegen prints the property of a MethodCall inside the call, so that the call has its receiver. PromoteInterposedTemporaries never gave it a name. So the method of `o.who(args)` was read behind every statement that the compiler moves out of the arguments: the body of an inlined IIFE, a dependency of a memo block, an earlier argument that has a name. A statement that mutates the receiver then decided which function ran. When a statement between the load and the call can mutate the receiver, the pass now names the property at its load, and codegen prints `Reflect.apply(method, receiver, [args])`. The mutation analysis decides: a statement counts when it mutates a value of the mutable range of the receiver. Without memoization no ranges are shared, so every mutation counts there. Other member calls keep their text. The key of `o[key](args)` is an operand of the load. When the method stays in the call, the key now gets a name like any other operand. --- src/jsc/RuntimeTranspilerCache.rs | 4 +- src/react_compiler/codegen.rs | 79 +++- src/react_compiler/hir/environment.rs | 6 + .../promote_used_temporaries.rs | 297 +++++++++++++- .../pure-components/MethodCalls.tsx | 44 +++ .../same-as-bundle/pure-components/main.tsx | 3 +- .../bundler/transpiler/react-compiler.test.ts | 366 ++++++++++++++++++ 7 files changed, 787 insertions(+), 12 deletions(-) create mode 100644 test/aot/react-compiler/same-as-bundle/pure-components/MethodCalls.tsx diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index cafb4f0281bf..41dc2d04f05a 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -85,7 +85,9 @@ bun_core::declare_scope!(cache, visible); /// Version 50: The body of a class with lowered class decorators names the class by the /// binding that holds what the decorators return. /// Version 51: A `bun:` specifier that names no builtin is printed as it is written. -const EXPECTED_VERSION: u32 = 51; +/// Version 52: `--react-compiler` reads the method and the key of a member call ahead of a statement +/// that can change them. +const EXPECTED_VERSION: u32 = 52; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/src/react_compiler/codegen.rs b/src/react_compiler/codegen.rs index 29a8a3ac03cb..a835717f2873 100644 --- a/src/react_compiler/codegen.rs +++ b/src/react_compiler/codegen.rs @@ -96,10 +96,11 @@ enum WellKnown { Underscore, MemoCacheSentinel, EarlyReturnSentinel, + Reflect, } impl WellKnown { - const COUNT: usize = 6; + const COUNT: usize = 7; } /// Host-side state shared across nested function-expression codegen so the @@ -1940,14 +1941,19 @@ fn codegen_base_instruction_value( Ok(maybe_wrap_hook_call(cx, call_expr, callee.identifier)) } InstructionValue::MethodCall { - receiver: _, + receiver, property, args, .. } => { let member_expr = codegen_place_to_expression(cx, property)?; if !matches!(member_expr.data, ExprData::EDot(_) | ExprData::EIndex(_)) { - return Err(method_call_property_err(property.loc, member_expr)); + if !cx.env.explicit_receiver_calls.contains(property.identifier) { + return Err(method_call_property_err(property.loc, member_expr)); + } + let call_expr = + codegen_explicit_receiver_call(cx, member_expr, receiver, args, loc)?; + return Ok(maybe_wrap_hook_call(cx, call_expr, property.identifier)); } let arguments = codegen_arguments(cx, args)?; let call_expr = Expr::init( @@ -2318,6 +2324,73 @@ fn codegen_base_instruction_value( // Function expression codegen // ============================================================================= +/// Not in upstream: the call of a method that PromoteUsedTemporaries read ahead of a statement, as +/// `Reflect.apply(method, receiver, [...args])`. `method.call(receiver, ...args)` reads `call` of the method, +/// which a member call does not: a proxy or a method with a `call` of its own answers that read. +fn codegen_explicit_receiver_call( + cx: &mut Context, + method: Expr, + receiver: &Place, + args: &[PlaceOrSpread], + loc: Loc, +) -> Result { + let reflect = cx.cg.well_known_global(WellKnown::Reflect, b"Reflect"); + // `find_symbol` of the host sees what the file and the body of the function declare, not a nested block. + let is_global = cx + .cg + .host + .symbols() + .get(reflect.inner_index() as usize) + .is_some_and(|symbol| symbol.kind == bun_ast::symbol::Kind::Unbound) + && !cx.env.identifiers.iter().any(|identifier| { + identifier + .name + .as_ref() + .is_some_and(|name| name.value() == b"Reflect") + }); + if !is_global { + return Err(method_call_property_err(receiver.loc, method)); + } + let this_value = codegen_place_to_expression(cx, receiver)?; + // The read of the method printed the receiver once. PromoteUsedTemporaries leaves only a local without a name. + if cx.env.identifiers[receiver.identifier.0 as usize] + .name + .is_none() + { + let ExprData::EIdentifier(local) = this_value.data else { + return Err(method_call_property_err(receiver.loc, this_value)); + }; + cx.cg.host.record_usage(local.ref_); + } + let arguments = Expr::init( + E::Array { + items: codegen_arguments(cx, args)?, + ..Default::default() + }, + loc, + ); + let mut apply_args: ExprNodeList = AstAlloc::vec_with_capacity(3); + apply_args.push(method); + apply_args.push(this_value); + apply_args.push(arguments); + Ok(Expr::init( + E::Call { + target: Expr::init( + E::Dot { + target: Expr::init_identifier(reflect, loc), + name: StoreStr::new(b"apply"), + name_loc: loc, + ..Default::default() + }, + loc, + ), + args: apply_args, + ..Default::default() + }, + loc, + )) +} + fn codegen_function_expression( cx: &mut Context, name: &Option, diff --git a/src/react_compiler/hir/environment.rs b/src/react_compiler/hir/environment.rs index 32cbfd324bb6..728746aa5a10 100644 --- a/src/react_compiler/hir/environment.rs +++ b/src/react_compiler/hir/environment.rs @@ -1,5 +1,6 @@ use crate::collections::FxHashMap as HashMap; use crate::collections::FxHashSet as HashSet; +use crate::collections::IdBitSet; use crate::diagnostics::CompilerDiagnostic; use crate::diagnostics::CompilerError; @@ -121,6 +122,10 @@ pub struct Environment { // identifiers on first use, then updated with each generated name. // Matches Babel's generateUid behavior of checking hasBinding/hasReference. uid_known_names: Option>, + + /// Not in upstream: the properties of the MethodCalls that PromoteUsedTemporaries gave a name, to read the + /// method ahead of a statement. Codegen prints such a call with its receiver as an argument. + pub(crate) explicit_receiver_calls: IdBitSet, } /// An outlined function entry, stored on Environment during compilation. @@ -218,6 +223,7 @@ impl Environment { default_mutating_hook: None, outlined_functions: AstAlloc::vec(), uid_known_names: None, + explicit_receiver_calls: IdBitSet::new(), config, } } diff --git a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs index 23046cb01b83..ebf110627765 100644 --- a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs +++ b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs @@ -12,8 +12,10 @@ use crate::collections::FxHashSet as HashSet; use crate::collections::IdBitSet; use crate::collections::IdMap; +use crate::hir::AliasingEffect; use crate::hir::ArrayElement; use crate::hir::DeclarationId; +use crate::hir::EvaluationOrder; use crate::hir::FunctionId; use crate::hir::IdentifierId; use crate::hir::IdentifierName; @@ -21,6 +23,7 @@ use crate::hir::InstructionKind; use crate::hir::InstructionValue; use crate::hir::JsxAttribute; use crate::hir::JsxTag; +use crate::hir::MutableRangeId; use crate::hir::ObjectPropertyOrSpread; use crate::hir::ParamPattern; use crate::hir::Place; @@ -35,6 +38,7 @@ use crate::hir::ScopeId; use crate::hir::environment::Environment; use crate::hir::is_primitive_type; use crate::hir::visitors::Places; +use crate::reactive_scopes::visitors::ReactiveFunctionVisitor; // ============================================================================= // State @@ -586,6 +590,8 @@ fn visit_hir_function_for_promotion(func_id: FunctionId, state: &mut State, env: enum Effect { None, Read, + /// A read that runs an iterator. Only `promote_late_method` tells it from `Read`. + Iterate, Write, } @@ -595,10 +601,11 @@ struct Temporary { effect: Effect, } -/// Positions of the last statement that reads and of the last statement that writes. +/// Positions of the last statement that reads, that runs an iterator, and that writes. #[derive(Clone, Copy, Default)] struct Statements { read: u32, + iterate: u32, write: u32, } @@ -613,6 +620,8 @@ struct InterState<'a> { /// fbt wants these inside the macro call: they stay inline and their operands get the names. inline_macro_operands: &'a HashSet, operands_of_macro_operands: IdMap, + /// The last operand that kept its place as a const while a statement behind it can mutate an object. + late_const: Option, } impl<'a> InterState<'a> { @@ -624,6 +633,7 @@ impl<'a> InterState<'a> { value_block_statements: None, inline_macro_operands, operands_of_macro_operands: IdMap::new(), + late_const: None, } } @@ -647,6 +657,10 @@ impl<'a> InterState<'a> { match effect { Effect::None => {} Effect::Read => statements.read = self.position, + Effect::Iterate => { + statements.read = self.position; + statements.iterate = self.position; + } Effect::Write => statements.write = self.position, } } @@ -656,6 +670,10 @@ impl<'a> InterState<'a> { match temporary.effect { Effect::None => {} Effect::Read => statements.read = statements.read.max(temporary.position), + Effect::Iterate => { + statements.read = statements.read.max(temporary.position); + statements.iterate = statements.iterate.max(temporary.position); + } Effect::Write => statements.write = statements.write.max(temporary.position), } } @@ -666,11 +684,16 @@ impl<'a> InterState<'a> { let write = self.statements.write.max(in_value_block.write); match temporary.effect { Effect::None => false, - Effect::Read => write > temporary.position, + Effect::Read | Effect::Iterate => write > temporary.position, Effect::Write => read.max(write) > temporary.position, } } + /// A statement of the block behind the temporary runs an iterator. `promote_late_method` is for a block only. + fn iterates_behind(&self, temporary: Temporary) -> bool { + temporary.effect != Effect::None && self.statements.iterate > temporary.position + } + fn enter_value_block(&mut self) -> Option { let outer = self.value_block_statements; self.value_block_statements = Some(outer.unwrap_or_default()); @@ -694,10 +717,18 @@ fn promote_interposed_block( return; } - for stmt in block { + for (index, stmt) in block.iter().enumerate() { match stmt { ReactiveStatement::Instruction(instr) => { - promote_interposed_instruction(instr, state, inter_state, consts, globals, env); + promote_interposed_instruction( + instr, + &block[..index], + state, + inter_state, + consts, + globals, + env, + ); } ReactiveStatement::Scope(scope) => { // The memo block compares its dependencies first, and what writes must not print in it: it may not run. @@ -756,7 +787,15 @@ fn promote_interposed_place( let Some(temporary) = inline_temporary(place, state, inter_state, env) else { return Effect::None; }; - if !inter_state.conflicts(temporary) || consts.contains(place.identifier) { + let conflicts = inter_state.conflicts(temporary); + if !conflicts && !inter_state.iterates_behind(temporary) { + return temporary.effect; + } + if consts.contains(place.identifier) { + inter_state.late_const = Some(place.identifier); + return temporary.effect; + } + if !conflicts { return temporary.effect; } if inter_state @@ -882,7 +921,7 @@ fn instruction_effect( .iter() .any(|element| matches!(element, ArrayElement::Spread(_))) => { - Effect::Read + Effect::Iterate } InstructionValue::ObjectExpression { properties, .. } if properties @@ -903,8 +942,10 @@ fn instruction_effect( } /// Returns the effect of the expression that the instruction prints as. +/// `before` holds the statements of the block in front of the instruction. A value block has none. fn promote_interposed_instruction( instr: &ReactiveInstruction, + before: &[ReactiveStatement], state: &mut State, inter_state: &mut InterState<'_>, consts: &mut IdBitSet, @@ -956,7 +997,14 @@ fn promote_interposed_instruction( } _ => {} } - promote_interposed_value(&instr.value, state, inter_state, consts, globals, env) + let effect = + promote_interposed_value(&instr.value, state, inter_state, consts, globals, env); + if let InstructionValue::MethodCall { property, .. } = iv + && inter_state.late_const == Some(property.identifier) + { + promote_late_method(iv, before, state, inter_state, consts, globals, env); + } + effect } _ => { let outer = inter_state.enter_value_block(); @@ -1020,6 +1068,7 @@ fn promote_interposed_value( for instr in instructions { effect = effect.max(promote_interposed_instruction( instr, + &[], state, inter_state, consts, @@ -1060,6 +1109,240 @@ fn promote_interposed_value( } } +/// Not in upstream. Codegen prints the property of a MethodCall inside the call, so that the call has its +/// receiver. So the method is read behind every statement between its load and the call. When such a statement +/// can mutate the receiver, the property gets a name here, and codegen calls it with the receiver given. +/// A statement that writes stands there in most member calls. Only the mutation analysis tells the few that can. +#[cold] +fn promote_late_method( + call: &InstructionValue, + before: &[ReactiveStatement], + state: &mut State, + inter_state: &mut InterState<'_>, + consts: &IdBitSet, + globals: &IdBitSet, + env: &mut Environment, +) { + let InstructionValue::MethodCall { + receiver, property, .. + } = call + else { + return; + }; + let Some(temporary) = inline_temporary(property, state, inter_state, env) else { + return; + }; + let declaration_of = + |place: &Place| env.identifiers[place.identifier.0 as usize].declaration_id; + let mut behind = Behind { + property: declaration_of(property), + receiver: declaration_of(receiver), + state, + inter_state, + mutations: Mutations { + env, + consts, + globals, + receiver: env.enable_memoization().then(|| { + env.identifiers[receiver.identifier.0 as usize] + .mutable_range + .id + }), + }, + in_call: crate::hir::visitors::operands(call, &env.functions) + .map(declaration_of) + .collect(), + is_mutated: false, + load: None, + is_receiver_a_local: false, + }; + behind.walk(before); + let Behind { + is_mutated, + load, + is_receiver_a_local, + .. + } = behind; + let Some(load) = load else { + return; + }; + // Without memoization every mutation counts. The receiver still has the range in which something mutates it. + let mutated_until = env.identifiers[receiver.identifier.0 as usize] + .mutable_range + .end; + if is_mutated && mutated_until.0 > load.id.0 + 1 { + promote_identifier(property.identifier, state, env); + inter_state.promoted(temporary); + env.explicit_receiver_calls.insert(property.identifier); + // The call prints the receiver again. Only the name of a local can print twice. + if !is_receiver_a_local + && let Some(temporary) = inline_temporary(receiver, state, inter_state, env) + { + promote_identifier(receiver.identifier, state, env); + inter_state.promoted(temporary); + } + } else if let ReactiveValue::Instruction(load @ InstructionValue::ComputedLoad { .. }) = + &load.value + { + // The method stays in the call, and the key with it. The key is an operand like any other. + let operands = crate::hir::visitors::each_instruction_value_operand(load, env); + promote_interposed_operands(operands, state, inter_state, consts, env); + } +} + +/// What prints between the load of a method and its call, read from the call back to the load. +struct Behind<'a, 'b> { + property: DeclarationId, + receiver: DeclarationId, + state: &'b State, + inter_state: &'b InterState<'b>, + mutations: Mutations<'b>, + /// The temporaries that print inside the call. They run behind the read of the method in the source too. + in_call: IdBitSet, + is_mutated: bool, + load: Option<&'a ReactiveInstruction>, + is_receiver_a_local: bool, +} + +impl<'a> Behind<'a, '_> { + /// Goes back to the load, and then on to what defines the receiver. Returns true at the end of the search. + fn walk(&mut self, statements: &'a [ReactiveStatement]) -> bool { + if !self.mutations.env.has_stack() { + return true; + } + for statement in statements.iter().rev() { + let is_at_the_end = match statement { + ReactiveStatement::Instruction(instruction) => self.instruction(instruction), + // A pruned scope prints its statements in the block around it. + ReactiveStatement::PrunedScope(scope) => self.walk(&scope.instructions), + ReactiveStatement::Scope(scope) => { + if self.load.is_none() { + self.mutations.visit_scope(scope, &mut self.is_mutated); + } + false + } + ReactiveStatement::Terminal(terminal) => { + if self.load.is_none() { + self.mutations + .visit_terminal(terminal, &mut self.is_mutated); + } + false + } + }; + if is_at_the_end { + return true; + } + } + false + } + + fn instruction(&mut self, instruction: &'a ReactiveInstruction) -> bool { + let env = self.mutations.env; + let declaration_id = instruction + .lvalue + .as_ref() + .map(|lvalue| env.identifiers[lvalue.identifier.0 as usize].declaration_id); + if self.load.is_some() { + if declaration_id != Some(self.receiver) { + return false; + } + self.is_receiver_a_local = matches!( + &instruction.value, + ReactiveValue::Instruction(InstructionValue::LoadLocal { place, .. }) + if matches!( + env.identifiers[place.identifier.0 as usize].name, + Some(IdentifierName::Named(_)) + ) + ); + return true; + } + if declaration_id == Some(self.property) { + self.load = Some(instruction); + return false; + } + if let Some(lvalue) = &instruction.lvalue + && declaration_id.is_some_and(|id| self.in_call.contains(id)) + && inline_temporary(lvalue, self.state, self.inter_state, env).is_some() + { + Operands(env).visit_value(instruction.id, &instruction.value, &mut self.in_call); + return false; + } + self.mutations + .visit_instruction(instruction, &mut self.is_mutated); + false + } +} + +/// The operands of a value, by declaration. +struct Operands<'a>(&'a Environment); + +impl ReactiveFunctionVisitor for Operands<'_> { + type State = IdBitSet; + + fn env(&self) -> &Environment { + self.0 + } + + fn visit_place(&self, _id: EvaluationOrder, place: &Place, operands: &mut Self::State) { + operands.insert(self.0.identifiers[place.identifier.0 as usize].declaration_id); + } +} + +/// Sets its state when a statement can mutate the receiver of a method call. +struct Mutations<'a> { + env: &'a Environment, + consts: &'a IdBitSet, + globals: &'a IdBitSet, + /// The values that are mutated together have one mutable range. A memo block mutates only what it declares, + /// so what is in a memo block has another range. `None` without memoization: there no two values share a range. + receiver: Option, +} + +impl Mutations<'_> { + fn reaches(&self, place: &Place) -> bool { + self.receiver.is_none_or(|receiver| { + self.env.identifiers[place.identifier.0 as usize] + .mutable_range + .id + == receiver + }) + } +} + +impl ReactiveFunctionVisitor for Mutations<'_> { + type State = bool; + + fn env(&self) -> &Environment { + self.env + } + + fn visit_instruction(&self, instruction: &ReactiveInstruction, is_mutated: &mut bool) { + if let Some(effects) = &instruction.effects { + *is_mutated |= effects.iter().any(|effect| match effect { + AliasingEffect::Mutate { value, .. } + | AliasingEffect::MutateConditionally { value } + | AliasingEffect::MutateTransitive { value } + | AliasingEffect::MutateTransitiveConditionally { value } => self.reaches(value), + _ => false, + }); + } + if !matches!(instruction.value, ReactiveValue::Instruction(_)) { + self.traverse_value(instruction.id, &instruction.value, is_mutated); + } + } + + /// The last instruction of a value block has no record of its effects. It can mutate what it uses. + fn visit_value(&self, id: EvaluationOrder, value: &ReactiveValue, is_mutated: &mut bool) { + let ReactiveValue::Instruction(iv) = value else { + return self.traverse_value(id, value, is_mutated); + }; + let operands = crate::hir::visitors::each_instruction_value_operand(iv, self.env); + *is_mutated |= instruction_effect(iv, &operands, self.consts, self.globals, self.env) + >= Effect::Iterate + && operands.iter().any(|operand| self.reaches(operand)); + } +} + /// The value of a terminal prints as part of the statement of that terminal. fn promote_interposed_terminal_value( value: &ReactiveValue, diff --git a/test/aot/react-compiler/same-as-bundle/pure-components/MethodCalls.tsx b/test/aot/react-compiler/same-as-bundle/pure-components/MethodCalls.tsx new file mode 100644 index 000000000000..7d9ddab41f3c --- /dev/null +++ b/test/aot/react-compiler/same-as-bundle/pure-components/MethodCalls.tsx @@ -0,0 +1,44 @@ +// A statement in an argument mutates the receiver of the call. The compiler reads the method ahead of the statement, and calls it with the receiver given. +type Props = { a: number; text: string }; +type Named = { name: string; who: (this: Named, suffix?: string) => string; inner?: Named }; + +function who(this: Named, suffix = "") { + return this.name + ":old" + suffix; +} +function other(this: Named) { + return this.name + ":new"; +} +const make = (name: string): Named => ({ name, who }); + +export function StatementReplacesTheMethod({ a, text }: Props) { + "use memo"; + const named = make(text + a); + return [ + named.who( + (() => { + named.who = other; + return "!"; + })(), + ), + ]; +} + +export function ReceiverIsAPropertyLoad({ a, text }: Props) { + "use memo"; + const outer = { inner: make(text + a) }; + return [ + outer.inner.who( + (() => { + outer.inner.who = other; + return "!"; + })(), + ), + ]; +} + +export function KeyIsAMemberThatIsUpdated({ a, text }: Props) { + "use memo"; + const parts = [(n: number) => text + ":first:" + n, (n: number) => text + ":second:" + n]; + const state = { i: a % 2 }; + return [parts[state.i](state.i++), state.i]; +} diff --git a/test/aot/react-compiler/same-as-bundle/pure-components/main.tsx b/test/aot/react-compiler/same-as-bundle/pure-components/main.tsx index b748211ebc43..acacba457d16 100644 --- a/test/aot/react-compiler/same-as-bundle/pure-components/main.tsx +++ b/test/aot/react-compiler/same-as-bundle/pure-components/main.tsx @@ -1,6 +1,7 @@ // Each component renders three times: twice with equal props, once with others. The components are pure, so the output is the same with the compiler off. import { render } from "../react-stand-in/render"; import * as JoinedText from "./JoinedText"; +import * as MethodCalls from "./MethodCalls"; import * as Shapes from "./Shapes"; type Component = (props: unknown) => unknown; @@ -11,6 +12,6 @@ const allProps = [ { a: 1, text: "x", list, nested: { value: 7 } }, { a: 2, text: "y", list: [3, 4], nested: null }, ]; -for (const [name, component] of Object.entries({ ...Shapes, ...JoinedText })) { +for (const [name, component] of Object.entries({ ...Shapes, ...JoinedText, ...MethodCalls })) { for (const props of allProps) console.log(name, JSON.stringify(render(component as Component, props))); } diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index ff34a7a9f3e4..7a8dcca73436 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -3973,6 +3973,372 @@ describe("bundler", () => { }); } + // Codegen prints the method of `o.who(...)` inside the call, so that the + // call has its receiver. So the method was read behind every statement that + // stands between its load and the call: the body of an inlined IIFE, a + // dependency of a memo block of an argument, an earlier argument that has a + // name. A statement that mutates the receiver then decided which function + // ran, or whether the read threw. The key of `o[key](...)` was read there + // too. Now such a method is read where the source reads it, and called with + // the receiver given. Each form renders with a new cache, with the same + // cache and equal props, and with the same cache and other props. + for (const [name, options] of [ + ["plain", { reactCompiler: false, target: "browser" }], + ["browser", { reactCompiler: true, target: "browser" }], + ["bun", { reactCompiler: true, target: "bun" }], + ["minify-syntax", { reactCompiler: true, target: "browser", minifySyntax: true }], + ] as const) { + itBundled(`react-compiler/MethodIsReadAheadOfAStatementThatCanChangeIt-${name}`, { + files: { + "/entry.ts": /* ts */ ` + import { render } from "react"; + import * as forms from "./forms"; + const who = function (this: { name: string } | undefined) { + return (this === undefined ? "no this" : this.name) + ":old"; + }; + const lines: string[] = []; + for (const [name, Form] of Object.entries(forms)) { + const results: string[] = []; + for (const props of [{ name: "A", n: 2 }, { name: "A", n: 2 }, { name: "C", n: 3 }]) { + try { + results.push(String(render(Form, { ...props, api: { name: props.name, who } }).props.children)); + } catch (e) { + results.push("threw " + e.message); + } + } + lines.push(name + "=" + results.join(" | ")); + } + console.log(lines.join("\\n")); + `, + "/forms.jsx": /* jsx */ ` + import { useMemo } from "react"; + + // A method says which object it ran on, and which of the two functions it is. + function who(...suffix) { + return (this === undefined ? "no this" : this.name) + ":old" + suffix.filter(s => typeof s === "string").join(""); + } + function other() { + return (this === undefined ? "no this" : this.name) + ":new"; + } + const make = name => ({ name, who, alt: other, inner: { name: name + ".inner", who } }); + const replace = o => { + o.who = other; + return ""; + }; + const replaceAndCount = o => { + o.who = other; + return 1; + }; + const withGetter = o => + Object.defineProperty(o, "who", { + get() { + const name = this.name; + return () => name + ":old"; + }, + }); + const guarded = o => + Object.defineProperty(o, "who", { + get() { + if (!this.ok) throw new Error("the read of the method"); + return () => "called"; + }, + }); + const iterable = name => ({ + name, + who, + *[Symbol.iterator]() { + this.who = other; + yield 1; + }, + }); + const same = o => o; + const pick = log => { + log.push("key"); + return "who"; + }; + const tick = log => { + log.push("argument"); + return 1; + }; + const retarget = selector => { + selector.key = "alt"; + return ""; + }; + // Answers every read, also a read of "call". + const answersEveryRead = new Proxy(who, { get: () => () => "a read of the method" }); + const withItsOwnCall = Object.assign( + function () { + return this.name + ":old"; + }, + { call: () => "its own call" }, + ); + const sortBy = name => [name, "z"]; + const size = list => list.length; + class Box { + constructor(v) { + this.v = v; + } + } + const format = box => "v" + box.v; + + // The body of an inlined IIFE is a statement between the read of the method and the call. + export function StatementReplacesTheMethod(p) { + const o = make(p.name); + return
{o.who((() => { o.who = other; return ""; })())}
; + } + export function StatementReplacesAComputedMethod(p) { + const o = make(p.name); + const key = "who"; + return
{o[key]((() => { o[key] = other; return ""; })())}
; + } + // A manual memo callback is inlined too. + export function MemoCallbackReplacesTheMethod(p) { + const o = make(p.name); + return
{o.who(useMemo(() => { o.who = other; return ""; }, []))}
; + } + // An IIFE with two returns is a labeled block. + export function LabeledBlockReplacesTheMethod(p) { + const o = make(p.name); + return
{o.who((() => { if (p.n > 2) { o.who = other; return "!"; } o.who = other; return ""; })())}
; + } + // The getter reads what the statement changes. + export function StatementChangesWhatAGetterReads(p) { + const o = withGetter({ name: p.name }); + return
{o.who((() => { o.name = "changed"; return ""; })())}
; + } + // The read of the method throws, and the statement would stop that. + export function StatementBehindAReadThatThrows(p) { + const o = guarded({ ok: false, name: p.name }); + return
{o.who((() => { o.ok = true; return ""; })())}
; + } + // The receiver is null, so the statement must not run. + export function StatementBehindANullReceiver(p) { + const box = { inner: null, ran: [p.name] }; + let out; + try { + out = box.inner.who((() => { box.ran.push("statement"); return ""; })()); + } catch (e) { + out = (e instanceof TypeError ? "TypeError" : e) + ", ran: " + box.ran; + } + return
{out}
; + } + // The first argument has a name, because the statement of the second one follows it. + export function EarlierArgumentReplacesTheMethod(p) { + const o = make(p.name); + const local = { n: 0 }; + return
{o.who(replace(o), (() => { local.n = 1; return ""; })())}
; + } + // A postfix update of a member is a statement too. + export function EarlierArgumentAheadOfAMemberUpdate(p) { + const o = make(p.name); + const state = { i: 0 }; + return
{o.who(replace(o), state.i++)}
; + } + // Client mode: the first argument has a name, because the memo block of the second one follows it. + export function ArgumentAheadOfAMemoBlock(p) { + const o = make(p.name); + return
{o.who(replace(o), {p.n})}
; + } + // Client mode: the memo block of the element depends on the sum, so the sum has a name. + export function DependencyOfAMemoBlock(p) { + const o = make(p.name); + return
{o.who({replaceAndCount(o) + p.n})}
; + } + // The call is the test of a ternary, which has no record of what it mutates. + export function DependencyWithACallInATest(p) { + const o = make(p.name); + return
{o.who({replace(o) ? p.n : -p.n})}
; + } + // The spread runs the iterator of the receiver. + export function DependencyThatIteratesTheReceiver(p) { + const o = iterable(p.name); + return
{o.who({[...o].length + p.n})}
; + } + // The receiver is an expression. It runs once. + export function ReceiverIsAPropertyLoad(p) { + const o = make(p.name); + return
{o.inner.who((() => { o.inner.who = other; return ""; })())}
; + } + export function ReceiverIsACall(p) { + const o = make(p.name); + let calls = 0; + const result = (calls++, same(o)).who((() => { o.who = other; return ""; })()); + return
{result + calls}
; + } + // The statement assigns the variable of the receiver, and then replaces the method of the new object. + export function StatementReassignsTheReceiver(p) { + let o = make(p.name); + return
{o.who((() => { o = make("B"); o.who = other; return ""; })())}
; + } + // The call with the receiver given must not read "call" of the method. + export function MethodIsAProxy(p) { + const o = { name: p.name, who: answersEveryRead }; + return
{o.who((() => { o.touched = true; return ""; })())}
; + } + export function MethodHasItsOwnCall(p) { + const o = { name: p.name, who: withItsOwnCall }; + return
{o.who((() => { o.touched = true; return ""; })())}
; + } + // The arguments run before the call throws. + export function MethodIsMissing(p) { + const o = { name: p.name }; + const log = []; + let out; + try { + out = o.missing((() => { o.touched = true; log.push("statement"); return ""; })(), tick(log)); + } catch (e) { + out = (e instanceof TypeError ? "TypeError" : e) + " after " + log; + } + return
{out}
; + } + // The key of a computed method is read where it stands, too. + export function KeyIsACall(p) { + const o = make(p.name); + const log = []; + const result = o[pick(log)]((() => { log.push("statement"); return ""; })()); + return
{result + " " + log}
; + } + export function KeyIsReassigned(p) { + const o = make(p.name); + let key = "who"; + return
{o[key]((() => { key = "alt"; return ""; })())}
; + } + export function KeyIsAMemberThatIsUpdated(p) { + const fns = [() => p.name + ":first", () => p.name + ":second"]; + const state = { i: 0 }; + return
{fns[state.i](state.i++)}
; + } + export function KeyAheadOfADependency(p) { + const o = make(p.name); + const log = []; + const result = o[pick(log)]({tick(log) + p.n}); + return
{result + " " + log}
; + } + export function KeyAheadOfAnEarlierArgument(p) { + const o = make(p.name); + const selector = { key: "who" }; + return
{o[selector.key](retarget(selector), {p.n})}
; + } + // Nothing mutates the receiver here, so the method stays in the call and only the key gets a name. + export function KeyIsAConversionOfAMemberThatIsUpdated(p) { + const fns = { 0: () => p.name + ":first", 1: () => p.name + ":second" }; + const state = { i: 0 }; + return
{fns[String(state.i)](state.i++)}
; + } + // Statements stand between the read of the method and the call, and none of them can mutate the receiver. + export function PushOfAnElementWithComputedProps(p) { + const items = []; + items.push(); + return
{items.length + ":" + items[0].props.title}
; + } + export function PushOfAnElementOverAnotherLocal(p) { + const sorted = sortBy(p.name); + const out = []; + out.push(); + return
{out.length + ":" + out[0].props.title + ":" + sorted}
; + } + export function PushOfAnElementOverANewObject(p) { + const items = []; + items.push(); + return
{items.length + ":" + items[0].props.title}
; + } + export function MethodOfAPropBehindAStatement(p) { + const local = { n: 0 }; + return
{p.api.who((() => { local.n = 1; return ""; })())}
; + } + `, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + "/node_modules/react/index.js": /* js */ ` + const caches = new Map(); + let current; + export const render = (Component, props) => { + current = Component; + return Component(props); + }; + export const memoCache = size => { + let cache = caches.get(current); + if (!cache) caches.set(current, (cache = new Array(size).fill(Symbol.for("react.memo_cache_sentinel")))); + return cache; + }; + export const useMemo = fn => fn(); + `, + "/node_modules/react/compiler-runtime.js": /* js */ ` + import { memoCache } from "./index.js"; + export const c = size => memoCache(size); + `, + "/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 }); + `, + }, + ...options, + backend: "cli", + onAfterBundle(api) { + if (name !== "browser") return; + const functions = Array.from( + api.readFile("/out.js").matchAll(/^function (\w+)\(p\) \{$([\s\S]*?)^\}$/gm), + ([, name, body]) => ({ name, body }), + ); + // A compiled function starts with its memo cache. + expect(functions.filter(({ body }) => !/^\s*let \$ = c\(\d+\);/.test(body)).map(({ name }) => name)).toEqual( + [], + ); + // Where no statement can mutate the receiver, the call is the member call that the source has. + // The names of the temporaries are normalized. + expect( + functions + .filter(({ body }) => !body.includes("Reflect.apply(")) + .map(({ name, body }) => { + const call = body.match(/\b(?:items|out)\.push\(t\d+\)|\bfns\[t\d+\]\(t\d+\)|\bt\d+\.who\(""\)/); + return name + ": " + call?.[0].replace(/\bt\d+\b/g, "t"); + }), + ).toEqual([ + "KeyIsAConversionOfAMemberThatIsUpdated: fns[t](t)", + "PushOfAnElementWithComputedProps: items.push(t)", + "PushOfAnElementOverAnotherLocal: out.push(t)", + "PushOfAnElementOverANewObject: items.push(t)", + 'MethodOfAPropBehindAStatement: t.who("")', + ]); + }, + run: { + stdout: ` + ArgumentAheadOfAMemoBlock=A:old | A:old | C:old + DependencyOfAMemoBlock=A:old | A:old | C:old + DependencyThatIteratesTheReceiver=A:old | A:old | C:old + DependencyWithACallInATest=A:old | A:old | C:old + EarlierArgumentAheadOfAMemberUpdate=A:old | A:old | C:old + EarlierArgumentReplacesTheMethod=A:old | A:old | C:old + KeyAheadOfADependency=A:old key,argument | A:old key,argument | C:old key,argument + KeyAheadOfAnEarlierArgument=A:old | A:old | C:old + KeyIsACall=A:old key,statement | A:old key,statement | C:old key,statement + KeyIsAConversionOfAMemberThatIsUpdated=A:first | A:first | C:first + KeyIsAMemberThatIsUpdated=A:first | A:first | C:first + KeyIsReassigned=A:old | A:old | C:old + LabeledBlockReplacesTheMethod=A:old | A:old | C:old! + MemoCallbackReplacesTheMethod=A:old | A:old | C:old + MethodHasItsOwnCall=A:old | A:old | C:old + MethodIsAProxy=A:old | A:old | C:old + MethodIsMissing=TypeError after statement,argument | TypeError after statement,argument | TypeError after statement,argument + MethodOfAPropBehindAStatement=A:old | A:old | C:old + PushOfAnElementOverANewObject=1:v2 | 1:v2 | 1:v3 + PushOfAnElementOverAnotherLocal=1:2:A,z | 1:2:A,z | 1:2:C,z + PushOfAnElementWithComputedProps=1:2 | 1:2 | 1:3 + ReceiverIsACall=A:old1 | A:old1 | C:old1 + ReceiverIsAPropertyLoad=A.inner:old | A.inner:old | C.inner:old + StatementBehindANullReceiver=TypeError, ran: A | TypeError, ran: A | TypeError, ran: C + StatementBehindAReadThatThrows=threw the read of the method | threw the read of the method | threw the read of the method + StatementChangesWhatAGetterReads=A:old | A:old | C:old + StatementReassignsTheReceiver=A:old | A:old | C:old + StatementReplacesAComputedMethod=A:old | A:old | C:old + StatementReplacesTheMethod=A:old | A:old | C:old + `, + }, + }); + } + itBundled("react-compiler/SourceMapOfCatch", { files: { "/entry.jsx": /* jsx */ ` From b231713c15bc895ef09e0314039a7c5ebc30d468 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:09:29 +0000 Subject: [PATCH 02/13] react-compiler: allocate the set of a late method walk once --- .../reactive_scopes/promote_used_temporaries.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs index ebf110627765..655efcf53702 100644 --- a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs +++ b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs @@ -1149,13 +1149,14 @@ fn promote_late_method( .id }), }, - in_call: crate::hir::visitors::operands(call, &env.functions) - .map(declaration_of) - .collect(), + in_call: IdBitSet::with_ids(env.identifiers.len()), is_mutated: false, load: None, is_receiver_a_local: false, }; + for operand in crate::hir::visitors::operands(call, &env.functions) { + behind.in_call.insert(declaration_of(operand)); + } behind.walk(before); let Behind { is_mutated, From be9b27e78f8b955d97fcf736d6446c398582c22c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:11:49 +0000 Subject: [PATCH 03/13] react-compiler: test a flagged method call beside a Reflect of the file or the function --- .../bundler/transpiler/react-compiler.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 7a8dcca73436..f374c0c4b454 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4339,6 +4339,83 @@ describe("bundler", () => { }); } + // The call with the receiver given is `Reflect.apply(method, receiver, [...])`. + // A function that can see another `Reflect` is left as it is written. + itBundled("react-compiler/MethodReadAheadOfAStatementNeedsTheGlobalReflect", { + files: { + "/entry.jsx": /* jsx */ ` + import { ShadowedInTheFile } from "./shadowed"; + + function who() { + return this.name + ":old"; + } + function other() { + return this.name + ":new"; + } + function ShadowedInTheBody(p) { + const Reflect = { apply: () => "the Reflect of the body" }; + const o = { name: p.name, who }; + return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof Reflect}
; + } + function ShadowedInABlock(p) { + const o = { name: p.name, who }; + let out = ""; + if (p.name) { + const Reflect = { apply: () => "the Reflect of the block" }; + out = o.who((() => { o.who = other; return ""; })()) + " " + typeof Reflect; + } + return
{out}
; + } + function UsesTheGlobal(p) { + const o = { name: p.name, who }; + return
{o.who((() => { o.who = other; return ""; })()) + " " + Reflect.ownKeys(o)}
; + } + for (const Form of [ShadowedInTheFile, ShadowedInTheBody, ShadowedInABlock, UsesTheGlobal]) { + console.log(Form.name + "=" + Form({ name: "A" }).props.children); + } + `, + "/shadowed.jsx": /* jsx */ ` + const Reflect = { apply: () => "the Reflect of the file" }; + function who() { + return this.name + ":old"; + } + function other() { + return this.name + ":new"; + } + export function ShadowedInTheFile(p) { + const o = { name: p.name, who }; + return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof Reflect.apply}
; + } + `, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + "/node_modules/react/index.js": ``, + "/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: "browser", + onAfterBundle(api) { + // A compiled function starts with its memo cache. + const compiled = api.readFile("/out.js").matchAll(/function (\w+)\(p\) \{\s*let \$ = c\(\d+\);/g); + expect(Array.from(compiled, match => match[1])).toEqual(["UsesTheGlobal"]); + }, + run: { + stdout: ` + ShadowedInTheFile=A:old function + ShadowedInTheBody=A:old object + ShadowedInABlock=A:old object + UsesTheGlobal=A:old name,who + `, + }, + }); + itBundled("react-compiler/SourceMapOfCatch", { files: { "/entry.jsx": /* jsx */ ` From 2edbfec9ceb10ed7a7b8b61425ddbe1117da5e20 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:29:24 +0000 Subject: [PATCH 04/13] react-compiler: call a method that was read early with Function.prototype.call.call Reflect.apply makes an array for each call, and JavaScriptCore does not inline it: 267 instructions for each call with the JIT on, against 14 for Function.prototype.call.call and 5 for the member call. Neither reads anything from the method. --- src/react_compiler/codegen.rs | 54 +++++++++---------- .../bundler/transpiler/react-compiler.test.ts | 26 ++++----- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/react_compiler/codegen.rs b/src/react_compiler/codegen.rs index a835717f2873..d3c26e1b1aa5 100644 --- a/src/react_compiler/codegen.rs +++ b/src/react_compiler/codegen.rs @@ -96,7 +96,7 @@ enum WellKnown { Underscore, MemoCacheSentinel, EarlyReturnSentinel, - Reflect, + Function, } impl WellKnown { @@ -2325,8 +2325,9 @@ fn codegen_base_instruction_value( // ============================================================================= /// Not in upstream: the call of a method that PromoteUsedTemporaries read ahead of a statement, as -/// `Reflect.apply(method, receiver, [...args])`. `method.call(receiver, ...args)` reads `call` of the method, -/// which a member call does not: a proxy or a method with a `call` of its own answers that read. +/// `Function.prototype.call.call(method, receiver, ...args)`. `method.call(receiver, ...args)` reads `call` of the +/// method, which a member call does not: a proxy or a method with a `call` of its own answers that read. +/// `Reflect.apply(method, receiver, [...args])` makes an array for each call, and JavaScriptCore does not inline it. fn codegen_explicit_receiver_call( cx: &mut Context, method: Expr, @@ -2334,19 +2335,19 @@ fn codegen_explicit_receiver_call( args: &[PlaceOrSpread], loc: Loc, ) -> Result { - let reflect = cx.cg.well_known_global(WellKnown::Reflect, b"Reflect"); + let function = cx.cg.well_known_global(WellKnown::Function, b"Function"); // `find_symbol` of the host sees what the file and the body of the function declare, not a nested block. let is_global = cx .cg .host .symbols() - .get(reflect.inner_index() as usize) + .get(function.inner_index() as usize) .is_some_and(|symbol| symbol.kind == bun_ast::symbol::Kind::Unbound) && !cx.env.identifiers.iter().any(|identifier| { identifier .name .as_ref() - .is_some_and(|name| name.value() == b"Reflect") + .is_some_and(|name| name.value() == b"Function") }); if !is_global { return Err(method_call_property_err(receiver.loc, method)); @@ -2362,29 +2363,28 @@ fn codegen_explicit_receiver_call( }; cx.cg.host.record_usage(local.ref_); } - let arguments = Expr::init( - E::Array { - items: codegen_arguments(cx, args)?, - ..Default::default() - }, - loc, - ); - let mut apply_args: ExprNodeList = AstAlloc::vec_with_capacity(3); - apply_args.push(method); - apply_args.push(this_value); - apply_args.push(arguments); + let mut call_args: ExprNodeList = AstAlloc::vec_with_capacity(args.len() + 2); + call_args.push(method); + call_args.push(this_value); + for arg in args { + call_args.push(codegen_argument(cx, arg)?); + } + let mut target = Expr::init_identifier(function, loc); + for name in [b"prototype".as_slice(), b"call", b"call"] { + target = Expr::init( + E::Dot { + target, + name: StoreStr::new(name), + name_loc: loc, + ..Default::default() + }, + loc, + ); + } Ok(Expr::init( E::Call { - target: Expr::init( - E::Dot { - target: Expr::init_identifier(reflect, loc), - name: StoreStr::new(b"apply"), - name_loc: loc, - ..Default::default() - }, - loc, - ), - args: apply_args, + target, + args: call_args, ..Default::default() }, loc, diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index f374c0c4b454..ee363fe5fad1 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4290,7 +4290,7 @@ describe("bundler", () => { // The names of the temporaries are normalized. expect( functions - .filter(({ body }) => !body.includes("Reflect.apply(")) + .filter(({ body }) => !body.includes("Function.prototype.call.call(")) .map(({ name, body }) => { const call = body.match(/\b(?:items|out)\.push\(t\d+\)|\bfns\[t\d+\]\(t\d+\)|\bt\d+\.who\(""\)/); return name + ": " + call?.[0].replace(/\bt\d+\b/g, "t"); @@ -4339,9 +4339,9 @@ describe("bundler", () => { }); } - // The call with the receiver given is `Reflect.apply(method, receiver, [...])`. - // A function that can see another `Reflect` is left as it is written. - itBundled("react-compiler/MethodReadAheadOfAStatementNeedsTheGlobalReflect", { + // The call with the receiver given is `Function.prototype.call.call(method, receiver, ...)`. + // A function that can see another `Function` is left as it is written. + itBundled("react-compiler/MethodReadAheadOfAStatementNeedsTheGlobalFunction", { files: { "/entry.jsx": /* jsx */ ` import { ShadowedInTheFile } from "./shadowed"; @@ -4353,29 +4353,29 @@ describe("bundler", () => { return this.name + ":new"; } function ShadowedInTheBody(p) { - const Reflect = { apply: () => "the Reflect of the body" }; + const Function = { prototype: { call: { call: () => "the Function of the body" } } }; const o = { name: p.name, who }; - return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof Reflect}
; + return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof Function}
; } function ShadowedInABlock(p) { const o = { name: p.name, who }; let out = ""; if (p.name) { - const Reflect = { apply: () => "the Reflect of the block" }; - out = o.who((() => { o.who = other; return ""; })()) + " " + typeof Reflect; + const Function = { prototype: { call: { call: () => "the Function of the block" } } }; + out = o.who((() => { o.who = other; return ""; })()) + " " + typeof Function; } return
{out}
; } function UsesTheGlobal(p) { const o = { name: p.name, who }; - return
{o.who((() => { o.who = other; return ""; })()) + " " + Reflect.ownKeys(o)}
; + return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof Function}
; } for (const Form of [ShadowedInTheFile, ShadowedInTheBody, ShadowedInABlock, UsesTheGlobal]) { console.log(Form.name + "=" + Form({ name: "A" }).props.children); } `, "/shadowed.jsx": /* jsx */ ` - const Reflect = { apply: () => "the Reflect of the file" }; + const Function = { prototype: { call: { call: () => "the Function of the file" } } }; function who() { return this.name + ":old"; } @@ -4384,7 +4384,7 @@ describe("bundler", () => { } export function ShadowedInTheFile(p) { const o = { name: p.name, who }; - return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof Reflect.apply}
; + return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof Function}
; } `, "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, @@ -4408,10 +4408,10 @@ describe("bundler", () => { }, run: { stdout: ` - ShadowedInTheFile=A:old function + ShadowedInTheFile=A:old object ShadowedInTheBody=A:old object ShadowedInABlock=A:old object - UsesTheGlobal=A:old name,who + UsesTheGlobal=A:old function `, }, }); From f8545ab6702a93903c09cd839f9fe397aa6de184 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:46:40 +0000 Subject: [PATCH 05/13] react-compiler: decide an early method read by the recorded mutations alone - The pass takes a mutation of a value in the mutable range of the receiver as the reason to read the method early. It no longer compares the end of a range with an instruction id. - A receiver that nothing mutates after it is made keeps its member call. So does a property that has the signature of a method. - The receiver of a call that reads its method early always gets a name. - Codegen asks once for each function whether `Function` is the global, with a lookup that records no use. A function that is refused keeps an import that only its types use out of the output. - The tests build a table of callee shapes by statement producers, and a table of keys. --- src/js_parser/react_compiler_host.rs | 12 + src/react_compiler/codegen.rs | 48 +- src/react_compiler/observer.rs | 3 + src/react_compiler/program.rs | 6 + .../promote_used_temporaries.rs | 167 ++-- ...ment-of-an-argument-replaces-the-method.js | 42 + .../pure-components/MethodCalls.tsx | 13 + .../bundler/transpiler/react-compiler.test.ts | 914 +++++++++++------- test/cli/test/bun-test.test.ts | 44 +- 9 files changed, 763 insertions(+), 486 deletions(-) create mode 100644 test/aot/parallel/a-method-that-the-react-compiler-reads-early-is-called-with-its-receiver/a-statement-of-an-argument-replaces-the-method.js diff --git a/src/js_parser/react_compiler_host.rs b/src/js_parser/react_compiler_host.rs index 425bafcd61c8..1d8c69638a6a 100644 --- a/src/js_parser/react_compiler_host.rs +++ b/src/js_parser/react_compiler_host.rs @@ -177,6 +177,18 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> bun_react_compiler::Host p.find_symbol(bun_ast::Loc::EMPTY, name).expect("oom").r#ref } + fn is_unbound_global(&mut self, name: &'static [u8]) -> bool { + let p = &mut *self.p; + let Ok(found) = p.find_symbol_with_record_usage::(bun_ast::Loc::EMPTY, name) else { + return false; + }; + // No symbol has the name yet, or only the one of a global. + !found.is_inside_with_scope + && (found.r#ref.is_empty() + || p.symbols[found.r#ref.inner_index() as usize].kind + == js_ast::symbol::Kind::Unbound) + } + fn record_usage(&mut self, ref_: js_ast::Ref) { self.p.record_usage(ref_); } diff --git a/src/react_compiler/codegen.rs b/src/react_compiler/codegen.rs index d3c26e1b1aa5..6e1daea38978 100644 --- a/src/react_compiler/codegen.rs +++ b/src/react_compiler/codegen.rs @@ -229,6 +229,21 @@ pub(crate) fn codegen_function( context: &mut ProgramContext, unique_identifiers: HashSet, ) -> Result { + // Not in upstream: `codegen_explicit_receiver_call` prints the global `Function`. The identifiers say what the + // function binds, and the host what is around it. The question comes first, so that a function that stays as + // it is written has no use of that name on record. + if !env.explicit_receiver_calls.is_empty() { + let binds_function = env.identifiers.iter().any(|identifier| { + (identifier.name.as_ref()).is_some_and(|name| name.value() == b"Function") + }); + if binds_function || !cg.host.is_unbound_global(b"Function") { + return Err(crate::diagnostics::cold_todo( + "A method call that reads its method ahead of its arguments needs the global `Function`", + None, + )); + } + } + let mut cx = Context::new(env, cg, unique_identifiers); // Fast Refresh: Bun handles HMR via its own React Refresh transform; the @@ -2327,7 +2342,9 @@ fn codegen_base_instruction_value( /// Not in upstream: the call of a method that PromoteUsedTemporaries read ahead of a statement, as /// `Function.prototype.call.call(method, receiver, ...args)`. `method.call(receiver, ...args)` reads `call` of the /// method, which a member call does not: a proxy or a method with a `call` of its own answers that read. -/// `Reflect.apply(method, receiver, [...args])` makes an array for each call, and JavaScriptCore does not inline it. +/// `Reflect.apply(method, receiver, [...args])` makes an array for each call, and is many times slower under +/// JavaScriptCore. A method that is not callable throws from `Function.prototype.call`, and the message does not +/// name the member. fn codegen_explicit_receiver_call( cx: &mut Context, method: Expr, @@ -2335,37 +2352,18 @@ fn codegen_explicit_receiver_call( args: &[PlaceOrSpread], loc: Loc, ) -> Result { - let function = cx.cg.well_known_global(WellKnown::Function, b"Function"); - // `find_symbol` of the host sees what the file and the body of the function declare, not a nested block. - let is_global = cx - .cg - .host - .symbols() - .get(function.inner_index() as usize) - .is_some_and(|symbol| symbol.kind == bun_ast::symbol::Kind::Unbound) - && !cx.env.identifiers.iter().any(|identifier| { - identifier - .name - .as_ref() - .is_some_and(|name| name.value() == b"Function") - }); - if !is_global { - return Err(method_call_property_err(receiver.loc, method)); - } - let this_value = codegen_place_to_expression(cx, receiver)?; - // The read of the method printed the receiver once. PromoteUsedTemporaries leaves only a local without a name. + // The load of the method printed the receiver once, so only a name can print it again. if cx.env.identifiers[receiver.identifier.0 as usize] .name .is_none() { - let ExprData::EIdentifier(local) = this_value.data else { - return Err(method_call_property_err(receiver.loc, this_value)); - }; - cx.cg.host.record_usage(local.ref_); + return Err(method_call_property_err(receiver.loc, method)); } + // `codegen_function` checked that the name is the global. + let function = cx.cg.well_known_global(WellKnown::Function, b"Function"); let mut call_args: ExprNodeList = AstAlloc::vec_with_capacity(args.len() + 2); call_args.push(method); - call_args.push(this_value); + call_args.push(codegen_place_to_expression(cx, receiver)?); for arg in args { call_args.push(codegen_argument(cx, arg)?); } diff --git a/src/react_compiler/observer.rs b/src/react_compiler/observer.rs index bfda911b43c3..1d11c4671307 100644 --- a/src/react_compiler/observer.rs +++ b/src/react_compiler/observer.rs @@ -300,6 +300,9 @@ impl Host for RecordingHost<'_> { self.referenced.push(reference); reference } + fn is_unbound_global(&mut self, name: &'static [u8]) -> bool { + self.host.is_unbound_global(name) + } fn record_usage(&mut self, ref_: Ref) { self.referenced.push(ref_); self.host.record_usage(ref_); diff --git a/src/react_compiler/program.rs b/src/react_compiler/program.rs index 1c848652085d..90f69a914833 100644 --- a/src/react_compiler/program.rs +++ b/src/react_compiler/program.rs @@ -149,6 +149,12 @@ pub trait Host { self.new_generated(name) } + /// Whether `name` is the global around the function being compiled: no scope from its body outward binds it. + /// The lookup records no use, so a caller can ask ahead of a decision not to print the name. + fn is_unbound_global(&mut self, _name: &'static [u8]) -> bool { + false + } + fn record_usage(&mut self, ref_: Ref); fn add_import_record(&mut self, path: &[u8], kind: ImportKind) -> (u32, Ref); } diff --git a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs index 655efcf53702..c5e16ce94e2b 100644 --- a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs +++ b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs @@ -620,8 +620,6 @@ struct InterState<'a> { /// fbt wants these inside the macro call: they stay inline and their operands get the names. inline_macro_operands: &'a HashSet, operands_of_macro_operands: IdMap, - /// The last operand that kept its place as a const while a statement behind it can mutate an object. - late_const: Option, } impl<'a> InterState<'a> { @@ -633,7 +631,6 @@ impl<'a> InterState<'a> { value_block_statements: None, inline_macro_operands, operands_of_macro_operands: IdMap::new(), - late_const: None, } } @@ -787,15 +784,7 @@ fn promote_interposed_place( let Some(temporary) = inline_temporary(place, state, inter_state, env) else { return Effect::None; }; - let conflicts = inter_state.conflicts(temporary); - if !conflicts && !inter_state.iterates_behind(temporary) { - return temporary.effect; - } - if consts.contains(place.identifier) { - inter_state.late_const = Some(place.identifier); - return temporary.effect; - } - if !conflicts { + if !inter_state.conflicts(temporary) || consts.contains(place.identifier) { return temporary.effect; } if inter_state @@ -999,10 +988,21 @@ fn promote_interposed_instruction( } let effect = promote_interposed_value(&instr.value, state, inter_state, consts, globals, env); + // The operands are decided: an argument that got a name is a statement behind the method now. if let InstructionValue::MethodCall { property, .. } = iv - && inter_state.late_const == Some(property.identifier) + && let Some(temporary) = inline_temporary(property, state, inter_state, env) + && (inter_state.conflicts(temporary) || inter_state.iterates_behind(temporary)) { - promote_late_method(iv, before, state, inter_state, consts, globals, env); + promote_late_method( + iv, + before, + temporary, + state, + inter_state, + consts, + globals, + env, + ); } effect } @@ -1112,11 +1112,14 @@ fn promote_interposed_value( /// Not in upstream. Codegen prints the property of a MethodCall inside the call, so that the call has its /// receiver. So the method is read behind every statement between its load and the call. When such a statement /// can mutate the receiver, the property gets a name here, and codegen calls it with the receiver given. +/// Otherwise the method stays in the call, and a computed key gets a name like any other operand. /// A statement that writes stands there in most member calls. Only the mutation analysis tells the few that can. #[cold] +#[allow(clippy::too_many_arguments)] fn promote_late_method( call: &InstructionValue, before: &[ReactiveStatement], + temporary: Temporary, state: &mut State, inter_state: &mut InterState<'_>, consts: &IdBitSet, @@ -1129,14 +1132,10 @@ fn promote_late_method( else { return; }; - let Some(temporary) = inline_temporary(property, state, inter_state, env) else { - return; - }; let declaration_of = |place: &Place| env.identifiers[place.identifier.0 as usize].declaration_id; let mut behind = Behind { property: declaration_of(property), - receiver: declaration_of(receiver), state, inter_state, mutations: Mutations { @@ -1149,36 +1148,25 @@ fn promote_late_method( .id }), }, - in_call: IdBitSet::with_ids(env.identifiers.len()), + in_call: crate::hir::visitors::operands(call, &env.functions) + .map(declaration_of) + .collect(), is_mutated: false, load: None, - is_receiver_a_local: false, }; - for operand in crate::hir::visitors::operands(call, &env.functions) { - behind.in_call.insert(declaration_of(operand)); - } behind.walk(before); let Behind { - is_mutated, - load, - is_receiver_a_local, - .. + is_mutated, load, .. } = behind; let Some(load) = load else { return; }; - // Without memoization every mutation counts. The receiver still has the range in which something mutates it. - let mutated_until = env.identifiers[receiver.identifier.0 as usize] - .mutable_range - .end; - if is_mutated && mutated_until.0 > load.id.0 + 1 { + if is_mutated && can_method_change(receiver, property, env) { promote_identifier(property.identifier, state, env); inter_state.promoted(temporary); env.explicit_receiver_calls.insert(property.identifier); - // The call prints the receiver again. Only the name of a local can print twice. - if !is_receiver_a_local - && let Some(temporary) = inline_temporary(receiver, state, inter_state, env) - { + // The call prints the receiver again, so the receiver needs a name too. + if let Some(temporary) = inline_temporary(receiver, state, inter_state, env) { promote_identifier(receiver.identifier, state, env); inter_state.promoted(temporary); } @@ -1191,85 +1179,75 @@ fn promote_late_method( } } +/// What the inference says without a look at the statements. A receiver that nothing mutates once it is made keeps +/// its methods. A property that has the signature of a method is taken for that method, whatever a statement does +/// to the receiver. A hook has its signature from its name alone, so it does not count. +fn can_method_change(receiver: &Place, property: &Place, env: &Environment) -> bool { + let range = &env.identifiers[receiver.identifier.0 as usize].mutable_range; + let type_ = &env.types[env.identifiers[property.identifier.0 as usize].type_.0 as usize]; + range.end.0 > range.start.0 + 1 + && !(env.get_function_signature(type_).ok().flatten()) + .is_some_and(|signature| signature.hook_kind.is_none()) +} + /// What prints between the load of a method and its call, read from the call back to the load. struct Behind<'a, 'b> { property: DeclarationId, - receiver: DeclarationId, state: &'b State, inter_state: &'b InterState<'b>, mutations: Mutations<'b>, /// The temporaries that print inside the call. They run behind the read of the method in the source too. - in_call: IdBitSet, + in_call: HashSet, is_mutated: bool, load: Option<&'a ReactiveInstruction>, - is_receiver_a_local: bool, } impl<'a> Behind<'a, '_> { - /// Goes back to the load, and then on to what defines the receiver. Returns true at the end of the search. + /// Returns true at the load of the method. fn walk(&mut self, statements: &'a [ReactiveStatement]) -> bool { - if !self.mutations.env.has_stack() { + let env = self.mutations.env; + if !env.has_stack() { return true; } for statement in statements.iter().rev() { - let is_at_the_end = match statement { - ReactiveStatement::Instruction(instruction) => self.instruction(instruction), + match statement { + ReactiveStatement::Instruction(instruction) => { + let lvalue = instruction.lvalue.as_ref(); + let declaration_id = lvalue + .map(|lvalue| env.identifiers[lvalue.identifier.0 as usize].declaration_id); + if declaration_id == Some(self.property) { + self.load = Some(instruction); + return true; + } + if let Some(lvalue) = lvalue + && declaration_id.is_some_and(|id| self.in_call.contains(&id)) + && inline_temporary(lvalue, self.state, self.inter_state, env).is_some() + { + Operands(env).visit_value( + instruction.id, + &instruction.value, + &mut self.in_call, + ); + } else { + self.mutations + .visit_instruction(instruction, &mut self.is_mutated); + } + } // A pruned scope prints its statements in the block around it. - ReactiveStatement::PrunedScope(scope) => self.walk(&scope.instructions), - ReactiveStatement::Scope(scope) => { - if self.load.is_none() { - self.mutations.visit_scope(scope, &mut self.is_mutated); + ReactiveStatement::PrunedScope(scope) => { + if self.walk(&scope.instructions) { + return true; } - false + } + ReactiveStatement::Scope(scope) => { + self.mutations.visit_scope(scope, &mut self.is_mutated); } ReactiveStatement::Terminal(terminal) => { - if self.load.is_none() { - self.mutations - .visit_terminal(terminal, &mut self.is_mutated); - } - false + self.mutations + .visit_terminal(terminal, &mut self.is_mutated); } - }; - if is_at_the_end { - return true; - } - } - false - } - - fn instruction(&mut self, instruction: &'a ReactiveInstruction) -> bool { - let env = self.mutations.env; - let declaration_id = instruction - .lvalue - .as_ref() - .map(|lvalue| env.identifiers[lvalue.identifier.0 as usize].declaration_id); - if self.load.is_some() { - if declaration_id != Some(self.receiver) { - return false; } - self.is_receiver_a_local = matches!( - &instruction.value, - ReactiveValue::Instruction(InstructionValue::LoadLocal { place, .. }) - if matches!( - env.identifiers[place.identifier.0 as usize].name, - Some(IdentifierName::Named(_)) - ) - ); - return true; - } - if declaration_id == Some(self.property) { - self.load = Some(instruction); - return false; - } - if let Some(lvalue) = &instruction.lvalue - && declaration_id.is_some_and(|id| self.in_call.contains(id)) - && inline_temporary(lvalue, self.state, self.inter_state, env).is_some() - { - Operands(env).visit_value(instruction.id, &instruction.value, &mut self.in_call); - return false; } - self.mutations - .visit_instruction(instruction, &mut self.is_mutated); false } } @@ -1278,7 +1256,7 @@ impl<'a> Behind<'a, '_> { struct Operands<'a>(&'a Environment); impl ReactiveFunctionVisitor for Operands<'_> { - type State = IdBitSet; + type State = HashSet; fn env(&self) -> &Environment { self.0 @@ -1295,7 +1273,8 @@ struct Mutations<'a> { consts: &'a IdBitSet, globals: &'a IdBitSet, /// The values that are mutated together have one mutable range. A memo block mutates only what it declares, - /// so what is in a memo block has another range. `None` without memoization: there no two values share a range. + /// so what is in a memo block has another range. `None` without memoization: no two values share a range + /// there, and every mutation counts. receiver: Option, } diff --git a/test/aot/parallel/a-method-that-the-react-compiler-reads-early-is-called-with-its-receiver/a-statement-of-an-argument-replaces-the-method.js b/test/aot/parallel/a-method-that-the-react-compiler-reads-early-is-called-with-its-receiver/a-statement-of-an-argument-replaces-the-method.js new file mode 100644 index 000000000000..48f99c3e18cb --- /dev/null +++ b/test/aot/parallel/a-method-that-the-react-compiler-reads-early-is-called-with-its-receiver/a-statement-of-an-argument-replaces-the-method.js @@ -0,0 +1,42 @@ +// Flags: --react-compiler +// The React Compiler moves a statement out of an argument of `a.b(..)`, and reads `a.b` ahead of it. The call is then `Function.prototype.call.call(method, receiver, ..)`. +function who(suffix = "") { + return this.name + ":old" + suffix; +} +function other() { + return this.name + ":new"; +} +const make = name => ({ name, who, inner: { name: name + ".inner", who } }); + +function statementReplacesTheMethod(text) { + "use memo"; + const named = make(text); + return named.who((() => { named.who = other; return "!"; })()); +} +function receiverIsAPropertyLoad(text) { + "use memo"; + const outer = make(text); + return outer.inner.who((() => { outer.inner.who = other; return "!"; })()); +} +function statementInASpreadArgument(text) { + "use memo"; + const named = make(text); + return named.who(...(() => { named.who = other; return ["!"]; })()); +} +function keyIsAMemberThatIsUpdated(text) { + "use memo"; + const parts = [n => text + ":first:" + n, n => text + ":second:" + n]; + const state = { i: 0 }; + return parts[state.i](state.i++) + state.i; +} +// The method of an array keeps its member call. +function pushBehindAMemberUpdate(text) { + "use memo"; + const out = []; + const counter = { n: 0 }; + for (const letter of text) out.push(letter + counter.n++); + return out.join(); +} +for (const run of [statementReplacesTheMethod, receiverIsAPropertyLoad, statementInASpreadArgument, keyIsAMemberThatIsUpdated, pushBehindAMemberUpdate]) { + console.log(run.name, run("ab"), run("ab"), run("cd")); +} diff --git a/test/aot/react-compiler/same-as-bundle/pure-components/MethodCalls.tsx b/test/aot/react-compiler/same-as-bundle/pure-components/MethodCalls.tsx index 7d9ddab41f3c..d884caa25931 100644 --- a/test/aot/react-compiler/same-as-bundle/pure-components/MethodCalls.tsx +++ b/test/aot/react-compiler/same-as-bundle/pure-components/MethodCalls.tsx @@ -23,6 +23,19 @@ export function StatementReplacesTheMethod({ a, text }: Props) { ]; } +export function StatementInASpreadArgument({ a, text }: Props) { + "use memo"; + const named = make(text + a); + return [ + named.who( + ...((): [string] => { + named.who = other; + return ["!"]; + })(), + ), + ]; +} + export function ReceiverIsAPropertyLoad({ a, text }: Props) { "use memo"; const outer = { inner: make(text + a) }; diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index ee363fe5fad1..b6bc68b30038 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -3980,388 +3980,521 @@ describe("bundler", () => { // name. A statement that mutates the receiver then decided which function // ran, or whether the read threw. The key of `o[key](...)` was read there // too. Now such a method is read where the source reads it, and called with - // the receiver given. Each form renders with a new cache, with the same + // the receiver given. Each function renders with a new cache, with the same // cache and equal props, and with the same cache and other props. - for (const [name, options] of [ - ["plain", { reactCompiler: false, target: "browser" }], - ["browser", { reactCompiler: true, target: "browser" }], - ["bun", { reactCompiler: true, target: "bun" }], - ["minify-syntax", { reactCompiler: true, target: "browser", minifySyntax: true }], - ] as const) { - itBundled(`react-compiler/MethodIsReadAheadOfAStatementThatCanChangeIt-${name}`, { - files: { - "/entry.ts": /* ts */ ` - import { render } from "react"; - import * as forms from "./forms"; - const who = function (this: { name: string } | undefined) { - return (this === undefined ? "no this" : this.name) + ":old"; - }; - const lines: string[] = []; - for (const [name, Form] of Object.entries(forms)) { - const results: string[] = []; - for (const props of [{ name: "A", n: 2 }, { name: "A", n: 2 }, { name: "C", n: 3 }]) { - try { - results.push(String(render(Form, { ...props, api: { name: props.name, who } }).props.children)); - } catch (e) { - results.push("threw " + e.message); + { + type Cell = { name: string; source: string; expected: (name: string) => string }; + const cells: Cell[] = []; + + // Each callee reads a method of `target`. Each producer puts a statement + // that replaces that method between the read and the call. + const callees = { + Member: { setup: `const o = make(p.name);`, target: `o`, callee: `o.who` }, + ComputedMember: { setup: `const o = make(p.name); const key = "who";`, target: `o`, callee: `o[key]` }, + MemberOfAMember: { setup: `const o = make(p.name);`, target: `o.inner`, callee: `o.inner.who` }, + MemberOfACall: { setup: `const o = make(p.name);`, target: `o`, callee: `same(o).who` }, + MemberOfALogical: { setup: `const o = make(p.name); const b = make("B");`, target: `o`, callee: `(o ?? b).who` }, + }; + const producers = { + BehindAnInlinedFunction: (t: string) => `(() => { ${t}.who = other; return ""; })()`, + BehindAManualMemo: (t: string) => `useMemo(() => { ${t}.who = other; return ""; }, [])`, + // An inlined function with two returns is a labeled block. + BehindALabeledBlock: (t: string) => + `(() => { if (p.n > 2) { ${t}.who = other; return ""; } ${t}.who = other; return ""; })()`, + // The first argument has a name, because a statement or a memo block follows it. + BehindAnArgumentAndAStatement: (t: string) => `replace(${t}), (() => { local.n = 1; return ""; })()`, + BehindAnArgumentAndAMemberUpdate: (t: string) => `replace(${t}), local.n++`, + BehindAnArgumentAndAMemoBlock: (t: string) => `replace(${t}), {p.n}`, + // The memo block of the element depends on the sum, so the sum has a name. + BehindADependencyOfAMemoBlock: (t: string) => `{replaceAndCount(${t}) + p.n}`, + BehindASpreadAndAMemoBlock: (t: string) => `...replaceAndList(${t}), {p.n}`, + }; + for (const [calleeName, { setup, target, callee }] of Object.entries(callees)) { + for (const [producerName, producer] of Object.entries(producers)) { + cells.push({ + name: calleeName + producerName, + source: ` + export function ${calleeName + producerName}(p) { + ${setup} + const local = { n: 0 }; + return
{${callee}(${producer(target)})}
; + }`, + expected: name => name + ":old", + }); + } + } + + // Each key is read ahead of a statement that changes what it evaluates + // to, or that shows when it ran. Nothing mutates a prop, so the method of + // a prop stays in the call and only its key gets a name. + const keys = { + KeyIsACall: { + setup: `const log = [];`, + key: `pick(log)`, + statement: `log.push("statement")`, + argument: `note(log)`, + count: `noteAndCount(log)`, + result: `result + " " + log`, + expected: (name: string) => name + ":old key,statement", + }, + KeyIsAMember: { + setup: `const selector = { key: "who" };`, + key: `selector.key`, + statement: `selector.key = "alt"`, + argument: `retarget(selector)`, + count: `retargetAndCount(selector)`, + result: `result`, + expected: (name: string) => name + ":old", + }, + }; + const receivers = { + OfALocal: { setup: `const o = make(p.name);`, receiver: `o` }, + OfAProp: { setup: ``, receiver: `p.api` }, + }; + const keyProducers = { + BehindAnInlinedFunction: (key: (typeof keys)["KeyIsACall"]) => `(() => { ${key.statement}; return ""; })()`, + BehindAnArgumentAndAMemoBlock: (key: (typeof keys)["KeyIsACall"]) => `${key.argument}, {p.n}`, + BehindADependencyOfAMemoBlock: (key: (typeof keys)["KeyIsACall"]) => `{${key.count} + p.n}`, + }; + for (const [keyName, key] of Object.entries(keys)) { + for (const [receiverName, { setup, receiver }] of Object.entries(receivers)) { + for (const [producerName, producer] of Object.entries(keyProducers)) { + cells.push({ + name: keyName + receiverName + producerName, + source: ` + export function ${keyName + receiverName + producerName}(p) { + ${setup} + ${key.setup} + const result = ${receiver}[${key.key}](${producer(key)}); + return
{${key.result}}
; + }`, + expected: key.expected, + }); + } + } + } + // A namespace lists its exports by name. + const expectedOfCells = cells + .map(({ name, expected }) => `${name}=${expected("A")} | ${expected("A")} | ${expected("C")}`) + .sort(); + + // The forms that are not a cell of the tables, with what each prints. + const expectedOfForms = [ + "DependencyThatIteratesTheReceiver=A:old | A:old | C:old", + "DependencyWithACallInATest=A:old | A:old | C:old", + "EarlierArgumentIsAChainOfMethods=A:olda | A:olda | C:oldc", + "EarlierArgumentIsAMethodOfAProp=A:oldA:old | A:oldA:old | C:oldC:old", + "EarlierArgumentOverAnotherLocal=A:old1 | A:old1 | C:old1", + "KeyInAScopeThatAHookPrunes=A:old 0,key,statement | A:old 0,key,statement | C:old 0,key,statement", + "KeyIsAConversionOfAMemberThatIsUpdated=A:first | A:first | C:first", + "KeyIsAMemberThatIsUpdated=A:first | A:first | C:first", + "KeyIsReassigned=A:old | A:old | C:old", + "KeyOfAMethodOfAProp=A:old | A:old | C:old", + "LaterArgumentBehindADependency=A:old | A:old | C:old", + "LaterArgumentBehindAStatement=A:old | A:old | C:old", + "MemoBlocksAheadOfTheReceiver=A:old16 | A:old16 | C:old16", + "MethodHasItsOwnCall=A:old | A:old | C:old", + "MethodIsAProxy=A:old | A:old | C:old", + "MethodIsMissing=TypeError after statement,argument | TypeError after statement,argument | TypeError after statement,argument", + "MethodOfAPropBehindAStatement=A:old | A:old | C:old", + "PushBehindAMemberUpdate=A0,x1 | A0,x1 | C0,x1", + "PushOfAnElementWithComputedProps=1:2 | 1:2 | 1:3", + "PushOfAnObjectWithACallInALogical=2:true | 2:true | 2:false", + "SpreadOfAStatementThatReplacesTheMethod=A:oldxy | A:oldxy | C:oldxy", + "StatementBehindANullReceiver=TypeError, ran: A | TypeError, ran: A | TypeError, ran: C", + "StatementBehindAReadThatThrows=threw the read of the method | threw the read of the method | threw the read of the method", + "StatementChangesWhatAGetterReads=A:old | A:old | C:old", + "StatementReassignsTheReceiver=A:old | A:old | C:old", + ]; + + // Where no statement can mutate the receiver, the call is the member call + // that the source has. The names of the temporaries are normalized. + const memberCalls = { + browser: { + // The memo block of the receiver holds nothing that the statement mutates. + EarlierArgumentIsAMethodOfAProp: "o.who(t, t)", + EarlierArgumentOverAnotherLocal: "o.who(t, t)", + KeyIsAConversionOfAMemberThatIsUpdated: "fns[t](t)", + // The argument that replaces the method prints inside the call, behind the read of the method. + LaterArgumentBehindADependency: "o.who(t, replace(o))", + LaterArgumentBehindAStatement: 'o.who("", replace(o))', + // Nothing mutates a prop. + KeyInAScopeThatAHookPrunes: 't[t]("")', + KeyOfAMethodOfAProp: "t[t](t)", + MethodOfAPropBehindAStatement: 't.who("")', + // The inference takes `push` of an array for that method. + PushBehindAMemberUpdate: "items.push(row + t)", + PushOfAnElementWithComputedProps: "items.push(t)", + PushOfAnObjectWithACallInALogical: "items.push(t)", + }, + // Without memo blocks no two values share a mutable range, and every mutation between the read and the call counts. + bun: { + KeyInAScopeThatAHookPrunes: 't[t]("")', + KeyOfAMethodOfAProp: "t[t](t)", + MethodOfAPropBehindAStatement: 't.who("")', + PushBehindAMemberUpdate: "items.push(row + t)", + }, + }; + const memberCall = + /\bitems\.push\((?:row \+ )?t\d+\)|\bfns\[t\d+\]\(t\d+\)|\bt\d+\[t\d+\]\((?:t\d+|"")\)|\b(?:t\d+|o)\.who\((?:t\d+|""|replace\(o\)|, )*\)/; + + for (const [name, options] of [ + ["plain", { reactCompiler: false, target: "browser" }], + ["browser", { reactCompiler: true, target: "browser" }], + ["bun", { reactCompiler: true, target: "bun" }], + ["minify-syntax", { reactCompiler: true, target: "browser", minifySyntax: true }], + ["bun-minify-syntax", { reactCompiler: true, target: "bun", minifySyntax: true }], + ] as const) { + itBundled(`react-compiler/MethodIsReadAheadOfAStatementThatCanChangeIt-${name}`, { + files: { + "/entry.ts": /* ts */ ` + import { render } from "react"; + import * as cells from "./cells"; + import * as forms from "./forms"; + const who = function (this: { name: string } | undefined) { + return (this === undefined ? "no this" : this.name) + ":old"; + }; + const lines: string[] = []; + for (const [name, Form] of [...Object.entries(cells), ...Object.entries(forms)]) { + const results: string[] = []; + for (const props of [{ name: "A", n: 2 }, { name: "A", n: 2 }, { name: "C", n: 3 }]) { + try { + results.push(String(render(Form, { ...props, api: { name: props.name, who } }).props.children)); + } catch (e) { + results.push("threw " + e.message); + } } + lines.push(name + "=" + results.join(" | ")); } - lines.push(name + "=" + results.join(" | ")); - } - console.log(lines.join("\\n")); - `, - "/forms.jsx": /* jsx */ ` - import { useMemo } from "react"; - - // A method says which object it ran on, and which of the two functions it is. - function who(...suffix) { - return (this === undefined ? "no this" : this.name) + ":old" + suffix.filter(s => typeof s === "string").join(""); - } - function other() { - return (this === undefined ? "no this" : this.name) + ":new"; - } - const make = name => ({ name, who, alt: other, inner: { name: name + ".inner", who } }); - const replace = o => { - o.who = other; - return ""; - }; - const replaceAndCount = o => { - o.who = other; - return 1; - }; - const withGetter = o => - Object.defineProperty(o, "who", { - get() { - const name = this.name; - return () => name + ":old"; + console.log(lines.join("\\n")); + `, + "/helpers.js": /* js */ ` + // A method says which object it ran on, and which of the two functions it is. + export function who(...suffix) { + return (this === undefined ? "no this" : this.name) + ":old" + suffix.filter(s => typeof s === "string").join(""); + } + export function other() { + return (this === undefined ? "no this" : this.name) + ":new"; + } + export const make = name => ({ name, who, alt: other, inner: { name, who } }); + export const same = o => o; + export const replace = o => { + o.who = other; + return ""; + }; + export const replaceAndCount = o => { + o.who = other; + return 1; + }; + export const replaceAndList = o => { + o.who = other; + return ["", ""]; + }; + export const pick = log => { + log.push("key"); + return "who"; + }; + export const note = log => { + log.push("statement"); + return ""; + }; + export const noteAndCount = log => { + log.push("statement"); + return 1; + }; + export const retarget = selector => { + selector.key = "alt"; + return ""; + }; + export const retargetAndCount = selector => { + selector.key = "alt"; + return 1; + }; + export const tick = log => { + log.push("argument"); + return 1; + }; + export const size = list => list.length; + `, + "/cells.jsx": + ` + import { useMemo } from "react"; + import { make, note, noteAndCount, other, pick, replace, replaceAndCount, replaceAndList, retarget, retargetAndCount, same } from "./helpers"; + ` + cells.map(cell => cell.source).join(""), + "/forms.jsx": /* jsx */ ` + import { useState } from "react"; + import { make, other, pick, replace, size, tick, who } from "./helpers"; + + const withGetter = o => + Object.defineProperty(o, "who", { + get() { + const name = this.name; + return () => name + ":old"; + }, + }); + const guarded = o => + Object.defineProperty(o, "who", { + get() { + if (!this.ok) throw new Error("the read of the method"); + return () => "called"; + }, + }); + const iterable = name => ({ + name, + who, + *[Symbol.iterator]() { + this.who = other; + yield 1; }, }); - const guarded = o => - Object.defineProperty(o, "who", { - get() { - if (!this.ok) throw new Error("the read of the method"); - return () => "called"; + // Answers every read, also a read of "call". + const answersEveryRead = new Proxy(who, { get: () => () => "a read of the method" }); + const withItsOwnCall = Object.assign( + function () { + return this.name + ":old"; }, - }); - const iterable = name => ({ - name, - who, - *[Symbol.iterator]() { - this.who = other; - yield 1; - }, - }); - const same = o => o; - const pick = log => { - log.push("key"); - return "who"; - }; - const tick = log => { - log.push("argument"); - return 1; - }; - const retarget = selector => { - selector.key = "alt"; - return ""; - }; - // Answers every read, also a read of "call". - const answersEveryRead = new Proxy(who, { get: () => () => "a read of the method" }); - const withItsOwnCall = Object.assign( - function () { - return this.name + ":old"; - }, - { call: () => "its own call" }, - ); - const sortBy = name => [name, "z"]; - const size = list => list.length; - class Box { - constructor(v) { - this.v = v; - } - } - const format = box => "v" + box.v; + { call: () => "its own call" }, + ); - // The body of an inlined IIFE is a statement between the read of the method and the call. - export function StatementReplacesTheMethod(p) { - const o = make(p.name); - return
{o.who((() => { o.who = other; return ""; })())}
; - } - export function StatementReplacesAComputedMethod(p) { - const o = make(p.name); - const key = "who"; - return
{o[key]((() => { o[key] = other; return ""; })())}
; - } - // A manual memo callback is inlined too. - export function MemoCallbackReplacesTheMethod(p) { - const o = make(p.name); - return
{o.who(useMemo(() => { o.who = other; return ""; }, []))}
; - } - // An IIFE with two returns is a labeled block. - export function LabeledBlockReplacesTheMethod(p) { - const o = make(p.name); - return
{o.who((() => { if (p.n > 2) { o.who = other; return "!"; } o.who = other; return ""; })())}
; - } - // The getter reads what the statement changes. - export function StatementChangesWhatAGetterReads(p) { - const o = withGetter({ name: p.name }); - return
{o.who((() => { o.name = "changed"; return ""; })())}
; - } - // The read of the method throws, and the statement would stop that. - export function StatementBehindAReadThatThrows(p) { - const o = guarded({ ok: false, name: p.name }); - return
{o.who((() => { o.ok = true; return ""; })())}
; - } - // The receiver is null, so the statement must not run. - export function StatementBehindANullReceiver(p) { - const box = { inner: null, ran: [p.name] }; - let out; - try { - out = box.inner.who((() => { box.ran.push("statement"); return ""; })()); - } catch (e) { - out = (e instanceof TypeError ? "TypeError" : e) + ", ran: " + box.ran; + // The getter reads what the statement changes. + export function StatementChangesWhatAGetterReads(p) { + const o = withGetter({ name: p.name }); + return
{o.who((() => { o.name = "changed"; return ""; })())}
; } - return
{out}
; - } - // The first argument has a name, because the statement of the second one follows it. - export function EarlierArgumentReplacesTheMethod(p) { - const o = make(p.name); - const local = { n: 0 }; - return
{o.who(replace(o), (() => { local.n = 1; return ""; })())}
; - } - // A postfix update of a member is a statement too. - export function EarlierArgumentAheadOfAMemberUpdate(p) { - const o = make(p.name); - const state = { i: 0 }; - return
{o.who(replace(o), state.i++)}
; - } - // Client mode: the first argument has a name, because the memo block of the second one follows it. - export function ArgumentAheadOfAMemoBlock(p) { - const o = make(p.name); - return
{o.who(replace(o), {p.n})}
; - } - // Client mode: the memo block of the element depends on the sum, so the sum has a name. - export function DependencyOfAMemoBlock(p) { - const o = make(p.name); - return
{o.who({replaceAndCount(o) + p.n})}
; - } - // The call is the test of a ternary, which has no record of what it mutates. - export function DependencyWithACallInATest(p) { - const o = make(p.name); - return
{o.who({replace(o) ? p.n : -p.n})}
; - } - // The spread runs the iterator of the receiver. - export function DependencyThatIteratesTheReceiver(p) { - const o = iterable(p.name); - return
{o.who({[...o].length + p.n})}
; - } - // The receiver is an expression. It runs once. - export function ReceiverIsAPropertyLoad(p) { - const o = make(p.name); - return
{o.inner.who((() => { o.inner.who = other; return ""; })())}
; - } - export function ReceiverIsACall(p) { - const o = make(p.name); - let calls = 0; - const result = (calls++, same(o)).who((() => { o.who = other; return ""; })()); - return
{result + calls}
; - } - // The statement assigns the variable of the receiver, and then replaces the method of the new object. - export function StatementReassignsTheReceiver(p) { - let o = make(p.name); - return
{o.who((() => { o = make("B"); o.who = other; return ""; })())}
; - } - // The call with the receiver given must not read "call" of the method. - export function MethodIsAProxy(p) { - const o = { name: p.name, who: answersEveryRead }; - return
{o.who((() => { o.touched = true; return ""; })())}
; - } - export function MethodHasItsOwnCall(p) { - const o = { name: p.name, who: withItsOwnCall }; - return
{o.who((() => { o.touched = true; return ""; })())}
; - } - // The arguments run before the call throws. - export function MethodIsMissing(p) { - const o = { name: p.name }; - const log = []; - let out; - try { - out = o.missing((() => { o.touched = true; log.push("statement"); return ""; })(), tick(log)); - } catch (e) { - out = (e instanceof TypeError ? "TypeError" : e) + " after " + log; + // The read of the method throws, and the statement would stop that. + export function StatementBehindAReadThatThrows(p) { + const o = guarded({ ok: false, name: p.name }); + return
{o.who((() => { o.ok = true; return ""; })())}
; } - return
{out}
; - } - // The key of a computed method is read where it stands, too. - export function KeyIsACall(p) { - const o = make(p.name); - const log = []; - const result = o[pick(log)]((() => { log.push("statement"); return ""; })()); - return
{result + " " + log}
; - } - export function KeyIsReassigned(p) { - const o = make(p.name); - let key = "who"; - return
{o[key]((() => { key = "alt"; return ""; })())}
; - } - export function KeyIsAMemberThatIsUpdated(p) { - const fns = [() => p.name + ":first", () => p.name + ":second"]; - const state = { i: 0 }; - return
{fns[state.i](state.i++)}
; - } - export function KeyAheadOfADependency(p) { - const o = make(p.name); - const log = []; - const result = o[pick(log)]({tick(log) + p.n}); - return
{result + " " + log}
; - } - export function KeyAheadOfAnEarlierArgument(p) { - const o = make(p.name); - const selector = { key: "who" }; - return
{o[selector.key](retarget(selector), {p.n})}
; - } - // Nothing mutates the receiver here, so the method stays in the call and only the key gets a name. - export function KeyIsAConversionOfAMemberThatIsUpdated(p) { - const fns = { 0: () => p.name + ":first", 1: () => p.name + ":second" }; - const state = { i: 0 }; - return
{fns[String(state.i)](state.i++)}
; - } - // Statements stand between the read of the method and the call, and none of them can mutate the receiver. - export function PushOfAnElementWithComputedProps(p) { - const items = []; - items.push(); - return
{items.length + ":" + items[0].props.title}
; - } - export function PushOfAnElementOverAnotherLocal(p) { - const sorted = sortBy(p.name); - const out = []; - out.push(); - return
{out.length + ":" + out[0].props.title + ":" + sorted}
; - } - export function PushOfAnElementOverANewObject(p) { - const items = []; - items.push(); - return
{items.length + ":" + items[0].props.title}
; - } - export function MethodOfAPropBehindAStatement(p) { - const local = { n: 0 }; - return
{p.api.who((() => { local.n = 1; return ""; })())}
; - } - `, - "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, - "/node_modules/react/index.js": /* js */ ` - const caches = new Map(); - let current; - export const render = (Component, props) => { - current = Component; - return Component(props); - }; - export const memoCache = size => { - let cache = caches.get(current); - if (!cache) caches.set(current, (cache = new Array(size).fill(Symbol.for("react.memo_cache_sentinel")))); - return cache; - }; - export const useMemo = fn => fn(); - `, - "/node_modules/react/compiler-runtime.js": /* js */ ` - import { memoCache } from "./index.js"; - export const c = size => memoCache(size); - `, - "/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 }); - `, - }, - ...options, - backend: "cli", - onAfterBundle(api) { - if (name !== "browser") return; - const functions = Array.from( - api.readFile("/out.js").matchAll(/^function (\w+)\(p\) \{$([\s\S]*?)^\}$/gm), - ([, name, body]) => ({ name, body }), - ); - // A compiled function starts with its memo cache. - expect(functions.filter(({ body }) => !/^\s*let \$ = c\(\d+\);/.test(body)).map(({ name }) => name)).toEqual( - [], - ); - // Where no statement can mutate the receiver, the call is the member call that the source has. - // The names of the temporaries are normalized. - expect( - functions - .filter(({ body }) => !body.includes("Function.prototype.call.call(")) - .map(({ name, body }) => { - const call = body.match(/\b(?:items|out)\.push\(t\d+\)|\bfns\[t\d+\]\(t\d+\)|\bt\d+\.who\(""\)/); - return name + ": " + call?.[0].replace(/\bt\d+\b/g, "t"); - }), - ).toEqual([ - "KeyIsAConversionOfAMemberThatIsUpdated: fns[t](t)", - "PushOfAnElementWithComputedProps: items.push(t)", - "PushOfAnElementOverAnotherLocal: out.push(t)", - "PushOfAnElementOverANewObject: items.push(t)", - 'MethodOfAPropBehindAStatement: t.who("")', - ]); - }, - run: { - stdout: ` - ArgumentAheadOfAMemoBlock=A:old | A:old | C:old - DependencyOfAMemoBlock=A:old | A:old | C:old - DependencyThatIteratesTheReceiver=A:old | A:old | C:old - DependencyWithACallInATest=A:old | A:old | C:old - EarlierArgumentAheadOfAMemberUpdate=A:old | A:old | C:old - EarlierArgumentReplacesTheMethod=A:old | A:old | C:old - KeyAheadOfADependency=A:old key,argument | A:old key,argument | C:old key,argument - KeyAheadOfAnEarlierArgument=A:old | A:old | C:old - KeyIsACall=A:old key,statement | A:old key,statement | C:old key,statement - KeyIsAConversionOfAMemberThatIsUpdated=A:first | A:first | C:first - KeyIsAMemberThatIsUpdated=A:first | A:first | C:first - KeyIsReassigned=A:old | A:old | C:old - LabeledBlockReplacesTheMethod=A:old | A:old | C:old! - MemoCallbackReplacesTheMethod=A:old | A:old | C:old - MethodHasItsOwnCall=A:old | A:old | C:old - MethodIsAProxy=A:old | A:old | C:old - MethodIsMissing=TypeError after statement,argument | TypeError after statement,argument | TypeError after statement,argument - MethodOfAPropBehindAStatement=A:old | A:old | C:old - PushOfAnElementOverANewObject=1:v2 | 1:v2 | 1:v3 - PushOfAnElementOverAnotherLocal=1:2:A,z | 1:2:A,z | 1:2:C,z - PushOfAnElementWithComputedProps=1:2 | 1:2 | 1:3 - ReceiverIsACall=A:old1 | A:old1 | C:old1 - ReceiverIsAPropertyLoad=A.inner:old | A.inner:old | C.inner:old - StatementBehindANullReceiver=TypeError, ran: A | TypeError, ran: A | TypeError, ran: C - StatementBehindAReadThatThrows=threw the read of the method | threw the read of the method | threw the read of the method - StatementChangesWhatAGetterReads=A:old | A:old | C:old - StatementReassignsTheReceiver=A:old | A:old | C:old - StatementReplacesAComputedMethod=A:old | A:old | C:old - StatementReplacesTheMethod=A:old | A:old | C:old - `, - }, - }); + // The receiver is null, so the statement must not run. + export function StatementBehindANullReceiver(p) { + const box = { inner: null, ran: [p.name] }; + let out; + try { + out = box.inner.who((() => { box.ran.push("statement"); return ""; })()); + } catch (e) { + out = (e instanceof TypeError ? "TypeError" : e) + ", ran: " + box.ran; + } + return
{out}
; + } + // The statement assigns the variable of the receiver, and then replaces the method of the new object. + export function StatementReassignsTheReceiver(p) { + let o = make(p.name); + return
{o.who((() => { o = make("B"); o.who = other; return ""; })())}
; + } + // The call is the test of a ternary, which has no record of what it mutates. + export function DependencyWithACallInATest(p) { + const o = make(p.name); + return
{o.who({replace(o) ? p.n : -p.n})}
; + } + // The spread runs the iterator of the receiver. + export function DependencyThatIteratesTheReceiver(p) { + const o = iterable(p.name); + return
{o.who({[...o].length + p.n})}
; + } + // Memo blocks stand ahead of the receiver, and another value shares its memo block. + export function MemoBlocksAheadOfTheReceiver(p) { + const a1 = {p.n + 1}; + const a2 = {p.n + 2}; + const a3 = {p.n + 3}; + const a4 = {p.n + 4}; + const a5 = {p.n + 5}; + const a6 = {p.n + 6}; + const list = []; + const o = make(p.name); + list.push(p.n); + const result = o.who((() => { o.who = other; return ""; })()); + return
{result + list.length + [a1, a2, a3, a4, a5, a6].length}
; + } + // The call with the receiver given must not read "call" of the method. + export function MethodIsAProxy(p) { + const o = { name: p.name, who: answersEveryRead }; + return
{o.who((() => { o.touched = true; return ""; })())}
; + } + export function MethodHasItsOwnCall(p) { + const o = { name: p.name, who: withItsOwnCall }; + return
{o.who((() => { o.touched = true; return ""; })())}
; + } + // The arguments run before the call throws. + export function MethodIsMissing(p) { + const o = { name: p.name }; + const log = []; + let out; + try { + out = o.missing((() => { o.touched = true; log.push("statement"); return ""; })(), tick(log)); + } catch (e) { + out = (e instanceof TypeError ? "TypeError" : e) + " after " + log; + } + return
{out}
; + } + // A spread argument prints in the call with the receiver given. + export function SpreadOfAStatementThatReplacesTheMethod(p) { + const o = make(p.name); + return
{o.who(...(() => { o.who = other; return ["x", "y"]; })())}
; + } + export function KeyIsReassigned(p) { + const o = make(p.name); + let key = "who"; + return
{o[key]((() => { key = "alt"; return ""; })())}
; + } + // A postfix update of a member is a statement too. + export function KeyIsAMemberThatIsUpdated(p) { + const fns = [() => p.name + ":first", () => p.name + ":second"]; + const state = { i: 0 }; + return
{fns[state.i](state.i++)}
; + } + export function KeyIsAConversionOfAMemberThatIsUpdated(p) { + const fns = { 0: () => p.name + ":first", 1: () => p.name + ":second" }; + const state = { i: 0 }; + return
{fns[String(state.i)](state.i++)}
; + } + export function KeyOfAMethodOfAProp(p) { + const keys = ["who", "alt"]; + const state = { i: 0 }; + return
{p.api[keys[state.i]](state.i++)}
; + } + // A hook stands in the scope of \`log\`, so that scope is pruned. The call does not mutate \`log\`: it stands + // behind the scope, and the load of its method in it. + export function KeyInAScopeThatAHookPrunes(p) { + const log = []; + const [n] = useState(0); + log.push(n); + const result = p.api[pick(log)]((() => { log.push("statement"); return ""; })()); + return
{result + " " + log}
; + } + export function MethodOfAPropBehindAStatement(p) { + const local = { n: 0 }; + return
{p.api.who((() => { local.n = 1; return ""; })())}
; + } + // The earlier argument gets a name ahead of the memo block, and it cannot mutate the receiver. + export function EarlierArgumentOverAnotherLocal(p) { + const o = make(p.name); + const log = []; + return
{o.who(String(tick(log)), {p.n})}
; + } + export function EarlierArgumentIsAMethodOfAProp(p) { + const o = make(p.name); + return
{o.who(p.api.who(), {p.n})}
; + } + // The call mutates its arguments together with the receiver, so the mutation of one of them counts. + export function EarlierArgumentIsAChainOfMethods(p) { + const o = make(p.name); + return
{o.who(p.name.trim().toLowerCase(), {p.n})}
; + } + // The statement mutates another object, and the argument that replaces the method prints inside the call. + export function LaterArgumentBehindAStatement(p) { + const o = make(p.name); + const local = { n: 0 }; + return
{o.who((() => { local.n = 1; return ""; })(), replace(o))}
; + } + export function LaterArgumentBehindADependency(p) { + const o = make(p.name); + return
{o.who({size(Object.keys(p)) + p.n}, replace(o))}
; + } + // The commonest member call behind a statement. + export function PushOfAnElementWithComputedProps(p) { + const items = []; + items.push(); + return
{items.length + ":" + items[0].props.title}
; + } + export function PushBehindAMemberUpdate(p) { + const items = []; + const state = { i: 0 }; + for (const row of [p.name, "x"]) { + items.push(row + state.i++); + } + return
{items.join()}
; + } + // The test of the logical expression is a call on a value that shares the memo block of the receiver. + export function PushOfAnObjectWithACallInALogical(p) { + const others = p.name.split("").map(letter => letter + "!"); + const items = [{ value: "a", disabled: others.includes("a!") }]; + items.push({ value: "b", disabled: others.includes("A!") || p.n > 5 }); + return
{items.length + ":" + items[1].disabled}
; + } + `, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + "/node_modules/react/index.js": /* js */ ` + const caches = new Map(); + let current; + export const render = (Component, props) => { + current = Component; + return Component(props); + }; + export const memoCache = size => { + let cache = caches.get(current); + if (!cache) caches.set(current, (cache = new Array(size).fill(Symbol.for("react.memo_cache_sentinel")))); + return cache; + }; + export const useMemo = fn => fn(); + export const useState = value => [value, () => {}]; + `, + "/node_modules/react/compiler-runtime.js": /* js */ ` + import { memoCache } from "./index.js"; + export const c = size => memoCache(size); + `, + "/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 }); + `, + }, + ...options, + backend: "cli", + onAfterBundle(api) { + if (name !== "browser" && name !== "bun") return; + const bodies = Object.fromEntries( + Array.from(api.readFile("/out.js").matchAll(/^function (\w+)\(p\) \{$([\s\S]*?)^\}$/gm), match => [ + match[1], + match[2], + ]), + ); + expect( + Object.fromEntries( + Object.keys(memberCalls[name]).map(form => [ + form, + bodies[form].includes("Function.prototype.call.call(") + ? "Function.prototype.call.call" + : bodies[form].match(memberCall)?.[0].replace(/\bt\d+\b/g, "t"), + ]), + ), + ).toEqual(memberCalls[name]); + if (name !== "browser") return; + // A compiled function starts with its memo cache. In these two the load of the method is in the memo block + // of `log` and the call is behind that block, which codegen cannot print. + expect(Object.keys(bodies).filter(form => !/^\s*let \$ = c\(\d+\);/.test(bodies[form]))).toEqual([ + "KeyIsACallOfAPropBehindAnInlinedFunction", + "KeyIsACallOfAPropBehindADependencyOfAMemoBlock", + ]); + }, + run: { stdout: [...expectedOfCells, ...expectedOfForms].join("\n") }, + }); + } } // The call with the receiver given is `Function.prototype.call.call(method, receiver, ...)`. - // A function that can see another `Function` is left as it is written. + // A function that can see another `Function` is left as it is written, and + // a binding that only a type uses stays unused. itBundled("react-compiler/MethodReadAheadOfAStatementNeedsTheGlobalFunction", { files: { "/entry.jsx": /* jsx */ ` + import { other, shadow, who } from "./shared"; import { ShadowedInTheFile } from "./shadowed"; + import { InterfaceInTheFile } from "./interface"; + import { ImportOfAType } from "./import-of-a-type"; + import { TypeImport } from "./type-import"; - function who() { - return this.name + ":old"; - } - function other() { - return this.name + ":new"; - } function ShadowedInTheBody(p) { - const Function = { prototype: { call: { call: () => "the Function of the body" } } }; + const Function = shadow; const o = { name: p.name, who }; return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof Function}
; } - function ShadowedInABlock(p) { + function ShadowedInALoop(p) { const o = { name: p.name, who }; let out = ""; - if (p.name) { - const Function = { prototype: { call: { call: () => "the Function of the block" } } }; + for (const Function of [shadow]) { out = o.who((() => { o.who = other; return ""; })()) + " " + typeof Function; } return
{out}
; @@ -4370,23 +4503,65 @@ describe("bundler", () => { const o = { name: p.name, who }; return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof Function}
; } - for (const Form of [ShadowedInTheFile, ShadowedInTheBody, ShadowedInABlock, UsesTheGlobal]) { + for (const Form of [ + ShadowedInTheFile, + ShadowedInTheBody, + ShadowedInALoop, + ImportOfAType, + UsesTheGlobal, + InterfaceInTheFile, + TypeImport, + ]) { console.log(Form.name + "=" + Form({ name: "A" }).props.children); } `, - "/shadowed.jsx": /* jsx */ ` - const Function = { prototype: { call: { call: () => "the Function of the file" } } }; - function who() { + "/shared.ts": /* ts */ ` + export type Named = { name: string; who: (this: Named, suffix?: string) => string }; + export function who(this: Named) { return this.name + ":old"; } - function other() { + export function other(this: Named) { return this.name + ":new"; } + export const shadow = { prototype: { call: { call: () => "shadowed" } } }; + export interface Function { + (): string; + } + `, + "/shadowed.jsx": /* jsx */ ` + import { other, shadow, who } from "./shared"; + const Function = shadow; export function ShadowedInTheFile(p) { const o = { name: p.name, who }; return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof Function}
; } `, + "/interface.tsx": /* tsx */ ` + import { type Named, other, who } from "./shared"; + interface Function { + (): string; + } + export function InterfaceInTheFile(p: { name: string; callback?: Function }) { + const o: Named = { name: p.name, who }; + return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof globalThis.Function}
; + } + `, + "/type-import.tsx": /* tsx */ ` + import type { Function } from "./shared"; + import { type Named, other, who } from "./shared"; + export function TypeImport(p: { name: string; callback?: Function }) { + const o: Named = { name: p.name, who }; + return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof globalThis.Function}
; + } + `, + // Only types use the import, so it names nothing when the file runs. A use on record would keep it. + "/import-of-a-type.tsx": /* tsx */ ` + import { Function, type Named, other, who } from "./shared"; + export function ImportOfAType(p: { name: string; callback?: Function }) { + const o: Named = { name: p.name, who }; + return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof globalThis.Function}
; + } + `, "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, "/node_modules/react/index.js": ``, "/node_modules/react/jsx-dev-runtime.js": /* js */ ` @@ -4404,14 +4579,21 @@ describe("bundler", () => { onAfterBundle(api) { // A compiled function starts with its memo cache. const compiled = api.readFile("/out.js").matchAll(/function (\w+)\(p\) \{\s*let \$ = c\(\d+\);/g); - expect(Array.from(compiled, match => match[1])).toEqual(["UsesTheGlobal"]); + expect(Array.from(compiled, match => match[1]).sort()).toEqual([ + "InterfaceInTheFile", + "TypeImport", + "UsesTheGlobal", + ]); }, run: { stdout: ` ShadowedInTheFile=A:old object ShadowedInTheBody=A:old object - ShadowedInABlock=A:old object + ShadowedInALoop=A:old object + ImportOfAType=A:old function UsesTheGlobal=A:old function + InterfaceInTheFile=A:old function + TypeImport=A:old function `, }, }); diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index 16ecb3c3281a..a0ba057e58f2 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -2107,7 +2107,7 @@ describe.concurrent("--react-compiler", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - return { compiled: stdout.match(/compiled: (.*)/)?.[1] ?? stderr, stderr, exitCode }; + return { compiled: stdout.match(/compiled: (.*)/)?.[1] ?? stderr, stdout, stderr, exitCode }; } const everything = "List useTotal Story Other"; @@ -2245,6 +2245,48 @@ describe.concurrent("--react-compiler", () => { expect(result.exitCode).toBe(0); }); + // The compiler calls a method that it read ahead of a statement as `Function.prototype.call.call(method, receiver)`. + // `bun test` prints the names of a file as they are written, so a function that binds `Function` stays as it is. + test("a function that binds Function keeps a method call that a statement of an argument could change", async () => { + const result = await compiled( + { + "src/Shadows.jsx": ` + function who() { + return this.name + ":old"; + } + function other() { + return this.name + ":new"; + } + const shadow = { prototype: { call: { call: () => "shadowed" } } }; + export function InALoop(p) { + const o = { name: p.name, who }; + let out = ""; + for (const Function of [shadow]) { + out = o.who((() => { o.who = other; return ""; })()) + " " + typeof Function; + } + return
{out}
; + } + export function TheGlobal(p) { + const o = { name: p.name, who }; + return
{o.who((() => { o.who = other; return ""; })()) + " " + typeof Function}
; + } + `, + "compiled.test.tsx": ` + import { test } from "bun:test"; + import * as all from "./src/Shadows"; + test("which are compiled", () => { + ${printCompiled} + console.log("rendered: " + Object.values(all).map(Form => Form({ name: "A" }).props.children).join(", ")); + }); + `, + }, + ["--react-compiler"], + ); + expect(result.stdout).toContain("rendered: A:old object, A:old function"); + expect(result.compiled).toBe("TheGlobal"); + expect(result.exitCode).toBe(0); + }); + test("a data: URL that is longer than a path can be is not compiled", async () => { const result = await compiled( { From 2f9818251de03a9f202f0308309a10828ffa5a67 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:03:21 +0000 Subject: [PATCH 06/13] react-compiler: list the member calls that still read behind a statement as a todo test --- .../bundler/transpiler/react-compiler.test.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index b6bc68b30038..a28c53a4deff 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4598,6 +4598,119 @@ describe("bundler", () => { }, }); + // Member calls that still read the method, or an argument, behind a statement. Each line says what the function + // prints today in place of what the plain build prints. + for (const target of ["browser", "bun"] as const) { + itBundled(`react-compiler/MethodIsStillReadBehindAStatement-${target}`, { + todo: true, + files: { + "/entry.jsx": /* jsx */ ` + function who(...suffix) { + return (this === undefined ? "no this" : this.name) + ":old" + suffix.filter(s => typeof s === "string").join(""); + } + function other() { + return (this === undefined ? "no this" : this.name) + ":new"; + } + const table = { name: "table", who }; + const replace = o => { + o.who = other; + return ""; + }; + const restore = o => { + o.who = who; + return ""; + }; + const newJoin = function () { + return this[0] + ":new"; + }; + let version = 0; + const bump = () => { + version++; + return ""; + }; + const versioned = name => + Object.defineProperty({ name }, "who", { + get() { + const v = version % 2; + return () => name + ":" + v; + }, + }); + + // The pass does not run in a function inside the component: "A:second". + function CallInACallback(p) { + const fns = [() => p.name + ":first", () => p.name + ":second"]; + const state = { i: 0 }; + const run = () => fns[state.i](state.i++); + return
{run()}
; + } + // A method of the props object is called as a function of its own: "no this:old". + function MethodOfTheProps(p) { + const local = { n: 0 }; + return
{p.who((() => { local.n = 1; return ""; })())}
; + } + // A load from a module-level object counts as a constant: "table:newA". + function MethodOfAModuleLevelObject(p) { + const local = { n: 0 }; + const result = table.who(replace(table), (() => { local.n = 1; return ""; })()); + return
{result + restore(table) + p.name}
; + } + // The inference takes \`join\` of an array for the method of arrays. Without memo blocks: "A:new". + function MethodOfAnArrayIsReplaced(p) { + const items = [p.name, "x"]; + return
{items.join((() => { items.join = newJoin; return "-"; })())}
; + } + // The getter reads what no value of the function holds. With memo blocks: "A:1". + function GetterReadsModuleState(p) { + const o = versioned(p.name); + const local = { n: 0 }; + const result = o.who(bump(), (() => { local.n = 1; return ""; })()); + bump(); + return
{result}
; + } + // A spread argument is iterated at the call: "A:oldxy". + function SpreadIsIteratedBehindAStatement(p) { + const o = { name: p.name, who }; + const list = ["x"]; + return
{o.who(...list, (() => { list.push("y"); return ""; })())}
; + } + for (const Form of [ + CallInACallback, + MethodOfTheProps, + MethodOfAModuleLevelObject, + MethodOfAnArrayIsReplaced, + GetterReadsModuleState, + SpreadIsIteratedBehindAStatement, + ]) { + console.log(Form.name + "=" + Form({ name: "A", who }).props.children); + } + `, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + "/node_modules/react/index.js": ``, + "/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: ` + CallInACallback=A:first + MethodOfTheProps=A:old + MethodOfAModuleLevelObject=table:oldA + MethodOfAnArrayIsReplaced=A-x + GetterReadsModuleState=A:0 + SpreadIsIteratedBehindAStatement=A:oldx + `, + }, + }); + } + itBundled("react-compiler/SourceMapOfCatch", { files: { "/entry.jsx": /* jsx */ ` From babe719cdc54a71f8a1c3a320c64c7b2fa1315ef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:10:32 +0000 Subject: [PATCH 07/13] react-compiler: list every kind of effect in the scan for a mutation of the receiver A kind of effect that a sync adds no longer falls into a wildcard arm. --- src/react_compiler/hir/environment.rs | 4 +++- .../reactive_scopes/promote_used_temporaries.rs | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/react_compiler/hir/environment.rs b/src/react_compiler/hir/environment.rs index 728746aa5a10..627c21f33bfd 100644 --- a/src/react_compiler/hir/environment.rs +++ b/src/react_compiler/hir/environment.rs @@ -124,7 +124,9 @@ pub struct Environment { uid_known_names: Option>, /// Not in upstream: the properties of the MethodCalls that PromoteUsedTemporaries gave a name, to read the - /// method ahead of a statement. Codegen prints such a call with its receiver as an argument. + /// method ahead of a statement. Codegen prints such a call with its receiver as an argument. A property that + /// has its name as the declaration of a memo block is not in the set, and stays the invariant error that it + /// is upstream (facebook/react#34072). pub(crate) explicit_receiver_calls: IdBitSet, } diff --git a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs index c5e16ce94e2b..1ccfba4ebd2c 100644 --- a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs +++ b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs @@ -1303,7 +1303,21 @@ impl ReactiveFunctionVisitor for Mutations<'_> { | AliasingEffect::MutateConditionally { value } | AliasingEffect::MutateTransitive { value } | AliasingEffect::MutateTransitiveConditionally { value } => self.reaches(value), - _ => false, + // No wildcard: a kind of effect that a sync adds has to be put on one side here. + AliasingEffect::Freeze { .. } + | AliasingEffect::Capture { .. } + | AliasingEffect::Alias { .. } + | AliasingEffect::MaybeAlias { .. } + | AliasingEffect::Assign { .. } + | AliasingEffect::Create { .. } + | AliasingEffect::CreateFrom { .. } + | AliasingEffect::ImmutableCapture { .. } + | AliasingEffect::Apply(_) + | AliasingEffect::CreateFunction { .. } + | AliasingEffect::MutateFrozen { .. } + | AliasingEffect::MutateGlobal { .. } + | AliasingEffect::Impure { .. } + | AliasingEffect::Render { .. } => false, }); } if !matches!(instruction.value, ReactiveValue::Instruction(_)) { From 82ce8d8530ff9a4b95df620f965f86246a100f41 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:33:25 +0000 Subject: [PATCH 08/13] aot: name the new --react-compiler test of bun-test.test.ts among those that fail under --aot Under --aot the text of a function is the text as it is written, so the test cannot tell that the component is compiled. The other tests of that block are named for the same reason. --- test/aot/runner-suites.known.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test/aot/runner-suites.known.txt b/test/aot/runner-suites.known.txt index b1b0ee19d1ff..76605a92cd62 100644 --- a/test/aot/runner-suites.known.txt +++ b/test/aot/runner-suites.known.txt @@ -3,6 +3,7 @@ # `! ` before a line: in a Bun built with assertions one of them stops that test's child run. # `x64 ` or `arm64 ` before a line: that test fails on that architecture alone. test/cli/test/bun-test.test.ts: --react-compiler > a compiled file is cached whatever its size ["--react-compiler"] +test/cli/test/bun-test.test.ts: --react-compiler > a function that binds Function keeps a method call that a statement of an argument could change test/cli/test/bun-test.test.ts: --react-compiler > a pattern test/cli/test/bun-test.test.ts: --react-compiler > exclude alone test/cli/test/bun-test.test.ts: --react-compiler > false, and the flag From 83a5e22e0164f89a9e4e95f413d46f6e6d56c005 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:42:18 +0000 Subject: [PATCH 09/13] react-compiler: keep each new comment to one line, and name a null prop receiver among the calls still read late --- src/jsc/RuntimeTranspilerCache.rs | 3 +-- src/react_compiler/codegen.rs | 11 ++--------- src/react_compiler/hir/environment.rs | 5 +---- src/react_compiler/program.rs | 3 +-- .../promote_used_temporaries.rs | 15 ++++----------- test/bundler/transpiler/react-compiler.test.ts | 18 ++++++++++++++++++ 6 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 41dc2d04f05a..516f476d7546 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -85,8 +85,7 @@ bun_core::declare_scope!(cache, visible); /// Version 50: The body of a class with lowered class decorators names the class by the /// binding that holds what the decorators return. /// Version 51: A `bun:` specifier that names no builtin is printed as it is written. -/// Version 52: `--react-compiler` reads the method and the key of a member call ahead of a statement -/// that can change them. +/// Version 52: `--react-compiler` reads the method of a member call ahead of a statement that can change it. const EXPECTED_VERSION: u32 = 52; /// Source files smaller than this are not written to / read from the on-disk diff --git a/src/react_compiler/codegen.rs b/src/react_compiler/codegen.rs index 6e1daea38978..79094735d683 100644 --- a/src/react_compiler/codegen.rs +++ b/src/react_compiler/codegen.rs @@ -229,9 +229,7 @@ pub(crate) fn codegen_function( context: &mut ProgramContext, unique_identifiers: HashSet, ) -> Result { - // Not in upstream: `codegen_explicit_receiver_call` prints the global `Function`. The identifiers say what the - // function binds, and the host what is around it. The question comes first, so that a function that stays as - // it is written has no use of that name on record. + // Not in upstream: asked first, so that a function that stays as written has no use of `Function` on record. if !env.explicit_receiver_calls.is_empty() { let binds_function = env.identifiers.iter().any(|identifier| { (identifier.name.as_ref()).is_some_and(|name| name.value() == b"Function") @@ -2339,12 +2337,7 @@ fn codegen_base_instruction_value( // Function expression codegen // ============================================================================= -/// Not in upstream: the call of a method that PromoteUsedTemporaries read ahead of a statement, as -/// `Function.prototype.call.call(method, receiver, ...args)`. `method.call(receiver, ...args)` reads `call` of the -/// method, which a member call does not: a proxy or a method with a `call` of its own answers that read. -/// `Reflect.apply(method, receiver, [...args])` makes an array for each call, and is many times slower under -/// JavaScriptCore. A method that is not callable throws from `Function.prototype.call`, and the message does not -/// name the member. +/// Not in upstream: `Function.prototype.call.call(method, receiver, ...args)`, which reads nothing of the method. fn codegen_explicit_receiver_call( cx: &mut Context, method: Expr, diff --git a/src/react_compiler/hir/environment.rs b/src/react_compiler/hir/environment.rs index 627c21f33bfd..f17cae1a50d6 100644 --- a/src/react_compiler/hir/environment.rs +++ b/src/react_compiler/hir/environment.rs @@ -123,10 +123,7 @@ pub struct Environment { // Matches Babel's generateUid behavior of checking hasBinding/hasReference. uid_known_names: Option>, - /// Not in upstream: the properties of the MethodCalls that PromoteUsedTemporaries gave a name, to read the - /// method ahead of a statement. Codegen prints such a call with its receiver as an argument. A property that - /// has its name as the declaration of a memo block is not in the set, and stays the invariant error that it - /// is upstream (facebook/react#34072). + /// Not in upstream: the MethodCall properties that phase 3 named, not those of facebook/react#34072. pub(crate) explicit_receiver_calls: IdBitSet, } diff --git a/src/react_compiler/program.rs b/src/react_compiler/program.rs index 90f69a914833..50731b0a68c3 100644 --- a/src/react_compiler/program.rs +++ b/src/react_compiler/program.rs @@ -149,8 +149,7 @@ pub trait Host { self.new_generated(name) } - /// Whether `name` is the global around the function being compiled: no scope from its body outward binds it. - /// The lookup records no use, so a caller can ask ahead of a decision not to print the name. + /// Whether no scope from the body of the function outward binds `name`. The lookup records no use. fn is_unbound_global(&mut self, _name: &'static [u8]) -> bool { false } diff --git a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs index 1ccfba4ebd2c..da91bcdf7543 100644 --- a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs +++ b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs @@ -1109,11 +1109,7 @@ fn promote_interposed_value( } } -/// Not in upstream. Codegen prints the property of a MethodCall inside the call, so that the call has its -/// receiver. So the method is read behind every statement between its load and the call. When such a statement -/// can mutate the receiver, the property gets a name here, and codegen calls it with the receiver given. -/// Otherwise the method stays in the call, and a computed key gets a name like any other operand. -/// A statement that writes stands there in most member calls. Only the mutation analysis tells the few that can. +/// Not in upstream: names a method that a statement behind its load can change, or else the key of a computed one. #[cold] #[allow(clippy::too_many_arguments)] fn promote_late_method( @@ -1179,14 +1175,13 @@ fn promote_late_method( } } -/// What the inference says without a look at the statements. A receiver that nothing mutates once it is made keeps -/// its methods. A property that has the signature of a method is taken for that method, whatever a statement does -/// to the receiver. A hook has its signature from its name alone, so it does not count. +/// False for a receiver that nothing mutates once it is made, and for a property that has the signature of a method. fn can_method_change(receiver: &Place, property: &Place, env: &Environment) -> bool { let range = &env.identifiers[receiver.identifier.0 as usize].mutable_range; let type_ = &env.types[env.identifiers[property.identifier.0 as usize].type_.0 as usize]; range.end.0 > range.start.0 + 1 && !(env.get_function_signature(type_).ok().flatten()) + // A hook has its signature from its name alone. .is_some_and(|signature| signature.hook_kind.is_none()) } @@ -1272,9 +1267,7 @@ struct Mutations<'a> { env: &'a Environment, consts: &'a IdBitSet, globals: &'a IdBitSet, - /// The values that are mutated together have one mutable range. A memo block mutates only what it declares, - /// so what is in a memo block has another range. `None` without memoization: no two values share a range - /// there, and every mutation counts. + /// The mutable range that the receiver shares. `None` without memoization, where every mutation counts. receiver: Option, } diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index a28c53a4deff..ac806bddf26c 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4635,6 +4635,10 @@ describe("bundler", () => { return () => name + ":" + v; }, }); + const note = log => { + log.push("argument"); + return ""; + }; // The pass does not run in a function inside the component: "A:second". function CallInACallback(p) { @@ -4673,6 +4677,18 @@ describe("bundler", () => { const list = ["x"]; return
{o.who(...list, (() => { list.push("y"); return ""; })())}
; } + // Nothing mutates a prop, so its method is read at the call, behind the argument: "TypeError, ran: argument". + function MethodOfAPropThatIsNull(p) { + const log = []; + const state = { i: 0 }; + let out; + try { + out = p.none.who(note(log), state.i++); + } catch (e) { + out = (e instanceof TypeError ? "TypeError" : e) + ", ran: " + (log.length ? log : "nothing"); + } + return
{out}
; + } for (const Form of [ CallInACallback, MethodOfTheProps, @@ -4680,6 +4696,7 @@ describe("bundler", () => { MethodOfAnArrayIsReplaced, GetterReadsModuleState, SpreadIsIteratedBehindAStatement, + MethodOfAPropThatIsNull, ]) { console.log(Form.name + "=" + Form({ name: "A", who }).props.children); } @@ -4706,6 +4723,7 @@ describe("bundler", () => { MethodOfAnArrayIsReplaced=A-x GetterReadsModuleState=A:0 SpreadIsIteratedBehindAStatement=A:oldx + MethodOfAPropThatIsNull=TypeError, ran: nothing `, }, }); From 158241c218e50c007d8f7a5cc37224657e4d2fb8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:04:49 +0000 Subject: [PATCH 10/13] react-compiler: assert that each call whose method a statement replaces prints with the receiver given A function that is left as it is written prints the right result too, so the bun variant, which has no memo cache to look for, could pass with nothing compiled. --- test/bundler/transpiler/react-compiler.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index ac806bddf26c..76f4c78a989b 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4009,6 +4009,14 @@ describe("bundler", () => { BehindADependencyOfAMemoBlock: (t: string) => `{replaceAndCount(${t}) + p.n}`, BehindASpreadAndAMemoBlock: (t: string) => `...replaceAndList(${t}), {p.n}`, }; + // A function that is left as it is written prints the right result too. So each of these calls has to print with + // the receiver given. Without memo blocks the last three producers leave no statement. + const replaced = (producerNames: string[]) => + Object.keys(callees).flatMap(calleeName => producerNames.map(producerName => calleeName + producerName)); + const receiverGiven = { + browser: replaced(Object.keys(producers)), + bun: replaced(Object.keys(producers).slice(0, -3)), + }; for (const [calleeName, { setup, target, callee }] of Object.entries(callees)) { for (const [producerName, producer] of Object.entries(producers)) { cells.push({ @@ -4461,6 +4469,9 @@ describe("bundler", () => { ]), ), ).toEqual(memberCalls[name]); + expect(receiverGiven[name].filter(cell => !bodies[cell].includes("Function.prototype.call.call("))).toEqual( + [], + ); if (name !== "browser") return; // A compiled function starts with its memo cache. In these two the load of the method is in the memo block // of `log` and the call is behind that block, which codegen cannot print. From c615ee00b89572c2b7202a6a9f5d88e0ba336a58 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:01:31 +0000 Subject: [PATCH 11/13] react-compiler: find the load of a method ahead of the scope that holds its call A call whose result is a primitive, and whose arguments are not mutable, is a member of no scope. It can stand in the range of the scope of another value, with the load of its method ahead of that scope. The walk from the call back to the load stopped at the start of the scope, so the method and the key of such a call were still read behind the statements between. The statements ahead of a scope run before the call in it, for a memo block and for a pruned scope. The walk now goes on into them. --- .../promote_used_temporaries.rs | 50 +++++++++-- .../bundler/transpiler/react-compiler.test.ts | 85 ++++++++++++++++++- 2 files changed, 124 insertions(+), 11 deletions(-) diff --git a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs index da91bcdf7543..67b76f5d3a21 100644 --- a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs +++ b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs @@ -702,6 +702,13 @@ impl<'a> InterState<'a> { } } +/// The statements that print in front of an instruction: those of its block, then those around the scope of that block. +#[derive(Clone, Copy)] +struct Before<'a> { + statements: &'a [ReactiveStatement], + outer: Option<&'a Before<'a>>, +} + fn promote_interposed_block( block: &ReactiveBlock, state: &mut State, @@ -709,17 +716,34 @@ fn promote_interposed_block( consts: &mut IdBitSet, globals: &mut IdBitSet, env: &mut Environment, +) { + promote_interposed_statements(block, None, state, inter_state, consts, globals, env); +} + +/// `outer` is what prints in front of the statements when they are those of a scope. +fn promote_interposed_statements( + block: &ReactiveBlock, + outer: Option<&Before<'_>>, + state: &mut State, + inter_state: &mut InterState<'_>, + consts: &mut IdBitSet, + globals: &mut IdBitSet, + env: &mut Environment, ) { if !env.has_stack() { return; } for (index, stmt) in block.iter().enumerate() { + let before = Before { + statements: &block[..index], + outer, + }; match stmt { ReactiveStatement::Instruction(instr) => { promote_interposed_instruction( instr, - &block[..index], + &before, state, inter_state, consts, @@ -730,8 +754,9 @@ fn promote_interposed_block( ReactiveStatement::Scope(scope) => { // The memo block compares its dependencies first, and what writes must not print in it: it may not run. inter_state.statement(Effect::Read); - promote_interposed_block( + promote_interposed_statements( &scope.instructions, + Some(&before), state, inter_state, consts, @@ -740,8 +765,9 @@ fn promote_interposed_block( ); } ReactiveStatement::PrunedScope(scope) => { - promote_interposed_block( + promote_interposed_statements( &scope.instructions, + Some(&before), state, inter_state, consts, @@ -931,10 +957,10 @@ fn instruction_effect( } /// Returns the effect of the expression that the instruction prints as. -/// `before` holds the statements of the block in front of the instruction. A value block has none. +/// `before` holds the statements in front of the instruction. A value block has none. fn promote_interposed_instruction( instr: &ReactiveInstruction, - before: &[ReactiveStatement], + before: &Before<'_>, state: &mut State, inter_state: &mut InterState<'_>, consts: &mut IdBitSet, @@ -1068,7 +1094,10 @@ fn promote_interposed_value( for instr in instructions { effect = effect.max(promote_interposed_instruction( instr, - &[], + &Before { + statements: &[], + outer: None, + }, state, inter_state, consts, @@ -1114,7 +1143,7 @@ fn promote_interposed_value( #[allow(clippy::too_many_arguments)] fn promote_late_method( call: &InstructionValue, - before: &[ReactiveStatement], + before: &Before<'_>, temporary: Temporary, state: &mut State, inter_state: &mut InterState<'_>, @@ -1150,7 +1179,12 @@ fn promote_late_method( is_mutated: false, load: None, }; - behind.walk(before); + let mut before = Some(before); + while let Some(&Before { statements, outer }) = before + && !behind.walk(statements) + { + before = outer; + } let Behind { is_mutated, load, .. } = behind; diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 76f4c78a989b..196c07074daa 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4093,6 +4093,9 @@ describe("bundler", () => { "EarlierArgumentIsAChainOfMethods=A:olda | A:olda | C:oldc", "EarlierArgumentIsAMethodOfAProp=A:oldA:old | A:oldA:old | C:oldC:old", "EarlierArgumentOverAnotherLocal=A:old1 | A:old1 | C:old1", + "KeyAheadOfAMemoBlock=B:oldalt;1i | B:oldalt;1i | B:oldalt;1i", + "KeyAheadOfAScopeThatAHookPrunes=A:old;2 | A:old;2 | C:old;2", + "KeyAheadOfAScopeThatALoopPrunes=A:old;2A:old;2 | A:old;2A:old;2 | C:old;2C:old;2", "KeyInAScopeThatAHookPrunes=A:old 0,key,statement | A:old 0,key,statement | C:old 0,key,statement", "KeyIsAConversionOfAMemberThatIsUpdated=A:first | A:first | C:first", "KeyIsAMemberThatIsUpdated=A:first | A:first | C:first", @@ -4101,7 +4104,11 @@ describe("bundler", () => { "LaterArgumentBehindADependency=A:old | A:old | C:old", "LaterArgumentBehindAStatement=A:old | A:old | C:old", "MemoBlocksAheadOfTheReceiver=A:old16 | A:old16 | C:old16", + "MethodAheadOfAMemoBlock=A:old;1 | A:old;1 | C:old;1", + "MethodAheadOfAScopeThatAHookPrunes=A:old;2 | A:old;2 | C:old;2", + "MethodAheadOfAScopeThatALoopPrunes=A1:old;2A2:old;2 | A1:old;2A2:old;2 | C1:old;2C2:old;2", "MethodHasItsOwnCall=A:old | A:old | C:old", + "MethodInAMemoBlockAheadOfTheCall=A:old;1 | A:old;1 | C:old;1", "MethodIsAProxy=A:old | A:old | C:old", "MethodIsMissing=TypeError after statement,argument | TypeError after statement,argument | TypeError after statement,argument", "MethodOfAPropBehindAStatement=A:old | A:old | C:old", @@ -4231,7 +4238,7 @@ describe("bundler", () => { ` + cells.map(cell => cell.source).join(""), "/forms.jsx": /* jsx */ ` import { useState } from "react"; - import { make, other, pick, replace, size, tick, who } from "./helpers"; + import { make, other, pick, replace, same, size, tick, who } from "./helpers"; const withGetter = o => Object.defineProperty(o, "who", { @@ -4370,6 +4377,77 @@ describe("bundler", () => { const result = p.api[pick(log)]((() => { log.push("statement"); return ""; })()); return
{result + " " + log}
; } + // The result of the call is a primitive and no argument is mutable, so the call is a member of no scope. + // It stands in the scope of \`last\`, which the loop prunes, and the load of its method ahead of that scope. + export function MethodAheadOfAScopeThatALoopPrunes(p) { + let out = ""; + for (const suffix of ["1", "2"]) { + const o = make(p.name + suffix); + let last; + const result = o.who((() => { o.who = other; return {o}; })(), (last = [suffix], "")) + ";"; + last.push(result); + out += result + last.length; + } + return
{out}
; + } + export function KeyAheadOfAScopeThatALoopPrunes(p) { + let out = ""; + for (const suffix of ["1", "2"]) { + let key = "who"; + let last; + const result = p.api[key]((() => { key = "alt"; return ""; })(), (last = [suffix], "")) + ";"; + last.push(result); + out += result + last.length; + } + return
{out}
; + } + // One hook prunes the scope that holds the load, and one the scope that holds the call. + export function MethodAheadOfAScopeThatAHookPrunes(p) { + const o = make(p.name); + const [n] = useState(0); + let last; + const result = o.who((() => { o.who = other; return {o}{n}; })(), (last = [], "")) + ";"; + const [m] = useState(0); + last.push(result, m); + return
{result + last.length}
; + } + export function KeyAheadOfAScopeThatAHookPrunes(p) { + let key = "who"; + const [n] = useState(0); + let last; + const result = p.api[key]((() => { key = "alt"; return n; })(), (last = [], "")) + ";"; + const [m] = useState(0); + last.push(result, m); + return
{result + last.length}
; + } + // The memo block of \`last\` holds the call. The scope ahead of it holds the load, and a hook prunes that scope. + export function MethodAheadOfAMemoBlock(p) { + const o = make(p.name); + const [n] = useState(0); + let last; + const result = o.who((() => { o.who = other; return {o}{n}; })(), (last = [], "")) + ";"; + last.push(result); + return
{result + last.length}
; + } + // Without a hook the memo block of \`o\` holds the load of the method, and codegen cannot print the call behind it. + export function MethodInAMemoBlockAheadOfTheCall(p) { + const o = make(p.name); + let last; + const result = o.who((() => { o.who = other; return {o}; })(), (last = [], "")) + ";"; + last.push(result); + return
{result + last.length}
; + } + // Nothing here is reactive, so the memo block of \`last\` does not depend on the method. That block holds the + // call, and the load of the method stands ahead of it. + export function KeyAheadOfAMemoBlock(p) { + const o = make("B"); + const frozen = {o}; + let key = same("who"); + let last; + const result = o[key]((key = "alt"), (last = [], "")) + ";"; + last.push(result); + return
{result + last.length + frozen.type}
; + } export function MethodOfAPropBehindAStatement(p) { const local = { n: 0 }; return
{p.api.who((() => { local.n = 1; return ""; })())}
; @@ -4473,11 +4551,12 @@ describe("bundler", () => { [], ); if (name !== "browser") return; - // A compiled function starts with its memo cache. In these two the load of the method is in the memo block - // of `log` and the call is behind that block, which codegen cannot print. + // A compiled function starts with its memo cache. In these three the load of the method is in a memo block + // and the call is behind that block, which codegen cannot print. expect(Object.keys(bodies).filter(form => !/^\s*let \$ = c\(\d+\);/.test(bodies[form]))).toEqual([ "KeyIsACallOfAPropBehindAnInlinedFunction", "KeyIsACallOfAPropBehindADependencyOfAMemoBlock", + "MethodInAMemoBlockAheadOfTheCall", ]); }, run: { stdout: [...expectedOfCells, ...expectedOfForms].join("\n") }, From fbcbc9c51a02ca83b0185ed31335acdc305cab61 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:31:38 +0000 Subject: [PATCH 12/13] react-compiler: find the load of a method ahead of a labeled block or a terminal that holds its call An early return puts the statements of a memo block in a labeled block. An inlined function with its one return in a case of a switch puts the call, and all behind it, in that case. In both forms the load of the method stands ahead of the block of the call, and the walk from the call back to the load stopped at the start of that block. Every block now hands the statements ahead of it to the blocks that it holds. A labeled block runs once, in sequence, like a scope. For a block of another terminal the whole terminal counts as a statement between the load and the call, because its other blocks can run first. --- .../promote_used_temporaries.rs | 86 ++++++++++++------- .../bundler/transpiler/react-compiler.test.ts | 39 +++++++++ 2 files changed, 96 insertions(+), 29 deletions(-) diff --git a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs index 67b76f5d3a21..1f6dc224728e 100644 --- a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs +++ b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs @@ -107,6 +107,7 @@ pub(crate) fn promote_used_temporaries( let mut inter_state = InterState::new(inline_macro_operands); promote_interposed_block( &func.body, + None, &mut state, &mut inter_state, &mut consts, @@ -702,26 +703,16 @@ impl<'a> InterState<'a> { } } -/// The statements that print in front of an instruction: those of its block, then those around the scope of that block. +/// What prints in front of an instruction: the statements of its block, then in `outer` those around that block. #[derive(Clone, Copy)] struct Before<'a> { + /// The terminal behind `statements` that holds the inner block, unless it is a label. Its other blocks can run first too. + terminal: Option<&'a ReactiveTerminalStatement>, statements: &'a [ReactiveStatement], outer: Option<&'a Before<'a>>, } fn promote_interposed_block( - block: &ReactiveBlock, - state: &mut State, - inter_state: &mut InterState<'_>, - consts: &mut IdBitSet, - globals: &mut IdBitSet, - env: &mut Environment, -) { - promote_interposed_statements(block, None, state, inter_state, consts, globals, env); -} - -/// `outer` is what prints in front of the statements when they are those of a scope. -fn promote_interposed_statements( block: &ReactiveBlock, outer: Option<&Before<'_>>, state: &mut State, @@ -736,6 +727,7 @@ fn promote_interposed_statements( for (index, stmt) in block.iter().enumerate() { let before = Before { + terminal: None, statements: &block[..index], outer, }; @@ -754,7 +746,7 @@ fn promote_interposed_statements( ReactiveStatement::Scope(scope) => { // The memo block compares its dependencies first, and what writes must not print in it: it may not run. inter_state.statement(Effect::Read); - promote_interposed_statements( + promote_interposed_block( &scope.instructions, Some(&before), state, @@ -765,7 +757,7 @@ fn promote_interposed_statements( ); } ReactiveStatement::PrunedScope(scope) => { - promote_interposed_statements( + promote_interposed_block( &scope.instructions, Some(&before), state, @@ -776,7 +768,15 @@ fn promote_interposed_statements( ); } ReactiveStatement::Terminal(terminal) => { - promote_interposed_terminal(terminal, state, inter_state, consts, globals, env); + promote_interposed_terminal( + terminal, + &before, + state, + inter_state, + consts, + globals, + env, + ); } } } @@ -1095,6 +1095,7 @@ fn promote_interposed_value( effect = effect.max(promote_interposed_instruction( instr, &Before { + terminal: None, statements: &[], outer: None, }, @@ -1180,9 +1181,20 @@ fn promote_late_method( load: None, }; let mut before = Some(before); - while let Some(&Before { statements, outer }) = before - && !behind.walk(statements) + while let Some(&Before { + terminal, + statements, + outer, + }) = before { + if let Some(terminal) = terminal { + behind + .mutations + .visit_terminal(terminal, &mut behind.is_mutated); + } + if behind.walk(statements) { + break; + } before = outer; } let Behind { @@ -1392,12 +1404,20 @@ fn promote_interposed_terminal_place( fn promote_interposed_terminal( stmt: &ReactiveTerminalStatement, + before: &Before<'_>, state: &mut State, inter_state: &mut InterState<'_>, consts: &mut IdBitSet, globals: &mut IdBitSet, env: &mut Environment, ) { + // A labeled block runs once, in sequence. An early return puts the statements of a memo block in one. + let is_sequence = matches!(stmt.terminal, ReactiveTerminal::Label { .. }); + let around = Before { + terminal: (!is_sequence).then_some(stmt), + ..*before + }; + let around = Some(&around); match &stmt.terminal { ReactiveTerminal::Break { .. } | ReactiveTerminal::Continue { .. } => {} ReactiveTerminal::Return { value, .. } | ReactiveTerminal::Throw { value, .. } => { @@ -1412,7 +1432,7 @@ fn promote_interposed_terminal( } => { promote_interposed_terminal_value(init, state, inter_state, consts, globals, env); promote_interposed_terminal_value(test, state, inter_state, consts, globals, env); - promote_interposed_block(loop_block, state, inter_state, consts, globals, env); + promote_interposed_block(loop_block, around, state, inter_state, consts, globals, env); if let Some(update) = update { promote_interposed_terminal_value(update, state, inter_state, consts, globals, env); } @@ -1429,25 +1449,25 @@ fn promote_interposed_terminal( let test = promote_interposed_value(test, state, inter_state, consts, globals, env); inter_state.exit_value_block(outer); inter_state.statement(init.max(test)); - promote_interposed_block(loop_block, state, inter_state, consts, globals, env); + promote_interposed_block(loop_block, around, state, inter_state, consts, globals, env); } ReactiveTerminal::ForIn { init, loop_block, .. } => { promote_interposed_terminal_value(init, state, inter_state, consts, globals, env); - promote_interposed_block(loop_block, state, inter_state, consts, globals, env); + promote_interposed_block(loop_block, around, state, inter_state, consts, globals, env); } ReactiveTerminal::DoWhile { loop_block, test, .. } => { - promote_interposed_block(loop_block, state, inter_state, consts, globals, env); + promote_interposed_block(loop_block, around, state, inter_state, consts, globals, env); promote_interposed_terminal_value(test, state, inter_state, consts, globals, env); } ReactiveTerminal::While { test, loop_block, .. } => { promote_interposed_terminal_value(test, state, inter_state, consts, globals, env); - promote_interposed_block(loop_block, state, inter_state, consts, globals, env); + promote_interposed_block(loop_block, around, state, inter_state, consts, globals, env); } ReactiveTerminal::If { test, @@ -1456,9 +1476,9 @@ fn promote_interposed_terminal( .. } => { promote_interposed_terminal_place(test, state, inter_state, consts, env); - promote_interposed_block(consequent, state, inter_state, consts, globals, env); + promote_interposed_block(consequent, around, state, inter_state, consts, globals, env); if let Some(alt) = alternate { - promote_interposed_block(alt, state, inter_state, consts, globals, env); + promote_interposed_block(alt, around, state, inter_state, consts, globals, env); } } ReactiveTerminal::Switch { test, cases, .. } => { @@ -1473,12 +1493,20 @@ fn promote_interposed_terminal( inter_state.statement(effect); for case in cases { if let Some(block) = &case.block { - promote_interposed_block(block, state, inter_state, consts, globals, env); + promote_interposed_block( + block, + around, + state, + inter_state, + consts, + globals, + env, + ); } } } ReactiveTerminal::Label { block, .. } => { - promote_interposed_block(block, state, inter_state, consts, globals, env); + promote_interposed_block(block, around, state, inter_state, consts, globals, env); } ReactiveTerminal::Try { block, @@ -1486,11 +1514,11 @@ fn promote_interposed_terminal( handler, .. } => { - promote_interposed_block(block, state, inter_state, consts, globals, env); + promote_interposed_block(block, around, state, inter_state, consts, globals, env); if let Some(binding) = handler_binding { promote_interposed_place(binding, state, inter_state, consts, env); } - promote_interposed_block(handler, state, inter_state, consts, globals, env); + promote_interposed_block(handler, around, state, inter_state, consts, globals, env); } } } diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 196c07074daa..5cb5a56fd336 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4094,8 +4094,10 @@ describe("bundler", () => { "EarlierArgumentIsAMethodOfAProp=A:oldA:old | A:oldA:old | C:oldC:old", "EarlierArgumentOverAnotherLocal=A:old1 | A:old1 | C:old1", "KeyAheadOfAMemoBlock=B:oldalt;1i | B:oldalt;1i | B:oldalt;1i", + "KeyAheadOfAMemoBlockWithAnEarlyReturn=B:oldalt;1i | B:oldalt;1i | B:oldalt;1i", "KeyAheadOfAScopeThatAHookPrunes=A:old;2 | A:old;2 | C:old;2", "KeyAheadOfAScopeThatALoopPrunes=A:old;2A:old;2 | A:old;2A:old;2 | C:old;2C:old;2", + "KeyAheadOfASwitchThatHoldsTheCall=A:old | A:old | C:old", "KeyInAScopeThatAHookPrunes=A:old 0,key,statement | A:old 0,key,statement | C:old 0,key,statement", "KeyIsAConversionOfAMemberThatIsUpdated=A:first | A:first | C:first", "KeyIsAMemberThatIsUpdated=A:first | A:first | C:first", @@ -4105,8 +4107,11 @@ describe("bundler", () => { "LaterArgumentBehindAStatement=A:old | A:old | C:old", "MemoBlocksAheadOfTheReceiver=A:old16 | A:old16 | C:old16", "MethodAheadOfAMemoBlock=A:old;1 | A:old;1 | C:old;1", + "MethodAheadOfAMemoBlockWithAnEarlyReturn=A:old;1 | A:old;1 | C:old;1", "MethodAheadOfAScopeThatAHookPrunes=A:old;2 | A:old;2 | C:old;2", "MethodAheadOfAScopeThatALoopPrunes=A1:old;2A2:old;2 | A1:old;2A2:old;2 | C1:old;2C2:old;2", + "MethodAheadOfASwitchThatHoldsTheCall=A:old | A:old | C:old", + "MethodAheadOfASwitchWithACaseThatFallsThrough=A:old | A:old | C:old", "MethodHasItsOwnCall=A:old | A:old | C:old", "MethodInAMemoBlockAheadOfTheCall=A:old;1 | A:old;1 | C:old;1", "MethodIsAProxy=A:old | A:old | C:old", @@ -4429,6 +4434,40 @@ describe("bundler", () => { last.push(result); return
{result + last.length}
; } + // An early return puts the statements of the memo block of \`last\` in a labeled block. + export function MethodAheadOfAMemoBlockWithAnEarlyReturn(p) { + const o = make(p.name); + const [n] = useState(0); + let last; + const result = o.who((() => { o.who = other; return {o}{n}; })(), (last = [], "")) + ";"; + if (p.n > 100) return null; + last.push(result); + return
{result + last.length}
; + } + export function KeyAheadOfAMemoBlockWithAnEarlyReturn(p) { + const o = make("B"); + const frozen = {o}; + let key = same("who"); + let last; + const result = o[key]((key = "alt"), (last = [], "")) + ";"; + if (p.n > 100) return null; + last.push(result); + return
{result + last.length + frozen.type}
; + } + // The inlined function has its one return in a case, so the call and all behind it stand in that case. + export function MethodAheadOfASwitchThatHoldsTheCall(p) { + const o = make(p.name); + return
{o.who((() => { switch (p.n) { default: o.who = other; return ""; } })())}
; + } + // The statement stands in another case, which falls through to the case of the call. + export function MethodAheadOfASwitchWithACaseThatFallsThrough(p) { + const o = make(p.name); + return
{o.who((() => { switch (p.n) { case 2: o.who = other; default: return ""; } })())}
; + } + export function KeyAheadOfASwitchThatHoldsTheCall(p) { + let key = "who"; + return
{p.api[key]((() => { switch (p.n) { default: key = "alt"; return ""; } })())}
; + } // Without a hook the memo block of \`o\` holds the load of the method, and codegen cannot print the call behind it. export function MethodInAMemoBlockAheadOfTheCall(p) { const o = make(p.name); From 24e62cdad735fa36191e97b6b382b5a053db420b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 10 Nov 2026 03:00:07 +0000 Subject: [PATCH 13/13] react-compiler: assert that the walk finds the load of a method, and pin what the walk leaves as it is A walk that ends with no load is a call in a value block. A debug build now asserts that, so that a block that cuts the chain shows up in a debug run. New rows: a key with an effect ahead of a memo block, a call two memo blocks deep and one two pruned scopes deep, a call behind a loop in an inlined function, two calls that the walk finds through the chain and leaves as member calls, and a call in a case of a switch that prints with the receiver given although nothing between touches the receiver. The browser and the bun variant assert the printed call of these forms, and the browser variant asserts that the read of the method or of the key stands ahead of the memo block of the call. The todo test names four more forms that still read the method late: an array pattern over the receiver, a getter and a conversion to a string that replace the method, and a proxy trap. --- .../promote_used_temporaries.rs | 4 +- .../bundler/transpiler/react-compiler.test.ts | 202 +++++++++++++++++- 2 files changed, 202 insertions(+), 4 deletions(-) diff --git a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs index 1f6dc224728e..433d8c4056f9 100644 --- a/src/react_compiler/reactive_scopes/promote_used_temporaries.rs +++ b/src/react_compiler/reactive_scopes/promote_used_temporaries.rs @@ -706,7 +706,7 @@ impl<'a> InterState<'a> { /// What prints in front of an instruction: the statements of its block, then in `outer` those around that block. #[derive(Clone, Copy)] struct Before<'a> { - /// The terminal behind `statements` that holds the inner block, unless it is a label. Its other blocks can run first too. + /// The terminal behind `statements` that holds the inner block, unless it is a label. All of it counts, the call too. terminal: Option<&'a ReactiveTerminalStatement>, statements: &'a [ReactiveStatement], outer: Option<&'a Before<'a>>, @@ -1201,6 +1201,8 @@ fn promote_late_method( is_mutated, load, .. } = behind; let Some(load) = load else { + // A call in a value block prints in place, and the load of its method with it. + debug_assert!(inter_state.value_block_statements.is_some() || !env.has_stack()); return; }; if is_mutated && can_method_change(receiver, property, env) { diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 5cb5a56fd336..2b179749dc7d 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4013,10 +4013,35 @@ describe("bundler", () => { // the receiver given. Without memo blocks the last three producers leave no statement. const replaced = (producerNames: string[]) => Object.keys(callees).flatMap(calleeName => producerNames.map(producerName => calleeName + producerName)); + // The forms by hand whose call stands in a block of its own, with the load of its method ahead of that block. + const formsWithTheReceiverGiven = [ + "MethodAheadOfAMemoBlock", + "MethodAheadOfAMemoBlockWithAnEarlyReturn", + "MethodAheadOfAScopeThatAHookPrunes", + "MethodAheadOfAScopeThatALoopPrunes", + "MethodAheadOfASwitchThatHoldsTheCall", + "MethodAheadOfASwitchWithACaseThatFallsThrough", + "MethodAheadOfASwitchWithNothingBetween", + "MethodAheadOfTwoMemoBlocks", + "MethodAheadOfTwoScopesThatALoopPrunes", + "MethodBehindALoopAheadOfAScopeThatAHookPrunes", + ]; const receiverGiven = { - browser: replaced(Object.keys(producers)), - bun: replaced(Object.keys(producers).slice(0, -3)), + browser: [...replaced(Object.keys(producers)), ...formsWithTheReceiverGiven], + bun: [...replaced(Object.keys(producers).slice(0, -3)), ...formsWithTheReceiverGiven], }; + // The walk finds the load of these ahead of the block of the call, and nothing between mutates the receiver. The + // member call stays, with its key named. + const keyedCallsAheadOfABlock = [ + "KeyAheadOfAMemoBlock", + "KeyAheadOfAMemoBlockWithAnEarlyReturn", + "KeyAheadOfAScopeThatAHookPrunes", + "KeyAheadOfAScopeThatALoopPrunes", + "KeyAheadOfASwitchThatHoldsTheCall", + "KeyThatMutatesTheReceiverAheadOfAMemoBlock", + "KeyThatMutatesTheReceiverAheadOfAScopeThatALoopPrunes", + "KeyWithAnEffectAheadOfAMemoBlock", + ]; for (const [calleeName, { setup, target, callee }] of Object.entries(callees)) { for (const [producerName, producer] of Object.entries(producers)) { cells.push({ @@ -4103,6 +4128,9 @@ describe("bundler", () => { "KeyIsAMemberThatIsUpdated=A:first | A:first | C:first", "KeyIsReassigned=A:old | A:old | C:old", "KeyOfAMethodOfAProp=A:old | A:old | C:old", + "KeyThatMutatesTheReceiverAheadOfAMemoBlock=B:old;1 | B:old;1 | B:old;1", + "KeyThatMutatesTheReceiverAheadOfAScopeThatALoopPrunes=A1:old;2A2:old;2 | A1:old;2A2:old;2 | C1:old;2C2:old;2", + "KeyWithAnEffectAheadOfAMemoBlock=B:old;1i1 | B:old;1i2 | B:old;1i3", "LaterArgumentBehindADependency=A:old | A:old | C:old", "LaterArgumentBehindAStatement=A:old | A:old | C:old", "MemoBlocksAheadOfTheReceiver=A:old16 | A:old16 | C:old16", @@ -4112,6 +4140,10 @@ describe("bundler", () => { "MethodAheadOfAScopeThatALoopPrunes=A1:old;2A2:old;2 | A1:old;2A2:old;2 | C1:old;2C2:old;2", "MethodAheadOfASwitchThatHoldsTheCall=A:old | A:old | C:old", "MethodAheadOfASwitchWithACaseThatFallsThrough=A:old | A:old | C:old", + "MethodAheadOfASwitchWithNothingBetween=A:old | A:old | C:old", + "MethodAheadOfTwoMemoBlocks=A:old;12 | A:old;12 | C:old;12", + "MethodAheadOfTwoScopesThatALoopPrunes=A1:old;22A2:old;22 | A1:old;22A2:old;22 | C1:old;22C2:old;22", + "MethodBehindALoopAheadOfAScopeThatAHookPrunes=A:old;2 | A:old;2 | C:old;2", "MethodHasItsOwnCall=A:old | A:old | C:old", "MethodInAMemoBlockAheadOfTheCall=A:old;1 | A:old;1 | C:old;1", "MethodIsAProxy=A:old | A:old | C:old", @@ -4235,6 +4267,15 @@ describe("bundler", () => { return 1; }; export const size = list => list.length; + // Counts its conversions to a property key. The hook reads the count in every render. + let conversions = 0; + export const countedKey = { + toString() { + conversions++; + return "who"; + }, + }; + export const useConversions = () => conversions; `, "/cells.jsx": ` @@ -4243,7 +4284,7 @@ describe("bundler", () => { ` + cells.map(cell => cell.source).join(""), "/forms.jsx": /* jsx */ ` import { useState } from "react"; - import { make, other, pick, replace, same, size, tick, who } from "./helpers"; + import { countedKey, make, other, pick, replace, same, size, tick, useConversions, who } from "./helpers"; const withGetter = o => Object.defineProperty(o, "who", { @@ -4468,6 +4509,87 @@ describe("bundler", () => { let key = "who"; return
{p.api[key]((() => { switch (p.n) { default: key = "alt"; return ""; } })())}
; } + // Nothing touches the receiver here. On the way out of a block of a terminal the whole terminal counts, the + // call too, so this call prints with the receiver given. + export function MethodAheadOfASwitchWithNothingBetween(p) { + const o = make(p.name); + const local = { n: 0 }; + return
{o.who((() => { switch (p.n) { default: local.n = 1; return ""; } })())}
; + } + // The loop prunes both scopes: that of \`outerList\`, and that of \`last\` in it, which holds the call. + export function MethodAheadOfTwoScopesThatALoopPrunes(p) { + let out = ""; + for (const suffix of ["1", "2"]) { + const o = make(p.name + suffix); + let outerList, last; + const result = + o.who((() => { o.who = other; return {o}; })(), (outerList = [suffix], ""), (last = [suffix], "")) + + ";"; + last.push(result); + outerList.push(result); + out += result + last.length + outerList.length; + } + return
{out}
; + } + // The loop keeps the body of the function a statement with --minify-syntax too. + export function MethodBehindALoopAheadOfAScopeThatAHookPrunes(p) { + const o = make(p.name); + const [n] = useState(0); + let last; + const result = + o.who((() => { for (const key of ["who"]) o[key] = other; return {o}{n}; })(), (last = [], "")) + ";"; + const [m] = useState(0); + last.push(result, m); + return
{result + last.length}
; + } + // The call stands in the memo block of \`last\`, and that block in the memo block of \`outerList\`. + export function MethodAheadOfTwoMemoBlocks(p) { + const o = make(p.name); + const [n] = useState(0); + let outerList, last; + const result = + o.who((() => { o.who = other; return {o}{n}; })(), (outerList = [], ""), (last = [], "")) + ";"; + last.push(result); + outerList.push(last, p.n); + return
{result + last.length + outerList.length}
; + } + // No statement stands between the load and the call. The key has an effect, and the memo block of \`last\` + // must not hold it: the block runs once, and the source converts the key in every render. + export function KeyWithAnEffectAheadOfAMemoBlock(p) { + const o = make("B"); + const frozen = {o}; + let last; + const result = o[String(countedKey)]((last = [], "")) + ";"; + last.push(result); + const conversions = useConversions(); + return
{result + last.length + frozen.type + conversions}
; + } + // The key mutates the receiver ahead of the load of the method, and nothing does between the load and the + // call. The walk finds the load ahead of the block of the call, and the member call stays. + export function KeyThatMutatesTheReceiverAheadOfAMemoBlock(p) { + const o = make("B"); + let last; + const result = + o[(o.touched = true, same("who"))]( + (() => { const x = make("X"); x.who = other; return {o}{x}; })(), + (last = [], ""), + ) + ";"; + last.push(result); + return
{result + last.length}
; + } + export function KeyThatMutatesTheReceiverAheadOfAScopeThatALoopPrunes(p) { + let out = ""; + for (const suffix of ["1", "2"]) { + const o = make(p.name + suffix); + const local = { n: 0 }; + let last; + const result = + o[(o.touched = true, "who")]((() => { local.n = 1; return {o}; })(), (last = [suffix], "")) + ";"; + last.push(result); + out += result + last.length; + } + return
{out}
; + } // Without a hook the memo block of \`o\` holds the load of the method, and codegen cannot print the call behind it. export function MethodInAMemoBlockAheadOfTheCall(p) { const o = make(p.name); @@ -4590,6 +4712,17 @@ describe("bundler", () => { [], ); if (name !== "browser") return; + expect( + keyedCallsAheadOfABlock.filter( + form => + bodies[form].includes("Function.prototype.call.call(") || !/\b(?:t\d+|o)\[t\d+\]\(/.test(bodies[form]), + ), + ).toEqual([]); + // The read of the method, or of the key, stands ahead of the memo block that holds the call. + expect(bodies.MethodAheadOfAMemoBlock).toMatch( + /const (t\d+) = t\d+\.who;[\s\S]*\bif \(\$\[\d+\] !== [\s\S]*Function\.prototype\.call\.call\(\1, /, + ); + expect(bodies.KeyAheadOfAMemoBlock).toMatch(/const (t\d+) = key;[\s\S]*\bif \(\$\[\d+\] [\s\S]*\bo\[\1\]\(/); // A compiled function starts with its memo cache. In these three the load of the method is in a memo block // and the call is behind that block, which codegen cannot print. expect(Object.keys(bodies).filter(form => !/^\s*let \$ = c\(\d+\);/.test(bodies[form]))).toEqual([ @@ -4768,6 +4901,41 @@ describe("bundler", () => { log.push("argument"); return ""; }; + // Each of these runs code of the receiver where the inference records no mutation of it. + const withIterator = name => ({ + name, + who, + *[Symbol.iterator]() { + this.who = other; + yield "x"; + }, + }); + const withGetter = name => ({ + name, + who, + get swap() { + this.who = other; + return "g"; + }, + }); + const withToString = name => ({ + name, + who, + toString() { + this.who = other; + return "s"; + }, + }); + const withTrap = name => + new Proxy( + { name, who }, + { + ownKeys(target) { + target.who = other; + return ["name"]; + }, + }, + ); // The pass does not run in a function inside the component: "A:second". function CallInACallback(p) { @@ -4818,6 +4986,26 @@ describe("bundler", () => { } return
{out}
; } + // An array pattern runs the iterator of the receiver: "A:new". + function ArrayPatternOverTheReceiver(p) { + const o = withIterator(p.name); + return
{o.who((() => { const [x] = o; return x; })())}
; + } + // A getter of the receiver replaces the method: "A:new". + function GetterOfTheReceiverReplacesTheMethod(p) { + const o = withGetter(p.name); + return
{o.who((() => { const g = o.swap; return g; })())}
; + } + // The conversion to a string runs \`toString\` of the receiver: "A:new". + function ConversionOfTheReceiverToAString(p) { + const o = withToString(p.name); + return
{o.who((() => { const s = String(o); return s; })())}
; + } + // The head of the loop runs the \`ownKeys\` trap of the receiver: "A:new". + function TrapOfTheReceiverReplacesTheMethod(p) { + const o = withTrap(p.name); + return
{o.who((() => { for (const key in o) {} return ""; })())}
; + } for (const Form of [ CallInACallback, MethodOfTheProps, @@ -4826,6 +5014,10 @@ describe("bundler", () => { GetterReadsModuleState, SpreadIsIteratedBehindAStatement, MethodOfAPropThatIsNull, + ArrayPatternOverTheReceiver, + GetterOfTheReceiverReplacesTheMethod, + ConversionOfTheReceiverToAString, + TrapOfTheReceiverReplacesTheMethod, ]) { console.log(Form.name + "=" + Form({ name: "A", who }).props.children); } @@ -4853,6 +5045,10 @@ describe("bundler", () => { GetterReadsModuleState=A:0 SpreadIsIteratedBehindAStatement=A:oldx MethodOfAPropThatIsNull=TypeError, ran: nothing + ArrayPatternOverTheReceiver=A:oldx + GetterOfTheReceiverReplacesTheMethod=A:oldg + ConversionOfTheReceiverToAString=A:olds + TrapOfTheReceiverReplacesTheMethod=A:old `, }, });