Skip to content

Commit 40b4a5b

Browse files
authored
[compiler] ValidateExhaustiveDeps disallows unnecessary non-reactive deps (facebook#34472)
Just to be consistent, we disallow unnecessary deps even if they're known to be non-reactive.
1 parent df75af4 commit 40b4a5b

File tree

4 files changed

+139
-51
lines changed

4 files changed

+139
-51
lines changed

compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,8 @@ function printErrorSummary(category: ErrorCategory, message: string): string {
600600
case ErrorCategory.Suppression:
601601
case ErrorCategory.Syntax:
602602
case ErrorCategory.UseMemo:
603-
case ErrorCategory.VoidUseMemo: {
603+
case ErrorCategory.VoidUseMemo:
604+
case ErrorCategory.MemoDependencies: {
604605
heading = 'Error';
605606
break;
606607
}
@@ -658,6 +659,10 @@ export enum ErrorCategory {
658659
* Checks that manual memoization is preserved
659660
*/
660661
PreserveManualMemo = 'PreserveManualMemo',
662+
/**
663+
* Checks for exhaustive useMemo/useCallback dependencies without extraneous values
664+
*/
665+
MemoDependencies = 'MemoDependencies',
661666
/**
662667
* Checks for known incompatible libraries
663668
*/
@@ -1055,6 +1060,16 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
10551060
preset: LintRulePreset.RecommendedLatest,
10561061
};
10571062
}
1063+
case ErrorCategory.MemoDependencies: {
1064+
return {
1065+
category,
1066+
severity: ErrorSeverity.Error,
1067+
name: 'memo-dependencies',
1068+
description:
1069+
'Validates that useMemo() and useCallback() specify comprehensive dependencies without extraneous values. See [`useMemo()` docs](https://react.dev/reference/react/useMemo) for more information.',
1070+
preset: LintRulePreset.RecommendedLatest,
1071+
};
1072+
}
10581073
case ErrorCategory.IncompatibleLibrary: {
10591074
return {
10601075
category,

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

Lines changed: 71 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import {
2222
Identifier,
2323
IdentifierId,
2424
InstructionKind,
25+
isStableType,
2526
isSubPath,
27+
isUseRefType,
2628
LoadGlobal,
2729
ManualMemoDependency,
2830
Place,
@@ -46,9 +48,10 @@ const DEBUG = false;
4648
* or less times.
4749
*
4850
* TODOs:
49-
* - Better handling of cases where we infer multiple dependencies related to a single
50-
* variable. Eg if the user has dep `x` and we inferred `x.y, x.z`, the user's dep
51-
* is sufficient.
51+
* - Handle cases of mixed optional and non-optional versions of the same path,
52+
* eg referecing both x.y.z and x.y?.z in the same memo block. we should collapse
53+
* this into a single canonical dep that we look for in the manual deps. see the
54+
* existing exhaustive deps rule for implementation.
5255
* - Handle cases where the user deps were not simple identifiers + property chains.
5356
* We try to detect this in ValidateUseMemo but we miss some cases. The problem
5457
* is that invalid forms can be value blocks or function calls that don't get
@@ -108,7 +111,7 @@ export function validateExhaustiveDependencies(
108111
);
109112
visitCandidateDependency(value.decl, temporaries, dependencies, locals);
110113
const inferred: Array<InferredDependency> = Array.from(dependencies);
111-
// Sort dependencies by name, and path, with shorter/non-optional paths first
114+
// Sort dependencies by name and path, with shorter/non-optional paths first
112115
inferred.sort((a, b) => {
113116
if (a.kind === 'Global' && b.kind == 'Global') {
114117
return a.binding.name.localeCompare(b.binding.name);
@@ -205,6 +208,31 @@ export function validateExhaustiveDependencies(
205208
reason: 'Unexpected function dependency',
206209
loc: value.loc,
207210
});
211+
/**
212+
* Dependencies technically only need to include reactive values. However,
213+
* reactivity inference for general values is subtle since it involves all
214+
* of our complex control and data flow analysis. To keep results more
215+
* stable and predictable to developers, we intentionally stay closer to
216+
* the rules of the classic exhaustive-deps rule. Values should be included
217+
* as dependencies if either of the following is true:
218+
* - They're reactive
219+
* - They're non-reactive and not a known-stable value type.
220+
*
221+
* Thus `const ref: Ref = cond ? ref1 : ref2` has to be a dependency
222+
* (assuming `cond` is reactive) since it's reactive despite being a ref.
223+
*
224+
* Similarly, `const x = [1,2,3]` has to be a dependency since even
225+
* though it's non reactive, it's not a known stable type.
226+
*
227+
* TODO: consider reimplementing a simpler form of reactivity inference.
228+
* Ideally we'd consider `const ref: Ref = cond ? ref1 : ref2` as a required
229+
* dependency even if our data/control flow tells us that `cond` is non-reactive.
230+
* It's simpler for developers to reason about based on a more structural/AST
231+
* driven approach.
232+
*/
233+
const isRequiredDependency =
234+
reactive.has(inferredDependency.identifier.id) ||
235+
!isStableType(inferredDependency.identifier);
208236
let hasMatchingManualDependency = false;
209237
for (const manualDependency of manualDependencies) {
210238
if (
@@ -216,19 +244,18 @@ export function validateExhaustiveDependencies(
216244
) {
217245
hasMatchingManualDependency = true;
218246
matched.add(manualDependency);
247+
if (!isRequiredDependency) {
248+
extra.push(manualDependency);
249+
}
219250
}
220251
}
221-
if (!hasMatchingManualDependency) {
252+
if (isRequiredDependency && !hasMatchingManualDependency) {
222253
missing.push(inferredDependency);
223254
}
224255
}
225256

226257
for (const dep of startMemo.deps ?? []) {
227-
if (
228-
matched.has(dep) ||
229-
(dep.root.kind === 'NamedLocal' &&
230-
!reactive.has(dep.root.value.identifier.id))
231-
) {
258+
if (matched.has(dep)) {
232259
continue;
233260
}
234261
extra.push(dep);
@@ -247,36 +274,39 @@ export function validateExhaustiveDependencies(
247274
];
248275
}
249276
if (missing.length !== 0) {
250-
// Error
251277
const diagnostic = CompilerDiagnostic.create({
252-
category: ErrorCategory.PreserveManualMemo,
278+
category: ErrorCategory.MemoDependencies,
253279
reason: 'Found non-exhaustive dependencies',
254280
description:
255281
'Missing dependencies can cause a value not to update when those inputs change, ' +
256-
'resulting in stale UI. This memoization cannot be safely rewritten by the compiler.',
282+
'resulting in stale UI',
257283
suggestions,
258284
});
259285
for (const dep of missing) {
286+
let reactiveStableValueHint = '';
287+
if (isStableType(dep.identifier)) {
288+
reactiveStableValueHint =
289+
'. Refs, setState functions, and other "stable" values generally do not need to be added as dependencies, but this variable may change over time to point to different values';
290+
}
260291
diagnostic.withDetails({
261292
kind: 'error',
262-
message: `Missing dependency \`${printInferredDependency(dep)}\``,
293+
message: `Missing dependency \`${printInferredDependency(dep)}\`${reactiveStableValueHint}`,
263294
loc: dep.loc,
264295
});
265296
}
266297
error.pushDiagnostic(diagnostic);
267298
} else if (extra.length !== 0) {
268299
const diagnostic = CompilerDiagnostic.create({
269-
category: ErrorCategory.PreserveManualMemo,
300+
category: ErrorCategory.MemoDependencies,
270301
reason: 'Found unnecessary memoization dependencies',
271302
description:
272303
'Unnecessary dependencies can cause a value to update more often than necessary, ' +
273-
'which can cause effects to run more than expected. This memoization cannot be safely ' +
274-
'rewritten by the compiler',
304+
'which can cause effects to run more than expected',
275305
});
276306
diagnostic.withDetails({
277307
kind: 'error',
278308
message: `Unnecessary dependencies ${extra.map(dep => `\`${printManualMemoDependency(dep)}\``).join(', ')}`,
279-
loc: value.loc,
309+
loc: startMemo.depsLoc ?? value.loc,
280310
});
281311
error.pushDiagnostic(diagnostic);
282312
}
@@ -287,10 +317,15 @@ export function validateExhaustiveDependencies(
287317
startMemo = null;
288318
}
289319

290-
collectDependencies(fn, temporaries, {
291-
onStartMemoize,
292-
onFinishMemoize,
293-
});
320+
collectDependencies(
321+
fn,
322+
temporaries,
323+
{
324+
onStartMemoize,
325+
onFinishMemoize,
326+
},
327+
false, // isFunctionExpression
328+
);
294329
return error.asResult();
295330
}
296331

@@ -383,12 +418,20 @@ function collectDependencies(
383418
locals: Set<IdentifierId>,
384419
) => void;
385420
} | null,
421+
isFunctionExpression: boolean,
386422
): Extract<Temporary, {kind: 'Function'}> {
387423
const optionals = findOptionalPlaces(fn);
388424
if (DEBUG) {
389425
console.log(prettyFormat(optionals));
390426
}
391427
const locals: Set<IdentifierId> = new Set();
428+
if (isFunctionExpression) {
429+
for (const param of fn.params) {
430+
const place = param.kind === 'Identifier' ? param : param.place;
431+
locals.add(place.identifier.id);
432+
}
433+
}
434+
392435
const dependencies: Set<InferredDependency> = new Set();
393436
function visit(place: Place): void {
394437
visitCandidateDependency(place, temporaries, dependencies, locals);
@@ -523,7 +566,11 @@ function collectDependencies(
523566
break;
524567
}
525568
case 'PropertyLoad': {
526-
if (typeof value.property === 'number') {
569+
if (
570+
typeof value.property === 'number' ||
571+
(isUseRefType(value.object.identifier) &&
572+
value.property === 'current')
573+
) {
527574
visit(value.object);
528575
break;
529576
}
@@ -553,6 +600,7 @@ function collectDependencies(
553600
value.loweredFunc.func,
554601
temporaries,
555602
null,
603+
true, // isFunctionExpression
556604
);
557605
temporaries.set(lvalue.identifier.id, functionDeps);
558606
addDependency(functionDeps, dependencies, locals);

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-exhaustive-deps.expect.md

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,16 @@ function Component({x, y, z}) {
2626
}, [x]);
2727
const f = useMemo(() => {
2828
return [];
29-
}, [x, y.z, z?.y?.a]);
30-
return <Stringify results={[a, b, c, d, e, f]} />;
29+
}, [x, y.z, z?.y?.a, UNUSED_GLOBAL]);
30+
const ref1 = useRef(null);
31+
const ref2 = useRef(null);
32+
const ref = z ? ref1 : ref2;
33+
const cb = useMemo(() => {
34+
return () => {
35+
return ref.current;
36+
};
37+
}, []);
38+
return <Stringify results={[a, b, c, d, e, f, cb]} />;
3139
}
3240

3341
```
@@ -36,11 +44,11 @@ function Component({x, y, z}) {
3644
## Error
3745
3846
```
39-
Found 4 errors:
47+
Found 5 errors:
4048

41-
Compilation Skipped: Found non-exhaustive dependencies
49+
Error: Found non-exhaustive dependencies
4250

43-
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI. This memoization cannot be safely rewritten by the compiler..
51+
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI.
4452

4553
error.invalid-exhaustive-deps.ts:7:11
4654
5 | function Component({x, y, z}) {
@@ -51,9 +59,9 @@ error.invalid-exhaustive-deps.ts:7:11
5159
9 | const b = useMemo(() => {
5260
10 | return x.y.z?.a;
5361

54-
Compilation Skipped: Found non-exhaustive dependencies
62+
Error: Found non-exhaustive dependencies
5563

56-
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI. This memoization cannot be safely rewritten by the compiler..
64+
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI.
5765

5866
error.invalid-exhaustive-deps.ts:10:11
5967
8 | }, [x?.y.z?.a.b]);
@@ -64,9 +72,9 @@ error.invalid-exhaustive-deps.ts:10:11
6472
12 | const c = useMemo(() => {
6573
13 | return x?.y.z.a?.b;
6674

67-
Compilation Skipped: Found non-exhaustive dependencies
75+
Error: Found non-exhaustive dependencies
6876

69-
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI. This memoization cannot be safely rewritten by the compiler..
77+
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI.
7078

7179
error.invalid-exhaustive-deps.ts:13:11
7280
11 | }, [x.y.z.a]);
@@ -77,22 +85,31 @@ error.invalid-exhaustive-deps.ts:13:11
7785
15 | const d = useMemo(() => {
7886
16 | return x?.y?.[(console.log(y), z?.b)];
7987

80-
Compilation Skipped: Found unnecessary memoization dependencies
81-
82-
Unnecessary dependencies can cause a value to update more often than necessary, which can cause effects to run more than expected. This memoization cannot be safely rewritten by the compiler.
83-
84-
error.invalid-exhaustive-deps.ts:23:20
85-
21 | return e;
86-
22 | }, [x]);
87-
> 23 | const f = useMemo(() => {
88-
| ^^^^^^^
89-
> 24 | return [];
90-
| ^^^^^^^^^^^^^^
91-
> 25 | }, [x, y.z, z?.y?.a]);
92-
| ^^^^ Unnecessary dependencies `x`, `y.z`, `z?.y?.a`
93-
26 | return <Stringify results={[a, b, c, d, e, f]} />;
94-
27 | }
95-
28 |
88+
Error: Found unnecessary memoization dependencies
89+
90+
Unnecessary dependencies can cause a value to update more often than necessary, which can cause effects to run more than expected.
91+
92+
error.invalid-exhaustive-deps.ts:25:5
93+
23 | const f = useMemo(() => {
94+
24 | return [];
95+
> 25 | }, [x, y.z, z?.y?.a, UNUSED_GLOBAL]);
96+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Unnecessary dependencies `x`, `y.z`, `z?.y?.a`, `UNUSED_GLOBAL`
97+
26 | const ref1 = useRef(null);
98+
27 | const ref2 = useRef(null);
99+
28 | const ref = z ? ref1 : ref2;
100+
101+
Error: Found non-exhaustive dependencies
102+
103+
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI.
104+
105+
error.invalid-exhaustive-deps.ts:31:13
106+
29 | const cb = useMemo(() => {
107+
30 | return () => {
108+
> 31 | return ref.current;
109+
| ^^^ Missing dependency `ref`. Refs, setState functions, and other "stable" values generally do not need to be added as dependencies, but this variable may change over time to point to different values
110+
32 | };
111+
33 | }, []);
112+
34 | return <Stringify results={[a, b, c, d, e, f, cb]} />;
96113
```
97114
98115

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-exhaustive-deps.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ function Component({x, y, z}) {
2222
}, [x]);
2323
const f = useMemo(() => {
2424
return [];
25-
}, [x, y.z, z?.y?.a]);
26-
return <Stringify results={[a, b, c, d, e, f]} />;
25+
}, [x, y.z, z?.y?.a, UNUSED_GLOBAL]);
26+
const ref1 = useRef(null);
27+
const ref2 = useRef(null);
28+
const ref = z ? ref1 : ref2;
29+
const cb = useMemo(() => {
30+
return () => {
31+
return ref.current;
32+
};
33+
}, []);
34+
return <Stringify results={[a, b, c, d, e, f, cb]} />;
2735
}

0 commit comments

Comments
 (0)