Skip to content

react-compiler: read a function expression's own name through the symbol the function keeps - #48444

Open
robobun wants to merge 1 commit into
mainfrom
robobun/e85da5bf/react-compiler-own-name
Open

robobun wants to merge 1 commit into
mainfrom
robobun/e85da5bf/react-compiler-own-name

Conversation

@robobun

@robobun robobun commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build --react-compiler miscompiles a function expression that reads its own name in a callback: memo(function Tree() { … [0].map(n => <Tree />) }). The output throws ReferenceError: Tree3 is not defined or renders another Tree. Since 1.4.0. No user reported it.
  • Codegen makes a new symbol for each named local (Context::ref_for_id, src/react_compiler/codegen.rs:470). Nothing declares this one: the function keeps the parser's symbol (react-compiler: register the locals of a compiled function in its own scope #42463).

Fix

  • Lowering returns the identifier that reads of PendingCompile::own_name became. Codegen prints the parser's symbol for it.
  • Correct: the name and its reads are then one symbol for the renamer. Upstream prints both under one text.
  • Verified: test/bundler/transpiler/react-compiler.test.ts (5 new cases fail on main), test/aot/react-compiler.test.ts, upstream fixtures, lint suites.
  • Self-reviewed: 28 concerns raised, 27 addressed. Not done: a generated grid as the test.

Background

  • A function expression declares its own name in its own scope. The compiler prints a new body with new symbols, and the bundler's renamer names them.
  • Weighed: never compile such a function (upstream compiles it). Or treat the name as outer: the callback moves to module level, where it is unbound.

Downsides

  • Release binary: +256 bytes of text. Per compiled named function: +1 map lookup.
  • On bun test --react-compiler, a function with a block local of its own name now reads that local, silently. Main throws Name_0 is not defined.
  • A function that reads its own name and arguments in a callback threw. It now renders stale arguments, until react-compiler: do not compile a function that reads the implicit arguments #46496.
Notes

Repro. a.jsx, over a stand-in react whose memo returns the function, whose jsxDEV(type, props, key) returns { type, props, key }, and whose react/compiler-runtime exports c(n):

import { memo } from "react";
const Tree = memo(function Tree({ x }) { return <i x={x} kids={[0].map(n => <Tree key={n} x={n} />)} />; });
let v; try { const e = Tree({ x: 1 }); v = [typeof e.props.kids[0].type, e.props.kids[0].props.x].join(); } catch (err) { v = err.name + ": " + err.message; }
console.log("R", v);

