Skip to content

Commit a46e4b8

Browse files
committed
[compiler] Add catching useStates that shadow a reactive value
1 parent 361c22a commit a46e4b8

18 files changed

+246
-121
lines changed

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,9 @@ export enum ErrorCategory {
575575
// Checks for no setState in effect bodies
576576
EffectSetState = 'EffectSetState',
577577

578-
EffectDerivationsOfState = 'EffectDerivationsOfState',
578+
EffectDerivationDeriveInRender = 'EffectDerivationDeriveInRender',
579+
580+
EffectDerivationShadowingParentState = 'EffectDerivationShadowingParentState',
579581

580582
// Validates against try/catch in place of error boundaries
581583
ErrorBoundaries = 'ErrorBoundaries',
@@ -692,12 +694,21 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
692694
recommended: false,
693695
};
694696
}
695-
case ErrorCategory.EffectDerivationsOfState: {
697+
case ErrorCategory.EffectDerivationDeriveInRender: {
698+
return {
699+
category,
700+
name: 'no-deriving-state-in-effects',
701+
description:
702+
'Validates if a useEffect is deriving state from props and/or local state that could be calculated in render.',
703+
recommended: false,
704+
};
705+
}
706+
case ErrorCategory.EffectDerivationShadowingParentState: {
696707
return {
697708
category,
698709
name: 'no-deriving-state-in-effects',
699710
description:
700-
'Validates against deriving values from state in an effect',
711+
'Validates if a useEffect is deriving state from parent state and if the component is updating the shadowed state locally.',
701712
recommended: false,
702713
};
703714
}

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

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

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

5556
/**
@@ -80,6 +81,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
8081
const functions: Map<IdentifierId, FunctionExpression> = new Map();
8182
const locals: Map<IdentifierId, IdentifierId> = new Map();
8283
const derivationCache: Map<IdentifierId, DerivationMetadata> = new Map();
84+
const shadowingUseState: Map<string, Array<SourceLocation>> = new Map();
8385

8486
const effectSetStates: Map<
8587
string | undefined | null,
@@ -94,7 +96,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
9496
if (param.kind === 'Identifier') {
9597
derivationCache.set(param.identifier.id, {
9698
place: param,
97-
sources: new Set([param]),
99+
sources: [param],
98100
typeOfValue: 'fromProps',
99101
});
100102
}
@@ -104,7 +106,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
104106
if (props != null && props.kind === 'Identifier') {
105107
derivationCache.set(props.identifier.id, {
106108
place: props,
107-
sources: new Set([props]),
109+
sources: [props],
108110
typeOfValue: 'fromProps',
109111
});
110112
}
@@ -116,7 +118,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
116118
for (const instr of block.instructions) {
117119
const {lvalue, value} = instr;
118120

119-
parseInstr(instr, derivationCache, setStateCalls);
121+
parseInstr(instr, derivationCache, setStateCalls, shadowingUseState);
120122

121123
if (value.kind === 'LoadLocal') {
122124
locals.set(lvalue.identifier.id, value.place.identifier.id);
@@ -168,6 +170,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
168170
const compilerError = generateCompilerError(
169171
setStateCalls,
170172
effectSetStates,
173+
shadowingUseState,
171174
errors,
172175
);
173176

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

198192
/*
199193
* If we use a setState from an invalid useEffect elsewhere then we probably have to
@@ -205,15 +199,29 @@ function generateCompilerError(
205199
error.type !== 'fromState'
206200
) {
207201
compilerDiagnostic = CompilerDiagnostic.create({
208-
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.`,
209-
category: `Local state shadows parent state.`,
202+
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`,
203+
category: ErrorCategory.EffectDerivationShadowingParentState,
210204
severity: ErrorSeverity.InvalidReact,
205+
reason:
206+
'You might not need an effect. Local state shadows parent state.',
211207
}).withDetail({
212208
kind: 'error',
213209
loc: error.loc,
214-
message: 'this setState synchronizes the state',
210+
message: `this derives values from props ${error.type === 'fromPropsOrState' ? 'and local state ' : ''}to synchronize state`,
215211
});
216212

213+
for (const derivedDep of error.derivedDepsNames) {
214+
if (shadowingUseState.has(derivedDep)) {
215+
for (const loc of shadowingUseState.get(derivedDep)!) {
216+
compilerDiagnostic.withDetail({
217+
kind: 'error',
218+
loc: loc,
219+
message: `this useState shadows ${derivedDep}`,
220+
});
221+
}
222+
}
223+
}
224+
217225
for (const [key, setStateCallArray] of effectSetStates) {
218226
if (setStateCallArray.length === 0) {
219227
continue;
@@ -235,9 +243,11 @@ function generateCompilerError(
235243
}
236244
} else {
237245
compilerDiagnostic = CompilerDiagnostic.create({
238-
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.`,
239-
category: `Derive values in render, not effects.`,
246+
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.`,
247+
category: ErrorCategory.EffectDerivationDeriveInRender,
240248
severity: ErrorSeverity.InvalidReact,
249+
reason:
250+
'You might not need an effect. Derive values in render, not effects.',
241251
}).withDetail({
242252
kind: 'error',
243253
loc: error.loc,
@@ -271,7 +281,7 @@ function updateDerivationMetadata(
271281
): void {
272282
let newValue: DerivationMetadata = {
273283
place: target,
274-
sources: new Set(),
284+
sources: [],
275285
typeOfValue: typeOfValue ?? 'ignored',
276286
};
277287

@@ -286,9 +296,9 @@ function updateDerivationMetadata(
286296
place.identifier.name === null ||
287297
place.identifier.name?.kind === 'promoted'
288298
) {
289-
newValue.sources.add(target);
299+
newValue.sources.push(target);
290300
} else {
291-
newValue.sources.add(place);
301+
newValue.sources.push(place);
292302
}
293303
}
294304
}
@@ -301,38 +311,19 @@ function parseInstr(
301311
instr: Instruction,
302312
derivationCache: Map<IdentifierId, DerivationMetadata>,
303313
setStateCalls: Map<string | undefined | null, Array<Place>>,
314+
shadowingUseState: Map<string, Array<SourceLocation>>,
304315
): void {
305316
// Recursively parse function expressions
317+
let typeOfValue: TypeOfValue = 'ignored';
318+
319+
let sources: Array<DerivationMetadata> = [];
306320
if (instr.value.kind === 'FunctionExpression') {
307321
for (const [, block] of instr.value.loweredFunc.func.body.blocks) {
308322
for (const instr of block.instructions) {
309-
parseInstr(instr, derivationCache, setStateCalls);
323+
parseInstr(instr, derivationCache, setStateCalls, shadowingUseState);
310324
}
311325
}
312-
}
313-
314-
let typeOfValue: TypeOfValue = 'ignored';
315-
316-
// Catch any useState hook calls
317-
let sources: Array<DerivationMetadata> = [];
318-
if (
319-
instr.value.kind === 'Destructure' &&
320-
instr.value.lvalue.pattern.kind === 'ArrayPattern' &&
321-
isUseStateType(instr.value.value.identifier)
322-
) {
323-
typeOfValue = 'fromState';
324-
325-
const stateValueSource = instr.value.lvalue.pattern.items[0];
326-
if (stateValueSource.kind === 'Identifier') {
327-
sources.push({
328-
place: stateValueSource,
329-
typeOfValue: typeOfValue,
330-
sources: new Set([stateValueSource]),
331-
});
332-
}
333-
}
334-
335-
if (
326+
} else if (
336327
instr.value.kind === 'CallExpression' &&
337328
isSetStateType(instr.value.callee.identifier) &&
338329
instr.value.args.length === 1 &&
@@ -348,6 +339,22 @@ function parseInstr(
348339
instr.value.callee,
349340
]);
350341
}
342+
} else if (
343+
(instr.value.kind === 'CallExpression' ||
344+
instr.value.kind === 'MethodCall') &&
345+
isUseStateType(instr.lvalue.identifier) &&
346+
instr.value.args.length > 0
347+
) {
348+
const stateValueSource = instr.value.args[0];
349+
if (stateValueSource.kind === 'Identifier') {
350+
sources.push({
351+
place: stateValueSource,
352+
typeOfValue: typeOfValue,
353+
sources: [stateValueSource],
354+
});
355+
}
356+
357+
typeOfValue = joinValue(typeOfValue, 'fromState');
351358
}
352359

353360
for (const operand of eachInstructionOperand(instr)) {
@@ -358,6 +365,27 @@ function parseInstr(
358365

359366
typeOfValue = joinValue(typeOfValue, opSource.typeOfValue);
360367
sources.push(opSource);
368+
369+
if (
370+
(instr.value.kind === 'CallExpression' ||
371+
instr.value.kind === 'MethodCall') &&
372+
opSource.typeOfValue === 'fromProps' &&
373+
isUseStateType(instr.lvalue.identifier)
374+
) {
375+
opSource.sources.forEach(source => {
376+
if (source.identifier.name !== null) {
377+
if (shadowingUseState.has(source.identifier.name.value)) {
378+
shadowingUseState
379+
.get(source.identifier.name.value)
380+
?.push(instr.lvalue.loc);
381+
} else {
382+
shadowingUseState.set(source.identifier.name.value, [
383+
instr.lvalue.loc,
384+
]);
385+
}
386+
}
387+
});
388+
}
361389
}
362390

363391
if (typeOfValue !== 'ignored') {
@@ -411,16 +439,26 @@ function parseBlockPhi(
411439
derivationCache: Map<IdentifierId, DerivationMetadata>,
412440
): void {
413441
for (const phi of block.phis) {
442+
let typeOfValue: TypeOfValue = 'ignored';
443+
let sources: Array<DerivationMetadata> = [];
414444
for (const operand of phi.operands.values()) {
415-
const phiSource = derivationCache.get(operand.identifier.id);
416-
if (phiSource !== undefined) {
417-
updateDerivationMetadata(
418-
phi.place,
419-
[phiSource],
420-
phiSource?.typeOfValue,
421-
derivationCache,
422-
);
445+
const opSource = derivationCache.get(operand.identifier.id);
446+
447+
if (opSource === undefined) {
448+
continue;
423449
}
450+
451+
typeOfValue = joinValue(typeOfValue, opSource?.typeOfValue ?? 'ignored');
452+
sources.push(opSource);
453+
}
454+
455+
if (typeOfValue !== 'ignored') {
456+
updateDerivationMetadata(
457+
phi.place,
458+
sources,
459+
typeOfValue,
460+
derivationCache,
461+
);
424462
}
425463
}
426464
}
@@ -524,7 +562,7 @@ function validateEffect(
524562
}
525563

526564
for (const call of derivedSetStateCall) {
527-
const placeNames = Array.from(call.derivedDep.sources)
565+
const derivedDepsStr = Array.from(call.derivedDep.sources)
528566
.map(place => {
529567
return place.identifier.name?.value;
530568
})
@@ -534,19 +572,24 @@ function validateEffect(
534572
let errorDescription = '';
535573

536574
if (call.derivedDep.typeOfValue === 'fromProps') {
537-
errorDescription = `props [${placeNames}].`;
575+
errorDescription = `props [${derivedDepsStr}]`;
538576
} else if (call.derivedDep.typeOfValue === 'fromState') {
539-
errorDescription = `local state [${placeNames}].`;
577+
errorDescription = `local state [${derivedDepsStr}]`;
540578
} else {
541-
errorDescription = `both props and local state [${placeNames}].`;
579+
errorDescription = `both props and local state [${derivedDepsStr}]`;
542580
}
543581

544582
errors.push({
545583
type: call.derivedDep.typeOfValue,
546-
description: `This setState() appears to derive a value from ${errorDescription}`,
584+
description: `${errorDescription}`,
547585
loc: call.loc,
548586
setStateName:
549587
call.loc !== GeneratedSource ? call.loc.identifierName : undefined,
588+
derivedDepsNames: Array.from(call.derivedDep.sources)
589+
.map(place => {
590+
return place.identifier.name?.value ?? '';
591+
})
592+
.filter(Boolean),
550593
});
551594
}
552595
}

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 |

0 commit comments

Comments
 (0)