Skip to content

Commit b870042

Browse files
authored
[compiler] Validate against component/hook factories (#34305)
Previously, the compiler would incorrectly attempt to compile nested components/hooks defined inside non-React functions. This would lead to scope reference errors at runtime because the compiler would optimize the nested React function without understanding its closure over the parent function's variables. This PR adds detection when non-React functions declare components or hooks, and reports a clear error before compilation. I put this under a new compiler flag defaulting to false. I'll run a test on this internally first, but I expect we should be able to just turn it on in both compiler (so we stop miscompiling) and linter. Closes #33978 Playground example: https://react-compiler-playground-git-pr34305-fbopensource.vercel.app/#N4Igzg9grgTgxgUxALhAejQAgAIDcCGANgJYAm+ALggHIQAiAngHb4C2xcRhDAwjApQSkeEVgAcITBEwpgA8jAASECAGswAHSkAPCTAqYAZlCZwKxSZgDmCCgEkmYqBQAU+AJSZgWzJjiSwAwB1GHwxMQQYTABeTBdPaIA+Lx9fPwCDAAt8JlJCBB5sphsYuITk7yY0tPwAOklCnJt4gG5U3wBfNqZ2zH4KWCqAHmJHZ0wGopto4CK8gqmEDsw0RO7O7tT+wcwQsIiYbo6QDqA
1 parent 33a1095 commit b870042

File tree

7 files changed

+253
-5
lines changed

7 files changed

+253
-5
lines changed

compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,9 @@ export enum ErrorCategory {
541541
// Checking for valid usage of manual memoization
542542
UseMemo = 'UseMemo',
543543

544+
// Checking for higher order functions acting as factories for components/hooks
545+
Factories = 'Factories',
546+
544547
// Checks that manual memoization is preserved
545548
PreserveManualMemo = 'PreserveManualMemo',
546549

@@ -703,6 +706,16 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
703706
recommended: true,
704707
};
705708
}
709+
case ErrorCategory.Factories: {
710+
return {
711+
category,
712+
name: 'component-hook-factories',
713+
description:
714+
'Validates against higher order functions defining nested components or hooks. ' +
715+
'Components and hooks should be defined at the module level',
716+
recommended: true,
717+
};
718+
}
706719
case ErrorCategory.FBT: {
707720
return {
708721
category,

compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,20 @@ function findFunctionsToCompile(
494494
): Array<CompileSource> {
495495
const queue: Array<CompileSource> = [];
496496
const traverseFunction = (fn: BabelFn, pass: CompilerPass): void => {
497+
// In 'all' mode, compile only top level functions
498+
if (
499+
pass.opts.compilationMode === 'all' &&
500+
fn.scope.getProgramParent() !== fn.scope.parent
501+
) {
502+
return;
503+
}
504+
497505
const fnType = getReactFunctionType(fn, pass);
506+
507+
if (pass.opts.environment.validateNoDynamicallyCreatedComponentsOrHooks) {
508+
validateNoDynamicallyCreatedComponentsOrHooks(fn, pass, programContext);
509+
}
510+
498511
if (fnType === null || programContext.alreadyCompiled.has(fn.node)) {
499512
return;
500513
}
@@ -839,6 +852,73 @@ function shouldSkipCompilation(
839852
return false;
840853
}
841854

855+
/**
856+
* Validates that Components/Hooks are always defined at module level. This prevents scope reference
857+
* errors that occur when the compiler attempts to optimize the nested component/hook while its
858+
* parent function remains uncompiled.
859+
*/
860+
function validateNoDynamicallyCreatedComponentsOrHooks(
861+
fn: BabelFn,
862+
pass: CompilerPass,
863+
programContext: ProgramContext,
864+
): void {
865+
const parentNameExpr = getFunctionName(fn);
866+
const parentName =
867+
parentNameExpr !== null && parentNameExpr.isIdentifier()
868+
? parentNameExpr.node.name
869+
: '<anonymous>';
870+
871+
const validateNestedFunction = (
872+
nestedFn: NodePath<
873+
t.FunctionDeclaration | t.FunctionExpression | t.ArrowFunctionExpression
874+
>,
875+
): void => {
876+
if (
877+
nestedFn.node === fn.node ||
878+
programContext.alreadyCompiled.has(nestedFn.node)
879+
) {
880+
return;
881+
}
882+
883+
if (nestedFn.scope.getProgramParent() !== nestedFn.scope.parent) {
884+
const nestedFnType = getReactFunctionType(nestedFn as BabelFn, pass);
885+
const nestedFnNameExpr = getFunctionName(nestedFn as BabelFn);
886+
const nestedName =
887+
nestedFnNameExpr !== null && nestedFnNameExpr.isIdentifier()
888+
? nestedFnNameExpr.node.name
889+
: '<anonymous>';
890+
if (nestedFnType === 'Component' || nestedFnType === 'Hook') {
891+
CompilerError.throwDiagnostic({
892+
category: ErrorCategory.Factories,
893+
severity: ErrorSeverity.InvalidReact,
894+
reason: `Components and hooks cannot be created dynamically`,
895+
description: `The function \`${nestedName}\` appears to be a React ${nestedFnType.toLowerCase()}, but it's defined inside \`${parentName}\`. Components and Hooks should always be declared at module scope`,
896+
details: [
897+
{
898+
kind: 'error',
899+
message: 'this function dynamically created a component/hook',
900+
loc: parentNameExpr?.node.loc ?? fn.node.loc ?? null,
901+
},
902+
{
903+
kind: 'error',
904+
message: 'the component is created here',
905+
loc: nestedFnNameExpr?.node.loc ?? nestedFn.node.loc ?? null,
906+
},
907+
],
908+
});
909+
}
910+
}
911+
912+
nestedFn.skip();
913+
};
914+
915+
fn.traverse({
916+
FunctionDeclaration: validateNestedFunction,
917+
FunctionExpression: validateNestedFunction,
918+
ArrowFunctionExpression: validateNestedFunction,
919+
});
920+
}
921+
842922
function getReactFunctionType(
843923
fn: BabelFn,
844924
pass: CompilerPass,
@@ -877,11 +957,6 @@ function getReactFunctionType(
877957
return componentSyntaxType;
878958
}
879959
case 'all': {
880-
// Compile only top level functions
881-
if (fn.scope.getProgramParent() !== fn.scope.parent) {
882-
return null;
883-
}
884-
885960
return getComponentOrHookLike(fn, hookPattern) ?? 'Other';
886961
}
887962
default: {

compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,13 @@ export const EnvironmentConfigSchema = z.object({
650650
* useMemo(() => { ... }, [...]);
651651
*/
652652
validateNoVoidUseMemo: z.boolean().default(false),
653+
654+
/**
655+
* Validates that Components/Hooks are always defined at module level. This prevents scope
656+
* reference errors that occur when the compiler attempts to optimize the nested component/hook
657+
* while its parent function remains uncompiled.
658+
*/
659+
validateNoDynamicallyCreatedComponentsOrHooks: z.boolean().default(false),
653660
});
654661

655662
export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoDynamicallyCreatedComponentsOrHooks
6+
export function getInput(a) {
7+
const Wrapper = () => {
8+
const handleChange = () => {
9+
a.onChange();
10+
};
11+
12+
return <input onChange={handleChange} />;
13+
};
14+
15+
return Wrapper;
16+
}
17+
18+
export const FIXTURE_ENTRYPOINT = {
19+
fn: getInput,
20+
isComponent: false,
21+
params: [{onChange() {}}],
22+
};
23+
24+
```
25+
26+
27+
## Error
28+
29+
```
30+
Found 1 error:
31+
32+
Error: Components and hooks cannot be created dynamically
33+
34+
The function `Wrapper` appears to be a React component, but it's defined inside `getInput`. Components and Hooks should always be declared at module scope
35+
36+
error.nested-component-in-normal-function.ts:2:16
37+
1 | // @validateNoDynamicallyCreatedComponentsOrHooks
38+
> 2 | export function getInput(a) {
39+
| ^^^^^^^^ this function dynamically created a component/hook
40+
3 | const Wrapper = () => {
41+
4 | const handleChange = () => {
42+
5 | a.onChange();
43+
44+
error.nested-component-in-normal-function.ts:3:8
45+
1 | // @validateNoDynamicallyCreatedComponentsOrHooks
46+
2 | export function getInput(a) {
47+
> 3 | const Wrapper = () => {
48+
| ^^^^^^^ the component is created here
49+
4 | const handleChange = () => {
50+
5 | a.onChange();
51+
6 | };
52+
```
53+
54+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// @validateNoDynamicallyCreatedComponentsOrHooks
2+
export function getInput(a) {
3+
const Wrapper = () => {
4+
const handleChange = () => {
5+
a.onChange();
6+
};
7+
8+
return <input onChange={handleChange} />;
9+
};
10+
11+
return Wrapper;
12+
}
13+
14+
export const FIXTURE_ENTRYPOINT = {
15+
fn: getInput,
16+
isComponent: false,
17+
params: [{onChange() {}}],
18+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoDynamicallyCreatedComponentsOrHooks
6+
import {useState} from 'react';
7+
8+
function createCustomHook(config) {
9+
function useConfiguredState() {
10+
const [state, setState] = useState(0);
11+
12+
const increment = () => {
13+
setState(state + config.step);
14+
};
15+
16+
return [state, increment];
17+
}
18+
19+
return useConfiguredState;
20+
}
21+
22+
export const FIXTURE_ENTRYPOINT = {
23+
fn: createCustomHook,
24+
isComponent: false,
25+
params: [{step: 1}],
26+
};
27+
28+
```
29+
30+
31+
## Error
32+
33+
```
34+
Found 1 error:
35+
36+
Error: Components and hooks cannot be created dynamically
37+
38+
The function `useConfiguredState` appears to be a React hook, but it's defined inside `createCustomHook`. Components and Hooks should always be declared at module scope
39+
40+
error.nested-hook-in-normal-function.ts:4:9
41+
2 | import {useState} from 'react';
42+
3 |
43+
> 4 | function createCustomHook(config) {
44+
| ^^^^^^^^^^^^^^^^ this function dynamically created a component/hook
45+
5 | function useConfiguredState() {
46+
6 | const [state, setState] = useState(0);
47+
7 |
48+
49+
error.nested-hook-in-normal-function.ts:5:11
50+
3 |
51+
4 | function createCustomHook(config) {
52+
> 5 | function useConfiguredState() {
53+
| ^^^^^^^^^^^^^^^^^^ the component is created here
54+
6 | const [state, setState] = useState(0);
55+
7 |
56+
8 | const increment = () => {
57+
```
58+
59+
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// @validateNoDynamicallyCreatedComponentsOrHooks
2+
import {useState} from 'react';
3+
4+
function createCustomHook(config) {
5+
function useConfiguredState() {
6+
const [state, setState] = useState(0);
7+
8+
const increment = () => {
9+
setState(state + config.step);
10+
};
11+
12+
return [state, increment];
13+
}
14+
15+
return useConfiguredState;
16+
}
17+
18+
export const FIXTURE_ENTRYPOINT = {
19+
fn: createCustomHook,
20+
isComponent: false,
21+
params: [{step: 1}],
22+
};

0 commit comments

Comments
 (0)