bun build ./a.jsx --outdir=out && bun out/a.js prints R function,0. With --react-compiler, 1.4.3, 1.4.4 and main print R ReferenceError: Tree3 is not defined. The built text holds var Tree = memo(function Tree2(t0) { and jsxDEV(Tree3, ....

Forms by release (release binaries, and a debug build of main):

Form 1.4.0 1.4.1, 1.4.2 1.4.4, main
the repro right by chance (lands on the outer const) right by chance Tree3 is not defined
the repro with --minify Tree is not defined Tree is not defined b is not defined
const Tree = memo(function Row…<Row />…) right by chance, Row is not defined with --minify the same Row2 is not defined
three files that each export const Tree = memo(function Tree…) every file renders the first Tree right by chance {"A":"C","B":"C","C":"B"} at exit 0
the file imports another Tree under another name renders that import renders that import Tree4 is not defined
[0].map(() => typeof Tree) right by chance, "undefined" with --minify the same "undefined"

A 1.4.3 canary (367d939) prints what 1.4.4 prints for the repro. Before #42463 the new symbol was a module-level one and printed under its bare name, so it was whatever top-level thing had that name. Since #42463 it is a local of the function, and the renamer numbers it.

Which functions are hit. Only a function that the compiler goes on to compile. The compile set does not change here. A read of the own name in the body itself stops the compiler ([InferMutationAliasingEffects] Expected value kind to be initialized), and so does the usual items.map(item => <Tree … />) over a prop since #44805 item 5 restored upstream's Expected a node for all identifiers. The existing test react-compiler/FunctionExpressionThatNamesItself pins those as not compiled and is unchanged. What is left is a callback passed to a method of an array that the function makes itself: an array literal, a spread copy, Object.keys.

Upstream. babel-plugin-react-compiler 1.0.0 compiles the repro (CompileSuccess) and prints <Tree key={n} x={n} /> inside function Tree(t0). Upstream never binds the name: it prints the function id and the read as the same text. react/react#38100 (bail on such a read) and react/react#37511 (initialize the binding, oxc port) are open. If a sync ports #38100, these functions stop being compiled and the (t0) assertions of the new test flip. 1.0.0 does not compile typeof Tree or Tree.length in a callback (Todo Unsupported declaration type for hoisting). The port never had that check, so those are compiled here, and the new test says so.

Measured, main 2596ac5 against this change (debug builds unless said):

  • Host::new_local calls: the repro 7 -> 6. The 1,806 upstream fixtures 8,816 -> 8,816 (gdb breakpoint counts).
  • Compiles that do not read their own name: resolve_binding_with_loc calls 13,393 vs 13,393, ref_for_id calls 25,856 vs 25,856, lowered HIR dumps that differ 0 of 1,583.
  • Fixture outputs changed: 0 of 1,585 (diff -rq).
  • Release binary text: 137,930,427 -> 137,930,683 bytes, +256 (size). Symbols of react_compiler: +437 bytes (nm -S).
  • Names that a bundle reads and nothing declares, 10 probe files in 3 build modes (default, --minify-identifiers, --target=bun): the own-name forms 7 -> 0. The 4 sibling files below are still flagged.
  • Compile set: 0 of 14 functions change outcome in two probe files. Fixture slot counts: 0 failures.
  • bun test --react-compiler probes that are right: 7 -> 9 of 16. With --aot: 6 -> 16 of 16.
  • A generated grid of 17,940 function expressions (13 names, each form rendered three times and compared with the same file built without the flag). 13,200 are compiled, on main and here. Cells that differ from the uncompiled run: bun build --target=browser 11,910 -> 418, bun test --react-compiler 7,228 -> 3,134. With --minify-identifiers 418, with --target=bun --minify-syntax 268 (main not run there). Cells that main gets right and this change gets wrong: 0. The 418 are all nested function expressions (below).

Still wrong, not changed here.

  • A nested function expression whose own name lowering renamed (function cb(n) { return typeof cb_0 + n; }). Same mint, other carrier: its name is printed by its text. Since 1.4.0.
  • A component in a TypeScript namespace that reads an exported member from a callback (UI2 is not defined). Main only.
  • arguments read from a callback. react-compiler: do not compile a function that reads the implicit arguments #46496 is open for it, after [compiler] Bail on use of arguments object react/react#37645.
  • A function expression with a parameter of its own name, where that name is one the new body calls: memo(function c({ x, c }) {…}) builds to function c(t0) { let $ = c(6); and throws a RangeError. The cache call lands on the function. On 1.4.4 too. Another mechanism: the name's symbol is linked to the parameter that the new body drops.
  • const Row = memo(function Row…) prints as function Row2 in a bundle, so .name is Row2. Cosmetic, older than this change. The new test matches function Tree\d*\(t0\) for that reason.
  • On bun test --react-compiler no renamer runs, and a symbol prints under the name it was made with. A local that has the text of the own name then takes the read: a block local of that name, or a temporary t1 in a function named t1. Main prints the read as Name_0, which is not bound: a tag or a member read throws there, and typeof gives "undefined". With this change the read takes the value of that local, with no error. bun build and --aot are right for these. The rule belongs in RenameVariables' reserved names, after the nested name above is printed through its binding.

Removed after the self-review. A first version also bound the own name ahead of the parameters in lowering, and renamed a local with that text in codegen, for the no-renamer rows above. It broke memo(function C() { [x].map(function C(k) { return typeof C + k; }); … }), which 1.4.4 and main get right when the component renders no children: lowering then gave the nested function's reads the name C_0, and that function is printed by its text. That form is now a row of the test.

Merge order. #48308 has a parameter own_name and a comment "A read of the function's own name is a local that nothing declares: it keeps its spelling". After this change that is no longer true. Whichever lands second removes that exemption. #48269, #48308, #48310 and this change each set EXPECTED_VERSION to 52. #48310 and this change each add a file to the pure-components program, on the same two lines of its main.tsx: keep both.

Suites run on a debug build. test/bundler/transpiler/react-compiler.test.ts (150 pass), react-compiler-fixtures.test.ts (3,293 pass), test/cli/lint/react-compiler.test.ts, test/cli/lint/conformance.test.ts, the --react-compiler cases of test/cli/test/bun-test.test.ts, test/aot/react-compiler*.test.ts, and bun run rust:test -p bun_aot react_compiler (52 pass).


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bundler/transpiler/react-compiler.test.ts

…bol the function keeps

A compiled function expression keeps the symbol that the parser gave its
name. Codegen made a new symbol for each read of that name, which nothing
declares: the bundler's renamer numbered it, so `Tree3` threw, or was
another `Tree` of the bundle.

Lowering now gets `PendingCompile::own_name` and returns the identifier
that the reads of it became. Codegen prints the parser's symbol for that
declaration.
@robobun

robobun commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:15 AM PT - Nov 10th, 2026

✅ @robobun, your commit b288f8fd725d6c3af506df2fb5b2e76408be08b9 passed in Build #139645! 🎉


🧪   To try this PR locally:

bunx bun-pr 48444

That installs a local version of the PR into your bun-48444 executable, so you can run:

bun-48444 --bun

@robobun

robobun commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the fix is pushed, and the diff is green where CI ran.

How I reproduced it: a stand-in react in node_modules (memo returns the function, jsxDEV(type, props, key) returns { type, props, key }, react/compiler-runtime exports c(n)), and the a.jsx from the Notes of the description.

  • bun build --react-compiler ./a.jsx --outdir=out && bun out/a.js prints R ReferenceError: Tree3 is not defined on 1.4.4 and on a debug build of main (2596ac5).
  • The same command prints R function,0 with this branch, as it does without the flag.
  • Three files that each export const Tree = memo(function Tree…) print {"A":"C","B":"C","C":"B"} on main and {"A":"A","B":"B","C":"C"} with this branch.

CI on b288f8f (build 139645): 184 of 188 jobs passed. The other 4 are the darwin x64 - test-bun jobs. They expired with no free agent and are queued again. No test that this change touches failed.

PR: #48444

@coderabbitai

coderabbitai Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

The React Compiler now carries a function expression’s own-name binding through lowering and codegen. Codegen resolves matching references to the parser symbol. Regression coverage was added, and the transpiler cache format version increased to 52.

Changes

Function-expression own-name handling

Layer / File(s) Summary
Pass the own-name binding through compilation
src/react_compiler/program.rs, src/react_compiler/pipeline.rs, src/react_compiler/lowering/build_hir/mod.rs, src/react_compiler/lint.rs
Pending compilation forwards the own-name reference. Lowering returns the matching identifier when present, and the pipeline passes it to codegen.
Resolve the own-name symbol in codegen
src/react_compiler/codegen.rs, src/react_compiler/pipeline.rs, src/react_compiler/observer.rs, src/react_compiler/DESIGN.md, src/jsc/RuntimeTranspilerCache.rs
Codegen stores the own-name declaration and parser symbol. When a reference matches the declaration, codegen records and returns the parser symbol. Documentation describes this handling, and the transpiler cache format version changes to 52.
Verify own-name output and cache behavior
test/aot/react-compiler/same-as-bundle/pure-components/*, test/bundler/transpiler/react-compiler.test.ts
New fixtures and tests cover recursive references, name reads, shadowing, collisions, minification variants, and Bun test compilation.

Suggested reviewers: jarred-sumner

Merge Risk: Minimal · up to b288f

This fixes a miscompile where a compiled named function expression that reads its own name in a callback could throw a ReferenceError or point at a different function. The change is narrow, comes with regression tests, and bumps the transpiler cache version so stale output is not reused. No blocking risk remains. The author's known limitations are outside this change.

Pre-merge checks: Passed 4

Check name Status Explanation
Title check Passed The title clearly describes the main change: preserving the function expression’s own name symbol for reads.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description explains the problem, the fix, and how the author verified the change. It does not use the template headings, but it includes the required information and is mostly complete.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 98cda0d and b288f8f.

Files selected for processing: 11

  • src/jsc/RuntimeTranspilerCache.rs
  • src/react_compiler/DESIGN.md
  • src/react_compiler/codegen.rs
  • src/react_compiler/lint.rs
  • src/react_compiler/lowering/build_hir/mod.rs
  • src/react_compiler/observer.rs
  • src/react_compiler/pipeline.rs
  • src/react_compiler/program.rs
  • test/aot/react-compiler/same-as-bundle/pure-components/OwnNames.tsx
  • test/aot/react-compiler/same-as-bundle/pure-components/main.tsx
  • test/bundler/transpiler/react-compiler.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked that every caller of the changed lowering::lower / Codegen::new signatures is updated (lint.rs, compile_outlined_fn pass None, which is right since neither compiles a function that keeps its own name), that the EXPECTED_VERSION bump in RuntimeTranspilerCache.rs is present for the changed output, and that the outlined-function path sharing cx.cg cannot hit own_name (outlined functions capture no context, so no own-name read reaches them). I did not run the new test matrix.

Extended reasoning...

The change threads a function expression's own-name Ref from program.rs through pipeline.rs and lowering into codegen, where identifier resolution maps the matching declaration to the parser's symbol instead of a fresh local; it touches no security-sensitive surface. The one confirmed inline finding concerns the no-renamer runtime path, which the PR description itself acknowledges as still wrong; the rest of the signature and cache-version mechanics check out by reading.

Comment thread src/react_compiler/codegen.rs Resolved
@robobun

robobun commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

Another branch fixed this same read and is not opened as a PR, because this PR owns the bug. I built the src/ of #48484 (which has this PR under it) on 2596ac5 and ran the repro of the description. It prints the right value, also with --minify.

Two things still differ from the run without the flag on that build. No open PR has them.

Form Mode Without the flag On #48484
function Self with a block { let Self = name + "!" }, and typeof Self in a callback bun test --react-compiler "function" "string"
function $ with rest.map($ => …), and typeof $ in a later callback bun test --react-compiler "function" "object" (the memo cache)
function t1 (a hook), and typeof t1 in a callback bun test --react-compiler "function" "undefined"
function Self with Self = name every mode TypeError no error, the title is name

The first three are the second Downsides bullet here. The review thread above held that fix back for a nested function with the same name. #48484 now leaves that function as written.

5be1a24 on robobun/9b3a1b54/own-name-reservation is one commit on top of #48484 for both:

  • The text of the own name goes into the globals of collect_referenced_globals (src/react_compiler/reactive_scopes/rename_variables.rs), so RenameVariables gives it to no local.
  • A store to the own name is a Todo in lower(), and the function stays as written. The first store to a name becomes its declaration, so Self = name compiled to let Self = name.
  • One new case in test/cli/test/bun-test.test.ts has the four forms. It fails on react-compiler: leave a function as written when a function expression in it refers to its own name #48484 and passes with the commit.

Checked on a debug build of 2596ac5 with the src/ of that commit:

Not built: #48475. A component in a namespace that reads a member in a callback, and a bundled require("react") in a callback, still throw on this build as on main.

@robobun

robobun commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up for the second Downsides bullet: under bun test --react-compiler, a local takes the name that the function reads.

The reservation exists as one commit on top of #48484: cd1729b on robobun/74a0c70a/react-compiler-own-name-reserved. No PR is open for it. A self-review of the commit concluded that the fix belongs in this stack and not in a third PR, and that a maintainer has to answer two questions first. They are at the end.

What the commit does

  • rename_variables gets the binding that the reads of the own name became, with the text of the name, as an argument from pipeline::compile_fn. It names that binding first, so no local takes the text.
  • Differences from 5be1a24 (the comment above): no Environment field, the own binding is not renamed to X$0, an outlined function gets no reservation, and the lint output mode of a fixture pragma is unchanged (a test holds that).
  • It has no Todo for a store to the own name. That part of 5be1a24 is a separate fix, and it is still needed.

Measured (debug builds of #48484 and of the commit, bun test --react-compiler, transpiler cache off)

#48484 the commit
Rows of the test that are wrong, of 10 8 0
Upstream fixture results that differ 0 of 1,806
Allocator calls in compile_fn for 3 inputs (gdb counts) 1,865 / 1,896 / 1,861 the same
Release code of bun_react_compiler, .text (size, no LTO) 1,219,002 1,219,402

The rows are the parameter t0, the temporaries t1 and t2, the memo cache $, a local of a block, a hook, and two functions that read the name as a JSX tag. The suites pass on the debug build: react-compiler.test.ts (155), react-compiler-fixtures.test.ts (3,293), test/cli/lint/react-compiler.test.ts (26), the --react-compiler block of bun-test.test.ts (32), test/aot/react-compiler.test.ts (24, in groups).

Two questions for a maintainer

  1. Compile or stop. babel-plugin-react-compiler 1.0.0 leaves six of these ten functions as written (Unsupported declaration type for hoisting): the ones that read the name as a value. It compiles the two that read it as a JSX tag, and prints reads of BlockLocalTag_0 and t0$0, which nothing binds. [compiler] Bail out safely on named function expression self references react/react#38100 proposes a Todo for every read of the own name. With such a Todo in lower(), as react-compiler: leave a function as written when a function expression in it refers to its own name #48484 has for a nested function, each row is right on every print path and this commit is not needed.
  2. The layer, if these functions stay compiled. The own name is one of several names that a compiled body reads from outside and that a local can take on this path. Of 12 probes, 9 are wrong on react-compiler: leave a function as written when a function expression in it refers to its own name #48484 and 8 with this commit. The others are an outer variable that the function only assigns and the classic JSX factory (react-compiler: do not name a local like an outer variable the function assigns, or like the JSX factory #48269), a local named NaN or Infinity (react-compiler: emit a number node for NaN and +/-Infinity, not a name #48561), a local named require, and a _c or _temp that the file names later. An experiment in the parser host made 12 of 12 right with the reservation off. On the path with no renamer it calls the rename_locals_named_like_a_variable_around of react-compiler: declare what is outlined from a nested function in the scope that holds it #48308 after every compile, with the own name included. It also adds a hash to the module-level names that the compiler makes (_c, _temp), and looks a global such as NaN up from the module scope. A bundle of the probes is byte-identical with that rule. With this commit, 10 of its 24 functions print other local names. The experiment is not ready: it also renames a nested function expression, so helper.name becomes helper$1.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants