@samitouri / QOS-React / commits / b870042915

[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

lauren committed Aug 27, 2025 at 13:59 UTC b870042915c8a7cc1524feb7e0b5cbe7453a7648
7 files changed +253 -5
compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts
+13
@@ -541,6 +541,9 @@ export enum ErrorCategory {
541 // Checking for valid usage of manual memoization
542 UseMemo = 'UseMemo',
543
544 + // Checking for higher order functions acting as factories for components/hooks
545 + Factories = 'Factories',
546 +
547 // Checks that manual memoization is preserved
548 PreserveManualMemo = 'PreserveManualMemo',
549
@@ -703,6 +706,16 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
706 recommended: true,
707 };
708 }
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 + }
719 case ErrorCategory.FBT: {
720 return {
721 category,
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts
+80 -5
@@ -494,7 +494,20 @@ function findFunctionsToCompile(
494 ): Array<CompileSource> {
495 const queue: Array<CompileSource> = [];
496 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 +
505 const fnType = getReactFunctionType(fn, pass);
506 +
507 + if (pass.opts.environment.validateNoDynamicallyCreatedComponentsOrHooks) {
508 + validateNoDynamicallyCreatedComponentsOrHooks(fn, pass, programContext);
509 + }
510 +
511 if (fnType === null || programContext.alreadyCompiled.has(fn.node)) {
512 return;
513 }
@@ -839,6 +852,73 @@ function shouldSkipCompilation(
852 return false;
853 }
854
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 +
922 function getReactFunctionType(
923 fn: BabelFn,
924 pass: CompilerPass,
@@ -877,11 +957,6 @@ function getReactFunctionType(
957 return componentSyntaxType;
958 }
959 case 'all': {
880 - // Compile only top level functions
881 - if (fn.scope.getProgramParent() !== fn.scope.parent) {
882 - return null;
883 - }
884 -
960 return getComponentOrHookLike(fn, hookPattern) ?? 'Other';
961 }
962 default: {
compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+7
@@ -650,6 +650,13 @@ export const EnvironmentConfigSchema = z.object({
650 * useMemo(() => { ... }, [...]);
651 */
652 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),
660 });
661
662 export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nested-component-in-normal-function.expect.md new
+54
@@ -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 +
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nested-component-in-normal-function.js new
+18
@@ -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 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nested-hook-in-normal-function.expect.md new
+59
@@ -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 +
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nested-hook-in-normal-function.js new
+22
@@ -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 +};