Skip to content

Commit e8bc72f

Browse files
committed
[compiler] Add new ValidateNoVoidUseMemo pass
Adds a new validation pass to validate against `useMemo`s that don't return anything. This usually indicates some kind of "useEffect"-like code that has side effects that need to be memoized to prevent overfiring, and is an anti-pattern. A follow up validation could also look at the return value of `useMemo`s to see if they are being used.
1 parent e062e57 commit e8bc72f

14 files changed

+370
-0
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import {
8282
import {inferTypes} from '../TypeInference';
8383
import {
8484
validateContextVariableLValues,
85+
validateNoVoidUseMemo,
8586
validateHooksUsage,
8687
validateMemoizedEffectDependencies,
8788
validateNoCapitalizedCalls,
@@ -167,6 +168,9 @@ function runWithEnvironment(
167168

168169
validateContextVariableLValues(hir);
169170
validateUseMemo(hir).unwrap();
171+
if (env.config.enableValidateNoVoidUseMemo) {
172+
validateNoVoidUseMemo(hir).unwrap();
173+
}
170174

171175
if (
172176
env.isInferredMemoEnabled &&

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,17 @@ export const EnvironmentConfigSchema = z.object({
631631
* ```
632632
*/
633633
lowerContextAccess: ExternalFunctionSchema.nullable().default(null),
634+
635+
/**
636+
* If enabled, will validate useMemos that don't return any values:
637+
*
638+
* Valid:
639+
* useMemo(() => foo, [foo]);
640+
* useMemo(() => { return foo }, [foo]);
641+
* Invalid:
642+
* useMemo(() => { ... }, [...]);
643+
*/
644+
enableValidateNoVoidUseMemo: z.boolean().default(false),
634645
});
635646

636647
export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
8+
import {CompilerError, ErrorSeverity} from '../CompilerError';
9+
import {
10+
HIRFunction,
11+
IdentifierId,
12+
FunctionExpression,
13+
SourceLocation,
14+
} from '../HIR';
15+
import {Result} from '../Utils/Result';
16+
17+
// TODO: see https://github.com/facebook/react-forget/blob/main/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts#L132-L156
18+
19+
/**
20+
* Validates that useMemo has at least one explicit return statement.
21+
*
22+
* Valid cases:
23+
* - useMemo(() => value) // implicit arrow function return
24+
* - useMemo(() => { return value; }) // explicit return
25+
* - useMemo(() => { return; }) // explicit undefined
26+
* - useMemo(() => { if (cond) return val; }) // at least one return
27+
*
28+
* Invalid cases:
29+
* - useMemo(() => { console.log(); }) // no return statement at all
30+
*/
31+
export function validateNoVoidUseMemo(
32+
fn: HIRFunction,
33+
): Result<void, CompilerError> {
34+
const errors = new CompilerError();
35+
const useMemoHooks = new Map<
36+
IdentifierId,
37+
{name: string; loc: SourceLocation}
38+
>();
39+
const funcExprs = new Map<IdentifierId, FunctionExpression>();
40+
41+
for (const [, block] of fn.body.blocks) {
42+
for (const instr of block.instructions) {
43+
if (!instr.lvalue) {
44+
continue;
45+
}
46+
47+
if (instr.value.kind === 'LoadGlobal') {
48+
if (
49+
instr.value.binding.kind === 'Global' &&
50+
instr.value.binding.name === 'useMemo'
51+
) {
52+
useMemoHooks.set(instr.lvalue.identifier.id, {
53+
name: instr.value.binding.name,
54+
loc: instr.loc,
55+
});
56+
}
57+
} else if (instr.value.kind === 'PropertyLoad') {
58+
if (instr.value.property === 'useMemo') {
59+
useMemoHooks.set(instr.lvalue.identifier.id, {
60+
name: instr.value.property,
61+
loc: instr.loc,
62+
});
63+
}
64+
} else if (instr.value.kind === 'FunctionExpression') {
65+
funcExprs.set(instr.lvalue.identifier.id, instr.value);
66+
}
67+
}
68+
}
69+
70+
for (const [, block] of fn.body.blocks) {
71+
for (const instr of block.instructions) {
72+
if (instr.value.kind === 'CallExpression') {
73+
const callee = instr.value.callee.identifier;
74+
const useMemoHook = useMemoHooks.get(callee.id);
75+
76+
if (useMemoHook !== undefined && instr.value.args.length > 0) {
77+
const firstArg = instr.value.args[0];
78+
if (firstArg.kind !== 'Identifier') {
79+
continue;
80+
}
81+
82+
let funcToCheck = funcExprs.get(firstArg.identifier.id);
83+
84+
if (!funcToCheck) {
85+
for (const [, searchBlock] of fn.body.blocks) {
86+
for (const searchInstr of searchBlock.instructions) {
87+
if (
88+
searchInstr.lvalue &&
89+
searchInstr.lvalue.identifier.id === firstArg.identifier.id &&
90+
searchInstr.value.kind === 'FunctionExpression'
91+
) {
92+
funcToCheck = searchInstr.value;
93+
break;
94+
}
95+
}
96+
if (funcToCheck) break;
97+
}
98+
}
99+
100+
if (funcToCheck) {
101+
const hasReturn = checkFunctionHasReturn(
102+
funcToCheck.loweredFunc.func,
103+
);
104+
105+
if (!hasReturn) {
106+
errors.push({
107+
severity: ErrorSeverity.InvalidReact,
108+
reason: `React Compiler has skipped optimizing this component because ${useMemoHook.name} doesn't return a value. ${useMemoHook.name} should only be used for memoizing values, not running arbitrary side effects.`,
109+
loc: useMemoHook.loc,
110+
suggestions: null,
111+
description: null,
112+
});
113+
}
114+
}
115+
}
116+
}
117+
}
118+
}
119+
return errors.asResult();
120+
}
121+
122+
function checkFunctionHasReturn(func: HIRFunction): boolean {
123+
for (const [, block] of func.body.blocks) {
124+
if (block.terminal.kind === 'return') {
125+
if (
126+
block.terminal.returnVariant === 'Explicit' ||
127+
block.terminal.returnVariant === 'Implicit'
128+
) {
129+
return true;
130+
}
131+
}
132+
}
133+
return false;
134+
}

