[compiler] Reuse DropManualMemoization for ValidateNoVoidUseMemo (#34001)
Much of the logic in the new validation pass is already implemented in DropManualMemoization, so let's combine them. I opted to keep the environment flag so we can more precisely control the rollout. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34001). * #34022 * #34002 * __->__ #34001
lauren committed
Jul 28, 2025 at 12:54 UTC
b5c16371091c1abbf77ec944c755bd139abc7568
5 files changed
+94
-169
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
+1
-5
@@ -82,7 +82,6 @@ import {
82
import {inferTypes} from '../TypeInference';
83
import {
84
validateContextVariableLValues,
85
- validateNoVoidUseMemo,
85
validateHooksUsage,
86
validateMemoizedEffectDependencies,
87
validateNoCapitalizedCalls,
@@ -168,9 +167,6 @@ function runWithEnvironment(
167
168
validateContextVariableLValues(hir);
169
validateUseMemo(hir).unwrap();
171
- if (env.config.validateNoVoidUseMemo) {
172
- validateNoVoidUseMemo(hir).unwrap();
173
- }
170
171
if (
172
env.isInferredMemoEnabled &&
@@ -178,7 +174,7 @@ function runWithEnvironment(
174
!env.config.disableMemoizationForDebugging &&
175
!env.config.enableChangeDetectionForDebugging
176
) {
181
- dropManualMemoization(hir);
177
+ dropManualMemoization(hir).unwrap();
178
log({kind: 'hir', name: 'DropManualMemoization', value: hir});
179
}
180
compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts
+65
-2
@@ -5,7 +5,12 @@
5
* LICENSE file in the root directory of this source tree.
6
*/
7
8
-import {CompilerError, SourceLocation} from '..';
8
+import {
9
+ CompilerDiagnostic,
10
+ CompilerError,
11
+ ErrorSeverity,
12
+ SourceLocation,
13
+} from '..';
14
import {
15
CallExpression,
16
Effect,
@@ -30,6 +35,7 @@ import {
35
makeInstructionId,
36
} from '../HIR';
37
import {createTemporaryPlace, markInstructionIds} from '../HIR/HIRBuilder';
38
+import {Result} from '../Utils/Result';
39
40
type ManualMemoCallee = {
41
kind: 'useMemo' | 'useCallback';
@@ -341,8 +347,14 @@ function extractManualMemoizationArgs(
347
* rely on type inference to find useMemo/useCallback invocations, and instead does basic tracking
348
* of globals and property loads to find both direct calls as well as usage via the React namespace,
349
* eg `React.useMemo()`.
350
+ *
351
+ * This pass also validates that useMemo callbacks return a value (not void), ensuring that useMemo
352
+ * is only used for memoizing values and not for running arbitrary side effects.
353
*/
345
-export function dropManualMemoization(func: HIRFunction): void {
354
+export function dropManualMemoization(
355
+ func: HIRFunction,
356
+): Result<void, CompilerError> {
357
+ const errors = new CompilerError();
358
const isValidationEnabled =
359
func.env.config.validatePreserveExistingMemoizationGuarantees ||
360
func.env.config.validateNoSetStateInRender ||
@@ -390,6 +402,41 @@ export function dropManualMemoization(func: HIRFunction): void {
402
manualMemo.kind,
403
sidemap,
404
);
405
+
406
+ /**
407
+ * Bailout on void return useMemos. This is an anti-pattern where code might be using
408
+ * useMemo like useEffect: running arbirtary side-effects synced to changes in specific
409
+ * values.
410
+ */
411
+ if (
412
+ func.env.config.validateNoVoidUseMemo &&
413
+ manualMemo.kind === 'useMemo'
414
+ ) {
415
+ const funcToCheck = sidemap.functions.get(
416
+ fnPlace.identifier.id,
417
+ )?.value;
418
+ if (funcToCheck !== undefined && funcToCheck.loweredFunc.func) {
419
+ if (!hasNonVoidReturn(funcToCheck.loweredFunc.func)) {
420
+ errors.pushDiagnostic(
421
+ CompilerDiagnostic.create({
422
+ severity: ErrorSeverity.InvalidReact,
423
+ category: 'useMemo() callbacks must return a value',
424
+ description: `This ${
425
+ manualMemo.loadInstr.value.kind === 'PropertyLoad'
426
+ ? 'React.useMemo'
427
+ : 'useMemo'
428
+ } callback doesn't return a value. useMemo is for computing and caching values, not for arbitrary side effects.`,
429
+ suggestions: null,
430
+ }).withDetail({
431
+ kind: 'error',
432
+ loc: instr.value.loc,
433
+ message: 'useMemo() callbacks must return a value',
434
+ }),
435
+ );
436
+ }
437
+ }
438
+ }
439
+
440
instr.value = getManualMemoizationReplacement(
441
fnPlace,
442
instr.value.loc,
@@ -486,6 +533,8 @@ export function dropManualMemoization(func: HIRFunction): void {
533
markInstructionIds(func.body);
534
}
535
}
536
+
537
+ return errors.asResult();
538
}
539
540
function findOptionalPlaces(fn: HIRFunction): Set<IdentifierId> {
@@ -530,3 +579,17 @@ function findOptionalPlaces(fn: HIRFunction): Set<IdentifierId> {
579
}
580
return optionals;
581
}
582
+
583
+function hasNonVoidReturn(func: HIRFunction): boolean {
584
+ for (const [, block] of func.body.blocks) {
585
+ if (block.terminal.kind === 'return') {
586
+ if (
587
+ block.terminal.returnVariant === 'Explicit' ||
588
+ block.terminal.returnVariant === 'Implicit'
589
+ ) {
590
+ return true;
591
+ }
592
+ }
593
+ }
594
+ return false;
595
+}
compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoVoidUseMemo.ts
deleted
-156
@@ -1,156 +0,0 @@
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
- Environment,
15
- Instruction,
16
- getHookKindForType,
17
-} from '../HIR';
18
-import {Result} from '../Utils/Result';
19
-
20
-type TemporariesSidemap = {
21
- useMemoHooks: Map<IdentifierId, {name: string; loc: SourceLocation}>;
22
- funcExprs: Map<IdentifierId, FunctionExpression>;
23
- react: Set<IdentifierId>;
24
-};
25
-
26
-/**
27
- * Validates that useMemo has at least one explicit return statement.
28
- *
29
- * Valid cases:
30
- * - useMemo(() => value) // implicit arrow function return
31
- * - useMemo(() => { return value; }) // explicit return
32
- * - useMemo(() => { return; }) // explicit undefined
33
- * - useMemo(() => { if (cond) return val; }) // at least one return
34
- *
35
- * Invalid cases:
36
- * - useMemo(() => { console.log(); }) // no return statement at all
37
- */
38
-export function validateNoVoidUseMemo(
39
- fn: HIRFunction,
40
-): Result<void, CompilerError> {
41
- const errors = new CompilerError();
42
- const sidemap: TemporariesSidemap = {
43
- useMemoHooks: new Map(),
44
- funcExprs: new Map(),
45
- react: new Set(),
46
- };
47
-
48
- for (const [, block] of fn.body.blocks) {
49
- for (const instr of block.instructions) {
50
- collectTemporaries(instr, fn.env, sidemap);
51
- }
52
- }
53
-
54
- for (const [, block] of fn.body.blocks) {
55
- for (const instr of block.instructions) {
56
- if (instr.value.kind === 'CallExpression') {
57
- const callee = instr.value.callee.identifier;
58
- const useMemoHook = sidemap.useMemoHooks.get(callee.id);
59
-
60
- if (useMemoHook !== undefined && instr.value.args.length > 0) {
61
- const firstArg = instr.value.args[0];
62
- if (firstArg.kind !== 'Identifier') {
63
- continue;
64
- }
65
-
66
- let funcToCheck = sidemap.funcExprs.get(firstArg.identifier.id);
67
-
68
- if (!funcToCheck) {
69
- for (const [, searchBlock] of fn.body.blocks) {
70
- for (const searchInstr of searchBlock.instructions) {
71
- if (
72
- searchInstr.lvalue &&
73
- searchInstr.lvalue.identifier.id === firstArg.identifier.id &&
74
- searchInstr.value.kind === 'FunctionExpression'
75
- ) {
76
- funcToCheck = searchInstr.value;
77
- break;
78
- }
79
- }
80
- if (funcToCheck) break;
81
- }
82
- }
83
-
84
- if (funcToCheck) {
85
- const hasReturn = checkFunctionHasNonVoidReturn(
86
- funcToCheck.loweredFunc.func,
87
- );
88
-
89
- if (!hasReturn) {
90
- errors.push({
91
- severity: ErrorSeverity.InvalidReact,
92
- 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.`,
93
- loc: useMemoHook.loc,
94
- suggestions: null,
95
- description: null,
96
- });
97
- }
98
- }
99
- }
100
- }
101
- }
102
- }
103
- return errors.asResult();
104
-}
105
-
106
-function checkFunctionHasNonVoidReturn(func: HIRFunction): boolean {
107
- for (const [, block] of func.body.blocks) {
108
- if (block.terminal.kind === 'return') {
109
- if (
110
- block.terminal.returnVariant === 'Explicit' ||
111
- block.terminal.returnVariant === 'Implicit'
112
- ) {
113
- return true;
114
- }
115
- }
116
- }
117
- return false;
118
-}
119
-
120
-function collectTemporaries(
121
- instr: Instruction,
122
- env: Environment,
123
- sidemap: TemporariesSidemap,
124
-): void {
125
- const {value, lvalue} = instr;
126
- switch (value.kind) {
127
- case 'FunctionExpression': {
128
- sidemap.funcExprs.set(lvalue.identifier.id, value);
129
- break;
130
- }
131
- case 'LoadGlobal': {
132
- const global = env.getGlobalDeclaration(value.binding, value.loc);
133
- const hookKind = global !== null ? getHookKindForType(env, global) : null;
134
- if (hookKind === 'useMemo') {
135
- sidemap.useMemoHooks.set(lvalue.identifier.id, {
136
- name: value.binding.name,
137
- loc: instr.loc,
138
- });
139
- } else if (value.binding.name === 'React') {
140
- sidemap.react.add(lvalue.identifier.id);
141
- }
142
- break;
143
- }
144
- case 'PropertyLoad': {
145
- if (sidemap.react.has(value.object.identifier.id)) {
146
- if (value.property === 'useMemo') {
147
- sidemap.useMemoHooks.set(lvalue.identifier.id, {
148
- name: value.property,
149
- loc: instr.loc,
150
- });
151
- }
152
- }
153
- break;
154
- }
155
- }
156
-}
compiler/packages/babel-plugin-react-compiler/src/Validation/index.ts
-1
@@ -6,7 +6,6 @@
6
*/
7
8
export {validateContextVariableLValues} from './ValidateContextVariableLValues';
9
-export {validateNoVoidUseMemo} from './ValidateNoVoidUseMemo';
9
export {validateHooksUsage} from './ValidateHooksUsage';
10
export {validateMemoizedEffectDependencies} from './ValidateMemoizedEffectDependencies';
11
export {validateNoCapitalizedCalls} from './ValidateNoCapitalizedCalls';
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.useMemo-no-return-value.expect.md
+28
-5
@@ -24,18 +24,41 @@ function Component() {
24
## Error
25
26
```
27
-Found 1 error:
27
+Found 2 errors:
28
29
-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.
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(() => {
35
- | ^^^^^^^ 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.
36
- 4 | console.log('computing');
37
- 5 | }, []);
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