Skip to content

Commit 4a8789c

Browse files
committed
[compiler] Fix false negatives and add data flow tree to compiler error for no-deriving-state-in-effects
Summary: Revamped the derivationCache graph. This fixes a bunch of bugs where sometimes we fail to track from which props/state we derived values from. Also, it is more intuitive and allows us to easily implement a Data Flow Tree. We can print this tree which gives insight on how the data is derived and should facilitate error resolution in complicated components Test Plan: Added a test case where we were failing to track derivations. Also updated the test cases with the new error containing the data flow tree
1 parent 2b390b0 commit 4a8789c

12 files changed

+288
-53
lines changed

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

Lines changed: 136 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -102,31 +102,24 @@ class DerivationCache {
102102
typeOfValue: typeOfValue ?? 'ignored',
103103
};
104104

105-
if (sourcesIds !== undefined) {
106-
for (const id of sourcesIds) {
107-
const sourcePlace = this.cache.get(id)?.place;
105+
if (isNamedIdentifier(derivedVar)) {
106+
newValue.sourcesIds.add(derivedVar.identifier.id);
107+
}
108108

109-
if (sourcePlace === undefined) {
110-
continue;
111-
}
109+
for (const id of sourcesIds) {
110+
const sourceMetadata = this.cache.get(id);
112111

113-
/*
114-
* If the identifier of the source is a promoted identifier, then
115-
* we should set the target as the source.
116-
*/
117-
if (
118-
sourcePlace.identifier.name === null ||
119-
sourcePlace.identifier.name?.kind === 'promoted'
120-
) {
121-
newValue.sourcesIds.add(derivedVar.identifier.id);
122-
} else {
123-
newValue.sourcesIds.add(sourcePlace.identifier.id);
124-
}
112+
if (sourceMetadata === undefined) {
113+
continue;
125114
}
126-
}
127115

128-
if (newValue.sourcesIds.size === 0) {
129-
newValue.sourcesIds.add(derivedVar.identifier.id);
116+
if (isNamedIdentifier(sourceMetadata.place)) {
117+
newValue.sourcesIds.add(sourceMetadata.place.identifier.id);
118+
} else {
119+
for (const sourcesSourceId of sourceMetadata.sourcesIds) {
120+
newValue.sourcesIds.add(sourcesSourceId);
121+
}
122+
}
130123
}
131124

132125
this.cache.set(derivedVar.identifier.id, newValue);
@@ -151,6 +144,12 @@ class DerivationCache {
151144
}
152145
}
153146

147+
function isNamedIdentifier(place: Place): boolean {
148+
return (
149+
place.identifier.name !== null && place.identifier.name?.kind === 'named'
150+
);
151+
}
152+
154153
/**
155154
* Validates that useEffect is not used for derived computations which could/should
156155
* be performed in render.
@@ -202,7 +201,9 @@ export function validateNoDerivedComputationsInEffects_exp(
202201
if (param.kind === 'Identifier') {
203202
context.derivationCache.cache.set(param.identifier.id, {
204203
place: param,
205-
sourcesIds: new Set([param.identifier.id]),
204+
sourcesIds: new Set(
205+
isNamedIdentifier(param) ? [param.identifier.id] : [],
206+
),
206207
typeOfValue: 'fromProps',
207208
});
208209
}
@@ -212,7 +213,9 @@ export function validateNoDerivedComputationsInEffects_exp(
212213
if (props != null && props.kind === 'Identifier') {
213214
context.derivationCache.cache.set(props.identifier.id, {
214215
place: props,
215-
sourcesIds: new Set([props.identifier.id]),
216+
sourcesIds: new Set(
217+
isNamedIdentifier(props) ? [props.identifier.id] : [],
218+
),
216219
typeOfValue: 'fromProps',
217220
});
218221
}
@@ -223,10 +226,10 @@ export function validateNoDerivedComputationsInEffects_exp(
223226
context.derivationCache.takeSnapshot();
224227

225228
for (const block of fn.body.blocks.values()) {
229+
recordPhiDerivations(block, context);
226230
for (const instr of block.instructions) {
227231
recordInstructionDerivations(instr, context, isFirstPass);
228232
}
229-
recordPhiDerivations(block, context);
230233
}
231234

232235
context.derivationCache.checkForChanges();
@@ -293,6 +296,7 @@ function recordInstructionDerivations(
293296
if (value.kind === 'FunctionExpression') {
294297
context.functions.set(lvalue.identifier.id, value);
295298
for (const [, block] of value.loweredFunc.func.body.blocks) {
299+
recordPhiDerivations(block, context);
296300
for (const instr of block.instructions) {
297301
recordInstructionDerivations(instr, context, isFirstPass);
298302
}
@@ -341,9 +345,7 @@ function recordInstructionDerivations(
341345
}
342346

343347
typeOfValue = joinValue(typeOfValue, operandMetadata.typeOfValue);
344-
for (const id of operandMetadata.sourcesIds) {
345-
sources.add(id);
346-
}
348+
sources.add(operand.identifier.id);
347349
}
348350

349351
if (typeOfValue === 'ignored') {
@@ -406,6 +408,60 @@ function recordInstructionDerivations(
406408
}
407409
}
408410

411+
function buildDataFlowTree(
412+
sourceId: IdentifierId,
413+
indent: string = '',
414+
isLast: boolean = true,
415+
context: ValidationContext,
416+
): string {
417+
const sourceMetadata = context.derivationCache.cache.get(sourceId);
418+
if (!sourceMetadata || !sourceMetadata.place.identifier.name?.value) {
419+
return '';
420+
}
421+
422+
const sourceName = sourceMetadata.place.identifier.name.value;
423+
const prefix = indent + (isLast ? '└── ' : '├── ');
424+
const childIndent = indent + (isLast ? ' ' : '│ ');
425+
426+
const childSourceIds = Array.from(sourceMetadata.sourcesIds).filter(
427+
id => id !== sourceId,
428+
);
429+
430+
const isOriginal = childSourceIds.length === 0;
431+
432+
let result = `${prefix}${sourceName}`;
433+
434+
if (isOriginal) {
435+
let typeLabel: string;
436+
if (sourceMetadata.typeOfValue === 'fromProps') {
437+
typeLabel = 'Prop';
438+
} else if (sourceMetadata.typeOfValue === 'fromState') {
439+
typeLabel = 'State';
440+
} else {
441+
typeLabel = 'Prop and State';
442+
}
443+
result += ` (${typeLabel})`;
444+
}
445+
446+
if (childSourceIds.length > 0) {
447+
result += '\n';
448+
childSourceIds.forEach((childId, index) => {
449+
const childTree = buildDataFlowTree(
450+
childId,
451+
childIndent,
452+
index === childSourceIds.length - 1,
453+
context,
454+
);
455+
if (childTree) {
456+
result += childTree + '\n';
457+
}
458+
});
459+
result = result.slice(0, -1);
460+
}
461+
462+
return result;
463+
}
464+
409465
function validateEffect(
410466
effectFunction: HIRFunction,
411467
context: ValidationContext,
@@ -508,27 +564,63 @@ function validateEffect(
508564
.length -
509565
1
510566
) {
511-
const derivedDepsStr = Array.from(derivedSetStateCall.sourceIds)
512-
.map(sourceId => {
513-
const sourceMetadata = context.derivationCache.cache.get(sourceId);
514-
return sourceMetadata?.place.identifier.name?.value;
515-
})
516-
.filter(Boolean)
517-
.join(', ');
518-
519-
let description;
520-
521-
if (derivedSetStateCall.typeOfValue === 'fromProps') {
522-
description = `From props: [${derivedDepsStr}]`;
523-
} else if (derivedSetStateCall.typeOfValue === 'fromState') {
524-
description = `From local state: [${derivedDepsStr}]`;
525-
} else {
526-
description = `From props and local state: [${derivedDepsStr}]`;
567+
const allSourceIds = Array.from(derivedSetStateCall.sourceIds);
568+
const trees = allSourceIds
569+
.map((id, index) =>
570+
buildDataFlowTree(id, '', index === allSourceIds.length - 1, context),
571+
)
572+
.filter(Boolean);
573+
574+
const propsSet = new Set<string>();
575+
const stateSet = new Set<string>();
576+
577+
for (const sourceId of derivedSetStateCall.sourceIds) {
578+
const sourceMetadata = context.derivationCache.cache.get(sourceId);
579+
if (
580+
sourceMetadata &&
581+
sourceMetadata.place.identifier.name?.value &&
582+
(sourceMetadata.sourcesIds.size === 0 ||
583+
(sourceMetadata.sourcesIds.size === 1 &&
584+
sourceMetadata.sourcesIds.has(sourceId)))
585+
) {
586+
const name = sourceMetadata.place.identifier.name.value;
587+
if (sourceMetadata.typeOfValue === 'fromProps') {
588+
propsSet.add(name);
589+
} else if (sourceMetadata.typeOfValue === 'fromState') {
590+
stateSet.add(name);
591+
} else if (sourceMetadata.typeOfValue === 'fromPropsAndState') {
592+
propsSet.add(name);
593+
stateSet.add(name);
594+
}
595+
}
596+
}
597+
598+
const propsArr = Array.from(propsSet);
599+
const stateArr = Array.from(stateSet);
600+
601+
let rootSources = '';
602+
if (propsArr.length > 0) {
603+
rootSources += `Props: [${propsArr.join(', ')}]`;
527604
}
605+
if (stateArr.length > 0) {
606+
if (rootSources) rootSources += '\n';
607+
rootSources += `State: [${stateArr.join(', ')}]`;
608+
}
609+
610+
const description = `Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user
611+
612+
This setState call is setting a derived value that depends on the following reactive sources:
613+
614+
${rootSources}
615+
616+
Data Flow Tree:
617+
${trees.join('\n')}
618+
619+
See: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state`;
528620

529621
context.errors.pushDiagnostic(
530622
CompilerDiagnostic.create({
531-
description: `Derived values (${description}) 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`,
623+
description: description,
532624
category: ErrorCategory.EffectDerivationsOfState,
533625
reason:
534626
'You might not need an effect. Derive values in render, not effects.',

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/effect-derived-computations/error.derived-state-conditionally-in-effect.expect.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,16 @@ Found 1 error:
3434
3535
Error: You might not need an effect. Derive values in render, not effects.
3636
37-
Derived values (From props: [value]) 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+
Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user
38+
39+
This setState call is setting a derived value that depends on the following reactive sources:
40+
41+
Props: [value]
42+
43+
Data Flow Tree:
44+
└── value (Prop)
45+
46+
See: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state.
3847
3948
error.derived-state-conditionally-in-effect.ts:9:6
4049
7 | useEffect(() => {

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/effect-derived-computations/error.derived-state-from-default-props.expect.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,16 @@ Found 1 error:
3131
3232
Error: You might not need an effect. Derive values in render, not effects.
3333
34-
Derived values (From props: [input]) 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.
34+
Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user
35+
36+
This setState call is setting a derived value that depends on the following reactive sources:
37+
38+
Props: [input]
39+
40+
Data Flow Tree:
41+
└── input (Prop)
42+
43+
See: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state.
3544
3645
error.derived-state-from-default-props.ts:9:4
3746
7 |

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/effect-derived-computations/error.derived-state-from-local-state-in-effect.expect.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,16 @@ Found 1 error:
2828
2929
Error: You might not need an effect. Derive values in render, not effects.
3030
31-
Derived values (From local state: [count]) 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.
31+
Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user
32+
33+
This setState call is setting a derived value that depends on the following reactive sources:
34+
35+
State: [count]
36+
37+
Data Flow Tree:
38+
└── count (State)
39+
40+
See: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state.
3241
3342
error.derived-state-from-local-state-in-effect.ts:10:6
3443
8 | useEffect(() => {

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/effect-derived-computations/error.derived-state-from-prop-local-state-and-component-scope.expect.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,18 @@ Found 1 error:
3838
3939
Error: You might not need an effect. Derive values in render, not effects.
4040
41-
Derived values (From props and local state: [firstName, lastName]) 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.
41+
Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user
42+
43+
This setState call is setting a derived value that depends on the following reactive sources:
44+
45+
Props: [firstName]
46+
State: [lastName]
47+
48+
Data Flow Tree:
49+
├── firstName (Prop)
50+
└── lastName (State)
51+
52+
See: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state.
4253
4354
error.derived-state-from-prop-local-state-and-component-scope.ts:11:4
4455
9 |
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoDerivedComputationsInEffects_exp
6+
7+
function Component({value}) {
8+
const [checked, setChecked] = useState('');
9+
10+
useEffect(() => {
11+
setChecked(value === '' ? [] : value.split(','));
12+
}, [value]);
13+
14+
return <div>{checked}</div>;
15+
}
16+
17+
```
18+
19+
20+
## Error
21+
22+
```
23+
Found 1 error:
24+
25+
Error: You might not need an effect. Derive values in render, not effects.
26+
27+
Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user
28+
29+
This setState call is setting a derived value that depends on the following reactive sources:
30+
31+
Props: [value]
32+
33+
Data Flow Tree:
34+
└── value (Prop)
35+
36+
See: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state.
37+
38+
error.derived-state-from-prop-setter-ternary.ts:7:4
39+
5 |
40+
6 | useEffect(() => {
41+
> 7 | setChecked(value === '' ? [] : value.split(','));
42+
| ^^^^^^^^^^ This should be computed during render, not in an effect
43+
8 | }, [value]);
44+
9 |
45+
10 | return <div>{checked}</div>;
46+
```
47+
48+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// @validateNoDerivedComputationsInEffects_exp
2+
3+
function Component({value}) {
4+
const [checked, setChecked] = useState('');
5+
6+
useEffect(() => {
7+
setChecked(value === '' ? [] : value.split(','));
8+
}, [value]);
9+
10+
return <div>{checked}</div>;
11+
}

0 commit comments

Comments
 (0)