@samitouri / QOS-React-2 / commits / 3653ae2de3

Option to infer React functions to compile

Adds a new option to infer which functions to compile, based on React's ESLint rule. The main difference is that in addition to checking the function name we also check that it creates JSX or calls a hook. This should cover a significant majority of components and reduce the chance of accidentally targeting non-components, but it will leave some false negatives. Note that some cases that the ESLint plugin infers as React functions don't work yet: we don't compile FunctionExpressions, only ArrowFunctionExpressions, and the way we handle ArrowFunctionExpression doesn't work with things like forwardRef or variable declarations. We'll need more updates to fully handle all these cases, which I'll do later in the stack.

Joe Savona committed Aug 29, 2023 at 22:09 UTC 3653ae2de35397bcd84cda8955f4628c94f90663
18 files changed +418
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Options.ts
+7
@@ -94,6 +94,12 @@ export type PluginOptions = {
94 *
95 */
96 enableOnlyOnReactScript: boolean;
97 +
98 + /**
99 + * Enable to make Forget infer which components to compile, based on the same rules
100 + * that React's ESLint rules use to detect components.
101 + */
102 + enableInferReactFunctions: boolean;
103 };
104
105 export type Logger = {
@@ -103,6 +109,7 @@ export type Logger = {
109 export const defaultOptions: PluginOptions = {
110 enableOnlyOnReactScript: false,
111 enableOnlyOnUseForgetDirective: false,
112 + enableInferReactFunctions: false,
113 panicOnBailout: true,
114 environment: null,
115 logger: null,
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Program.ts
+173
@@ -332,6 +332,11 @@ function shouldVisitNode(
332 }
333 }
334
335 + if (pass.opts.enableInferReactFunctions) {
336 + const isReactLike = isReactFunctionLike(fn);
337 + return isReactLike;
338 + }
339 +
340 return fn.scope.getProgramParent() === fn.scope.parent;
341 }
342
@@ -419,3 +424,171 @@ function buildBlockStatement(
424
425 return body.node;
426 }
427 +
428 +function isHookName(s: string): boolean {
429 + return /^use[A-Z0-9]/.test(s);
430 +}
431 +
432 +/**
433 + * We consider hooks to be a hook name identifier or a member expression
434 + * containing a hook name.
435 + */
436 +
437 +function isHook(path: NodePath<t.Expression | t.PrivateName>): boolean {
438 + if (path.isIdentifier()) {
439 + return isHookName(path.node.name);
440 + } else if (
441 + path.isMemberExpression() &&
442 + !path.node.computed &&
443 + isHook(path.get("property"))
444 + ) {
445 + const obj = path.get("object").node;
446 + const isPascalCaseNameSpace = /^[A-Z].*/;
447 + return obj.type === "Identifier" && isPascalCaseNameSpace.test(obj.name);
448 + } else {
449 + return false;
450 + }
451 +}
452 +
453 +/**
454 + * Checks if the node is a React component name. React component names must
455 + * always start with an uppercase letter.
456 + */
457 +
458 +function isComponentName(path: NodePath<t.Expression>): boolean {
459 + return path.isIdentifier() && /^[A-Z]/.test(path.node.name);
460 +}
461 +
462 +function isReactFunction(
463 + path: NodePath<t.Expression | t.PrivateName | t.V8IntrinsicIdentifier>,
464 + functionName: string
465 +): boolean {
466 + const node = path.node;
467 + return (
468 + (node.type === "Identifier" && node.name === functionName) ||
469 + (node.type === "MemberExpression" &&
470 + node.object.type === "Identifier" &&
471 + node.object.name === "React" &&
472 + node.property.type === "Identifier" &&
473 + node.property.name === functionName)
474 + );
475 +}
476 +
477 +/**
478 + * Checks if the node is a callback argument of forwardRef. This render function
479 + * should follow the rules of hooks.
480 + */
481 +
482 +function isForwardRefCallback(path: NodePath<t.Expression>): boolean {
483 + return !!(
484 + path.parentPath.isCallExpression() &&
485 + path.parentPath.get("callee").isExpression() &&
486 + isReactFunction(path.parentPath.get("callee"), "forwardRef")
487 + );
488 +}
489 +
490 +/**
491 + * Checks if the node is a callback argument of React.memo. This anonymous
492 + * functional component should follow the rules of hooks.
493 + */
494 +
495 +function isMemoCallback(path: NodePath<t.Expression>): boolean {
496 + return !!(
497 + path.parentPath.isCallExpression() &&
498 + path.parentPath.get("callee").isExpression() &&
499 + isReactFunction(path.parentPath.get("callee"), "memo")
500 + );
501 +}
502 +
503 +function isReactFunctionLike(
504 + node: NodePath<t.FunctionDeclaration | t.ArrowFunctionExpression>
505 +): boolean {
506 + const functionName = getFunctionName(node);
507 + if (functionName !== null) {
508 + if (!isComponentName(functionName) && !isHook(functionName)) {
509 + return false;
510 + }
511 + } else if (
512 + node.isExpression() &&
513 + !isForwardRefCallback(node) &&
514 + !isMemoCallback(node)
515 + ) {
516 + return false;
517 + } else {
518 + return false;
519 + }
520 +
521 + let invokesHooks = false;
522 + let createsJsx = false;
523 + node.traverse({
524 + JSX() {
525 + createsJsx = true;
526 + },
527 + CallExpression(call) {
528 + const callee = call.get("callee");
529 + if (callee.isExpression() && isHook(callee)) {
530 + invokesHooks = true;
531 + }
532 + },
533 + });
534 +
535 + return invokesHooks || createsJsx;
536 +}
537 +
538 +/**
539 + * Gets the static name of a function AST node. For function declarations it is
540 + * easy. For anonymous function expressions it is much harder. If you search for
541 + * `IsAnonymousFunctionDefinition()` in the ECMAScript spec you'll find places
542 + * where JS gives anonymous function expressions names. We roughly detect the
543 + * same AST nodes with some exceptions to better fit our use case.
544 + */
545 +
546 +function getFunctionName(
547 + path: NodePath<t.FunctionDeclaration | t.ArrowFunctionExpression>
548 +): NodePath<t.Expression> | null {
549 + if (path.isFunctionDeclaration()) {
550 + const id = path.get("id");
551 + if (id.isIdentifier()) {
552 + return id;
553 + }
554 + return null;
555 + }
556 + let id: NodePath<t.LVal | t.Expression | t.PrivateName> | null = null;
557 + const parent = path.parentPath;
558 + if (parent.isVariableDeclarator() && parent.get("init").node === path.node) {
559 + // const useHook = () => {};
560 + id = parent.get("id");
561 + } else if (
562 + parent.isAssignmentExpression() &&
563 + parent.get("right").node === path.node &&
564 + parent.get("operator") === "="
565 + ) {
566 + // useHook = () => {};
567 + id = parent.get("left");
568 + } else if (
569 + parent.isProperty() &&
570 + parent.get("value").node === path.node &&
571 + !parent.get("computed") &&
572 + parent.get("key").isLVal()
573 + ) {
574 + // {useHook: () => {}}
575 + // {useHook() {}}
576 + id = parent.get("key");
577 + } else if (
578 + parent.isAssignmentPattern() &&
579 + parent.get("right").node === path.node &&
580 + !parent.get("computed")
581 + ) {
582 + // const {useHook = () => {}} = {};
583 + // ({useHook = () => {}} = {});
584 + //
585 + // Kinda clowny, but we'd said we'd follow spec convention for
586 + // `IsAnonymousFunctionDefinition()` usage.
587 + id = parent.get("left");
588 + }
589 + if (id !== null && (id.isIdentifier() || id.isMemberExpression())) {
590 + return id;
591 + } else {
592 + return null;
593 + }
594 +}
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.todo.infer-function-assignment.expect.md new
+19
@@ -0,0 +1,19 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableInferReactFunctions
6 +const Component = (props) => {
7 + return <div />;
8 +};
9 +
10 +```
11 +
12 +
13 +## Error
14 +
15 +```
16 +Duplicate declaration "Component" (This is an error on an internal node. Probably an internal error.)
17 +```
18 +
19 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.todo.infer-function-assignment.js new
+4
@@ -0,0 +1,4 @@
1 +// @enableInferReactFunctions
2 +const Component = (props) => {
3 + return <div />;
4 +};
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-function-React-memo.expect.md new
+21
@@ -0,0 +1,21 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableInferReactFunctions
6 +React.memo((props) => {
7 + return <div />;
8 +});
9 +
10 +```
11 +
12 +## Code
13 +
14 +```javascript
15 +// @enableInferReactFunctions
16 +React.memo((props) => {
17 + return <div />;
18 +});
19 +
20 +```
21 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-function-React-memo.js new
+4
@@ -0,0 +1,4 @@
1 +// @enableInferReactFunctions
2 +React.memo((props) => {
3 + return <div />;
4 +});
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-function-forwardRef.expect.md new
+21
@@ -0,0 +1,21 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableInferReactFunctions
6 +React.forwardRef((props) => {
7 + return <div />;
8 +});
9 +
10 +```
11 +
12 +## Code
13 +
14 +```javascript
15 +// @enableInferReactFunctions
16 +React.forwardRef((props) => {
17 + return <div />;
18 +});
19 +
20 +```
21 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-function-forwardRef.js new
+4
@@ -0,0 +1,4 @@
1 +// @enableInferReactFunctions
2 +React.forwardRef((props) => {
3 + return <div />;
4 +});
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-functions-component-with-hook-call.expect.md new
+33
@@ -0,0 +1,33 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableInferReactFunctions
6 +function Component(props) {
7 + const [state, _] = useState(null);
8 + return [state];
9 +}
10 +
11 +```
12 +
13 +## Code
14 +
15 +```javascript
16 +import { unstable_useMemoCache as useMemoCache } from "react"; // @enableInferReactFunctions
17 +function Component(props) {
18 + const $ = useMemoCache(2);
19 + const [state] = useState(null);
20 + const c_0 = $[0] !== state;
21 + let t0;
22 + if (c_0) {
23 + t0 = [state];
24 + $[0] = state;
25 + $[1] = t0;
26 + } else {
27 + t0 = $[1];
28 + }
29 + return t0;
30 +}
31 +
32 +```
33 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-functions-component-with-hook-call.js new
+5
@@ -0,0 +1,5 @@
1 +// @enableInferReactFunctions
2 +function Component(props) {
3 + const [state, _] = useState(null);
4 + return [state];
5 +}
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-functions-component-with-jsx.expect.md new
+29
@@ -0,0 +1,29 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableInferReactFunctions
6 +function Component(props) {
7 + return <div />;
8 +}
9 +
10 +```
11 +
12 +## Code
13 +
14 +```javascript
15 +import { unstable_useMemoCache as useMemoCache } from "react"; // @enableInferReactFunctions
16 +function Component(props) {
17 + const $ = useMemoCache(1);
18 + let t0;
19 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
20 + t0 = <div />;
21 + $[0] = t0;
22 + } else {
23 + t0 = $[0];
24 + }
25 + return t0;
26 +}
27 +
28 +```
29 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-functions-component-with-jsx.js new
+4
@@ -0,0 +1,4 @@
1 +// @enableInferReactFunctions
2 +function Component(props) {
3 + return <div />;
4 +}
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-functions-hook-with-hook-call.expect.md new
+33
@@ -0,0 +1,33 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableInferReactFunctions
6 +function useStateValue(props) {
7 + const [state, _] = useState(null);
8 + return [state];
9 +}
10 +
11 +```
12 +
13 +## Code
14 +
15 +```javascript
16 +import { unstable_useMemoCache as useMemoCache } from "react"; // @enableInferReactFunctions
17 +function useStateValue(props) {
18 + const $ = useMemoCache(2);
19 + const [state] = useState(null);
20 + const c_0 = $[0] !== state;
21 + let t0;
22 + if (c_0) {
23 + t0 = [state];
24 + $[0] = state;
25 + $[1] = t0;
26 + } else {
27 + t0 = $[1];
28 + }
29 + return t0;
30 +}
31 +
32 +```
33 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-functions-hook-with-hook-call.js new
+5
@@ -0,0 +1,5 @@
1 +// @enableInferReactFunctions
2 +function useStateValue(props) {
3 + const [state, _] = useState(null);
4 + return [state];
5 +}
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-functions-hook-with-jsx.expect.md new
+29
@@ -0,0 +1,29 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableInferReactFunctions
6 +function useDiv(props) {
7 + return <div />;
8 +}
9 +
10 +```
11 +
12 +## Code
13 +
14 +```javascript
15 +import { unstable_useMemoCache as useMemoCache } from "react"; // @enableInferReactFunctions
16 +function useDiv(props) {
17 + const $ = useMemoCache(1);
18 + let t0;
19 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
20 + t0 = <div />;
21 + $[0] = t0;
22 + } else {
23 + t0 = $[0];
24 + }
25 + return t0;
26 +}
27 +
28 +```
29 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/infer-functions-hook-with-jsx.js new
+4
@@ -0,0 +1,4 @@
1 +// @enableInferReactFunctions
2 +function useDiv(props) {
3 + return <div />;
4 +}
compiler/packages/fixture-test-utils/src/compiler-utils.ts
+16
@@ -33,6 +33,7 @@ export function transformFixtureInput(
33 let validateNoSetStateInRender = true;
34 let enableEmitFreeze = null;
35 let enableOnlyOnReactScript = false;
36 + let enableInferReactFunctions = false;
37
38 if (firstLine.indexOf("@forgetDirective") !== -1) {
39 enableOnlyOnUseForgetDirective = true;
@@ -80,6 +81,20 @@ export function transformFixtureInput(
81 enableOnlyOnReactScript = true;
82 language = "flow";
83 }
84 + if (firstLine.indexOf("@enableInferReactFunctions") !== -1) {
85 + enableInferReactFunctions = true;
86 + }
87 + if (
88 + [
89 + enableInferReactFunctions,
90 + enableOnlyOnReactScript,
91 + enableOnlyOnUseForgetDirective,
92 + ].filter((x) => x === true).length > 1
93 + ) {
94 + throw new Error(
95 + "Cannot enable more than one of @enableInferReactFunctions, @enableOnlyOnReactScript, and @enableOnlyOnUseForgetDirective at once"
96 + );
97 + }
98
99 return pluginFn(
100 input,
@@ -111,6 +126,7 @@ export function transformFixtureInput(
126 },
127 enableOnlyOnUseForgetDirective,
128 enableOnlyOnReactScript,
129 + enableInferReactFunctions,
130 logger: null,
131 gating,
132 instrumentForget,
compiler/packages/sprout/src/SproutTodoFilter.ts
+7
@@ -438,6 +438,13 @@ const skipFilter = new Set([
438 "rules-of-hooks/rules-of-hooks-e66a744cffbe",
439 "rules-of-hooks/rules-of-hooks-eacfcaa6ef89",
440 "rules-of-hooks/rules-of-hooks-fe6042f7628b",
441 + "infer-functions-component-with-jsx",
442 + "infer-function-forwardRef",
443 + "infer-function-React-memo",
444 + "infer-functions-component-with-hook-call",
445 + "infer-functions-component-with-jsx",
446 + "infer-functions-hook-with-hook-call",
447 + "infer-functions-hook-with-jsx",
448 ]);
449
450 export default skipFilter;