compiler/packages/babel-plugin-react-compiler/src/Validation/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
export {validateContextVariableLValues} from './ValidateContextVariableLValues';
9+
export {validateNoVoidUseMemo} from './ValidateNoVoidUseMemo';
910
export {validateHooksUsage} from './ValidateHooksUsage';
1011
export {validateMemoizedEffectDependencies} from './ValidateMemoizedEffectDependencies';
1112
export {validateNoCapitalizedCalls} from './ValidateNoCapitalizedCalls';
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @enableValidateNoVoidUseMemo
6+
function Component() {
7+
const value = useMemo(() => {
8+
console.log('computing');
9+
}, []);
10+
return <div>{value}</div>;
11+
}
12+
13+
```
14+
15+
16+
## Error
17+
18+
```
19+
Found 1 error:
20+
21+
Error: React Compiler has skipped optimizing this component because useMemo doesn't return a value. useMemo should only be used for memoizing values, not running arbitrary side effects.
22+
23+
error.useMemo-no-return-value.ts:3:16
24+
1 | // @enableValidateNoVoidUseMemo
25+
2 | function Component() {
26+
> 3 | const value = useMemo(() => {
27+
| ^^^^^^^ React Compiler has skipped optimizing this component because useMemo doesn't return a value. useMemo should only be used for memoizing values, not running arbitrary side effects.
28+
4 | console.log('computing');
29+
5 | }, []);
30+
6 | return <div>{value}</div>;
31+
```
32+
33+
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// @enableValidateNoVoidUseMemo
2+
function Component() {
3+
const value = useMemo(() => {
4+
console.log('computing');
5+
}, []);
6+
return <div>{value}</div>;
7+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
2+
## Input
3+
4+
```javascript
5+
function Component() {
6+
const value = useMemo(() => computeValue(), []);
7+
return <div>{value}</div>;
8+
}
9+
10+
```
11+
12+
## Code
13+
14+
```javascript
15+
import { c as _c } from "react/compiler-runtime";
16+
function Component() {
17+
const $ = _c(2);
18+
let t0;
19+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
20+
t0 = computeValue();
21+
$[0] = t0;
22+
} else {
23+
t0 = $[0];
24+
}
25+
const value = t0;
26+
let t1;
27+
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
28+
t1 = <div>{value}</div>;
29+
$[1] = t1;
30+
} else {
31+
t1 = $[1];
32+
}
33+
return t1;
34+
}
35+
36+
```
37+
38+
### Eval output
39+
(kind: exception) Fixture not implemented
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
function Component() {
2+
const value = useMemo(() => computeValue(), []);
3+
return <div>{value}</div>;
4+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
2+
## Input
3+
4+
```javascript
5+
function Component() {
6+
const value = useMemo(() => {
7+
return;
8+
}, []);
9+
return <div>{value}</div>;
10+
}
11+
12+
```
13+
14+
## Code
15+
16+
```javascript
17+
import { c as _c } from "react/compiler-runtime";
18+
function Component() {
19+
const $ = _c(1);
20+
let t0;
21+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
22+
t0 = <div>{undefined}</div>;
23+
$[0] = t0;
24+
} else {
25+
t0 = $[0];
26+
}
27+
return t0;
28+
}
29+
30+
```
31+
32+
### Eval output
33+
(kind: exception) Fixture not implemented
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
function Component() {
2+
const value = useMemo(() => {
3+
return;
4+
}, []);
5+
return <div>{value}</div>;
6+
}

0 commit comments

Comments
 (0)