Skip to content

Commit 4745244

Browse files
committed
[compiler] Add catching useStates that shadow a reactive value
1 parent 2ae27a5 commit 4745244

14 files changed

+217
-123
lines changed

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

Lines changed: 98 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,15 @@ type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState';
4141
type DerivationMetadata = {
4242
typeOfValue: TypeOfValue;
4343
place: Place;
44-
sources: Set<Place>;
44+
sources: Array<Place>;
4545
};
4646

4747
type ErrorMetadata = {
4848
type: TypeOfValue;
4949
description: string | undefined;
5050
loc: SourceLocation;
5151
setStateName: string | undefined | null;
52+
derivedDepsNames: Array<string>;
5253
};
5354

5455
/**
@@ -79,6 +80,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
7980
const functions: Map<IdentifierId, FunctionExpression> = new Map();
8081
const locals: Map<IdentifierId, IdentifierId> = new Map();
8182
const derivationCache: Map<IdentifierId, DerivationMetadata> = new Map();
83+
const shadowingUseState: Map<string, Array<SourceLocation>> = new Map();
8284

8385
const effectSetStates: Map<
8486
string | undefined | null,
@@ -93,7 +95,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
9395
if (param.kind === 'Identifier') {
9496
derivationCache.set(param.identifier.id, {
9597
place: param,
96-
sources: new Set([param]),
98+
sources: [param],
9799
typeOfValue: 'fromProps',
98100
});
99101
}
@@ -103,7 +105,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
103105
if (props != null && props.kind === 'Identifier') {
104106
derivationCache.set(props.identifier.id, {
105107
place: props,
106-
sources: new Set([props]),
108+
sources: [props],
107109
typeOfValue: 'fromProps',
108110
});
109111
}
@@ -115,7 +117,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
115117
for (const instr of block.instructions) {
116118
const {lvalue, value} = instr;
117119

118-
parseInstr(instr, derivationCache, setStateCalls);
120+
parseInstr(instr, derivationCache, setStateCalls, shadowingUseState);
119121

120122
if (value.kind === 'LoadLocal') {
121123
locals.set(lvalue.identifier.id, value.place.identifier.id);
@@ -167,6 +169,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
167169
const compilerError = generateCompilerError(
168170
setStateCalls,
169171
effectSetStates,
172+
shadowingUseState,
170173
errors,
171174
);
172175

@@ -178,21 +181,12 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
178181
function generateCompilerError(
179182
setStateCalls: Map<string | undefined | null, Array<Place>>,
180183
effectSetStates: Map<string | undefined | null, Array<Place>>,
184+
shadowingUseState: Map<string, Array<SourceLocation>>,
181185
errors: Array<ErrorMetadata>,
182186
): CompilerError {
183187
const throwableErrors = new CompilerError();
184188
for (const error of errors) {
185189
let compilerDiagnostic: CompilerDiagnostic | undefined = undefined;
186-
let detailMessage = '';
187-
switch (error.type) {
188-
case 'fromProps':
189-
detailMessage = 'This state value shadows a value passed as a prop.';
190-
break;
191-
case 'fromPropsOrState':
192-
detailMessage =
193-
'This state value shadows a value passed as a prop or a value from state.';
194-
break;
195-
}
196190

197191
/*
198192
* If we use a setState from an invalid useEffect elsewhere then we probably have to
@@ -204,15 +198,27 @@ function generateCompilerError(
204198
error.type !== 'fromState'
205199
) {
206200
compilerDiagnostic = CompilerDiagnostic.create({
207-
description: `${error.description} This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.`,
208-
category: `Local state shadows parent state.`,
201+
description: `The setState within a useEffect is deriving from ${error.description}. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render`,
202+
category: `You might not need an effect. Local state shadows parent state.`,
209203
severity: ErrorSeverity.InvalidReact,
210204
}).withDetail({
211205
kind: 'error',
212206
loc: error.loc,
213-
message: 'this setState synchronizes the state',
207+
message: `this derives values from props ${error.type === 'fromPropsOrState' ? 'and local state ' : ''}to synchronize state`,
214208
});
215209

210+
for (const derivedDep of error.derivedDepsNames) {
211+
if (shadowingUseState.has(derivedDep)) {
212+
for (const loc of shadowingUseState.get(derivedDep)!) {
213+
compilerDiagnostic.withDetail({
214+
kind: 'error',
215+
loc: loc,
216+
message: `this useState shadows ${derivedDep}`,
217+
});
218+
}
219+
}
220+
}
221+
216222
for (const [key, setStateCallArray] of effectSetStates) {
217223
if (setStateCallArray.length === 0) {
218224
continue;
@@ -234,8 +240,8 @@ function generateCompilerError(
234240
}
235241
} else {
236242
compilerDiagnostic = CompilerDiagnostic.create({
237-
description: `${error.description} Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`,
238-
category: `Derive values in render, not effects.`,
243+
description: `${error.description ? error.description.charAt(0).toUpperCase() + error.description.slice(1) : ''}. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`,
244+
category: `You might not need an effect. Derive values in render, not effects.`,
239245
severity: ErrorSeverity.InvalidReact,
240246
}).withDetail({
241247
kind: 'error',
@@ -270,7 +276,7 @@ function updateDerivationMetadata(
270276
): void {
271277
let newValue: DerivationMetadata = {
272278
place: target,
273-
sources: new Set(),
279+
sources: [],
274280
typeOfValue: typeOfValue ?? 'ignored',
275281
};
276282

@@ -285,9 +291,9 @@ function updateDerivationMetadata(
285291
place.identifier.name === null ||
286292
place.identifier.name?.kind === 'promoted'
287293
) {
288-
newValue.sources.add(target);
294+
newValue.sources.push(target);
289295
} else {
290-
newValue.sources.add(place);
296+
newValue.sources.push(place);
291297
}
292298
}
293299
}
@@ -300,38 +306,19 @@ function parseInstr(
300306
instr: Instruction,
301307
derivationCache: Map<IdentifierId, DerivationMetadata>,
302308
setStateCalls: Map<string | undefined | null, Array<Place>>,
309+
shadowingUseState: Map<string, Array<SourceLocation>>,
303310
): void {
304311
// Recursively parse function expressions
312+
let typeOfValue: TypeOfValue = 'ignored';
313+
314+
let sources: Array<DerivationMetadata> = [];
305315
if (instr.value.kind === 'FunctionExpression') {
306316
for (const [, block] of instr.value.loweredFunc.func.body.blocks) {
307317
for (const instr of block.instructions) {
308-
parseInstr(instr, derivationCache, setStateCalls);
318+
parseInstr(instr, derivationCache, setStateCalls, shadowingUseState);
309319
}
310320
}
311-
}
312-
313-
let typeOfValue: TypeOfValue = 'ignored';
314-
315-
// Catch any useState hook calls
316-
let sources: Array<DerivationMetadata> = [];
317-
if (
318-
instr.value.kind === 'Destructure' &&
319-
instr.value.lvalue.pattern.kind === 'ArrayPattern' &&
320-
isUseStateType(instr.value.value.identifier)
321-
) {
322-
typeOfValue = 'fromState';
323-
324-
const stateValueSource = instr.value.lvalue.pattern.items[0];
325-
if (stateValueSource.kind === 'Identifier') {
326-
sources.push({
327-
place: stateValueSource,
328-
typeOfValue: typeOfValue,
329-
sources: new Set([stateValueSource]),
330-
});
331-
}
332-
}
333-
334-
if (
321+
} else if (
335322
instr.value.kind === 'CallExpression' &&
336323
isSetStateType(instr.value.callee.identifier) &&
337324
instr.value.args.length === 1 &&
@@ -347,6 +334,21 @@ function parseInstr(
347334
instr.value.callee,
348335
]);
349336
}
337+
} else if (
338+
(instr.value.kind === 'CallExpression' ||
339+
instr.value.kind === 'MethodCall') &&
340+
isUseStateType(instr.lvalue.identifier)
341+
) {
342+
const stateValueSource = instr.value.args[0];
343+
if (stateValueSource.kind === 'Identifier') {
344+
sources.push({
345+
place: stateValueSource,
346+
typeOfValue: typeOfValue,
347+
sources: [stateValueSource],
348+
});
349+
}
350+
351+
typeOfValue = joinValue(typeOfValue, 'fromState');
350352
}
351353

352354
for (const operand of eachInstructionOperand(instr)) {
@@ -357,6 +359,27 @@ function parseInstr(
357359

358360
typeOfValue = joinValue(typeOfValue, opSource.typeOfValue);
359361
sources.push(opSource);
362+
363+
if (
364+
(instr.value.kind === 'CallExpression' ||
365+
instr.value.kind === 'MethodCall') &&
366+
opSource.typeOfValue === 'fromProps' &&
367+
isUseStateType(instr.lvalue.identifier)
368+
) {
369+
opSource.sources.forEach(source => {
370+
if (source.identifier.name !== null) {
371+
if (shadowingUseState.has(source.identifier.name.value)) {
372+
shadowingUseState
373+
.get(source.identifier.name.value)
374+
?.push(instr.lvalue.loc);
375+
} else {
376+
shadowingUseState.set(source.identifier.name.value, [
377+
instr.lvalue.loc,
378+
]);
379+
}
380+
}
381+
});
382+
}
360383
}
361384

362385
if (typeOfValue !== 'ignored') {
@@ -410,16 +433,26 @@ function parseBlockPhi(
410433
derivationCache: Map<IdentifierId, DerivationMetadata>,
411434
): void {
412435
for (const phi of block.phis) {
436+
let typeOfValue: TypeOfValue = 'ignored';
437+
let sources: Array<DerivationMetadata> = [];
413438
for (const operand of phi.operands.values()) {
414-
const phiSource = derivationCache.get(operand.identifier.id);
415-
if (phiSource !== undefined) {
416-
updateDerivationMetadata(
417-
phi.place,
418-
[phiSource],
419-
phiSource?.typeOfValue,
420-
derivationCache,
421-
);
439+
const opSource = derivationCache.get(operand.identifier.id);
440+
441+
if (opSource === undefined) {
442+
continue;
422443
}
444+
445+
typeOfValue = joinValue(typeOfValue, opSource?.typeOfValue ?? 'ignored');
446+
sources.push(opSource);
447+
}
448+
449+
if (typeOfValue !== 'ignored') {
450+
updateDerivationMetadata(
451+
phi.place,
452+
sources,
453+
typeOfValue,
454+
derivationCache,
455+
);
423456
}
424457
}
425458
}
@@ -523,7 +556,7 @@ function validateEffect(
523556
}
524557

525558
for (const call of derivedSetStateCall) {
526-
const placeNames = Array.from(call.derivedDep.sources)
559+
const derivedDepsStr = Array.from(call.derivedDep.sources)
527560
.map(place => {
528561
return place.identifier.name?.value;
529562
})
@@ -533,19 +566,24 @@ function validateEffect(
533566
let errorDescription = '';
534567

535568
if (call.derivedDep.typeOfValue === 'fromProps') {
536-
errorDescription = `props [${placeNames}].`;
569+
errorDescription = `props [${derivedDepsStr}]`;
537570
} else if (call.derivedDep.typeOfValue === 'fromState') {
538-
errorDescription = `local state [${placeNames}].`;
571+
errorDescription = `local state [${derivedDepsStr}]`;
539572
} else {
540-
errorDescription = `both props and local state [${placeNames}].`;
573+
errorDescription = `both props and local state [${derivedDepsStr}]`;
541574
}
542575

543576
errors.push({
544577
type: call.derivedDep.typeOfValue,
545-
description: `This setState() appears to derive a value from ${errorDescription}`,
578+
description: `${errorDescription}`,
546579
loc: call.loc,
547580
setStateName:
548581
call.loc !== GeneratedSource ? call.loc.identifierName : undefined,
582+
derivedDepsNames: Array.from(call.derivedDep.sources)
583+
.map(place => {
584+
return place.identifier.name?.value ?? '';
585+
})
586+
.filter(Boolean),
549587
});
550588
}
551589
}

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ export const FIXTURE_ENTRYPOINT = {
3434
```
3535
Found 1 error:
3636
37-
Error: Derive values in render, not effects.
37+
Error: You might not need an effect. Derive values in render, not effects.
3838
39-
This setState() appears to derive a value from both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
39+
Both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
4040
4141
error.bug-derived-state-from-mixed-deps.ts:9:4
4242
7 |

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,37 @@ function Component({props, number}) {
3232
```
3333
Found 1 error:
3434
35-
Error: Local state shadows parent state.
35+
Error: You might not need an effect. Local state shadows parent state.
3636
37-
This setState() appears to derive a value from props [props, number]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.
37+
The setState within a useEffect is deriving from props [props, number]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render
3838
3939
error.derived-state-from-shadowed-props.ts:10:4
4040
8 |
4141
9 | useEffect(() => {
4242
> 10 | setDisplayValue(props.prefix + missDirection + nothing);
43-
| ^^^^^^^^^^^^^^^ this setState synchronizes the state
43+
| ^^^^^^^^^^^^^^^ this derives values from props to synchronize state
4444
11 | }, [props.prefix, missDirection, nothing]);
4545
12 |
4646
13 | return (
4747
48+
error.derived-state-from-shadowed-props.ts:7:42
49+
5 | const nothing = 0;
50+
6 | const missDirection = number;
51+
> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing);
52+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows props
53+
8 |
54+
9 | useEffect(() => {
55+
10 | setDisplayValue(props.prefix + missDirection + nothing);
56+
57+
error.derived-state-from-shadowed-props.ts:7:42
58+
5 | const nothing = 0;
59+
6 | const missDirection = number;
60+
> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing);
61+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows number
62+
8 |
63+
9 | useEffect(() => {
64+
10 | setDisplayValue(props.prefix + missDirection + nothing);
65+
4866
error.derived-state-from-shadowed-props.ts:16:8
4967
14 | <div
5068
15 | onClick={() => {

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ export const FIXTURE_ENTRYPOINT = {
3232
```
3333
Found 1 error:
3434
35-
Error: Derive values in render, not effects.
35+
Error: You might not need an effect. Derive values in render, not effects.
3636
37-
This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
37+
Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
3838
3939
error.derived-state-with-conditional.ts:9:6
4040
7 | useEffect(() => {

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ export const FIXTURE_ENTRYPOINT = {
3030
```
3131
Found 1 error:
3232
33-
Error: Derive values in render, not effects.
33+
Error: You might not need an effect. Derive values in render, not effects.
3434
35-
This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
35+
Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
3636
3737
error.derived-state-with-side-effects.ts:9:4
3838
7 | useEffect(() => {

0 commit comments

Comments
 (0)