Skip to content

Commit fb18ad3

Browse files
authored
[compiler] Exhaustive deps: extra tests, improve diagnostic (facebook#35213)
First, this adds some more tests and organizes them into an `exhaustive-deps/` subdirectory. Second, the diagnostics are overhauled. For each memo block we now report a single diagnostic which summarizes the issue, plus individual errors for each missing/extra dependency. Within the extra deps, we distinguish whether it's truly extra vs whether its just a more (too) precise version of an inferred dep. For example, if you depend on `x.y.z` but the inferred dep was `x.y`. Finally, we print the full inferred deps at the end as a hint (it's also a suggestion, but this makes it more clear what would be suggested).
1 parent ddff354 commit fb18ad3

File tree

28 files changed

+545
-160
lines changed

28 files changed

+545
-160
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,7 @@ export type ManualMemoDependency = {
807807
}
808808
| {kind: 'Global'; identifierName: string};
809809
path: DependencyPath;
810+
loc: SourceLocation;
810811
};
811812

812813
export type StartMemoize = {

compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function collectMaybeMemoDependencies(
6565
identifierName: value.binding.name,
6666
},
6767
path: [],
68+
loc: value.loc,
6869
};
6970
}
7071
case 'PropertyLoad': {
@@ -74,6 +75,7 @@ export function collectMaybeMemoDependencies(
7475
root: object.root,
7576
// TODO: determine if the access is optional
7677
path: [...object.path, {property: value.property, optional}],
78+
loc: value.loc,
7779
};
7880
}
7981
break;
@@ -95,6 +97,7 @@ export function collectMaybeMemoDependencies(
9597
constant: false,
9698
},
9799
path: [],
100+
loc: value.place.loc,
98101
};
99102
}
100103
break;

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

