Compare commits

...

1 Commits

Author SHA1 Message Date
Joe Savona
7455473e3a [compiler] Option to infer names for anonymous functions
Adds a `@enableNameAnonymousFunctions` feature to infer helpful names for anonymous functions within components and hooks. The logic is inspired by a custom Next.js transform, flagged to us by @eps1lon, that does something similar. Implementing this transform within React Compiler means that all React (Compiler) users can benefit from more helpful names when debugging.

The idea builds on the fact that JS engines try to infer helpful names for anonymous functions (in stack traces) when those functions are accessed through an object property lookup:

```js
({'a[xyz]': () => {
  throw new Error('hello!')
} }['a[xyz]'])()

// Stack trace:
Uncaught Error: hello!
    at a[xyz] (<anonymous>:1:26) // <-- note the name here
    at <anonymous>:1:60
```

The new NameAnonymousFunctions transform is gated by the above flag, which is off by default. It attemps to infer names for functions as follows:

First, determine a "local" name:
* Assigning a function to a named variable uses the variable name. `const f = () => {}` gets the name "f".
* Passing the function as an argument to a function gets the name of the function, ie `foo(() => ...)` get the name "foo()", `foo.bar(() => ...)` gets the name "foo.bar()". Note the parenthesis to help understand that it was part of a call.
* Passing the function to a known hook uses the name of the hook, `useEffect(() => ...)` uses "useEffect()".
* Passing the function as a JSX prop uses the element and attr name, eg `<div onClick={() => ...}` uses "<div>.onClick".

Second, the local name is combined with the name of the outer component/hook, so the final names will be strings like `Component[f]` or `useMyHook[useEffect()]`.
2025-09-09 09:16:26 -07:00
8 changed files with 544 additions and 2 deletions

View File

