[compiler] Cleanup and enable validateNoVoidUseMemo (#34882)
This is a great validation, so let's enable by default. Changes: * Move the validation logic into ValidateUseMemo alongside the new check that the useMemo result is used * Update the lint description * Make the void memo errors lint-only, they don't require us to skip compilation (as evidenced by the fact that we've had this validation off) --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34882). * #34855 * __->__ #34882
Joseph Savona committed
Oct 16, 2025 at 13:08 UTC
1324e1bb1f867e8b2108ca52a1d4e2d4ef56d2d9
18 files changed
+195
-179
compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts
+1
-1
@@ -988,7 +988,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
988
severity: ErrorSeverity.Error,
989
name: 'void-use-memo',
990
description:
991
- 'Validates that useMemos always return a value. See [`useMemo()` docs](https://react.dev/reference/react/useMemo) for more information.',
991
+ 'Validates that useMemos always return a value and that the result of the useMemo is used by the component/hook. See [`useMemo()` docs](https://react.dev/reference/react/useMemo) for more information.',
992
preset: LintRulePreset.RecommendedLatest,
993
};
994
}
compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+1
-1
@@ -659,7 +659,7 @@ export const EnvironmentConfigSchema = z.object({
659
* Invalid:
660
* useMemo(() => { ... }, [...]);
661
*/
662
- validateNoVoidUseMemo: z.boolean().default(false),
662
+ validateNoVoidUseMemo: z.boolean().default(true),
663
664
/**
665
* Validates that Components/Hooks are always defined at module level. This prevents scope
compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts
-48
@@ -438,40 +438,6 @@ export function dropManualMemoization(
438
continue;
439
}
440
441
- /**
442
- * Bailout on void return useMemos. This is an anti-pattern where code might be using
443
- * useMemo like useEffect: running arbirtary side-effects synced to changes in specific
444
- * values.
445
- */
446
- if (
447
- func.env.config.validateNoVoidUseMemo &&
448
- manualMemo.kind === 'useMemo'
449
- ) {
450
- const funcToCheck = sidemap.functions.get(
451
- fnPlace.identifier.id,
452
- )?.value;
453
- if (funcToCheck !== undefined && funcToCheck.loweredFunc.func) {
454
- if (!hasNonVoidReturn(funcToCheck.loweredFunc.func)) {
455
- errors.pushDiagnostic(
456
- CompilerDiagnostic.create({
457
- category: ErrorCategory.VoidUseMemo,
458
- reason: 'useMemo() callbacks must return a value',
459
- description: `This ${
460
- manualMemo.loadInstr.value.kind === 'PropertyLoad'
461
- ? 'React.useMemo()'
462
- : 'useMemo()'
463
- } callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects`,
464
- suggestions: null,
465
- }).withDetails({
466
- kind: 'error',
467
- loc: instr.value.loc,
468
- message: 'useMemo() callbacks must return a value',
469
- }),
470
- );
471
- }
472
- }
473
- }
474
-
441
instr.value = getManualMemoizationReplacement(
442
fnPlace,
443
instr.value.loc,
@@ -629,17 +595,3 @@ function findOptionalPlaces(fn: HIRFunction): Set<IdentifierId> {
595
}
596
return optionals;
597
}
632
-
633
-function hasNonVoidReturn(func: HIRFunction): boolean {
634
- for (const [, block] of func.body.blocks) {
635
- if (block.terminal.kind === 'return') {
636
- if (
637
- block.terminal.returnVariant === 'Explicit' ||
638
- block.terminal.returnVariant === 'Implicit'
639
- ) {
640
- return true;
641
- }
642
- }
643
- }
644
- return false;
645
-}
compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateUseMemo.ts
+34
-3
@@ -24,6 +24,7 @@ import {Result} from '../Utils/Result';
24
25
export function validateUseMemo(fn: HIRFunction): Result<void, CompilerError> {
26
const errors = new CompilerError();
27
+ const voidMemoErrors = new CompilerError();
28
const useMemos = new Set<IdentifierId>();
29
const react = new Set<IdentifierId>();
30
const functions = new Map<IdentifierId, FunctionExpression>();
@@ -125,7 +126,22 @@ export function validateUseMemo(fn: HIRFunction): Result<void, CompilerError> {
126
validateNoContextVariableAssignment(body.loweredFunc.func, errors);
127
128
if (fn.env.config.validateNoVoidUseMemo) {
128
- unusedUseMemos.set(lvalue.identifier.id, callee.loc);
129
+ if (!hasNonVoidReturn(body.loweredFunc.func)) {
130
+ voidMemoErrors.pushDiagnostic(
131
+ CompilerDiagnostic.create({
132
+ category: ErrorCategory.VoidUseMemo,
133
+ reason: 'useMemo() callbacks must return a value',
134
+ description: `This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects`,
135
+ suggestions: null,
136
+ }).withDetails({
137
+ kind: 'error',
138
+ loc: body.loc,
139
+ message: 'useMemo() callbacks must return a value',
140
+ }),
141
+ );
142
+ } else {
143
+ unusedUseMemos.set(lvalue.identifier.id, callee.loc);
144
+ }
145
}
146
break;
147
}
@@ -146,10 +162,10 @@ export function validateUseMemo(fn: HIRFunction): Result<void, CompilerError> {
162
* Even a DCE-based version could be bypassed with `noop(useMemo(...))`.
163
*/
164
for (const loc of unusedUseMemos.values()) {
149
- errors.pushDiagnostic(
165
+ voidMemoErrors.pushDiagnostic(
166
CompilerDiagnostic.create({
167
category: ErrorCategory.VoidUseMemo,
152
- reason: 'Unused useMemo()',
168
+ reason: 'useMemo() result is unused',
169
description: `This useMemo() value is unused. useMemo() is for computing and caching values, not for arbitrary side effects`,
170
suggestions: null,
171
}).withDetails({
@@ -160,6 +176,7 @@ export function validateUseMemo(fn: HIRFunction): Result<void, CompilerError> {
176
);
177
}
178
}
179
+ fn.env.logErrors(voidMemoErrors.asResult());
180
return errors.asResult();
181
}
182
@@ -192,3 +209,17 @@ function validateNoContextVariableAssignment(
209
}
210
}
211
}
212
+
213
+function hasNonVoidReturn(func: HIRFunction): boolean {
214
+ for (const [, block] of func.body.blocks) {
215
+ if (block.terminal.kind === 'return') {
216
+ if (
217
+ block.terminal.returnVariant === 'Explicit' ||
218
+ block.terminal.returnVariant === 'Implicit'
219
+ ) {
220
+ return true;
221
+ }
222
+ }
223
+ }
224
+ return false;
225
+}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-unused-usememo.expect.md
deleted
-35
@@ -1,35 +0,0 @@
1
-
2
-## Input
3
-
4
-```javascript
5
-// @validateNoVoidUseMemo
6
-function Component() {
7
- useMemo(() => {
8
- return [];
9
- }, []);
10
- return <div />;
11
-}
12
-
13
-```
14
-
15
-
16
-## Error
17
-
18
-```
19
-Found 1 error:
20
-
21
-Error: Unused useMemo()
22
-
23
-This useMemo() value is unused. useMemo() is for computing and caching values, not for arbitrary side effects.
24
-
25
-error.invalid-unused-usememo.ts:3:2
26
- 1 | // @validateNoVoidUseMemo
27
- 2 | function Component() {
28
-> 3 | useMemo(() => {
29
- | ^^^^^^^ useMemo() result is unused
30
- 4 | return [];
31
- 5 | }, []);
32
- 6 | return <div />;
33
-```
34
-
35
-
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.useMemo-no-return-value.expect.md
deleted
-64
@@ -1,64 +0,0 @@
1
-
2
-## Input
3
-
4
-```javascript
5
-// @validateNoVoidUseMemo
6
-function Component() {
7
- const value = useMemo(() => {
8
- console.log('computing');
9
- }, []);
10
- const value2 = React.useMemo(() => {
11
- console.log('computing');
12
- }, []);
13
- return (
14
- <div>
15
- {value}
16
- {value2}
17
- </div>
18
- );
19
-}
20
-
21
-```
22
-
23
-
24
-## Error
25
-
26
-```
27
-Found 2 errors:
28
-
29
-Error: useMemo() callbacks must return a value
30
-
31
-This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects.
32
-
33
-error.useMemo-no-return-value.ts:3:16
34
- 1 | // @validateNoVoidUseMemo
35
- 2 | function Component() {
36
-> 3 | const value = useMemo(() => {
37
- | ^^^^^^^^^^^^^^^
38
-> 4 | console.log('computing');
39
- | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
40
-> 5 | }, []);
41
- | ^^^^^^^^^ useMemo() callbacks must return a value
42
- 6 | const value2 = React.useMemo(() => {
43
- 7 | console.log('computing');
44
- 8 | }, []);
45
-
46
-Error: useMemo() callbacks must return a value
47
-
48
-This React.useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects.
49
-
50
-error.useMemo-no-return-value.ts:6:17
51
- 4 | console.log('computing');
52
- 5 | }, []);
53
-> 6 | const value2 = React.useMemo(() => {
54
- | ^^^^^^^^^^^^^^^^^^^^^
55
-> 7 | console.log('computing');
56
- | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
57
-> 8 | }, []);
58
- | ^^^^^^^^^ useMemo() callbacks must return a value
59
- 9 | return (
60
- 10 | <div>
61
- 11 | {value}
62
-```
63
-
64
-
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-unused-usememo.expect.md
new
+41
@@ -0,0 +1,41 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+// @validateNoVoidUseMemo @loggerTestOnly
6
+function Component() {
7
+ useMemo(() => {
8
+ return [];
9
+ }, []);
10
+ return <div />;
11
+}
12
+
13
+```
14
+
15
+## Code
16
+
17
+```javascript
18
+import { c as _c } from "react/compiler-runtime"; // @validateNoVoidUseMemo @loggerTestOnly
19
+function Component() {
20
+ const $ = _c(1);
21
+ let t0;
22
+ if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
23
+ t0 = <div />;
24
+ $[0] = t0;
25
+ } else {
26
+ t0 = $[0];
27
+ }
28
+ return t0;
29
+}
30
+
31
+```
32
+
33
+## Logs
34
+
35
+```
36
+{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() result is unused","description":"This useMemo() value is unused. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":3,"column":2,"index":67},"end":{"line":3,"column":9,"index":74},"filename":"invalid-unused-usememo.ts","identifierName":"useMemo"},"message":"useMemo() result is unused"}]}},"fnLoc":null}
37
+{"kind":"CompileSuccess","fnLoc":{"start":{"line":2,"column":0,"index":42},"end":{"line":7,"column":1,"index":127},"filename":"invalid-unused-usememo.ts"},"fnName":"Component","memoSlots":1,"memoBlocks":1,"memoValues":1,"prunedMemoBlocks":0,"prunedMemoValues":0}
38
+```
39
+
40
+### Eval output
41
+(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-unused-usememo.js
renamed
+1
-1
@@ -1,4 +1,4 @@
1
-// @validateNoVoidUseMemo
1
+// @validateNoVoidUseMemo @loggerTestOnly
2
function Component() {
3
useMemo(() => {
4
return [];
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-useMemo-no-return-value.expect.md
new
+59
@@ -0,0 +1,59 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+// @validateNoVoidUseMemo @loggerTestOnly
6
+function Component() {
7
+ const value = useMemo(() => {
8
+ console.log('computing');
9
+ }, []);
10
+ const value2 = React.useMemo(() => {
11
+ console.log('computing');
12
+ }, []);
13
+ return (
14
+ <div>
15
+ {value}
16
+ {value2}
17
+ </div>
18
+ );
19
+}
20
+
21
+```
22
+
23
+## Code
24
+
25
+```javascript
26
+import { c as _c } from "react/compiler-runtime"; // @validateNoVoidUseMemo @loggerTestOnly
27
+function Component() {
28
+ const $ = _c(1);
29
+
30
+ console.log("computing");
31
+
32
+ console.log("computing");
33
+ let t0;
34
+ if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
35
+ t0 = (
36
+ <div>
37
+ {undefined}
38
+ {undefined}
39
+ </div>
40
+ );
41
+ $[0] = t0;
42
+ } else {
43
+ t0 = $[0];
44
+ }
45
+ return t0;
46
+}
47
+
48
+```
49
+
50
+## Logs
51
+
52
+```
53
+{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() callbacks must return a value","description":"This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":3,"column":24,"index":89},"end":{"line":5,"column":3,"index":130},"filename":"invalid-useMemo-no-return-value.ts"},"message":"useMemo() callbacks must return a value"}]}},"fnLoc":null}
54
+{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() callbacks must return a value","description":"This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":6,"column":31,"index":168},"end":{"line":8,"column":3,"index":209},"filename":"invalid-useMemo-no-return-value.ts"},"message":"useMemo() callbacks must return a value"}]}},"fnLoc":null}
55
+{"kind":"CompileSuccess","fnLoc":{"start":{"line":2,"column":0,"index":42},"end":{"line":15,"column":1,"index":283},"filename":"invalid-useMemo-no-return-value.ts"},"fnName":"Component","memoSlots":1,"memoBlocks":1,"memoValues":1,"prunedMemoBlocks":0,"prunedMemoValues":0}
56
+```
57
+
58
+### Eval output
59
+(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-useMemo-no-return-value.js
renamed
+1
-1
@@ -1,4 +1,4 @@
1
-// @validateNoVoidUseMemo
1
+// @validateNoVoidUseMemo @loggerTestOnly
2
function Component() {
3
const value = useMemo(() => {
4
console.log('computing');
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-useMemo-return-empty.expect.md
new
+33
@@ -0,0 +1,33 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+// @loggerTestOnly
6
+function component(a) {
7
+ let x = useMemo(() => {
8
+ mutate(a);
9
+ }, []);
10
+ return x;
11
+}
12
+
13
+```
14
+
15
+## Code
16
+
17
+```javascript
18
+// @loggerTestOnly
19
+function component(a) {
20
+ mutate(a);
21
+}
22
+
23
+```
24
+
25
+## Logs
26
+
27
+```
28
+{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() callbacks must return a value","description":"This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":3,"column":18,"index":61},"end":{"line":5,"column":3,"index":87},"filename":"invalid-useMemo-return-empty.ts"},"message":"useMemo() callbacks must return a value"}]}},"fnLoc":null}
29
+{"kind":"CompileSuccess","fnLoc":{"start":{"line":2,"column":0,"index":19},"end":{"line":7,"column":1,"index":107},"filename":"invalid-useMemo-return-empty.ts"},"fnName":"component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":1,"prunedMemoValues":0}
30
+```
31
+
32
+### Eval output
33
+(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-useMemo-return-empty.js
renamed
+1
@@ -1,3 +1,4 @@
1
+// @loggerTestOnly
2
function component(a) {
3
let x = useMemo(() => {
4
mutate(a);
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro.expect.md
+2
-1
@@ -2,6 +2,7 @@
2
## Input
3
4
```javascript
5
+// @validateNoVoidUseMemo:false
6
function Component(props) {
7
const item = props.item;
8
const thumbnails = [];
@@ -22,7 +23,7 @@ function Component(props) {
23
## Code
24
25
```javascript
25
-import { c as _c } from "react/compiler-runtime";
26
+import { c as _c } from "react/compiler-runtime"; // @validateNoVoidUseMemo:false
27
function Component(props) {
28
const $ = _c(6);
29
const item = props.item;
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro.js
+1
@@ -1,3 +1,4 @@
1
+// @validateNoVoidUseMemo:false
2
function Component(props) {
3
const item = props.item;
4
const thumbnails = [];
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-nested-ifs.expect.md
+10
-1
@@ -6,6 +6,7 @@ function Component(props) {
6
const x = useMemo(() => {
7
if (props.cond) {
8
if (props.cond) {
9
+ return props.value;
10
}
11
}
12
}, [props.cond]);
@@ -24,10 +25,18 @@ export const FIXTURE_ENTRYPOINT = {
25
26
```javascript
27
function Component(props) {
27
- if (props.cond) {
28
+ let t0;
29
+ bb0: {
30
if (props.cond) {
31
+ if (props.cond) {
32
+ t0 = props.value;
33
+ break bb0;
34
+ }
35
}
36
+ t0 = undefined;
37
}
38
+ const x = t0;
39
+ return x;
40
}
41
42
export const FIXTURE_ENTRYPOINT = {
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-nested-ifs.js
+1
@@ -2,6 +2,7 @@ function Component(props) {
2
const x = useMemo(() => {
3
if (props.cond) {
4
if (props.cond) {
5
+ return props.value;
6
}
7
}
8
}, [props.cond]);
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-return-empty.expect.md
deleted
-22
@@ -1,22 +0,0 @@
1
-
2
-## Input
3
-
4
-```javascript
5
-function component(a) {
6
- let x = useMemo(() => {
7
- mutate(a);
8
- }, []);
9
- return x;
10
-}
11
-
12
-```
13
-
14
-## Code
15
-
16
-```javascript
17
-function component(a) {
18
- mutate(a);
19
-}
20
-
21
-```
22
-
\ No newline at end of file
compiler/packages/eslint-plugin-react-compiler/__tests__/PluginTest-test.ts
+9
-1
@@ -120,7 +120,15 @@ testRule('plugin-recommended', TestRecommendedRules, {
120
121
return <Child x={state} />;
122
}`,
123
- errors: [makeTestCaseError('Unused useMemo()')],
123
+ errors: [
124
+ makeTestCaseError('useMemo() callbacks must return a value'),
125
+ makeTestCaseError(
126
+ 'Calling setState from useMemo may trigger an infinite loop',
127
+ ),
128
+ makeTestCaseError(
129
+ 'Calling setState from useMemo may trigger an infinite loop',
130
+ ),
131
+ ],
132
},
133
{
134
name: 'Pipeline errors are reported',