From 150a6aabe9f434286c7d12aa553d064525dac381 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:28:31 +0000 Subject: [PATCH 1/9] react-compiler: declare what is outlined from a nested function in the scope that holds it The parser kept the functions that the compiler outlines from a nested function in a queue with no owner. Every statement list took the queue after each statement it visited. A second function behind the nested one in the same statement took them into its own body. A head such as a parameter default has no list in its scope at all. The name of each was listed in the module scope only, so the renamer gave it no number and two files of a bundle shared one `_temp`. Each visit_stmts call now takes, at its end, only what was outlined from the statements of its own list. In a block they go through the conversion of block-level function declarations and become a `let`. A label, and a head that declares nothing, count as the list around them. When that list is the module's, they go to the end of the module with those of a top-level function. A function in a head that declares a name gets them in its own body, as the --aot loader does. The name of an outlined function is listed in the scope that declares it. --- src/js_parser/p.rs | 9 +- src/js_parser/react_compiler_host.rs | 152 +++++- src/js_parser/visit/mod.rs | 25 +- src/jsc/RuntimeTranspilerCache.rs | 4 +- src/react_compiler/DESIGN.md | 26 + src/react_compiler/program.rs | 2 +- .../bundler/transpiler/react-compiler.test.ts | 491 ++++++++++++++++++ test/cli/test/bun-test.test.ts | 91 ++++ 8 files changed, 786 insertions(+), 14 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 3859c3391621..95118e60d5af 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -457,9 +457,11 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) react_compiler_result: Option, /// Visiting a function that is handed to the React Compiler, which re-declares its locals as new symbols. pub(crate) react_compiler_may_replace_body: bool, - /// Functions the compiler outlined from a function that isn't at the top level. `visit_stmts` puts them after the - /// statement it is visiting. + /// Functions the compiler outlined from a function that isn't at the top level, until the `visit_stmts` call that + /// visits the statement of that function puts them at the end of its list. pub(crate) react_compiler_outlined_from_nested: Vec, + /// The scope of the statement list that the innermost `visit_stmts` call visits. + pub(crate) react_compiler_list_scope: js_ast::StoreRef, /// only applicable when `.options.features.server_components` is /// configured to wrap exports. populated before visit pass starts. @@ -3435,7 +3437,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.runtime_imports.put(b"__require", ref_); } - fn will_use_renamer(&self) -> bool { + pub(crate) fn will_use_renamer(&self) -> bool { self.options.bundle || self.options.features.minify_identifiers } @@ -9798,6 +9800,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O react_compiler_result: None, react_compiler_may_replace_body: false, react_compiler_outlined_from_nested: Vec::new(), + react_compiler_list_scope: scope, server_components_wrap_ref: Ref::NONE, jest: Jest::default(), imported_mock_apis: Default::default(), diff --git a/src/js_parser/react_compiler_host.rs b/src/js_parser/react_compiler_host.rs index 425bafcd61c8..0141b4ded971 100644 --- a/src/js_parser/react_compiler_host.rs +++ b/src/js_parser/react_compiler_host.rs @@ -22,14 +22,39 @@ pub(crate) struct ReactCompilerCandidate { pub(crate) is_top_level: bool, } +/// Where what the compiler outlines from a function is declared ([`P::react_compiler_place_of_outlined`]). +#[derive(Clone, Copy)] +pub(crate) enum OutlinedPlace { + /// In the part that `bun_react_compiler::finish` makes, which declares each name at the top level of the module. + ModuleEnd, + /// At the end of the statement list of this scope. + List(js_ast::StoreRef), + /// In the body of the function itself. + OwnBody, +} + pub struct ReactCompilerHost<'p, 'a, const TS: bool, const SCAN_ONLY: bool> { p: &'p mut P<'a, TS, SCAN_ONLY>, + /// What is outlined from the function being compiled is declared in that function's own body. + outlines_into_body: bool, + /// The name that the function being compiled gives itself. A read of it is a local that nothing declares: it has + /// to keep its spelling. + own_name: Option, } impl<'p, 'a, const TS: bool, const SCAN_ONLY: bool> ReactCompilerHost<'p, 'a, TS, SCAN_ONLY> { #[inline] pub(crate) fn new(p: &'p mut P<'a, TS, SCAN_ONLY>) -> Self { - Self { p } + Self { + p, + outlines_into_body: false, + own_name: None, + } + } + + pub(crate) fn outlines_into_body_of(&mut self, own_name: Option) { + self.outlines_into_body = true; + self.own_name = own_name; } } @@ -143,7 +168,18 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> bun_react_compiler::Host fn new_local(&mut self, name: &[u8]) -> js_ast::Ref { let p = &mut *self.p; - let name = p.arena.alloc_slice_copy(name); + let mut name: &[u8] = p.arena.alloc_slice_copy(name); + // An outlined function that is declared in this body reads the names around the function from inside it. + // Without a renamer a local of the same name would hide one of them. + if self.outlines_into_body + && !p.will_use_renamer() + && self + .own_name + .is_none_or(|own| p.load_name_from_ref(own) != name) + && p.is_declared_around_compiled_function(name) + { + name = p.temp_var_name(name); + } let ref_ = p.new_symbol(js_ast::symbol::Kind::Other, name); // current_scope is the FunctionBody of the function being compiled. VecExt::append(&mut p.current_scope_mut().generated, ref_); @@ -192,6 +228,118 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> bun_react_compiler::Host } impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { + /// The scope that holds the function being compiled. `current_scope` is the body of that function, and its parent + /// holds the arguments. + fn scope_around_compiled_function(&self) -> Option> { + self.current_scope().parent.and_then(|args| args.parent) + } + + /// Where what is outlined from the function being compiled is declared. It is the statement list being visited, if + /// that list is in the scope that holds the function, or if every scope between the two declares nothing (a label, + /// the head of a `for` or the block of a `switch` with no `let`). The statement of a label is no list: a block + /// around it would take the label from a loop, so the list around the label declares them. A name at the top + /// level of the module has to be a declared symbol of a part, so the list of the module is the end of the module. + /// A function in a head that has names of its own (a parameter list, a `catch` binding, a `for` that declares) + /// has no list. + pub(crate) fn react_compiler_place_of_outlined(&self, is_top_level: bool) -> OutlinedPlace { + use js_ast::scope::Kind; + if is_top_level { + return OutlinedPlace::ModuleEnd; + } + let mut around = self.scope_around_compiled_function(); + let mut is_in_list = false; + while let Some(scope) = around { + is_in_list |= scope == self.react_compiler_list_scope; + if is_in_list { + if scope == self.module_scope { + return OutlinedPlace::ModuleEnd; + } + if scope.kind != Kind::Label { + return OutlinedPlace::List(scope); + } + } else if !matches!(scope.kind, Kind::Block | Kind::Label) || scope.members.count() > 0 + { + break; + } + around = scope.parent; + } + OutlinedPlace::OwnBody + } + + fn is_declared_around_compiled_function(&self, name: &[u8]) -> bool { + let mut around = self.scope_around_compiled_function(); + while let Some(scope) = around { + if scope.members.get(name).is_some() { + return true; + } + around = scope.parent; + } + false + } + + /// Declares what the compiler outlined, since it had `outlined_len` functions, from the nested function it just + /// compiled. `place` is where that function can reach it, and where it can reach the variables around that + /// function: a statement list, or else `body`, the function's own. + #[cold] + #[inline(never)] + pub(crate) fn declare_outlined_from_nested( + &mut self, + rc: &mut bun_react_compiler::ReactCompilerState, + outlined_len: usize, + place: OutlinedPlace, + body: &mut bun_alloc::ArenaVec<'a, js_ast::Stmt>, + ) { + let (is_in_list, mut declared_in) = match place { + OutlinedPlace::ModuleEnd => return, + OutlinedPlace::List(scope) => (true, scope), + OutlinedPlace::OwnBody => (false, self.current_scope), + }; + for stmt in rc.take_outlined_from(outlined_len) { + if let js_ast::StmtData::SFunction(function) = stmt.data + && let Some(name) = function.func.name + { + // `Host::new_generated` listed the name in the module scope. The renamer numbers a name with the scope + // that lists it, and no part declares this one at the top level. + let in_module_scope = &mut self.module_scope_mut().generated; + if let Some(at) = in_module_scope + .iter() + .rposition(|listed| *listed == name.ref_) + { + in_module_scope.remove(at); + } + VecExt::append(&mut declared_in.generated, name.ref_); + } + if is_in_list { + self.react_compiler_outlined_from_nested.push(stmt); + } else { + body.push(stmt); + } + } + } + + /// Moves what [`Self::declare_outlined_from_nested`] left since `start` into the statement list that was visited: + /// to the end of `visited`, or into `before` for a block, where `visit_stmts` makes a `let` of each like of the + /// block's own function declarations. The statement of a label leaves them to the list around the label. + #[cold] + #[inline(never)] + pub(crate) fn append_outlined_from_nested( + &mut self, + start: usize, + visited: &mut bun_alloc::ArenaVec<'a, js_ast::Stmt>, + before: &mut bun_alloc::ArenaVec<'a, js_ast::Stmt>, + ) { + let scope = self.current_scope(); + if scope.kind == js_ast::scope::Kind::Label { + return; + } + let list = if scope.kind_stops_hoisting() { + visited + } else { + before + }; + list.extend(self.react_compiler_outlined_from_nested.drain(start..)); + } + /// Drops the replaced body's symbols, or the renamer prints a new local `count` as `count2`. pub(crate) fn drop_symbols_of_replaced_function(&mut self, name: Option) { let mut body = self.current_scope; diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 0a8f52c60d92..bba6ab03a375 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -13,6 +13,7 @@ use crate::parser::{ Ref, RelocateVarsMode, ScopeOrder, StmtsKind, StrictModeFeature, StringVoidMap, VisitArgsOpts, VisitDeclOpts, is_eval_or_arguments, }; +use crate::react_compiler_host::OutlinedPlace; use bun_alloc::{ArenaVec as BumpVec, ArenaVecExt as _}; use bun_ast as js_ast; use bun_ast::G::{Decl, PropertyKind}; @@ -1572,6 +1573,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // `&mut`) so the pointer shares the local's base tag and survives the // direct `&mut before` reborrows in the loop below (Stacked Borrows). p.nearest_stmt_list = NonNull::new(core::ptr::addr_of_mut!(before)); + let prev_list_scope = + core::mem::replace(&mut p.react_compiler_list_scope, p.current_scope); + let outlined_start = p.react_compiler_outlined_from_nested.len(); let mut preprocessed_enum_i: usize = 0; @@ -1613,8 +1617,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O break 'list_getter &mut visited; }; p.visit_and_append_stmt(list, stmt)?; - // Outlined functions can use variables from this scope, so they can't go at the end of the module. - visited.extend(p.react_compiler_outlined_from_nested.drain(..)); + } + p.react_compiler_list_scope = prev_list_scope; + // Outlined functions can use variables from this scope, so they can't go at the end of the module. A + // function declaration is visible in all of its list, so they go to its end and not behind a statement. + if p.react_compiler_outlined_from_nested.len() > outlined_start { + p.append_outlined_from_nested(outlined_start, &mut visited, &mut before); } // Transform block-level function declarations into variable declarations @@ -1798,8 +1806,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // `Host::new_local` appends the locals of the compiled function here. let generated_len = p.current_scope().generated.len(); let outlined_len = rc.outlined_len(); + let outlined_place = p.react_compiler_place_of_outlined(pending.is_top_level); let compiled = { let host = &mut crate::react_compiler_host::ReactCompilerHost::new(p); + if matches!(outlined_place, OutlinedPlace::OwnBody) { + host.outlines_into_body_of(pending.own_name); + } bun_react_compiler::maybe_compile_pending( &mut rc, host, @@ -1808,19 +1820,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O name, ) }; - if !pending.is_top_level { - p.react_compiler_outlined_from_nested - .extend(rc.take_outlined_from(outlined_len)); - } - p.react_compiler = Some(rc); if let Some((new_body, result)) = compiled { stmts.clear(); stmts.extend(new_body); + if !matches!(outlined_place, OutlinedPlace::ModuleEnd) { + p.declare_outlined_from_nested(&mut rc, outlined_len, outlined_place, stmts); + } p.react_compiler_result = Some(result); p.drop_symbols_of_replaced_function(pending.own_name); } else { p.current_scope_mut().generated.truncate(generated_len); } + p.react_compiler = Some(rc); } if !p.should_remove_dead_statements() || !p.options.features.dead_code_elimination { diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index cafb4f0281bf..276a08724f1d 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: What the React Compiler outlines from a function that is nested in another one is +/// declared at the end of the list that holds that function, or in the function itself. +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/DESIGN.md b/src/react_compiler/DESIGN.md index c71953755797..9fcbb6b36f8c 100644 --- a/src/react_compiler/DESIGN.md +++ b/src/react_compiler/DESIGN.md @@ -128,6 +128,32 @@ function expression). The bundler's renamer then numbers the new symbols like the locals of any other function. `Host::new_generated` is for a name declared at module level, such as an outlined `_temp`. +What is outlined from a function that is nested in another one can read that +function's variables: `Host::is_module_level` is true for every binding outside +the compiled function. So the parser declares it where the nested function can +reach it and where it can reach those variables: + +- At the end of the statement list that holds the nested function. In a block + it is a `let`, like the block's own function declarations. A label, the head + of a `for` that declares nothing and a `switch` with no `let` count as the + list around them. When that list is the module's, it goes to the end of the + module like what is outlined from a top-level function: only a part can + declare a name of the module. +- In the nested function's own body when it stands in a head that declares a + name: a parameter list, a `catch` binding, a `for` that declares, the test of + a `case` in a `switch` that declares, the initializer of an enum member. It + is then a new function at each call. With no renamer, no local of the + compiled function takes a name that a scope around it declares. + +The parser lists the name in the scope of that declaration, and the renamer +numbers it there. The `--aot` loader places these functions itself (`Places` in +`src/aot/load/react_compiler/writer.rs`), and not always in the same place: +what is outlined from the test of a `case` goes in front of the statements of +that `case`. Upstream puts the outlined function after a nested function +declaration. For a nested arrow function or function expression it puts it at +the end of the program, where it throws if it reads a variable of an enclosing +function (facebook/react#34901). + ### The compiler as a linter `bun lint` has the validations as rules (`react/refs`, `react-hooks/refs`, ...). `lint.rs` is upstream's diff --git a/src/react_compiler/program.rs b/src/react_compiler/program.rs index 1c848652085d..aa8e8b965204 100644 --- a/src/react_compiler/program.rs +++ b/src/react_compiler/program.rs @@ -127,7 +127,7 @@ pub trait Host { /// resolved from the current scope the way the visit pass resolves it. fn jsx_classic_factory(&mut self, loc: Loc) -> Expr; - /// A new symbol for a module-level name, such as an outlined function. + /// A new symbol for a name that is not a local of the compiled function, such as an outlined function. fn new_generated(&mut self, name: &[u8]) -> Ref; /// A new symbol for a name that the compiled function (or one outlined from it) declares. diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index ff34a7a9f3e4..80697ffedb1e 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4356,6 +4356,497 @@ describe("bundler", () => { }, }); + const reactWithMemo = { + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + "/node_modules/react/index.js": `exports.memo = exports.forwardRef = fn => fn;`, + "/node_modules/react/compiler-runtime.js": ` + const sizes = []; + exports.c = size => { + sizes.push(size); + return new Array(size).fill(Symbol.for("react.memo_cache_sentinel")); + }; + exports.sizes = () => sizes; + `, + "/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => ({ type, props });`, + "/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => ({ type, props });`, + }; + + // Each form returns a component that it declares, with a callback that reads `k` of the form and one that reads the + // module only. Both are moved out of the component, and were declared behind the statement that ended first: in the + // body of a function behind the component, or in the body behind a head, which the head can't see. The first render + // threw `ReferenceError: _temp is not defined`. They are declared in the list of the statement that holds the + // component, once for all calls of the component. A component in a head that declares a name gets them in its own + // body, at each call. + const nestedComponentForms = { + "/entry.js": /* js */ ` + import { sizes } from "react/compiler-runtime"; + import * as forms from "./forms"; + import * as typed from "./forms-typed"; + const lines = []; + for (const [name, form] of Object.entries({ ...forms, ...typed }).sort(([a], [b]) => (a < b ? -1 : 1))) { + try { + const Leaf = form("red"); + const caches = sizes().length; + const first = Leaf({ x: 1 }).props; + const second = Leaf({ x: 2 }).props; + lines.push( + [ + name + ":", + first.x + "/" + first.on() + "/" + first.at(), + first.on.name === "on" || first.at.name === "at" ? "in place" : "moved out", + first.on === second.on && first.at === second.at ? "once" : "at each call", + ...new Set(sizes().slice(caches)), + ].join(" "), + ); + } catch (e) { + lines.push(name + " threw " + e); + } + } + console.log(lines.join("\\n")); + `, + "/forms.jsx": /* jsx */ ` + import { forwardRef, memo } from "react"; + const x0 = "!"; + + // A function behind the component, in the same statement. + export function secondDeclarator(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => { + const z = 1; + return z; + }; + return Leaf; + } + export function secondFunctionExpression(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = function () { + const z = 1; + return z; + }; + return Leaf; + } + export function arrowWithNoBlock(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => 1; + return Leaf; + } + export function threeDeclarators(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => 1, + third = () => 2; + return Leaf; + } + export function letDeclaration(k) { + let Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => 1; + return Leaf; + } + export function firstOfTwoComponents(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + Table = ({ x }) => k + x0} at={() => x0} />; + return Leaf; + } + export function secondOfTwoComponents(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + Table = ({ x }) => k + x0} at={() => x0} />; + return Table; + } + export function componentOfTheSecondFunction(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + makeInner = () => { + const Inner = ({ x }) => k + x0} at={() => x0} />; + return Inner; + }; + return makeInner(); + } + export function comparator(k) { + const Leaf = memo( + ({ x }) => k + x0} at={() => x0} />, + (p, q) => p.x === q.x, + ); + return Leaf; + } + export function comparatorBehindForwardRef(k) { + const Leaf = memo( + forwardRef(({ x }, ref) => k + x0} at={() => x0} />), + (p, q) => { + const same = p.x === q.x; + return same; + }, + ); + return Leaf; + } + export function classMethod(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + Other = class { + method() { + return 1; + } + }; + return Leaf; + } + export function objectMethod(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = { + method() { + return 1; + }, + }; + return Leaf; + } + export function calledFunction(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + one = (() => { + return 1; + })(); + return Leaf; + } + export function functionThatShadows(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = k => { + const z = k; + return z; + }; + return Leaf; + } + export function assignments(k) { + let Leaf, other; + (Leaf = memo(({ x }) => k + x0} at={() => x0} />)), (other = () => 1); + return Leaf; + } + export function ifBlock(k) { + if (k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => 1; + return Leaf; + } + } + export function switchCase(k) { + switch (k) { + case "red": + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => 1; + return Leaf; + } + } + export function labeledBlock(k) { + let out; + outer: { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => 1; + out = Leaf; + break outer; + } + return out; + } + let ofTheBlock; + { + const k = "red", + Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => 1; + ofTheBlock = Leaf; + } + export function blockOfTheModule() { + return ofTheBlock; + } + + // A head that holds the component and declares nothing, with a body behind it. + export function ifHead(k) { + let Leaf; + if ((Leaf = memo(({ x }) => k + x0} at={() => x0} />))) { + Leaf.seen = true; + } + return Leaf; + } + export function forHeadWithNoDeclaration(k) { + let Leaf; + for (Leaf = memo(({ x }) => k + x0} at={() => x0} />); ; ) { + return Leaf; + } + } + export function caseTest(k) { + let Leaf; + switch (0) { + case ((Leaf = memo(({ x }) => k + x0} at={() => x0} />)), 0): + return Leaf; + } + } + // A head that holds the component and declares a name: no statement list is in its scope. + export function forOfIterable(k) { + for (const Leaf of [memo(({ x }) => k + x0} at={() => x0} />)]) { + return Leaf; + } + } + export function forHead(k) { + for (let Leaf = memo(({ x }) => k + x0} at={() => x0} />); ; ) { + return Leaf; + } + } + export function forOfDefault(outer) { + for (const [k, Leaf = memo(({ x }) => k + x0} at={() => x0} />)] of [[outer]]) { + return Leaf; + } + } + export function catchDefault(outer) { + try { + throw { k: outer }; + } catch ({ k, Leaf = memo(({ x }) => k + x0} at={() => x0} />) }) { + return Leaf; + } + } + export function caseTestBesideALet(k) { + let Leaf; + switch (0) { + case ((Leaf = memo(({ x }) => k + suffix} at={() => x0} />)), 0): + let suffix = x0; + return Leaf; + } + } + export function defaultArgument(k, Leaf = memo(({ x }) => k + x0} at={() => x0} />)) { + return Leaf; + } + export const defaultArgumentOfAnArrow = (k, Leaf = memo(({ x }) => k + x0} at={() => x0} />)) => Leaf; + // The cache of the component has this name too. + export function defaultArgumentBehindADollar($, Leaf = memo(({ x }) => $ + x0} at={() => x0} />)) { + return Leaf; + } + + // These were right. + export function twoStatements(k) { + const Leaf = ({ x }) => k + x0} at={() => x0} />; + const other = () => 1; + return Leaf; + } + export function returned(k) { + return memo(({ x }) => k + x0} at={() => x0} />); + } + export function declaration(k) { + function Leaf({ x }) { + return k + x0} at={() => x0} />; + } + return Leaf; + } + `, + "/forms-typed.tsx": /* tsx */ ` + import { memo } from "react"; + const x0 = "!"; + + namespace Rows { + const k = "red"; + export const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => 1; + } + export function namespaceBody() { + return Rows.Leaf; + } + namespace ComparedRows { + const k = "red"; + export const Leaf = memo( + ({ x }) => k + x0} at={() => x0} />, + (p, q) => p.x === q.x, + ); + } + export function namespaceComparator() { + return ComparedRows.Leaf; + } + export function enumInitializer(k: string) { + let Leaf; + enum Rows { + First = ((Leaf = memo(({ x }) => k + x0} at={() => x0} />)), 0), + } + return Leaf; + } + `, + ...reactWithMemo, + }; + const nestedComponentsInAList = [ + "arrowWithNoBlock", + "assignments", + "blockOfTheModule", + "calledFunction", + "caseTest", + "classMethod", + "comparator", + "comparatorBehindForwardRef", + "componentOfTheSecondFunction", + "declaration", + "firstOfTwoComponents", + "forHeadWithNoDeclaration", + "functionThatShadows", + "ifBlock", + "ifHead", + "labeledBlock", + "letDeclaration", + "namespaceBody", + "namespaceComparator", + "objectMethod", + "returned", + "secondDeclarator", + "secondFunctionExpression", + "secondOfTwoComponents", + "switchCase", + "threeDeclarators", + "twoStatements", + ]; + const nestedComponentsInAHead = [ + "caseTestBesideALet", + "catchDefault", + "defaultArgument", + "defaultArgumentBehindADollar", + "defaultArgumentOfAnArrow", + "enumInitializer", + "forHead", + "forOfDefault", + "forOfIterable", + ]; + // --target=bun gives a component no cache. Node runs the two formats that are not strict code, where a function + // declared in a block is a variable of the function around the block too. + for (const [name, cache, runtime, options] of [ + ["Cli", " 2", "bun", { backend: "cli" }], + ["Api-bun", "", "bun", { backend: "api", target: "bun" }], + [ + "Cli-minify", + " 2", + "bun", + { backend: "cli", minifySyntax: true, minifyIdentifiers: true, minifyWhitespace: true }, + ], + ["Cli-cjs-node", " 2", "node", { backend: "cli", format: "cjs" }], + ["Cli-iife-node", " 2", "node", { backend: "cli", format: "iife" }], + ] as const) { + itBundled(`react-compiler/NestedComponentReachesItsCallback-${name}`, { + files: nestedComponentForms, + reactCompiler: true, + ...options, + run: { + runtime, + stdout: [ + ...nestedComponentsInAList.map(form => `${form}: 1/red!/! moved out once${cache}`), + ...nestedComponentsInAHead.map(form => `${form}: 1/red!/! moved out at each call${cache}`), + ] + .sort() + .join("\n"), + }, + onAfterBundle(api) { + // The minifier gives a name to a symbol that one scope lists, and not to one that the module scope lists too. + if (name === "Cli-minify") expect(api.readFile("/out.js")).not.toContain("_temp"); + }, + }); + } + + // The label takes one statement, and that statement took the callback: two statements are a block, and a `continue` + // can't name the label of a block. The bundle didn't parse. + itBundled("react-compiler/NestedComponentInTheTestOfALabeledLoop", { + files: { + "/entry.jsx": /* jsx */ ` + import { memo } from "react"; + const x0 = "!"; + function labeledWhile(k) { + let Leaf, + turns = 0; + again: while (!(Leaf = memo(({ x }) => k + x0} at={() => x0} />)) || turns++ < 1) { + continue again; + } + return Leaf; + } + function labeledDoWhile(k) { + let Leaf, + turns = 0; + again: do { + if (turns++ < 1) continue again; + } while (!(Leaf = memo(({ x }) => k + x0} at={() => x0} />))); + return Leaf; + } + for (const form of [labeledWhile, labeledDoWhile]) { + const Leaf = form("red"); + const first = Leaf({ x: 1 }).props; + const second = Leaf({ x: 2 }).props; + const callbacks = first.on === second.on && first.at === second.at ? "once" : "at each call"; + console.log(form.name + ":", first.x + "/" + first.on() + "/" + first.at(), callbacks); + } + `, + ...reactWithMemo, + }, + reactCompiler: true, + backend: "cli", + run: { stdout: "labeledWhile: 1/red!/! once\nlabeledDoWhile: 1/red!/! once" }, + }); + + // The bundler gave no number to a callback that was moved out of a nested component: it was `_temp` in every file. + // A component that couldn't reach its own called the one of another file, and one that could reach its own hid a + // `_temp` of another file that the function around it calls. The name is now listed in the scope that declares it. + // In a block it is a `let`: as a function declaration it is a variable of the whole bundle too where the code is + // not strict, and Node then gives the callback of block.jsx to `Top`. The components of heads.jsx stand in no + // function and no block, so their callbacks are names of the module, which only a part can declare. + for (const [name, runtime, format] of [ + ["", "bun", "esm"], + ["-cjs-node", "node", "cjs"], + ["-iife-node", "node", "iife"], + ] as const) { + itBundled(`react-compiler/NestedComponentAndTheTempOfAnotherFile${name}`, { + files: { + "/entry.jsx": /* jsx */ ` + import { memo } from "react"; + import { InABlock } from "./block.jsx"; + import { InACaseTest, InAForHead, InTheTestOfALabeledLoop, UnderAnIf } from "./heads.jsx"; + import { Top } from "./top.jsx"; + import { _temp as typed } from "./typed.js"; + const x0 = "!"; + function make(k) { + const Leaf = memo(({ x }) => k + x0} />, (p, q) => p.x === q.x); + return Leaf; + } + function makeWithTyped() { + const Leaf = ({ x }) => x0} typed={typed()} />; + return Leaf; + } + const AtTheTop = ({ x }) => "mine" + x0} />; + const all = [make("red"), Top, AtTheTop, InABlock, InTheTestOfALabeledLoop, InAForHead, InACaseTest, UnderAnIf]; + const called = all.map(Component => { + const { on } = Component({ x: 1 }).props; + return on() + (on.name === "on" ? " (in place)" : ""); + }); + console.log(called.join(" "), makeWithTyped()({ x: 1 }).props.typed); + `, + "/heads.jsx": /* jsx */ ` + import { memo } from "react"; + const mark = "!"; + export let InTheTestOfALabeledLoop, InAForHead, InACaseTest, UnderAnIf; + let turns = 0; + again: while (!(InTheTestOfALabeledLoop = memo(({ x }) => "label" + mark} />)) || turns++ < 1) { + continue again; + } + for (InAForHead = memo(({ x }) => "for" + mark} />); ; ) break; + switch (0) { + case ((InACaseTest = memo(({ x }) => "case" + mark} />)), 0): + } + if (mark) for (UnderAnIf = memo(({ x }) => "if" + mark} />); ; ) break; + `, + "/top.jsx": /* jsx */ ` + const y0 = "?"; + export const Top = ({ x }) => "top" + y0} />; + `, + "/block.jsx": /* jsx */ ` + export let InABlock; + { + const k = "block"; + const Leaf = ({ x }) => k + "!"} />; + InABlock = Leaf; + } + `, + "/typed.js": /* js */ ` + export function _temp() { + return "typed"; + } + `, + ...reactWithMemo, + }, + reactCompiler: true, + backend: "cli", + format, + run: { runtime, stdout: "red! top? mine! block! label! for! case! if! typed" }, + }); + } + // A local whose name ends in "Ref" is taken for a ref once its `.current` is read. Listing it in an effect's dependencies // used to be reported as reading a ref during render, unless it was initialized in one of a few ways. The expected sizes // come from babel-plugin-react-compiler 0.0.0-experimental-a1856f3-20260507, which only refuses `pier.bollard`. diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index 16ecb3c3281a..78922ad6c356 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -2245,6 +2245,97 @@ describe.concurrent("--react-compiler", () => { expect(result.exitCode).toBe(0); }); + // The callback is moved out of the component. It was declared in `compare`: "ReferenceError: _temp is not defined". + test("a component that is nested in a function calls its callback", async () => { + const result = await compiled( + { + "src/Rows.tsx": ` + const prefix = "clicked "; + export function makeRow(colour: string) { + const Row = ({ depth }) =>
  • prefix + colour} title={() => prefix}>{depth}
  • , + compare = (a, b) => a.depth === b.depth; + return Row; + } + export function makeRowInABlock(colour: string) { + if (colour) { + const Row = ({ depth }) =>
  • prefix + "dark " + colour} title={() => prefix}>{depth}
  • , + compare = (a, b) => a.depth === b.depth; + return Row; + } + } + `, + "compiled.test.tsx": ` + import { expect, test } from "bun:test"; + import { makeRow, makeRowInABlock } from "./src/Rows"; + test("which are compiled", () => { + const all = { Row: makeRow("red"), InABlock: makeRowInABlock("red") }; + ${printCompiled} + const clicked = Object.values(all).map(Row => Row({ depth: 1 }).props).map(props => props.onClick() + props.title()); + expect(clicked).toEqual(["clicked redclicked ", "clicked dark redclicked "]); + }); + `, + }, + ["--react-compiler"], + ); + expect(result.compiled).toBe("Row InABlock"); + expect(result.exitCode).toBe(0); + }); + + // In a head that declares a name the callback is declared in the component itself, whose cache would be named `$` + // too. No renamer runs here, so the cache gets another name, and `printCompiled` doesn't find it. The name that the + // last component gives itself has to stay: it renders itself by that name. + test("a component in a head calls a callback that reads a `$` around it", async () => { + const result = await compiled( + { + "node_modules/react/index.js": `export const memo = component => component;`, + "src/Rows.tsx": ` + import { memo } from "react"; + const $ = "module"; + export function besideAModuleVariable(k: string, Row = memo(({ depth }) =>
  • k + " " + $}>{depth}
  • )) { + return Row; + } + export function besideAParameter($: string, Row = memo(({ depth }) =>
  • "parameter " + $}>{depth}
  • )) { + return Row; + } + export function inAForHead($: string) { + for (let Row = memo(({ depth }) =>
  • "for " + $}>{depth}
  • ); ; ) return Row; + } + export function rendersItself(Tree = memo(function Tree({ item }) { + const [name, ...items] = Array.isArray(item) ? item : [item]; + if (!items.length) return ; + return
      {items.map((subItem, index) => )}
    ; + })) { + return Tree; + } + `, + "src/Global.tsx": ` + import { memo } from "react"; + export function besideAGlobal(Row = memo(({ depth }) =>
  • "global " + $}>{depth}
  • )) { + return Row; + } + `, + "compiled.test.tsx": ` + import { expect, test } from "bun:test"; + import { besideAGlobal } from "./src/Global"; + import { besideAModuleVariable, besideAParameter, inAForHead, rendersItself } from "./src/Rows"; + test("which are compiled", () => { + globalThis.$ = "jQuery"; + const Tree = rendersItself(); + const rows = { besideAModuleVariable: besideAModuleVariable("red"), besideAParameter: besideAParameter("red"), inAForHead: inAForHead("red"), besideAGlobal: besideAGlobal() }; + const all = { ...rows, Tree }; + console.log("compiled: " + Object.keys(all).filter(name => /= _c\\(\\d+\\)/.test(String(all[name]))).join(" ")); + const clicked = Object.values(rows).map(Row => Row({ depth: 1 }).props.onClick()); + expect(clicked).toEqual(["red module", "parameter red", "for red", "global jQuery"]); + expect(Tree({ item: ["root", "leaf"] }).props.children.map(child => child.type === Tree)).toEqual([true]); + }); + `, + }, + ["--react-compiler"], + ); + expect(result.compiled).toBe("besideAModuleVariable besideAParameter inAForHead besideAGlobal Tree"); + 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 ea2ebde2cc36b5da05651bbcd35e0e3dc93402e9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:36:36 +0000 Subject: [PATCH 2/9] react-compiler: rename the locals of a function in a head only when it outlines With no renamer, a local of a function that holds its own outlined callbacks must not take a name that a scope around the function declares. The host gave such a local another name when it made the symbol, for every function in a head. The parser now renames them after the compile, and only when the function outlined a callback. The two `bun test` cases ask the compiler runtime which components are compiled. In a run with --aot the text of a function is its source, so both cases failed in test/aot/runner-suites/bun-test-2.test.ts. --- src/js_parser/react_compiler_host.rs | 80 ++++++++++++++++------------ src/js_parser/visit/mod.rs | 9 ++-- test/cli/test/bun-test.test.ts | 28 ++++++++-- 3 files changed, 75 insertions(+), 42 deletions(-) diff --git a/src/js_parser/react_compiler_host.rs b/src/js_parser/react_compiler_host.rs index 0141b4ded971..0ad24b85a2e3 100644 --- a/src/js_parser/react_compiler_host.rs +++ b/src/js_parser/react_compiler_host.rs @@ -29,32 +29,22 @@ pub(crate) enum OutlinedPlace { ModuleEnd, /// At the end of the statement list of this scope. List(js_ast::StoreRef), - /// In the body of the function itself. - OwnBody, + /// In the body of the function itself. Its locals are the names that its body scope lists from `first_local` on. + /// `own_name` is the name that the function gives itself. + OwnBody { + first_local: usize, + own_name: Option, + }, } pub struct ReactCompilerHost<'p, 'a, const TS: bool, const SCAN_ONLY: bool> { p: &'p mut P<'a, TS, SCAN_ONLY>, - /// What is outlined from the function being compiled is declared in that function's own body. - outlines_into_body: bool, - /// The name that the function being compiled gives itself. A read of it is a local that nothing declares: it has - /// to keep its spelling. - own_name: Option, } impl<'p, 'a, const TS: bool, const SCAN_ONLY: bool> ReactCompilerHost<'p, 'a, TS, SCAN_ONLY> { #[inline] pub(crate) fn new(p: &'p mut P<'a, TS, SCAN_ONLY>) -> Self { - Self { - p, - outlines_into_body: false, - own_name: None, - } - } - - pub(crate) fn outlines_into_body_of(&mut self, own_name: Option) { - self.outlines_into_body = true; - self.own_name = own_name; + Self { p } } } @@ -168,18 +158,7 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> bun_react_compiler::Host fn new_local(&mut self, name: &[u8]) -> js_ast::Ref { let p = &mut *self.p; - let mut name: &[u8] = p.arena.alloc_slice_copy(name); - // An outlined function that is declared in this body reads the names around the function from inside it. - // Without a renamer a local of the same name would hide one of them. - if self.outlines_into_body - && !p.will_use_renamer() - && self - .own_name - .is_none_or(|own| p.load_name_from_ref(own) != name) - && p.is_declared_around_compiled_function(name) - { - name = p.temp_var_name(name); - } + let name = p.arena.alloc_slice_copy(name); let ref_ = p.new_symbol(js_ast::symbol::Kind::Other, name); // current_scope is the FunctionBody of the function being compiled. VecExt::append(&mut p.current_scope_mut().generated, ref_); @@ -241,9 +220,12 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { /// level of the module has to be a declared symbol of a part, so the list of the module is the end of the module. /// A function in a head that has names of its own (a parameter list, a `catch` binding, a `for` that declares) /// has no list. - pub(crate) fn react_compiler_place_of_outlined(&self, is_top_level: bool) -> OutlinedPlace { + pub(crate) fn react_compiler_place_of_outlined( + &self, + pending: &bun_react_compiler::PendingCompile, + ) -> OutlinedPlace { use js_ast::scope::Kind; - if is_top_level { + if pending.is_top_level { return OutlinedPlace::ModuleEnd; } let mut around = self.scope_around_compiled_function(); @@ -263,7 +245,10 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { } around = scope.parent; } - OutlinedPlace::OwnBody + OutlinedPlace::OwnBody { + first_local: self.current_scope().generated.len(), + own_name: pending.own_name, + } } fn is_declared_around_compiled_function(&self, name: &[u8]) -> bool { @@ -277,6 +262,29 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { false } + /// What is outlined into the body of the function being compiled reads the variables around that function from + /// inside it. With no renamer, a local of the function that has the name of one of them gets another name. A read + /// of `own_name` is a local that nothing declares: it keeps its spelling. + fn rename_locals_named_like_a_variable_around( + &mut self, + first_local: usize, + own_name: Option, + ) { + if self.will_use_renamer() { + return; + } + let own_name = own_name.map(|own| self.load_name_from_ref(own)); + for at in first_local..self.current_scope().generated.len() { + let local = self.current_scope().generated[at]; + let name = self.load_name_from_ref(local); + if own_name != Some(name) && self.is_declared_around_compiled_function(name) { + let renamed = self.temp_var_name(name); + self.symbols[local.inner_index() as usize].original_name = + js_ast::StoreStr::new(renamed); + } + } + } + /// Declares what the compiler outlined, since it had `outlined_len` functions, from the nested function it just /// compiled. `place` is where that function can reach it, and where it can reach the variables around that /// function: a statement list, or else `body`, the function's own. @@ -292,7 +300,13 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { let (is_in_list, mut declared_in) = match place { OutlinedPlace::ModuleEnd => return, OutlinedPlace::List(scope) => (true, scope), - OutlinedPlace::OwnBody => (false, self.current_scope), + OutlinedPlace::OwnBody { + first_local, + own_name, + } => { + self.rename_locals_named_like_a_variable_around(first_local, own_name); + (false, self.current_scope) + } }; for stmt in rc.take_outlined_from(outlined_len) { if let js_ast::StmtData::SFunction(function) = stmt.data diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index bba6ab03a375..f83e08541dc5 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1806,12 +1806,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // `Host::new_local` appends the locals of the compiled function here. let generated_len = p.current_scope().generated.len(); let outlined_len = rc.outlined_len(); - let outlined_place = p.react_compiler_place_of_outlined(pending.is_top_level); + let outlined_place = p.react_compiler_place_of_outlined(&pending); let compiled = { let host = &mut crate::react_compiler_host::ReactCompilerHost::new(p); - if matches!(outlined_place, OutlinedPlace::OwnBody) { - host.outlines_into_body_of(pending.own_name); - } bun_react_compiler::maybe_compile_pending( &mut rc, host, @@ -1823,7 +1820,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if let Some((new_body, result)) = compiled { stmts.clear(); stmts.extend(new_body); - if !matches!(outlined_place, OutlinedPlace::ModuleEnd) { + if !matches!(outlined_place, OutlinedPlace::ModuleEnd) + && rc.outlined_len() > outlined_len + { p.declare_outlined_from_nested(&mut rc, outlined_len, outlined_place, stmts); } p.react_compiler_result = Some(result); diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index 78922ad6c356..bfcb869aee06 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -2245,10 +2245,29 @@ describe.concurrent("--react-compiler", () => { expect(result.exitCode).toBe(0); }); + // These print which components ask the compiler runtime for a cache when they are called. In a run with --aot the + // text of a function is its source, so `printCompiled` finds nothing there. + const countsCaches = { + "node_modules/react/compiler-runtime.js": ` + export const c = size => { + globalThis.caches = (globalThis.caches ?? 0) + 1; + return Array(size).fill(Symbol.for("react.memo_cache_sentinel")); + }; + `, + }; + const printAsksForACache = ` + console.log("compiled: " + Object.keys(all).filter(name => { + const before = globalThis.caches ?? 0; + all[name]({ depth: 1, item: "leaf" }); + return (globalThis.caches ?? 0) > before; + }).join(" ")); + `; + // The callback is moved out of the component. It was declared in `compare`: "ReferenceError: _temp is not defined". test("a component that is nested in a function calls its callback", async () => { const result = await compiled( { + ...countsCaches, "src/Rows.tsx": ` const prefix = "clicked "; export function makeRow(colour: string) { @@ -2269,7 +2288,7 @@ describe.concurrent("--react-compiler", () => { import { makeRow, makeRowInABlock } from "./src/Rows"; test("which are compiled", () => { const all = { Row: makeRow("red"), InABlock: makeRowInABlock("red") }; - ${printCompiled} + ${printAsksForACache} const clicked = Object.values(all).map(Row => Row({ depth: 1 }).props).map(props => props.onClick() + props.title()); expect(clicked).toEqual(["clicked redclicked ", "clicked dark redclicked "]); }); @@ -2282,11 +2301,12 @@ describe.concurrent("--react-compiler", () => { }); // In a head that declares a name the callback is declared in the component itself, whose cache would be named `$` - // too. No renamer runs here, so the cache gets another name, and `printCompiled` doesn't find it. The name that the - // last component gives itself has to stay: it renders itself by that name. + // too. No renamer runs here, so the cache gets another name. The name that the last component gives itself has to + // stay: it renders itself by that name. test("a component in a head calls a callback that reads a `$` around it", async () => { const result = await compiled( { + ...countsCaches, "node_modules/react/index.js": `export const memo = component => component;`, "src/Rows.tsx": ` import { memo } from "react"; @@ -2323,7 +2343,7 @@ describe.concurrent("--react-compiler", () => { const Tree = rendersItself(); const rows = { besideAModuleVariable: besideAModuleVariable("red"), besideAParameter: besideAParameter("red"), inAForHead: inAForHead("red"), besideAGlobal: besideAGlobal() }; const all = { ...rows, Tree }; - console.log("compiled: " + Object.keys(all).filter(name => /= _c\\(\\d+\\)/.test(String(all[name]))).join(" ")); + ${printAsksForACache} const clicked = Object.values(rows).map(Row => Row({ depth: 1 }).props.onClick()); expect(clicked).toEqual(["red module", "parameter red", "for red", "global jQuery"]); expect(Tree({ item: ["root", "leaf"] }).props.children.map(child => child.type === Tree)).toEqual([true]); From 4ca484f6ddb6c151b5cc7fa62e1e36c23d380964 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:25:09 +0000 Subject: [PATCH 3/9] react-compiler: write the `let` of a callback outlined into a block, also beside a direct eval A block that holds a direct `eval` keeps its function declarations as they are written. A callback that was outlined into such a block stayed a function declaration. Where the code is not strict that is a variable of the function around the block too: in a cjs bundle the component of another file called it. The parser now writes `let _temp = function () {}` itself and does not hand the declaration to that conversion. Each comment that the branch adds is one line. DESIGN.md has the rule. --- src/js_parser/p.rs | 3 +- src/js_parser/react_compiler_host.rs | 70 ++++++++++++------- src/js_parser/visit/mod.rs | 3 +- src/jsc/RuntimeTranspilerCache.rs | 3 +- .../bundler/transpiler/react-compiler.test.ts | 31 ++++++++ 5 files changed, 77 insertions(+), 33 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 95118e60d5af..3cb59dc595a9 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -457,8 +457,7 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) react_compiler_result: Option, /// Visiting a function that is handed to the React Compiler, which re-declares its locals as new symbols. pub(crate) react_compiler_may_replace_body: bool, - /// Functions the compiler outlined from a function that isn't at the top level, until the `visit_stmts` call that - /// visits the statement of that function puts them at the end of its list. + /// Functions outlined from a nested function, until the `visit_stmts` call that visits its statement takes them. pub(crate) react_compiler_outlined_from_nested: Vec, /// The scope of the statement list that the innermost `visit_stmts` call visits. pub(crate) react_compiler_list_scope: js_ast::StoreRef, diff --git a/src/js_parser/react_compiler_host.rs b/src/js_parser/react_compiler_host.rs index 0ad24b85a2e3..c58023e40a5a 100644 --- a/src/js_parser/react_compiler_host.rs +++ b/src/js_parser/react_compiler_host.rs @@ -29,8 +29,7 @@ pub(crate) enum OutlinedPlace { ModuleEnd, /// At the end of the statement list of this scope. List(js_ast::StoreRef), - /// In the body of the function itself. Its locals are the names that its body scope lists from `first_local` on. - /// `own_name` is the name that the function gives itself. + /// In the body of the function itself, whose scope lists its locals from `first_local` on. OwnBody { first_local: usize, own_name: Option, @@ -207,19 +206,12 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> bun_react_compiler::Host } impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { - /// The scope that holds the function being compiled. `current_scope` is the body of that function, and its parent - /// holds the arguments. + /// `current_scope` is the body of the function being compiled, and its parent holds the arguments. fn scope_around_compiled_function(&self) -> Option> { self.current_scope().parent.and_then(|args| args.parent) } - /// Where what is outlined from the function being compiled is declared. It is the statement list being visited, if - /// that list is in the scope that holds the function, or if every scope between the two declares nothing (a label, - /// the head of a `for` or the block of a `switch` with no `let`). The statement of a label is no list: a block - /// around it would take the label from a loop, so the list around the label declares them. A name at the top - /// level of the module has to be a declared symbol of a part, so the list of the module is the end of the module. - /// A function in a head that has names of its own (a parameter list, a `catch` binding, a `for` that declares) - /// has no list. + /// Where what is outlined from the function being compiled is declared. src/react_compiler/DESIGN.md has the rule. pub(crate) fn react_compiler_place_of_outlined( &self, pending: &bun_react_compiler::PendingCompile, @@ -233,9 +225,11 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { while let Some(scope) = around { is_in_list |= scope == self.react_compiler_list_scope; if is_in_list { + // Only a part can declare a name of the module. if scope == self.module_scope { return OutlinedPlace::ModuleEnd; } + // A block around the statement of a label would take the label from a loop. if scope.kind != Kind::Label { return OutlinedPlace::List(scope); } @@ -262,9 +256,7 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { false } - /// What is outlined into the body of the function being compiled reads the variables around that function from - /// inside it. With no renamer, a local of the function that has the name of one of them gets another name. A read - /// of `own_name` is a local that nothing declares: it keeps its spelling. + /// With no renamer, a local would hide a variable of the same name that a function outlined into this body reads. fn rename_locals_named_like_a_variable_around( &mut self, first_local: usize, @@ -277,6 +269,7 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { for at in first_local..self.current_scope().generated.len() { let local = self.current_scope().generated[at]; let name = self.load_name_from_ref(local); + // A read of the function's own name is a local that nothing declares: it keeps its spelling. if own_name != Some(name) && self.is_declared_around_compiled_function(name) { let renamed = self.temp_var_name(name); self.symbols[local.inner_index() as usize].original_name = @@ -285,9 +278,7 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { } } - /// Declares what the compiler outlined, since it had `outlined_len` functions, from the nested function it just - /// compiled. `place` is where that function can reach it, and where it can reach the variables around that - /// function: a statement list, or else `body`, the function's own. + /// Declares in `place` what the nested function that was just compiled outlined. `body` is that function's own. #[cold] #[inline(never)] pub(crate) fn declare_outlined_from_nested( @@ -312,8 +303,7 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { if let js_ast::StmtData::SFunction(function) = stmt.data && let Some(name) = function.func.name { - // `Host::new_generated` listed the name in the module scope. The renamer numbers a name with the scope - // that lists it, and no part declares this one at the top level. + // The renamer numbers a name with the scope that lists it: take it out of the module scope. let in_module_scope = &mut self.module_scope_mut().generated; if let Some(at) = in_module_scope .iter() @@ -331,9 +321,7 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { } } - /// Moves what [`Self::declare_outlined_from_nested`] left since `start` into the statement list that was visited: - /// to the end of `visited`, or into `before` for a block, where `visit_stmts` makes a `let` of each like of the - /// block's own function declarations. The statement of a label leaves them to the list around the label. + /// Moves what was outlined since `start` into the visited list: to its end, or as a `let` each to the start of a block. #[cold] #[inline(never)] pub(crate) fn append_outlined_from_nested( @@ -343,15 +331,43 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { before: &mut bun_alloc::ArenaVec<'a, js_ast::Stmt>, ) { let scope = self.current_scope(); + // The list around the label takes them. if scope.kind == js_ast::scope::Kind::Label { return; } - let list = if scope.kind_stops_hoisting() { - visited - } else { - before + if scope.kind_stops_hoisting() { + visited.extend(self.react_compiler_outlined_from_nested.drain(start..)); + return; + } + // Where the code is not strict, a function declared in a block is a variable of the function around it too. + for at in start..self.react_compiler_outlined_from_nested.len() { + let stmt = self.react_compiler_outlined_from_nested[at]; + before.push(self.let_of_function_declaration(stmt)); + } + self.react_compiler_outlined_from_nested.truncate(start); + } + + fn let_of_function_declaration(&mut self, stmt: js_ast::Stmt) -> js_ast::Stmt { + let js_ast::StmtData::SFunction(mut declaration) = stmt.data else { + return stmt; + }; + let Some(name) = declaration.func.name.take() else { + return stmt; }; - list.extend(self.react_compiler_outlined_from_nested.drain(start..)); + let func = core::mem::take(&mut declaration.func); + let value = self.new_expr(js_ast::E::Function { func }, stmt.loc); + let binding = self.b(js_ast::B::Identifier { r#ref: name.ref_ }, name.loc); + self.s( + js_ast::S::Local { + kind: js_ast::s::Kind::KLet, + decls: js_ast::G::DeclList::init_one(js_ast::G::Decl { + binding, + value: Some(value), + }), + ..Default::default() + }, + stmt.loc, + ) } /// Drops the replaced body's symbols, or the renamer prints a new local `count` as `count2`. diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index f83e08541dc5..cf5ee4d881f3 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1619,8 +1619,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.visit_and_append_stmt(list, stmt)?; } p.react_compiler_list_scope = prev_list_scope; - // Outlined functions can use variables from this scope, so they can't go at the end of the module. A - // function declaration is visible in all of its list, so they go to its end and not behind a statement. + // Outlined functions can use variables from this scope: they go to the end of its list. if p.react_compiler_outlined_from_nested.len() > outlined_start { p.append_outlined_from_nested(outlined_start, &mut visited, &mut before); } diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 276a08724f1d..c46669db071c 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: What the React Compiler outlines from a function that is nested in another one is -/// declared at the end of the list that holds that function, or in the function itself. +/// Version 52: The React Compiler declares what it outlines from a nested function in the scope that holds it. const EXPECTED_VERSION: u32 = 52; /// Source files smaller than this are not written to / read from the on-disk diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 80697ffedb1e..7cfc98b48b64 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4847,6 +4847,37 @@ describe("bundler", () => { }); } + // A block that holds a direct `eval` keeps its function declarations as they are written. There the callback stayed + // a function declaration, which is a variable of the whole cjs bundle too, and `Top` called it. + for (const runtime of ["bun", "node"] as const) { + itBundled(`react-compiler/NestedComponentInABlockWithADirectEval-cjs-${runtime}`, { + files: { + "/entry.jsx": /* jsx */ ` + import { Top } from "./top.jsx"; + let InABlock; + { + eval(""); + const Leaf = ({ x }) => "mine"} />; + InABlock = Leaf; + } + const called = [InABlock, Top].map(Component => { + const { on } = Component({ x: 1 }).props; + return on() + (on.name === "on" ? " (in place)" : ""); + }); + console.log(called.join(" ")); + `, + "/top.jsx": /* jsx */ ` + export const Top = ({ x }) => "top"} />; + `, + ...reactWithMemo, + }, + reactCompiler: true, + backend: "cli", + format: "cjs", + run: { runtime, stdout: "mine top" }, + }); + } + // A local whose name ends in "Ref" is taken for a ref once its `.current` is read. Listing it in an effect's dependencies // used to be reported as reading a ref during render, unless it was initialized in one of a few ways. The expected sizes // come from babel-plugin-react-compiler 0.0.0-experimental-a1856f3-20260507, which only refuses `pier.bollard`. From 7af8a7fb52a4f35c5657abd00a9d8778533b03a4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:06:53 +0000 Subject: [PATCH 4/9] react-compiler: declare a callback in its component when the block holds a direct eval A block that holds a direct `eval` keeps its function declarations as they are written. There a callback outlined into the block has no form that is right everywhere. As a function declaration it is a variable of the function around the block too where the code is not strict, and a cjs bundle gave it to the component of another file. As a `let` in a `case` it is not set when another `case` calls a component that the `switch` declares. The callback is now declared in the component, like for a component in a head. This takes back the `let` that the parser wrote for every block: the conversion of block-level function declarations writes it again. --- src/js_parser/react_compiler_host.rs | 43 +++++-------------- src/react_compiler/DESIGN.md | 7 ++- .../bundler/transpiler/react-compiler.test.ts | 34 +++++++++++++-- 3 files changed, 46 insertions(+), 38 deletions(-) diff --git a/src/js_parser/react_compiler_host.rs b/src/js_parser/react_compiler_host.rs index c58023e40a5a..51aefcf81697 100644 --- a/src/js_parser/react_compiler_host.rs +++ b/src/js_parser/react_compiler_host.rs @@ -231,6 +231,10 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { } // A block around the statement of a label would take the label from a loop. if scope.kind != Kind::Label { + // Beside a direct `eval` a block keeps a function declaration, and a `case` can skip a `let`. + if scope.contains_direct_eval && !scope.kind_stops_hoisting() { + break; + } return OutlinedPlace::List(scope); } } else if !matches!(scope.kind, Kind::Block | Kind::Label) || scope.members.count() > 0 @@ -321,7 +325,7 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { } } - /// Moves what was outlined since `start` into the visited list: to its end, or as a `let` each to the start of a block. + /// Moves what was outlined since `start` into the visited list. A block gets each as a `let`, through `before`. #[cold] #[inline(never)] pub(crate) fn append_outlined_from_nested( @@ -335,39 +339,12 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { if scope.kind == js_ast::scope::Kind::Label { return; } - if scope.kind_stops_hoisting() { - visited.extend(self.react_compiler_outlined_from_nested.drain(start..)); - return; - } - // Where the code is not strict, a function declared in a block is a variable of the function around it too. - for at in start..self.react_compiler_outlined_from_nested.len() { - let stmt = self.react_compiler_outlined_from_nested[at]; - before.push(self.let_of_function_declaration(stmt)); - } - self.react_compiler_outlined_from_nested.truncate(start); - } - - fn let_of_function_declaration(&mut self, stmt: js_ast::Stmt) -> js_ast::Stmt { - let js_ast::StmtData::SFunction(mut declaration) = stmt.data else { - return stmt; - }; - let Some(name) = declaration.func.name.take() else { - return stmt; + let list = if scope.kind_stops_hoisting() { + visited + } else { + before }; - let func = core::mem::take(&mut declaration.func); - let value = self.new_expr(js_ast::E::Function { func }, stmt.loc); - let binding = self.b(js_ast::B::Identifier { r#ref: name.ref_ }, name.loc); - self.s( - js_ast::S::Local { - kind: js_ast::s::Kind::KLet, - decls: js_ast::G::DeclList::init_one(js_ast::G::Decl { - binding, - value: Some(value), - }), - ..Default::default() - }, - stmt.loc, - ) + list.extend(self.react_compiler_outlined_from_nested.drain(start..)); } /// Drops the replaced body's symbols, or the renamer prints a new local `count` as `count2`. diff --git a/src/react_compiler/DESIGN.md b/src/react_compiler/DESIGN.md index 9fcbb6b36f8c..53adc9503cb3 100644 --- a/src/react_compiler/DESIGN.md +++ b/src/react_compiler/DESIGN.md @@ -141,8 +141,11 @@ reach it and where it can reach those variables: declare a name of the module. - In the nested function's own body when it stands in a head that declares a name: a parameter list, a `catch` binding, a `for` that declares, the test of - a `case` in a `switch` that declares, the initializer of an enum member. It - is then a new function at each call. With no renamer, no local of the + a `case` in a `switch` that declares, the initializer of an enum member. Also + in a block that holds a direct `eval`: that block keeps a function + declaration as written, which is a `var` too where the code is not strict, + and a `let` in a `case` is not set when another `case` calls the function. + It is then a new function at each call. With no renamer, no local of the compiled function takes a name that a scope around it declares. The parser lists the name in the scope of that declaration, and the renamer diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 7cfc98b48b64..320f2fba3383 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4376,14 +4376,17 @@ describe("bundler", () => { // body of a function behind the component, or in the body behind a head, which the head can't see. The first render // threw `ReferenceError: _temp is not defined`. They are declared in the list of the statement that holds the // component, once for all calls of the component. A component in a head that declares a name gets them in its own - // body, at each call. + // body, at each call. So does one in a block that holds a direct `eval`: that block keeps its function declarations, + // and `Leaf` of the last form is called with no statement of its `case` run. const nestedComponentForms = { "/entry.js": /* js */ ` import { sizes } from "react/compiler-runtime"; import * as forms from "./forms"; import * as typed from "./forms-typed"; + import * as withEval from "./forms-with-eval"; const lines = []; - for (const [name, form] of Object.entries({ ...forms, ...typed }).sort(([a], [b]) => (a < b ? -1 : 1))) { + const all = { ...forms, ...typed, ...withEval }; + for (const [name, form] of Object.entries(all).sort(([a], [b]) => (a < b ? -1 : 1))) { try { const Leaf = form("red"); const caches = sizes().length; @@ -4657,6 +4660,28 @@ describe("bundler", () => { return Leaf; } `, + "/forms-with-eval.jsx": /* jsx */ ` + const x0 = "!"; + export function blockWithAnEval(k) { + { + eval(""); + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => 1; + return Leaf; + } + } + export function caseBesideAnEval(k) { + switch (k) { + case "never": + eval(""); + function Leaf({ x }) { + return k + x0} at={() => x0} />; + } + default: + return Leaf; + } + } + `, ...reactWithMemo, }; const nestedComponentsInAList = [ @@ -4689,6 +4714,8 @@ describe("bundler", () => { "twoStatements", ]; const nestedComponentsInAHead = [ + "blockWithAnEval", + "caseBesideAnEval", "caseTestBesideALet", "catchDefault", "defaultArgument", @@ -4848,7 +4875,8 @@ describe("bundler", () => { } // A block that holds a direct `eval` keeps its function declarations as they are written. There the callback stayed - // a function declaration, which is a variable of the whole cjs bundle too, and `Top` called it. + // a function declaration, which is a variable of the whole cjs bundle too, and `Top` called it. It is now declared + // in the component. for (const runtime of ["bun", "node"] as const) { itBundled(`react-compiler/NestedComponentInABlockWithADirectEval-cjs-${runtime}`, { files: { From 251800ce9fb84cecf5be7202b47c4e4ec22c7cdd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:10:15 +0000 Subject: [PATCH 5/9] ci: retrigger From 9bb324c8e02b7bd0f383de1194ba664cd056ca8d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:47:13 +0000 Subject: [PATCH 6/9] react-compiler: declare the callbacks of an async function declaration in a block in the function itself A block makes a `let` of a function declaration, but not of one that is async or a generator. That one stays a declaration, and every `case` of a `switch` can call it. Its outlined callbacks were a `let` of the `case` that declares it, so a call through another `case` threw `ReferenceError: Cannot access '_temp' before initialization`. They are now declared in the function itself, as for a block that holds a direct `eval`. --- src/js_parser/react_compiler_host.rs | 15 +++++++++-- src/react_compiler/DESIGN.md | 12 +++++---- .../bundler/transpiler/react-compiler.test.ts | 26 +++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/js_parser/react_compiler_host.rs b/src/js_parser/react_compiler_host.rs index 51aefcf81697..eb6d0860ed40 100644 --- a/src/js_parser/react_compiler_host.rs +++ b/src/js_parser/react_compiler_host.rs @@ -231,8 +231,10 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { } // A block around the statement of a label would take the label from a loop. if scope.kind != Kind::Label { - // Beside a direct `eval` a block keeps a function declaration, and a `case` can skip a `let`. - if scope.contains_direct_eval && !scope.kind_stops_hoisting() { + // A `case` can call a declaration that its block keeps, and skip the `let` of its callback. + if !scope.kind_stops_hoisting() + && (scope.contains_direct_eval || Self::is_kept_in_a_block(pending)) + { break; } return OutlinedPlace::List(scope); @@ -249,6 +251,15 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { } } + /// A block makes a `let` of a function declaration, but not of one that is async or a generator. + fn is_kept_in_a_block(pending: &bun_react_compiler::PendingCompile) -> bool { + use js_ast::flags::Function; + pending.own_name.is_some() + && pending.binding == pending.own_name + && (pending.flags.contains(Function::IsAsync) + || pending.flags.contains(Function::IsGenerator)) + } + fn is_declared_around_compiled_function(&self, name: &[u8]) -> bool { let mut around = self.scope_around_compiled_function(); while let Some(scope) = around { diff --git a/src/react_compiler/DESIGN.md b/src/react_compiler/DESIGN.md index 53adc9503cb3..7e2583199a76 100644 --- a/src/react_compiler/DESIGN.md +++ b/src/react_compiler/DESIGN.md @@ -142,11 +142,13 @@ reach it and where it can reach those variables: - In the nested function's own body when it stands in a head that declares a name: a parameter list, a `catch` binding, a `for` that declares, the test of a `case` in a `switch` that declares, the initializer of an enum member. Also - in a block that holds a direct `eval`: that block keeps a function - declaration as written, which is a `var` too where the code is not strict, - and a `let` in a `case` is not set when another `case` calls the function. - It is then a new function at each call. With no renamer, no local of the - compiled function takes a name that a scope around it declares. + in a block that holds a direct `eval` in front of it: that block keeps a + function declaration as written, which is a `var` too where the code is not + strict, and a `let` in a `case` is not set when another `case` calls the + function. Also when it is an async or generator function declaration in a + block, which a block always keeps. It is then a new function at each call. + With no renamer, no local of the compiled function takes a name that a scope + around it declares. The parser lists the name in the scope of that declaration, and the renamer numbers it there. The `--aot` loader places these functions itself (`Places` in diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 320f2fba3383..29b17f8025fe 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4906,6 +4906,32 @@ describe("bundler", () => { }); } + // A block keeps a function declaration that is async, so another `case` can call it. Its callback was a `let` of the + // `case` that declares it: "ReferenceError: Cannot access '_temp' before initialization". It is declared in the component. + itBundled("react-compiler/NestedAsyncComponentCalledThroughAnotherCase", { + files: { + "/entry.jsx": /* jsx */ ` + const x0 = "!"; + function viaAnotherCase(k) { + switch (k) { + case "never": + async function Leaf({ x }) { + return k + x0} />; + } + default: + return Leaf; + } + } + const { on } = (await viaAnotherCase("red")({ x: 1 })).props; + console.log(on(), on.name === "on" ? "in place" : "moved out"); + `, + ...reactWithMemo, + }, + reactCompiler: true, + backend: "cli", + run: { stdout: "red! moved out" }, + }); + // A local whose name ends in "Ref" is taken for a ref once its `.current` is read. Listing it in an effect's dependencies // used to be reported as reading a ref during render, unless it was initialized in one of a few ways. The expected sizes // come from babel-plugin-react-compiler 0.0.0-experimental-a1856f3-20260507, which only refuses `pier.bollard`. From 398e2de81bfc978b68540f167db5a0997e5a07f5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:27:05 +0000 Subject: [PATCH 7/9] react-compiler: declare a callback in its component when the file has the word eval and the component is in a block A direct `eval` marks its scope when it is visited. A nested component that stands in front of the `eval` in its block is compiled before that, so its callback still went to the block as a function declaration, and the block kept it as written. Where the code is not strict it is a variable of the function around the block too, and a cjs bundle gave it to the component of another file. The parser now looks for the word `eval` in the source, once for a file, when a nested function in a block asks. If the word is there, the callbacks of that function are declared in the function itself. The test of an async function declaration in a `case` has a generator too. --- src/js_parser/p.rs | 3 + src/js_parser/react_compiler_host.rs | 17 ++++- src/react_compiler/DESIGN.md | 12 ++-- .../bundler/transpiler/react-compiler.test.ts | 69 +++++++++++++++---- 4 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 3cb59dc595a9..bd8bf8db265c 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -461,6 +461,8 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) react_compiler_outlined_from_nested: Vec, /// The scope of the statement list that the innermost `visit_stmts` call visits. pub(crate) react_compiler_list_scope: js_ast::StoreRef, + /// Whether the source has the word `eval`, once a nested function in a block has asked. + pub(crate) react_compiler_source_has_eval: Option, /// only applicable when `.options.features.server_components` is /// configured to wrap exports. populated before visit pass starts. @@ -9800,6 +9802,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O react_compiler_may_replace_body: false, react_compiler_outlined_from_nested: Vec::new(), react_compiler_list_scope: scope, + react_compiler_source_has_eval: None, server_components_wrap_ref: Ref::NONE, jest: Jest::default(), imported_mock_apis: Default::default(), diff --git a/src/js_parser/react_compiler_host.rs b/src/js_parser/react_compiler_host.rs index eb6d0860ed40..d9592378e57c 100644 --- a/src/js_parser/react_compiler_host.rs +++ b/src/js_parser/react_compiler_host.rs @@ -213,7 +213,7 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { /// Where what is outlined from the function being compiled is declared. src/react_compiler/DESIGN.md has the rule. pub(crate) fn react_compiler_place_of_outlined( - &self, + &mut self, pending: &bun_react_compiler::PendingCompile, ) -> OutlinedPlace { use js_ast::scope::Kind; @@ -233,7 +233,8 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { if scope.kind != Kind::Label { // A `case` can call a declaration that its block keeps, and skip the `let` of its callback. if !scope.kind_stops_hoisting() - && (scope.contains_direct_eval || Self::is_kept_in_a_block(pending)) + && (Self::is_kept_in_a_block(pending) + || self.may_hold_a_direct_eval(&scope)) { break; } @@ -251,6 +252,18 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { } } + /// Beside a direct `eval` a block keeps every function declaration. The `eval` marks its scope only once visited. + fn may_hold_a_direct_eval(&mut self, scope: &js_ast::Scope) -> bool { + if scope.contains_direct_eval { + return true; + } + if self.react_compiler_source_has_eval.is_none() { + let has_eval = bun_core::strings::contains(&self.source.contents, b"eval"); + self.react_compiler_source_has_eval = Some(has_eval); + } + self.react_compiler_source_has_eval == Some(true) + } + /// A block makes a `let` of a function declaration, but not of one that is async or a generator. fn is_kept_in_a_block(pending: &bun_react_compiler::PendingCompile) -> bool { use js_ast::flags::Function; diff --git a/src/react_compiler/DESIGN.md b/src/react_compiler/DESIGN.md index 7e2583199a76..ccc5e62fa1be 100644 --- a/src/react_compiler/DESIGN.md +++ b/src/react_compiler/DESIGN.md @@ -142,11 +142,13 @@ reach it and where it can reach those variables: - In the nested function's own body when it stands in a head that declares a name: a parameter list, a `catch` binding, a `for` that declares, the test of a `case` in a `switch` that declares, the initializer of an enum member. Also - in a block that holds a direct `eval` in front of it: that block keeps a - function declaration as written, which is a `var` too where the code is not - strict, and a `let` in a `case` is not set when another `case` calls the - function. Also when it is an async or generator function declaration in a - block, which a block always keeps. It is then a new function at each call. + in a block of a file that has the word `eval`: beside a direct `eval` a block + keeps a function declaration as written, which is a `var` too where the code + is not strict, and a `let` in a `case` is not set when another `case` calls + the function. A direct `eval` marks its scope only when it is visited, so + the parser looks for the word. Also when it is an async or generator function + declaration in a block, which a block always keeps. It is then a new function + at each call. With no renamer, no local of the compiled function takes a name that a scope around it declares. diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 29b17f8025fe..83d28fdff375 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4376,8 +4376,8 @@ describe("bundler", () => { // body of a function behind the component, or in the body behind a head, which the head can't see. The first render // threw `ReferenceError: _temp is not defined`. They are declared in the list of the statement that holds the // component, once for all calls of the component. A component in a head that declares a name gets them in its own - // body, at each call. So does one in a block that holds a direct `eval`: that block keeps its function declarations, - // and `Leaf` of the last form is called with no statement of its `case` run. + // body, at each call. So does one in a block that holds a direct `eval`, in front of it or behind it: that block keeps + // its function declarations, and `Leaf` of the two `case` forms is called with no statement of its `case` run. const nestedComponentForms = { "/entry.js": /* js */ ` import { sizes } from "react/compiler-runtime"; @@ -4670,6 +4670,16 @@ describe("bundler", () => { return Leaf; } } + export function blockBeforeAnEval(k) { + let out; + { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => 1; + out = Leaf; + eval(""); + } + return out; + } export function caseBesideAnEval(k) { switch (k) { case "never": @@ -4681,6 +4691,17 @@ describe("bundler", () => { return Leaf; } } + export function caseBeforeAnEval(k) { + switch (k) { + case "never": + function Leaf({ x }) { + return k + x0} at={() => x0} />; + } + eval(""); + default: + return Leaf; + } + } `, ...reactWithMemo, }; @@ -4714,7 +4735,9 @@ describe("bundler", () => { "twoStatements", ]; const nestedComponentsInAHead = [ + "blockBeforeAnEval", "blockWithAnEval", + "caseBeforeAnEval", "caseBesideAnEval", "caseTestBesideALet", "catchDefault", @@ -4876,19 +4899,24 @@ describe("bundler", () => { // A block that holds a direct `eval` keeps its function declarations as they are written. There the callback stayed // a function declaration, which is a variable of the whole cjs bundle too, and `Top` called it. It is now declared - // in the component. + // in the component, also when the `eval` stands behind the component. for (const runtime of ["bun", "node"] as const) { itBundled(`react-compiler/NestedComponentInABlockWithADirectEval-cjs-${runtime}`, { files: { "/entry.jsx": /* jsx */ ` import { Top } from "./top.jsx"; - let InABlock; + let BehindAnEval, BeforeAnEval; { eval(""); - const Leaf = ({ x }) => "mine"} />; - InABlock = Leaf; + const Leaf = ({ x }) => "behind"} />; + BehindAnEval = Leaf; } - const called = [InABlock, Top].map(Component => { + { + const Leaf = ({ x }) => "before"} />; + BeforeAnEval = Leaf; + eval(""); + } + const called = [BehindAnEval, BeforeAnEval, Top].map(Component => { const { on } = Component({ x: 1 }).props; return on() + (on.name === "on" ? " (in place)" : ""); }); @@ -4902,17 +4930,18 @@ describe("bundler", () => { reactCompiler: true, backend: "cli", format: "cjs", - run: { runtime, stdout: "mine top" }, + run: { runtime, stdout: "behind before top" }, }); } - // A block keeps a function declaration that is async, so another `case` can call it. Its callback was a `let` of the - // `case` that declares it: "ReferenceError: Cannot access '_temp' before initialization". It is declared in the component. + // A block keeps a function declaration that is async or a generator, so another `case` can call it. Its callback + // was a `let` of the `case` that declares it: "ReferenceError: Cannot access '_temp' before initialization". It is + // declared in the component. itBundled("react-compiler/NestedAsyncComponentCalledThroughAnotherCase", { files: { "/entry.jsx": /* jsx */ ` const x0 = "!"; - function viaAnotherCase(k) { + function asyncComponent(k) { switch (k) { case "never": async function Leaf({ x }) { @@ -4922,14 +4951,26 @@ describe("bundler", () => { return Leaf; } } - const { on } = (await viaAnotherCase("red")({ x: 1 })).props; - console.log(on(), on.name === "on" ? "in place" : "moved out"); + function generatorComponent(k) { + switch (k) { + case "never": + function* Leaf({ x }) { + return k + x0} />; + } + default: + return Leaf; + } + } + const elements = [await asyncComponent("red")({ x: 1 }), generatorComponent("red")({ x: 1 }).next().value]; + for (const { on } of elements.map(element => element.props)) { + console.log(on(), on.name === "on" ? "in place" : "moved out"); + } `, ...reactWithMemo, }, reactCompiler: true, backend: "cli", - run: { stdout: "red! moved out" }, + run: { stdout: "red! moved out\nred! moved out" }, }); // A local whose name ends in "Ref" is taken for a ref once its `.current` is read. Listing it in an effect's dependencies From fd1f151cf671dbb780c21ccc12442d463d021caf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:42:52 +0000 Subject: [PATCH 8/9] react-compiler: take `eval` as the word only when it is a name of its own A component in a block of a file that has the word `eval` gets its outlined callbacks in its own body. The search took any text that has the letters. So `evaluate` or `retrieval` in a file moved the callbacks of each component in a block of that file into the component, as a new function at each call. The search now takes `eval` only when no ASCII letter, digit, `_` or `$` stands beside it. --- src/js_parser/react_compiler_host.rs | 18 +++++++++++++++++- src/react_compiler/DESIGN.md | 7 ++++--- test/bundler/transpiler/react-compiler.test.ts | 18 ++++++++++++++++-- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/js_parser/react_compiler_host.rs b/src/js_parser/react_compiler_host.rs index d9592378e57c..982b4bcc8063 100644 --- a/src/js_parser/react_compiler_host.rs +++ b/src/js_parser/react_compiler_host.rs @@ -205,6 +205,22 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> bun_react_compiler::Host } } +/// Whether `eval` stands in `source` as a name of its own. In `evaluate` and `retrieval` it is a part of a longer name. +fn has_the_word_eval(source: &[u8]) -> bool { + // A byte that is not ASCII is no part of a name here: it can be the end of a space, such as U+00A0. + let is_of_a_name = |byte: &u8| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'$'; + let mut searched = 0; + while let Some(found) = bun_core::strings::index_of(&source[searched..], b"eval") { + let start = searched + found; + searched = start + b"eval".len(); + let before = start.checked_sub(1).and_then(|at| source.get(at)); + if !before.is_some_and(is_of_a_name) && !source.get(searched).is_some_and(is_of_a_name) { + return true; + } + } + false +} + impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { /// `current_scope` is the body of the function being compiled, and its parent holds the arguments. fn scope_around_compiled_function(&self) -> Option> { @@ -258,7 +274,7 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { return true; } if self.react_compiler_source_has_eval.is_none() { - let has_eval = bun_core::strings::contains(&self.source.contents, b"eval"); + let has_eval = has_the_word_eval(&self.source.contents); self.react_compiler_source_has_eval = Some(has_eval); } self.react_compiler_source_has_eval == Some(true) diff --git a/src/react_compiler/DESIGN.md b/src/react_compiler/DESIGN.md index ccc5e62fa1be..72c112336670 100644 --- a/src/react_compiler/DESIGN.md +++ b/src/react_compiler/DESIGN.md @@ -146,9 +146,10 @@ reach it and where it can reach those variables: keeps a function declaration as written, which is a `var` too where the code is not strict, and a `let` in a `case` is not set when another `case` calls the function. A direct `eval` marks its scope only when it is visited, so - the parser looks for the word. Also when it is an async or generator function - declaration in a block, which a block always keeps. It is then a new function - at each call. + the parser looks for the word in the text of the file: a comment that has it + counts, a longer name such as `evaluate` does not. Also when it is an async + or generator function declaration in a block, which a block always keeps. It + is then a new function at each call. With no renamer, no local of the compiled function takes a name that a scope around it declares. diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 83d28fdff375..0b1c57ef7327 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4377,15 +4377,17 @@ describe("bundler", () => { // threw `ReferenceError: _temp is not defined`. They are declared in the list of the statement that holds the // component, once for all calls of the component. A component in a head that declares a name gets them in its own // body, at each call. So does one in a block that holds a direct `eval`, in front of it or behind it: that block keeps - // its function declarations, and `Leaf` of the two `case` forms is called with no statement of its `case` run. + // its function declarations, and `Leaf` of the two `case` forms is called with no statement of its `case` run. A + // longer name such as `evaluate` is no `eval`: the block of the last file gets them once. const nestedComponentForms = { "/entry.js": /* js */ ` import { sizes } from "react/compiler-runtime"; import * as forms from "./forms"; import * as typed from "./forms-typed"; import * as withEval from "./forms-with-eval"; + import * as withLongerNames from "./forms-with-longer-names"; const lines = []; - const all = { ...forms, ...typed, ...withEval }; + const all = { ...forms, ...typed, ...withEval, ...withLongerNames }; for (const [name, form] of Object.entries(all).sort(([a], [b]) => (a < b ? -1 : 1))) { try { const Leaf = form("red"); @@ -4703,11 +4705,23 @@ describe("bundler", () => { } } `, + "/forms-with-longer-names.jsx": /* jsx */ ` + const x0 = "!"; + const retrieval = 1, evaluate = 2, $eval = 3, _eval = 4, x2eval = 5, eval2 = 6, eval_ = 7, eval$ = 8; + export function blockBesideLongerNames(k) { + { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => retrieval + evaluate + $eval + _eval + x2eval + eval2 + eval_ + eval$; + return Leaf; + } + } + `, ...reactWithMemo, }; const nestedComponentsInAList = [ "arrowWithNoBlock", "assignments", + "blockBesideLongerNames", "blockOfTheModule", "calledFunction", "caseTest", From e5aec7fc143a9519b5d93d66ea402b09b3db1208 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:55:43 +0000 Subject: [PATCH 9/9] react-compiler: use the lexer's rule for the characters beside `eval` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search for the word `eval` took a byte that is not ASCII as the end of the word. A file with `evalúa` in a name, a string or a comment then had the word, and each component in a block of that file got its outlined callbacks in its own body, as a new function at each call. The search now decodes the character in front of the word and the one behind it. The word is `eval` only when neither is a part of an identifier. A space that is not ASCII, such as U+00A0, still ends the word. --- src/js_parser/react_compiler_host.rs | 10 ++++---- .../bundler/transpiler/react-compiler.test.ts | 23 ++++++++++++++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/js_parser/react_compiler_host.rs b/src/js_parser/react_compiler_host.rs index 982b4bcc8063..dc3a77c124a7 100644 --- a/src/js_parser/react_compiler_host.rs +++ b/src/js_parser/react_compiler_host.rs @@ -8,6 +8,7 @@ use bun_ast as js_ast; use bun_collections::VecExt; use crate::JSXImport; +use crate::lexer::is_identifier_continue; use crate::p::P; /// What [`P::enter_react_compiler_candidate`] decided about a function. @@ -207,14 +208,15 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> bun_react_compiler::Host /// Whether `eval` stands in `source` as a name of its own. In `evaluate` and `retrieval` it is a part of a longer name. fn has_the_word_eval(source: &[u8]) -> bool { - // A byte that is not ASCII is no part of a name here: it can be the end of a space, such as U+00A0. - let is_of_a_name = |byte: &u8| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'$'; + use bun_core::lexer::{char_and_size, last_char}; let mut searched = 0; while let Some(found) = bun_core::strings::index_of(&source[searched..], b"eval") { let start = searched + found; searched = start + b"eval".len(); - let before = start.checked_sub(1).and_then(|at| source.get(at)); - if !before.is_some_and(is_of_a_name) && !source.get(searched).is_some_and(is_of_a_name) { + // The character in front of the word is four bytes at most. + let (before, _) = last_char(&source[start.saturating_sub(4)..start]); + let (after, _) = char_and_size(source, searched); + if !is_identifier_continue(before) && !is_identifier_continue(after) { return true; } } diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 0b1c57ef7327..2f93d0bcdb61 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4378,7 +4378,8 @@ describe("bundler", () => { // component, once for all calls of the component. A component in a head that declares a name gets them in its own // body, at each call. So does one in a block that holds a direct `eval`, in front of it or behind it: that block keeps // its function declarations, and `Leaf` of the two `case` forms is called with no statement of its `case` run. A - // longer name such as `evaluate` is no `eval`: the block of the last file gets them once. + // longer name such as `evaluate` or `evalúa` is no `eval`: the block of forms-with-longer-names.jsx gets them once. + // In the last file U+00A0, a space, stands in front of the `eval`. const nestedComponentForms = { "/entry.js": /* js */ ` import { sizes } from "react/compiler-runtime"; @@ -4386,8 +4387,9 @@ describe("bundler", () => { import * as typed from "./forms-typed"; import * as withEval from "./forms-with-eval"; import * as withLongerNames from "./forms-with-longer-names"; + import * as withASpace from "./forms-with-eval-behind-a-space"; const lines = []; - const all = { ...forms, ...typed, ...withEval, ...withLongerNames }; + const all = { ...forms, ...typed, ...withEval, ...withLongerNames, ...withASpace }; for (const [name, form] of Object.entries(all).sort(([a], [b]) => (a < b ? -1 : 1))) { try { const Leaf = form("red"); @@ -4708,14 +4710,28 @@ describe("bundler", () => { "/forms-with-longer-names.jsx": /* jsx */ ` const x0 = "!"; const retrieval = 1, evaluate = 2, $eval = 3, _eval = 4, x2eval = 5, eval2 = 6, eval_ = 7, eval$ = 8; + const evalúa = 9, ñeval = 10; export function blockBesideLongerNames(k) { { const Leaf = ({ x }) => k + x0} at={() => x0} />, - other = () => retrieval + evaluate + $eval + _eval + x2eval + eval2 + eval_ + eval$; + other = () => retrieval + evaluate + $eval + _eval + x2eval + eval2 + eval_ + eval$ + evalúa + ñeval; return Leaf; } } `, + "/forms-with-eval-behind-a-space.jsx": /* jsx */ ` + const x0 = "!"; + export function blockBeforeAnEvalBehindANoBreakSpace(k) { + let out; + { + const Leaf = ({ x }) => k + x0} at={() => x0} />, + other = () => 1; + out = Leaf; + void\u00a0eval(""); + } + return out; + } + `, ...reactWithMemo, }; const nestedComponentsInAList = [ @@ -4750,6 +4766,7 @@ describe("bundler", () => { ]; const nestedComponentsInAHead = [ "blockBeforeAnEval", + "blockBeforeAnEvalBehindANoBreakSpace", "blockWithAnEval", "caseBeforeAnEval", "caseBesideAnEval",