Lines changed: 105 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -241,11 +241,10 @@ export function validateExhaustiveDependencies(
241241
matched.add(manualDependency);
242242
}
243243
}
244-
const isOptionalDependency =
245-
!reactive.has(inferredDependency.identifier.id) &&
246-
(isStableType(inferredDependency.identifier) ||
247-
isPrimitiveType(inferredDependency.identifier));
248-
if (hasMatchingManualDependency || isOptionalDependency) {
244+
if (
245+
hasMatchingManualDependency ||
246+
isOptionalDependency(inferredDependency, reactive)
247+
) {
249248
continue;
250249
}
251250
missing.push(inferredDependency);
@@ -274,54 +273,106 @@ export function validateExhaustiveDependencies(
274273
}
275274

276275
if (missing.length !== 0 || extra.length !== 0) {
277-
let suggestions: Array<CompilerSuggestion> | null = null;
276+
let suggestion: CompilerSuggestion | null = null;
278277
if (startMemo.depsLoc != null && typeof startMemo.depsLoc !== 'symbol') {
279-
suggestions = [
280-
{
281-
description: 'Update dependencies',
282-
range: [startMemo.depsLoc.start.index, startMemo.depsLoc.end.index],
283-
op: CompilerSuggestionOperation.Replace,
284-
text: `[${inferred.map(printInferredDependency).join(', ')}]`,
285-
},
286-
];
278+
suggestion = {
279+
description: 'Update dependencies',
280+
range: [startMemo.depsLoc.start.index, startMemo.depsLoc.end.index],
281+
op: CompilerSuggestionOperation.Replace,
282+
text: `[${inferred
283+
.filter(
284+
dep =>
285+
dep.kind === 'Local' && !isOptionalDependency(dep, reactive),
286+
)
287+
.map(printInferredDependency)
288+
.join(', ')}]`,
289+
};
287290
}
288-
if (missing.length !== 0) {
289-
const diagnostic = CompilerDiagnostic.create({
290-
category: ErrorCategory.MemoDependencies,
291-
reason: 'Found missing memoization dependencies',
292-
description:
293-
'Missing dependencies can cause a value not to update when those inputs change, ' +
294-
'resulting in stale UI',
295-
suggestions,
291+
const diagnostic = CompilerDiagnostic.create({
292+
category: ErrorCategory.MemoDependencies,
293+
reason: 'Found missing/extra memoization dependencies',
294+
description: [
295+
missing.length !== 0
296+
? 'Missing dependencies can cause a value to update less often than it should, ' +
297+
'resulting in stale UI'
298+
: null,
299+
extra.length !== 0
300+
? 'Extra dependencies can cause a value to update more often than it should, ' +
301+
'resulting in performance problems such as excessive renders or effects firing too often'
302+
: null,
303+
]
304+
.filter(Boolean)
305+
.join('. '),
306+
suggestions: suggestion != null ? [suggestion] : null,
307+
});
308+
for (const dep of missing) {
309+
let reactiveStableValueHint = '';
310+
if (isStableType(dep.identifier)) {
311+
reactiveStableValueHint =
312+
'. Refs, setState functions, and other "stable" values generally do not need to be added ' +
313+
'as dependencies, but this variable may change over time to point to different values';
314+
}
315+
diagnostic.withDetails({
316+
kind: 'error',
317+
message: `Missing dependency \`${printInferredDependency(dep)}\`${reactiveStableValueHint}`,
318+
loc: dep.loc,
296319
});
297-
for (const dep of missing) {
298-
let reactiveStableValueHint = '';
299-
if (isStableType(dep.identifier)) {
300-
reactiveStableValueHint =
301-
'. 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';
302-
}
320+
}
321+
for (const dep of extra) {
322+
if (dep.root.kind === 'Global') {
303323
diagnostic.withDetails({
304324
kind: 'error',
305-
message: `Missing dependency \`${printInferredDependency(dep)}\`${reactiveStableValueHint}`,
306-
loc: dep.loc,
325+
message:
326+
`Unnecessary dependency \`${printManualMemoDependency(dep)}\`. ` +
327+
'Values declared outside of a component/hook should not be listed as ' +
328+
'dependencies as the component will not re-render if they change',
329+
loc: dep.loc ?? startMemo.depsLoc ?? value.loc,
307330
});
331+
error.pushDiagnostic(diagnostic);
332+
} else {
333+
const root = dep.root.value;
334+
const matchingInferred = inferred.find(
335+
(
336+
inferredDep,
337+
): inferredDep is Extract<InferredDependency, {kind: 'Local'}> => {
338+
return (
339+
inferredDep.kind === 'Local' &&
340+
inferredDep.identifier.id === root.identifier.id &&
341+
isSubPathIgnoringOptionals(inferredDep.path, dep.path)
342+
);
343+
},
344+
);
345+
if (
346+
matchingInferred != null &&
347+
!isOptionalDependency(matchingInferred, reactive)
348+
) {
349+
diagnostic.withDetails({
350+
kind: 'error',
351+
message:
352+
`Overly precise dependency \`${printManualMemoDependency(dep)}\`, ` +
353+
`use \`${printInferredDependency(matchingInferred)}\` instead`,
354+
loc: dep.loc ?? startMemo.depsLoc ?? value.loc,
355+
});
356+
} else {
357+
/**
358+
* Else this dependency doesn't correspond to anything referenced in the memo function,
359+
* or is an optional dependency so we don't want to suggest adding it
360+
*/
361+
diagnostic.withDetails({
362+
kind: 'error',
363+
message: `Unnecessary dependency \`${printManualMemoDependency(dep)}\``,
364+
loc: dep.loc ?? startMemo.depsLoc ?? value.loc,
365+
});
366+
}
308367
}
309-
error.pushDiagnostic(diagnostic);
310-
} else if (extra.length !== 0) {
311-
const diagnostic = CompilerDiagnostic.create({
312-
category: ErrorCategory.MemoDependencies,
313-
reason: 'Found unnecessary memoization dependencies',
314-
description:
315-
'Unnecessary dependencies can cause a value to update more often than necessary, ' +
316-
'causing performance regressions and effects to fire more often than expected',
317-
});
368+
}
369+
if (suggestion != null) {
318370
diagnostic.withDetails({
319-
kind: 'error',
320-
message: `Unnecessary dependencies ${extra.map(dep => `\`${printManualMemoDependency(dep)}\``).join(', ')}`,
321-
loc: startMemo.depsLoc ?? value.loc,
371+
kind: 'hint',
372+
message: `Inferred dependencies: \`${suggestion.text}\``,
322373
});
323-
error.pushDiagnostic(diagnostic);
324374
}
375+
error.pushDiagnostic(diagnostic);
325376
}
326377

327378
dependencies.clear();
@@ -826,3 +877,14 @@ export function findOptionalPlaces(
826877
}
827878
return optionals;
828879
}
880+
881+
function isOptionalDependency(
882+
inferredDependency: Extract<InferredDependency, {kind: 'Local'}>,
883+
reactive: Set<IdentifierId>,
884+
): boolean {
885+
return (
886+
!reactive.has(inferredDependency.identifier.id) &&
887+
(isStableType(inferredDependency.identifier) ||
888+
isPrimitiveType(inferredDependency.identifier))
889+
);
890+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ function validateInferredDep(
242242
normalizedDep = {
243243
root: maybeNormalizedRoot.root,
244244
path: [...maybeNormalizedRoot.path, ...dep.path],
245+
loc: maybeNormalizedRoot.loc,
245246
};
246247
} else {
247248
CompilerError.invariant(dep.identifier.name?.kind === 'named', {
@@ -270,6 +271,7 @@ function validateInferredDep(
270271
constant: false,
271272
},
272273
path: [...dep.path],
274+
loc: GeneratedSource,
273275
};
274276
}
275277
for (const decl of declsWithinMemoBlock) {
@@ -383,6 +385,7 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
383385
constant: false,
384386
},
385387
path: [],
388+
loc: storeTarget.loc,
386389
});
387390
}
388391
}
@@ -413,6 +416,7 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
413416
constant: false,
414417
},
415418
path: [],
419+
loc: lvalue.loc,
416420
});
417421
}
418422
}

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

Lines changed: 0 additions & 109 deletions
This file was deleted.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateExhaustiveMemoizationDependencies
6+
7+
function Component() {
8+
const ref = useRef(null);
9+
const onChange = useCallback(() => {
10+
return ref.current.value;
11+
}, [ref.current.value]);
12+
13+
return <input ref={ref} onChange={onChange} />;
14+
}
15+
16+
```
17+
18+
19+
## Error
20+
21+
```
22+
Found 1 error:
23+
24+
Error: Found missing/extra memoization dependencies
25+
26+
Extra dependencies can cause a value to update more often than it should, resulting in performance problems such as excessive renders or effects firing too often.
27+
28+
error.invalid-dep-on-ref-current-value.ts:7:6
29+
5 | const onChange = useCallback(() => {
30+
6 | return ref.current.value;
31+
> 7 | }, [ref.current.value]);
32+
| ^^^^^^^^^^^^^^^^^ Unnecessary dependency `ref.current.value`
33+
8 |
34+
9 | return <input ref={ref} onChange={onChange} />;
35+
10 | }
36+
37+
Inferred dependencies: `[]`
38+
```
39+
40+

0 commit comments

Comments
 (0)