@samitouri / QOS-React / commits / 1324e1bb1f

[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',