diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 3859c3391621..bd8bf8db265c 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -457,9 +457,12 @@ 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 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, + /// 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. @@ -3435,7 +3438,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 +9801,8 @@ 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, + 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 425bafcd61c8..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. @@ -22,6 +23,20 @@ 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, whose scope lists its locals from `first_local` on. + 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>, } @@ -191,7 +206,189 @@ 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 { + 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(); + // 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; + } + } + 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> { + self.current_scope().parent.and_then(|args| args.parent) + } + + /// 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( + &mut self, + pending: &bun_react_compiler::PendingCompile, + ) -> OutlinedPlace { + use js_ast::scope::Kind; + if pending.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 { + // 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 { + // A `case` can call a declaration that its block keeps, and skip the `let` of its callback. + if !scope.kind_stops_hoisting() + && (Self::is_kept_in_a_block(pending) + || self.may_hold_a_direct_eval(&scope)) + { + break; + } + return OutlinedPlace::List(scope); + } + } else if !matches!(scope.kind, Kind::Block | Kind::Label) || scope.members.count() > 0 + { + break; + } + around = scope.parent; + } + OutlinedPlace::OwnBody { + first_local: self.current_scope().generated.len(), + own_name: pending.own_name, + } + } + + /// 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 = has_the_word_eval(&self.source.contents); + 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; + 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 { + if scope.members.get(name).is_some() { + return true; + } + around = scope.parent; + } + false + } + + /// 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, + 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); + // 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 = + js_ast::StoreStr::new(renamed); + } + } + } + + /// 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( + &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 { + 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 + && let Some(name) = function.func.name + { + // 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() + .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 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( + &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(); + // 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 + }; + 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..cf5ee4d881f3 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,11 @@ 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: 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); } // Transform block-level function declarations into variable declarations @@ -1798,6 +1805,7 @@ 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); let compiled = { let host = &mut crate::react_compiler_host::ReactCompilerHost::new(p); bun_react_compiler::maybe_compile_pending( @@ -1808,19 +1816,20 @@ 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) + && rc.outlined_len() > outlined_len + { + 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..c46669db071c 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -85,7 +85,8 @@ 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: 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 /// 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..72c112336670 100644 --- a/src/react_compiler/DESIGN.md +++ b/src/react_compiler/DESIGN.md @@ -128,6 +128,40 @@ 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. Also + 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 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. + +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..2f93d0bcdb61 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -4356,6 +4356,654 @@ 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. 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` 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"; + 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"; + import * as withASpace from "./forms-with-eval-behind-a-space"; + const lines = []; + 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"); + 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; + } + `, + "/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 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": + eval(""); + function Leaf({ x }) { + return k + x0} at={() => x0} />; + } + default: + return Leaf; + } + } + export function caseBeforeAnEval(k) { + switch (k) { + case "never": + function Leaf({ x }) { + return k + x0} at={() => x0} />; + } + eval(""); + default: + return Leaf; + } + } + `, + "/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$ + 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 = [ + "arrowWithNoBlock", + "assignments", + "blockBesideLongerNames", + "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 = [ + "blockBeforeAnEval", + "blockBeforeAnEvalBehindANoBreakSpace", + "blockWithAnEval", + "caseBeforeAnEval", + "caseBesideAnEval", + "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 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, 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 BehindAnEval, BeforeAnEval; + { + eval(""); + const Leaf = ({ x }) => "behind"} />; + BehindAnEval = Leaf; + } + { + 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)" : ""); + }); + console.log(called.join(" ")); + `, + "/top.jsx": /* jsx */ ` + export const Top = ({ x }) => "top"} />; + `, + ...reactWithMemo, + }, + reactCompiler: true, + backend: "cli", + format: "cjs", + run: { runtime, stdout: "behind before top" }, + }); + } + + // 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 asyncComponent(k) { + switch (k) { + case "never": + async function Leaf({ x }) { + return k + x0} />; + } + default: + return Leaf; + } + } + 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\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 // 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..bfcb869aee06 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -2245,6 +2245,117 @@ 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) { + 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") }; + ${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 "]); + }); + `, + }, + ["--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. 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"; + 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 }; + ${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]); + }); + `, + }, + ["--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( {