@samitouri / QOS-React / commits / a927040b7a

Add an optional validation to bail out on capitalized function calls

Some components stop being components over time and are used as regular functions instead, but they may have lingering hook calls. Those hook calls make it so the capitalized function calling them do not error (they appear to be a function to existing eslint rules), but they are nonetheless unsafe to memoize. This diff adds a conservative option to bail out on all capitalized function calls. There are a handful of known-non-component capitalized functions, like `Boolean`, `String`, and `Number`. This diff also adds the ability to supply capitalized function names that should not be considered in this analysis. I added three tests: 1. Ensure an error occurs in the obvious case 2. Ensure an error occurs when the value is aliased simply 3. Ensure the allowlist works This is my first commit so please go hard on me. I was unsure about where this code should live, so please nitpick.

Jordan Brown committed Feb 14, 2024 at 13:23 UTC a927040b7a67b2304ad484c903bb56e55f511b3b
10 files changed +209
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Pipeline.ts
+5
@@ -72,6 +72,7 @@ import {
72 validateContextVariableLValues,
73 validateHooksUsage,
74 validateMemoizedEffectDependencies,
75 + validateNoCapitalizedCalls,
76 validateNoRefAccessInRender,
77 validateNoSetStateInRender,
78 validatePreservedManualMemoization,
@@ -154,6 +155,10 @@ function* runWithEnvironment(
155 validateHooksUsage(hir);
156 }
157
158 + if (env.config.validateNoCapitalizedCalls) {
159 + validateNoCapitalizedCalls(hir);
160 + }
161 +
162 analyseFunctions(hir);
163 yield log({ kind: "hir", name: "AnalyseFunctions", value: hir });
164
compiler/packages/babel-plugin-react-forget/src/HIR/Environment.ts
+18
@@ -188,6 +188,18 @@ const EnvironmentConfigSchema = z.object({
188 */
189 validateMemoizedEffectDependencies: z.boolean().default(false),
190
191 + /**
192 + * Validates that there are no capitalized calls other than those allowed by the allowlist.
193 + * Calls to capitalized functions are often functions that used to be components and may
194 + * have lingering hook calls, which makes those calls risky to memoize.
195 + *
196 + * You can specify a list of capitalized calls to allowlist using this option. React Compiler
197 + * always includes its known global functions, including common functions like Boolean and String,
198 + * in this allowlist. You can enable this validation with no additional allowlisted calls by setting
199 + * this option to the empty array.
200 + */
201 + validateNoCapitalizedCalls: z.nullable(z.array(z.string())).default(null),
202 +
203 /*
204 * When enabled, the compiler assumes that hooks follow the Rules of React:
205 * - Hooks may memoize computation based on any of their parameters, thus
@@ -358,6 +370,12 @@ export function parseConfigPragma(pragma: string): EnvironmentConfig {
370 }
371 const keyVal = token.slice(1);
372 let [key, val]: any = keyVal.split(":");
373 +
374 + if (key === "validateNoCapitalizedCalls") {
375 + maybeConfig[key] = [];
376 + continue;
377 + }
378 +
379 if (typeof defaultConfig[key as keyof EnvironmentConfig] !== "boolean") {
380 // skip parsing non-boolean properties
381 continue;
compiler/packages/babel-plugin-react-forget/src/Validation/ValidateNoCapitalizedCalls.ts new
+61
@@ -0,0 +1,61 @@
1 +/*
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + */
7 +import { CompilerError, EnvironmentConfig } from "..";
8 +import { HIRFunction, IdentifierId } from "../HIR";
9 +import { DEFAULT_GLOBALS } from "../HIR/Globals";
10 +
11 +export function validateNoCapitalizedCalls(fn: HIRFunction): void {
12 + const envConfig: EnvironmentConfig = fn.env.config;
13 + const ALLOW_LIST = new Set([
14 + ...DEFAULT_GLOBALS.keys(),
15 + ...(envConfig.validateNoCapitalizedCalls ?? []),
16 + ]);
17 + /*
18 + * The hook pattern may allow uppercase names, like React$useState, so we need to be sure that we
19 + * do not error in those cases
20 + */
21 + const hookPattern =
22 + envConfig.hookPattern != null ? new RegExp(envConfig.hookPattern) : null;
23 + const isAllowed = (name: string): boolean => {
24 + return (
25 + ALLOW_LIST.has(name) || (hookPattern != null && hookPattern.test(name))
26 + );
27 + };
28 +
29 + const capitalLoadGlobals = new Map<IdentifierId, string>();
30 + for (const [, block] of fn.body.blocks) {
31 + for (const { lvalue, value } of block.instructions) {
32 + switch (value.kind) {
33 + case "LoadGlobal": {
34 + if (
35 + value.name != "" &&
36 + /^[A-Z]/.test(value.name) &&
37 + // We don't want to flag CONSTANTS()
38 + !(value.name.toUpperCase() === value.name) &&
39 + !isAllowed(value.name)
40 + ) {
41 + capitalLoadGlobals.set(lvalue.identifier.id, value.name);
42 + }
43 +
44 + break;
45 + }
46 + case "CallExpression": {
47 + const calleeIdentifier = value.callee.identifier.id;
48 + const calleeName = capitalLoadGlobals.get(calleeIdentifier);
49 + if (calleeName != null) {
50 + CompilerError.throwInvalidReact({
51 + reason: `Capitalized function calls may be calling components that use hooks, which make them dangerous to memoize. Ensure there are no hook calls in the function and rename it to begin with a lowercase letter to fix this error`,
52 + description: `${calleeName} may be a component.`,
53 + loc: value.loc,
54 + suggestions: null,
55 + });
56 + }
57 + }
58 + }
59 + }
60 + }
61 +}
compiler/packages/babel-plugin-react-forget/src/Validation/index.ts
+1
@@ -8,6 +8,7 @@
8 export { validateContextVariableLValues } from "./ValidateContextVariableLValues";
9 export { validateHooksUsage } from "./ValidateHooksUsage";
10 export { validateMemoizedEffectDependencies } from "./ValidateMemoizedEffectDependencies";
11 +export { validateNoCapitalizedCalls } from "./ValidateNoCapitalizedCalls";
12 export { validateNoRefAccessInRender } from "./ValidateNoRefAccesInRender";
13 export { validateNoSetStateInRender } from "./ValidateNoSetStateInRender";
14 export { validatePreservedManualMemoization } from "./ValidatePreservedManualMemoization";
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/capitalized-function-allowlist.expect.md new
+53
@@ -0,0 +1,53 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoCapitalizedCalls @hookPattern:".*\b(use[^$]+)$"
6 +import * as React from "react";
7 +const React$useState = React.useState;
8 +const THIS_IS_A_CONSTANT = () => {};
9 +function Component() {
10 + const b = Boolean(true); // OK
11 + const n = Number(3); // OK
12 + const s = String("foo"); // OK
13 + const [state, setState] = React$useState(0); // OK
14 + const [state2, setState2] = React.useState(1); // OK
15 + const constant = THIS_IS_A_CONSTANT(); // OK
16 + return 3;
17 +}
18 +
19 +export const FIXTURE_ENTRYPOINT = {
20 + fn: Component,
21 + params: [],
22 + isComponent: true,
23 +};
24 +
25 +```
26 +
27 +## Code
28 +
29 +```javascript
30 +// @validateNoCapitalizedCalls @hookPattern:".*\b(use[^$]+)$"
31 +import * as React from "react";
32 +const React$useState = React.useState;
33 +const THIS_IS_A_CONSTANT = () => {};
34 +function Component() {
35 + Boolean(true);
36 + Number(3);
37 + String("foo");
38 + React$useState(0);
39 + React.useState(1);
40 + THIS_IS_A_CONSTANT();
41 + return 3;
42 +}
43 +
44 +export const FIXTURE_ENTRYPOINT = {
45 + fn: Component,
46 + params: [],
47 + isComponent: true,
48 +};
49 +
50 +```
51 +
52 +### Eval output
53 +(kind: ok) 3
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/capitalized-function-allowlist.js new
+19
@@ -0,0 +1,19 @@
1 +// @validateNoCapitalizedCalls @hookPattern:".*\b(use[^$]+)$"
2 +import * as React from "react";
3 +const React$useState = React.useState;
4 +const THIS_IS_A_CONSTANT = () => {};
5 +function Component() {
6 + const b = Boolean(true); // OK
7 + const n = Number(3); // OK
8 + const s = String("foo"); // OK
9 + const [state, setState] = React$useState(0); // OK
10 + const [state2, setState2] = React.useState(1); // OK
11 + const constant = THIS_IS_A_CONSTANT(); // OK
12 + return 3;
13 +}
14 +
15 +export const FIXTURE_ENTRYPOINT = {
16 + fn: Component,
17 + params: [],
18 + isComponent: true,
19 +};
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.capitalized-function-call-aliased.expect.md new
+20
@@ -0,0 +1,20 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoCapitalizedCalls
6 +function Foo() {
7 + let x = Bar;
8 + x(); // ERROR
9 +}
10 +
11 +```
12 +
13 +
14 +## Error
15 +
16 +```
17 +[ReactForget] InvalidReact: Capitalized function calls may be calling components that use hooks, which make them dangerous to memoize. Ensure there are no hook calls in the function and rename it to begin with a lowercase letter to fix this error. Bar may be a component. (4:4)
18 +```
19 +
20 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.capitalized-function-call-aliased.js new
+5
@@ -0,0 +1,5 @@
1 +// @validateNoCapitalizedCalls
2 +function Foo() {
3 + let x = Bar;
4 + x(); // ERROR
5 +}
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.capitalized-function-call.expect.md new
+21
@@ -0,0 +1,21 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoCapitalizedCalls
6 +function Component() {
7 + const x = SomeFunc();
8 +
9 + return x;
10 +}
11 +
12 +```
13 +
14 +
15 +## Error
16 +
17 +```
18 +[ReactForget] InvalidReact: Capitalized function calls may be calling components that use hooks, which make them dangerous to memoize. Ensure there are no hook calls in the function and rename it to begin with a lowercase letter to fix this error. SomeFunc may be a component. (3:3)
19 +```
20 +
21 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.capitalized-function-call.js new
+6
@@ -0,0 +1,6 @@
1 +// @validateNoCapitalizedCalls
2 +function Component() {
3 + const x = SomeFunc();
4 +
5 + return x;
6 +}