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.
This commit is contained in:
Joe Savona
2023-08-29 22:09:40 +01:00
parent 6dbc48075b
commit 3653ae2de3
18 changed files with 418 additions and 0 deletions
@@ -94,6 +94,12 @@ export type PluginOptions = {
*
*/
enableOnlyOnReactScript: boolean;
/**
* Enable to make Forget infer which components to compile, based on the same rules
* that React's ESLint rules use to detect components.
*/
enableInferReactFunctions: boolean;
};
export type Logger = {
@@ -103,6 +109,7 @@ export type Logger = {
export const defaultOptions: PluginOptions = {
enableOnlyOnReactScript: false,
enableOnlyOnUseForgetDirective: false,
enableInferReactFunctions: false,
panicOnBailout: true,
environment: null,
logger: null,
@@ -332,6 +332,11 @@ function shouldVisitNode(
}
}
if (pass.opts.enableInferReactFunctions) {
const isReactLike = isReactFunctionLike(fn);
return isReactLike;
}
return fn.scope.getProgramParent() === fn.scope.parent;
}
@@ -419,3 +424,171 @@ function buildBlockStatement(
return body.node;
}
function isHookName(s: string): boolean {
return /^use[A-Z0-9]/.test(s);
}
/**
* We consider hooks to be a hook name identifier or a member expression
* containing a hook name.
*/
function isHook(path: NodePath<t.Expression | t.PrivateName>): boolean {
if (path.isIdentifier()) {
return isHookName(path.node.name);
} else if (
path.isMemberExpression() &&
!path.node.computed &&
isHook(path.get("property"))
) {
const obj = path.get("object").node;
const isPascalCaseNameSpace = /^[A-Z].*/;
return obj.type === "Identifier" && isPascalCaseNameSpace.test(obj.name);
} else {
return false;
}
}
/**
* Checks if the node is a React component name. React component names must
* always start with an uppercase letter.
*/
function isComponentName(path: NodePath<t.Expression>): boolean {
return path.isIdentifier() && /^[A-Z]/.test(path.node.name);
}
function isReactFunction(
path: NodePath<t.Expression | t.PrivateName | t.V8IntrinsicIdentifier>,
functionName: string
): boolean {
const node = path.node;
return (
(node.type === "Identifier" && node.name === functionName) ||
(node.type === "MemberExpression" &&
node.object.type === "Identifier" &&
node.object.name === "React" &&
node.property.type === "Identifier" &&
node.property.name === functionName)
);
}
/**
* Checks if the node is a callback argument of forwardRef. This render function
* should follow the rules of hooks.
*/
function isForwardRefCallback(path: NodePath<t.Expression>): boolean {
return !!(
path.parentPath.isCallExpression() &&
path.parentPath.get("callee").isExpression() &&
isReactFunction(path.parentPath.get("callee"), "forwardRef")
);
}
/**
* Checks if the node is a callback argument of React.memo. This anonymous
* functional component should follow the rules of hooks.
*/
function isMemoCallback(path: NodePath<t.Expression>): boolean {
return !!(
path.parentPath.isCallExpression() &&
path.parentPath.get("callee").isExpression() &&
isReactFunction(path.parentPath.get("callee"), "memo")
);
}
function isReactFunctionLike(
node: NodePath<t.FunctionDeclaration | t.ArrowFunctionExpression>
): boolean {
const functionName = getFunctionName(node);
if (functionName !== null) {
if (!isComponentName(functionName) && !isHook(functionName)) {
return false;
}
} else if (
node.isExpression() &&
!isForwardRefCallback(node) &&
!isMemoCallback(node)
) {
return false;
} else {
return false;
}
let invokesHooks = false;
let createsJsx = false;
node.traverse({
JSX() {
createsJsx = true;
},
CallExpression(call) {
const callee = call.get("callee");
if (callee.isExpression() && isHook(callee)) {
invokesHooks = true;
}
},
});
return invokesHooks || createsJsx;
}
/**
* Gets the static name of a function AST node. For function declarations it is
* easy. For anonymous function expressions it is much harder. If you search for
* `IsAnonymousFunctionDefinition()` in the ECMAScript spec you'll find places
* where JS gives anonymous function expressions names. We roughly detect the
* same AST nodes with some exceptions to better fit our use case.
*/
function getFunctionName(
path: NodePath<t.FunctionDeclaration | t.ArrowFunctionExpression>
): NodePath<t.Expression> | null {
if (path.isFunctionDeclaration()) {
const id = path.get("id");
if (id.isIdentifier()) {
return id;
}
return null;
}
let id: NodePath<t.LVal | t.Expression | t.PrivateName> | null = null;
const parent = path.parentPath;
if (parent.isVariableDeclarator() && parent.get("init").node === path.node) {
// const useHook = () => {};
id = parent.get("id");
} else if (
parent.isAssignmentExpression() &&
parent.get("right").node === path.node &&
parent.get("operator") === "="
) {
// useHook = () => {};
id = parent.get("left");
} else if (
parent.isProperty() &&
parent.get("value").node === path.node &&
!parent.get("computed") &&
parent.get("key").isLVal()
) {
// {useHook: () => {}}
// {useHook() {}}
id = parent.get("key");
} else if (
parent.isAssignmentPattern() &&
parent.get("right").node === path.node &&
!parent.get("computed")
) {
// const {useHook = () => {}} = {};
// ({useHook = () => {}} = {});
//
// Kinda clowny, but we'd said we'd follow spec convention for
// `IsAnonymousFunctionDefinition()` usage.
id = parent.get("left");
}
if (id !== null && (id.isIdentifier() || id.isMemberExpression())) {
return id;
} else {
return null;
}
}
@@ -0,0 +1,19 @@
## Input
```javascript
// @enableInferReactFunctions
const Component = (props) => {
return <div />;
};
```
## Error
```
Duplicate declaration "Component" (This is an error on an internal node. Probably an internal error.)
```
@@ -0,0 +1,4 @@
// @enableInferReactFunctions
const Component = (props) => {
return <div />;
};
@@ -0,0 +1,21 @@
## Input
```javascript
// @enableInferReactFunctions
React.memo((props) => {
return <div />;
});
```
## Code
```javascript
// @enableInferReactFunctions
React.memo((props) => {
return <div />;
});
```
@@ -0,0 +1,4 @@
// @enableInferReactFunctions
React.memo((props) => {
return <div />;
});
@@ -0,0 +1,21 @@
## Input
```javascript
// @enableInferReactFunctions
React.forwardRef((props) => {
return <div />;
});
```
## Code
```javascript
// @enableInferReactFunctions
React.forwardRef((props) => {
return <div />;
});
```
@@ -0,0 +1,4 @@
// @enableInferReactFunctions
React.forwardRef((props) => {
return <div />;
});
@@ -0,0 +1,33 @@
## Input
```javascript
// @enableInferReactFunctions
function Component(props) {
const [state, _] = useState(null);
return [state];
}
```
## Code
```javascript
import { unstable_useMemoCache as useMemoCache } from "react"; // @enableInferReactFunctions
function Component(props) {
const $ = useMemoCache(2);
const [state] = useState(null);
const c_0 = $[0] !== state;
let t0;
if (c_0) {
t0 = [state];
$[0] = state;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
```
@@ -0,0 +1,5 @@
// @enableInferReactFunctions
function Component(props) {
const [state, _] = useState(null);
return [state];
}
@@ -0,0 +1,29 @@
## Input
```javascript
// @enableInferReactFunctions
function Component(props) {
return <div />;
}
```
## Code
```javascript
import { unstable_useMemoCache as useMemoCache } from "react"; // @enableInferReactFunctions
function Component(props) {
const $ = useMemoCache(1);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = <div />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
```
@@ -0,0 +1,4 @@
// @enableInferReactFunctions
function Component(props) {
return <div />;
}
@@ -0,0 +1,33 @@
## Input
```javascript
// @enableInferReactFunctions
function useStateValue(props) {
const [state, _] = useState(null);
return [state];
}
```
## Code
```javascript
import { unstable_useMemoCache as useMemoCache } from "react"; // @enableInferReactFunctions
function useStateValue(props) {
const $ = useMemoCache(2);
const [state] = useState(null);
const c_0 = $[0] !== state;
let t0;
if (c_0) {
t0 = [state];
$[0] = state;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
```
@@ -0,0 +1,5 @@
// @enableInferReactFunctions
function useStateValue(props) {
const [state, _] = useState(null);
return [state];
}
@@ -0,0 +1,29 @@
## Input
```javascript
// @enableInferReactFunctions
function useDiv(props) {
return <div />;
}
```
## Code
```javascript
import { unstable_useMemoCache as useMemoCache } from "react"; // @enableInferReactFunctions
function useDiv(props) {
const $ = useMemoCache(1);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = <div />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
```
@@ -0,0 +1,4 @@
// @enableInferReactFunctions
function useDiv(props) {
return <div />;
}
@@ -33,6 +33,7 @@ export function transformFixtureInput(
let validateNoSetStateInRender = true;
let enableEmitFreeze = null;
let enableOnlyOnReactScript = false;
let enableInferReactFunctions = false;
if (firstLine.indexOf("@forgetDirective") !== -1) {
enableOnlyOnUseForgetDirective = true;
@@ -80,6 +81,20 @@ export function transformFixtureInput(
enableOnlyOnReactScript = true;
language = "flow";
}
if (firstLine.indexOf("@enableInferReactFunctions") !== -1) {
enableInferReactFunctions = true;
}
if (
[
enableInferReactFunctions,
enableOnlyOnReactScript,
enableOnlyOnUseForgetDirective,
].filter((x) => x === true).length > 1
) {
throw new Error(
"Cannot enable more than one of @enableInferReactFunctions, @enableOnlyOnReactScript, and @enableOnlyOnUseForgetDirective at once"
);
}
return pluginFn(
input,
@@ -111,6 +126,7 @@ export function transformFixtureInput(
},
enableOnlyOnUseForgetDirective,
enableOnlyOnReactScript,
enableInferReactFunctions,
logger: null,
gating,
instrumentForget,
@@ -438,6 +438,13 @@ const skipFilter = new Set([
"rules-of-hooks/rules-of-hooks-e66a744cffbe",
"rules-of-hooks/rules-of-hooks-eacfcaa6ef89",
"rules-of-hooks/rules-of-hooks-fe6042f7628b",
"infer-functions-component-with-jsx",
"infer-function-forwardRef",
"infer-function-React-memo",
"infer-functions-component-with-hook-call",
"infer-functions-component-with-jsx",
"infer-functions-hook-with-hook-call",
"infer-functions-hook-with-jsx",
]);
export default skipFilter;