@@ -103,6 +103,7 @@ import {validateNoFreezingKnownMutableFunctions} from '../Validation/ValidateNoF
import {inferMutationAliasingEffects} from '../Inference/InferMutationAliasingEffects';
import {inferMutationAliasingRanges} from '../Inference/InferMutationAliasingRanges';
import {validateNoDerivedComputationsInEffects} from '../Validation/ValidateNoDerivedComputationsInEffects';
import {nameAnonymousFunctions} from '../Transform/NameAnonymousFunctions';
export type CompilerPipelineValue =
| {kind: 'ast'; name: string; value: CodegenFunction}
@@ -414,6 +415,15 @@ function runWithEnvironment(
});
}
if (env.config.enableNameAnonymousFunctions) {
nameAnonymousFunctions(hir);
log({
kind: 'hir',
name: 'NameAnonymougFunctions',
value: hir,
});
}
const reactiveFunction = buildReactiveFunction(hir);
log({
kind: 'reactive',

View File

@@ -3566,6 +3566,8 @@ function lowerFunctionToValue(
let name: string | null = null;
if (expr.isFunctionExpression()) {
name = expr.get('id')?.node?.name ?? null;
} else if (expr.isFunctionDeclaration()) {
name = expr.get('id')?.node?.name ?? null;
}
const loweredFunc = lowerFunction(builder, expr);
if (!loweredFunc) {

View File

@@ -261,6 +261,8 @@ export const EnvironmentConfigSchema = z.object({
enableFire: z.boolean().default(false),
enableNameAnonymousFunctions: z.boolean().default(false),
/**
* Enables inference and auto-insertion of effect dependencies. Takes in an array of
* configurable module and import pairs to allow for user-land experimentation. For example,

View File

@@ -15,6 +15,7 @@ import {Type, makeType} from './Types';
import {z} from 'zod';
import type {AliasingEffect} from '../Inference/AliasingEffects';
import {isReservedWord} from '../Utils/Keyword';
import {Err, Ok, Result} from '../Utils/Result';
/*
* *******************************************************************************************
@@ -1298,6 +1299,15 @@ export function forkTemporaryIdentifier(
};
}
export function validateIdentifierName(
name: string,
): Result<ValidIdentifierName, null> {
if (isReservedWord(name) || !t.isValidIdentifier(name)) {
return Err(null);
}
return Ok(makeIdentifierName(name).value);
}
/**
* Creates a valid identifier name. This should *not* be used for synthesizing
* identifier names: only call this method for identifier names that appear in the

View File

@@ -43,6 +43,7 @@ import {
ValidIdentifierName,
getHookKind,
makeIdentifierName,
validateIdentifierName,
} from '../HIR/HIR';
import {printIdentifier, printInstruction, printPlace} from '../HIR/PrintHIR';
import {eachPatternOperand} from '../HIR/visitors';
@@ -2326,6 +2327,11 @@ function codegenInstructionValue(
),
reactiveFunction,
).unwrap();
const validatedName =
instrValue.name != null
? validateIdentifierName(instrValue.name)
: Err(null);
if (instrValue.type === 'ArrowFunctionExpression') {
let body: t.BlockStatement | t.Expression = fn.body;
if (body.body.length === 1 && loweredFunc.directives.length == 0) {
@@ -2337,14 +2343,28 @@ function codegenInstructionValue(
value = t.arrowFunctionExpression(fn.params, body, fn.async);
} else {
value = t.functionExpression(
fn.id ??
(instrValue.name != null ? t.identifier(instrValue.name) : null),
validatedName
.map<t.Identifier | null>(name => t.identifier(name))
.unwrapOr(null),
fn.params,
fn.body,
fn.generator,
fn.async,
);
}
if (
cx.env.config.enableNameAnonymousFunctions &&
validatedName.isErr() &&
instrValue.name != null
) {
const name = instrValue.name;
value = t.memberExpression(
t.objectExpression([t.objectProperty(t.stringLiteral(name), value)]),
t.stringLiteral(name),
true,
false,
);
}
break;
}
case 'TaggedTemplateExpression': {

View File

@@ -0,0 +1,173 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
FunctionExpression,
getHookKind,
HIRFunction,
IdentifierId,
} from '../HIR';
export function nameAnonymousFunctions(fn: HIRFunction): void {
if (fn.id == null) {
return;
}
const parentName = fn.id;
const functions = nameAnonymousFunctionsImpl(fn);
function visit(node: Node, prefix: string): void {
if (node.generatedName != null) {
/**
* Note that we don't generate a name for functions that already had one,
* so we'll only add the prefix to anonymous functions regardless of
* nesting depth.
*/
const name = `${prefix}${node.generatedName}]`;
node.fn.name = name;
}
/**
* Whether or not we generated a name for the function at this node,
* traverse into its nested functions to assign them names
*/
const nextPrefix = `${prefix}${node.generatedName ?? node.fn.name ?? '<anonymous>'} > `;
for (const inner of node.inner) {
visit(inner, nextPrefix);
}
}
for (const node of functions) {
visit(node, `${parentName}[`);
}
}
type Node = {
fn: FunctionExpression;
generatedName: string | null;
inner: Array<Node>;
};
function nameAnonymousFunctionsImpl(fn: HIRFunction): Array<Node> {
// Functions that we track to generate names for
const functions: Map<IdentifierId, Node> = new Map();
// Tracks temporaries that read from variables/globals/properties
const names: Map<IdentifierId, string> = new Map();
// Tracks all function nodes to bubble up for later renaming
const nodes: Array<Node> = [];
for (const block of fn.body.blocks.values()) {
for (const instr of block.instructions) {
const {lvalue, value} = instr;
switch (value.kind) {
case 'LoadGlobal': {
names.set(lvalue.identifier.id, value.binding.name);
break;
}
case 'LoadContext':
case 'LoadLocal': {
const name = value.place.identifier.name;
if (name != null && name.kind === 'named') {
names.set(lvalue.identifier.id, name.value);
}
break;
}
case 'PropertyLoad': {
const objectName = names.get(value.object.identifier.id);
if (objectName != null) {
names.set(
lvalue.identifier.id,
`${objectName}.${String(value.property)}`,
);
}
break;
}
case 'FunctionExpression': {
const inner = nameAnonymousFunctionsImpl(value.loweredFunc.func);
const node: Node = {
fn: value,
generatedName: null,
inner,
};
/**
* Bubble-up all functions, even if they're named, so that we can
* later generate names for any inner anonymous functions
*/
nodes.push(node);
if (value.name == null) {
// but only generate names for anonymous functions
functions.set(lvalue.identifier.id, node);
}
break;
}
case 'StoreContext':
case 'StoreLocal': {
const node = functions.get(value.value.identifier.id);
const variableName = value.lvalue.place.identifier.name;
if (
node != null &&
variableName != null &&
variableName.kind === 'named'
) {
node.generatedName = variableName.value;
functions.delete(value.value.identifier.id);
}
break;
}
case 'CallExpression':
case 'MethodCall': {
const callee =
value.kind === 'MethodCall' ? value.property : value.callee;
const hookKind = getHookKind(fn.env, callee.identifier);
let calleeName: string | null = null;
if (hookKind != null && hookKind !== 'Custom') {
calleeName = hookKind;
} else {
calleeName = names.get(callee.identifier.id) ?? '(anonymous)';
}
let fnArgCount = 0;
for (const arg of value.args) {
if (arg.kind === 'Identifier' && functions.has(arg.identifier.id)) {
fnArgCount++;
}
}
for (let i = 0; i < value.args.length; i++) {
const arg = value.args[i]!;
if (arg.kind === 'Spread') {
continue;
}
const node = functions.get(arg.identifier.id);
if (node != null) {
const generatedName =
fnArgCount > 1 ? `${calleeName}(arg${i})` : `${calleeName}()`;
node.generatedName = generatedName;
functions.delete(arg.identifier.id);
}
}
break;
}
case 'JsxExpression': {
for (const attr of value.props) {
if (attr.kind === 'JsxSpreadAttribute') {
continue;
}
const node = functions.get(attr.place.identifier.id);
if (node != null) {
const elementName =
value.tag.kind === 'BuiltinTag'
? value.tag.name
: (names.get(value.tag.identifier.id) ?? null);
const propName =
elementName == null
? attr.name
: `<${elementName}>.${attr.name}`;
node.generatedName = `${propName}`;
functions.delete(attr.place.identifier.id);
}
}
break;
}
}
}
}
return nodes;
}

View File

@@ -0,0 +1,272 @@
## Input
```javascript
// @enableNameAnonymousFunctions
import {useEffect} from 'react';
import {identity, Stringify, useIdentity} from 'shared-runtime';
import * as SharedRuntime from 'shared-runtime';
function Component(props) {
function named() {
const inner = () => props.named;
return inner();
}
const namedVariable = function () {
return props.namedVariable;
};
const methodCall = SharedRuntime.identity(() => props.methodCall);
const call = identity(() => props.call);
const builtinElementAttr = <div onClick={() => props.builtinElementAttr} />;
const namedElementAttr = <Stringify onClick={() => props.namedElementAttr} />;
const hookArgument = useIdentity(() => props.hookArgument);
useEffect(() => {
console.log(props.useEffect);
JSON.stringify(null, null, () => props.useEffect);
const g = () => props.useEffect;
console.log(g());
}, [props.useEffect]);
return (
<>
{named()}
{namedVariable()}
{methodCall()}
{call()}
{builtinElementAttr}
{namedElementAttr}
{hookArgument()}
</>
);
}
export const TODO_FIXTURE_ENTRYPOINT = {
fn: Component,
params: [
{
named: '<named>',
namedVariable: '<namedVariable>',
methodCall: '<methodCall>',
call: '<call>',
builtinElementAttr: '<builtinElementAttr>',
namedElementAttr: '<namedElementAttr>',
hookArgument: '<hookArgument>',
useEffect: '<useEffect>',
},
],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @enableNameAnonymousFunctions
import { useEffect } from "react";
import { identity, Stringify, useIdentity } from "shared-runtime";
import * as SharedRuntime from "shared-runtime";
function Component(props) {
const $ = _c(31);
let t0;
if ($[0] !== props.named) {
t0 = function named() {
const inner = { "Component[named > inner]": () => props.named }[
"Component[named > inner]"
];
return inner();
};
$[0] = props.named;
$[1] = t0;
} else {
t0 = $[1];
}
const named = t0;
let t1;
if ($[2] !== props.namedVariable) {
t1 = {
"Component[namedVariable]": function () {
return props.namedVariable;
},
}["Component[namedVariable]"];
$[2] = props.namedVariable;
$[3] = t1;
} else {
t1 = $[3];
}
const namedVariable = t1;
let t2;
if ($[4] !== props.methodCall) {
t2 = { "Component[SharedRuntime.identity()]": () => props.methodCall }[
"Component[SharedRuntime.identity()]"
];
$[4] = props.methodCall;
$[5] = t2;
} else {
t2 = $[5];
}
const methodCall = SharedRuntime.identity(t2);
let t3;
if ($[6] !== props.call) {
t3 = { "Component[identity()]": () => props.call }["Component[identity()]"];
$[6] = props.call;
$[7] = t3;
} else {
t3 = $[7];
}
const call = identity(t3);
let t4;
if ($[8] !== props.builtinElementAttr) {
t4 = (
<div
onClick={
{ "Component[<div>.onClick]": () => props.builtinElementAttr }[
"Component[<div>.onClick]"
]
}
/>
);
$[8] = props.builtinElementAttr;
$[9] = t4;
} else {
t4 = $[9];
}
const builtinElementAttr = t4;
let t5;
if ($[10] !== props.namedElementAttr) {
t5 = (
<Stringify
onClick={
{ "Component[<Stringify>.onClick]": () => props.namedElementAttr }[
"Component[<Stringify>.onClick]"
]
}
/>
);
$[10] = props.namedElementAttr;
$[11] = t5;
} else {
t5 = $[11];
}
const namedElementAttr = t5;
let t6;
if ($[12] !== props.hookArgument) {
t6 = { "Component[useIdentity()]": () => props.hookArgument }[
"Component[useIdentity()]"
];
$[12] = props.hookArgument;
$[13] = t6;
} else {
t6 = $[13];
}
const hookArgument = useIdentity(t6);
let t7;
let t8;
if ($[14] !== props.useEffect) {
t7 = {
"Component[useEffect()]": () => {
console.log(props.useEffect);
JSON.stringify(
null,
null,
{
"Component[useEffect() > JSON.stringify()]": () => props.useEffect,
}["Component[useEffect() > JSON.stringify()]"],
);
const g = { "Component[useEffect() > g]": () => props.useEffect }[
"Component[useEffect() > g]"
];
console.log(g());
},
}["Component[useEffect()]"];
t8 = [props.useEffect];
$[14] = props.useEffect;
$[15] = t7;
$[16] = t8;
} else {
t7 = $[15];
t8 = $[16];
}
useEffect(t7, t8);
let t9;
if ($[17] !== named) {
t9 = named();
$[17] = named;
$[18] = t9;
} else {
t9 = $[18];
}
let t10;
if ($[19] !== namedVariable) {
t10 = namedVariable();
$[19] = namedVariable;
$[20] = t10;
} else {
t10 = $[20];
}
const t11 = methodCall();
const t12 = call();
let t13;
if ($[21] !== hookArgument) {
t13 = hookArgument();
$[21] = hookArgument;
$[22] = t13;
} else {
t13 = $[22];
}
let t14;
if (
$[23] !== builtinElementAttr ||
$[24] !== namedElementAttr ||
$[25] !== t10 ||
$[26] !== t11 ||
$[27] !== t12 ||
$[28] !== t13 ||
$[29] !== t9
) {
t14 = (
<>
{t9}
{t10}
{t11}
{t12}
{builtinElementAttr}
{namedElementAttr}
{t13}
</>
);
$[23] = builtinElementAttr;
$[24] = namedElementAttr;
$[25] = t10;
$[26] = t11;
$[27] = t12;
$[28] = t13;
$[29] = t9;
$[30] = t14;
} else {
t14 = $[30];
}
return t14;
}
export const TODO_FIXTURE_ENTRYPOINT = {
fn: Component,
params: [
{
named: "<named>",
namedVariable: "<namedVariable>",
methodCall: "<methodCall>",
call: "<call>",
builtinElementAttr: "<builtinElementAttr>",
namedElementAttr: "<namedElementAttr>",
hookArgument: "<hookArgument>",
useEffect: "<useEffect>",
},
],
};
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,53 @@
// @enableNameAnonymousFunctions
import {useEffect} from 'react';
import {identity, Stringify, useIdentity} from 'shared-runtime';
import * as SharedRuntime from 'shared-runtime';
function Component(props) {
function named() {
const inner = () => props.named;
return inner();
}
const namedVariable = function () {
return props.namedVariable;
};
const methodCall = SharedRuntime.identity(() => props.methodCall);
const call = identity(() => props.call);
const builtinElementAttr = <div onClick={() => props.builtinElementAttr} />;
const namedElementAttr = <Stringify onClick={() => props.namedElementAttr} />;
const hookArgument = useIdentity(() => props.hookArgument);
useEffect(() => {
console.log(props.useEffect);
JSON.stringify(null, null, () => props.useEffect);
const g = () => props.useEffect;
console.log(g());
}, [props.useEffect]);
return (
<>
{named()}
{namedVariable()}
{methodCall()}
{call()}
{builtinElementAttr}
{namedElementAttr}
{hookArgument()}
</>
);
}
export const TODO_FIXTURE_ENTRYPOINT = {
fn: Component,
params: [
{
named: '<named>',
namedVariable: '<namedVariable>',
methodCall: '<methodCall>',
call: '<call>',
builtinElementAttr: '<builtinElementAttr>',
namedElementAttr: '<namedElementAttr>',
hookArgument: '<hookArgument>',
useEffect: '<useEffect>',
},
],
};