Skip to content

Commit 190adb1

Browse files
potetojorge-cab
authored andcommitted
[compiler][wip] Extend ValidateNoDerivedComputationsInEffects for props derived effects
This PR adds infra to disambiguate between two types of derived state in effects: 1. State derived from props 2. State derived from other state TODO: - [ ] Props tracking through destructuring and property access does not seem to be propagated correctly inside of Functions' instructions (or i might be misunderstanding how we track aliasing effects) - [ ] compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js should be failing - [ ] Handle "mixed" case where deps flow from at least one prop AND state. Should probably have a different error reason, to aid with categorization
1 parent 44f6c7c commit 190adb1

7 files changed

+194
-26
lines changed

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

Lines changed: 171 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,21 @@ import {
1212
FunctionExpression,
1313
HIRFunction,
1414
IdentifierId,
15+
Place,
1516
isSetStateType,
1617
isUseEffectHookType,
1718
} from '../HIR';
19+
import {printInstruction, printPlace} from '../HIR/PrintHIR';
1820
import {
1921
eachInstructionValueOperand,
2022
eachTerminalOperand,
2123
} from '../HIR/visitors';
2224

25+
type SetStateCall = {
26+
loc: SourceLocation;
27+
propsSource: Place | null; // null means state-derived, non-null means props-derived
28+
};
29+
2330
/**
2431
* Validates that useEffect is not used for derived computations which could/should
2532
* be performed in render.
@@ -47,12 +54,96 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
4754
const candidateDependencies: Map<IdentifierId, ArrayExpression> = new Map();
4855
const functions: Map<IdentifierId, FunctionExpression> = new Map();
4956
const locals: Map<IdentifierId, IdentifierId> = new Map();
57+
const derivedFromProps: Map<IdentifierId, Place> = new Map();
5058

5159
const errors = new CompilerError();
5260

61+
if (fn.fnType === 'Hook') {
62+
for (const param of fn.params) {
63+
if (param.kind === 'Identifier') {
64+
derivedFromProps.set(param.identifier.id, param);
65+
}
66+
}
67+
} else if (fn.fnType === 'Component') {
68+
const props = fn.params[0];
69+
if (props != null && props.kind === 'Identifier') {
70+
derivedFromProps.set(props.identifier.id, props);
71+
}
72+
}
73+
5374
for (const block of fn.body.blocks.values()) {
5475
for (const instr of block.instructions) {
5576
const {lvalue, value} = instr;
77+
78+
// Track props derivation through instruction effects
79+
if (instr.effects != null) {
80+
for (const effect of instr.effects) {
81+
switch (effect.kind) {
82+
case 'Assign':
83+
case 'Alias':
84+
case 'MaybeAlias':
85+
case 'Capture': {
86+
const source = derivedFromProps.get(effect.from.identifier.id);
87+
if (source != null) {
88+
derivedFromProps.set(effect.into.identifier.id, source);
89+
}
90+
break;
91+
}
92+
}
93+
}
94+
}
95+
96+
/**
97+
* TODO: figure out why property access off of props does not create an Assign or Alias/Maybe
98+
* Alias
99+
*
100+
* import {useEffect, useState} from 'react'
101+
*
102+
* function Component(props) {
103+
* const [displayValue, setDisplayValue] = useState('');
104+
*
105+
* useEffect(() => {
106+
* const computed = props.prefix + props.value + props.suffix;
107+
* ^^^^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^
108+
* we want to track that these are from props
109+
* setDisplayValue(computed);
110+
* }, [props.prefix, props.value, props.suffix]);
111+
*
112+
* return <div>{displayValue}</div>;
113+
* }
114+
*/
115+
if (value.kind === 'FunctionExpression') {
116+
for (const [, block] of value.loweredFunc.func.body.blocks) {
117+
for (const instr of block.instructions) {
118+
if (instr.effects != null) {
119+
console.group(printInstruction(instr));
120+
for (const effect of instr.effects) {
121+
console.log(effect);
122+
switch (effect.kind) {
123+
case 'Assign':
124+
case 'Alias':
125+
case 'MaybeAlias':
126+
case 'Capture': {
127+
const source = derivedFromProps.get(
128+
effect.from.identifier.id,
129+
);
130+
if (source != null) {
131+
derivedFromProps.set(effect.into.identifier.id, source);
132+
}
133+
break;
134+
}
135+
}
136+
}
137+
}
138+
console.groupEnd();
139+
}
140+
}
141+
}
142+
143+
for (const [, place] of derivedFromProps) {
144+
console.log(printPlace(place));
145+
}
146+
56147
if (value.kind === 'LoadLocal') {
57148
locals.set(lvalue.identifier.id, value.place.identifier.id);
58149
} else if (value.kind === 'ArrayExpression') {
@@ -89,6 +180,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
89180
validateEffect(
90181
effectFunction.loweredFunc.func,
91182
dependencies,
183+
derivedFromProps,
92184
errors,
93185
);
94186
}
@@ -104,35 +196,49 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
104196
function validateEffect(
105197
effectFunction: HIRFunction,
106198
effectDeps: Array<IdentifierId>,
199+
derivedFromProps: Map<IdentifierId, Place>,
107200
errors: CompilerError,
108201
): void {
109202
for (const operand of effectFunction.context) {
110203
if (isSetStateType(operand.identifier)) {
111204
continue;
112205
} else if (effectDeps.find(dep => dep === operand.identifier.id) != null) {
113206
continue;
207+
} else if (derivedFromProps.has(operand.identifier.id)) {
208+
continue;
114209
} else {
115210
// Captured something other than the effect dep or setState
211+
console.log('early return 1');
116212
return;
117213
}
118214
}
119215
for (const dep of effectDeps) {
216+
console.log({dep});
120217
if (
121218
effectFunction.context.find(operand => operand.identifier.id === dep) ==
122-
null
219+
null ||
220+
derivedFromProps.has(dep) === false
123221
) {
222+
console.log('early return 2');
124223
// effect dep wasn't actually used in the function
125224
return;
126225
}
127226
}
128227

129228
const seenBlocks: Set<BlockId> = new Set();
130229
const values: Map<IdentifierId, Array<IdentifierId>> = new Map();
230+
const effectDerivedFromProps: Map<IdentifierId, Place> = new Map();
231+
131232
for (const dep of effectDeps) {
233+
console.log({dep});
132234
values.set(dep, [dep]);
235+
const propsSource = derivedFromProps.get(dep);
236+
if (propsSource != null) {
237+
effectDerivedFromProps.set(dep, propsSource);
238+
}
133239
}
134240

135-
const setStateLocations: Array<SourceLocation> = [];
241+
const setStateCalls: Array<SetStateCall> = [];
136242
for (const block of effectFunction.body.blocks.values()) {
137243
for (const pred of block.preds) {
138244
if (!seenBlocks.has(pred)) {
@@ -142,17 +248,27 @@ function validateEffect(
142248
}
143249
for (const phi of block.phis) {
144250
const aggregateDeps: Set<IdentifierId> = new Set();
251+
let propsSource: Place | null = null;
252+
145253
for (const operand of phi.operands.values()) {
146254
const deps = values.get(operand.identifier.id);
147255
if (deps != null) {
148256
for (const dep of deps) {
149257
aggregateDeps.add(dep);
150258
}
151259
}
260+
const source = effectDerivedFromProps.get(operand.identifier.id);
261+
if (source != null) {
262+
propsSource = source;
263+
}
152264
}
265+
153266
if (aggregateDeps.size !== 0) {
154267
values.set(phi.place.identifier.id, Array.from(aggregateDeps));
155268
}
269+
if (propsSource != null) {
270+
effectDerivedFromProps.set(phi.place.identifier.id, propsSource);
271+
}
156272
}
157273
for (const instr of block.instructions) {
158274
switch (instr.value.kind) {
@@ -195,9 +311,16 @@ function validateEffect(
195311
) {
196312
const deps = values.get(instr.value.args[0].identifier.id);
197313
if (deps != null && new Set(deps).size === effectDeps.length) {
198-
setStateLocations.push(instr.value.callee.loc);
314+
const propsSource = effectDerivedFromProps.get(
315+
instr.value.args[0].identifier.id,
316+
);
317+
318+
setStateCalls.push({
319+
loc: instr.value.callee.loc,
320+
propsSource: propsSource ?? null,
321+
});
199322
} else {
200-
// doesn't depend on any deps
323+
// doesn't depend on all deps
201324
return;
202325
}
203326
}
@@ -207,6 +330,26 @@ function validateEffect(
207330
return;
208331
}
209332
}
333+
334+
// Track props derivation through instruction effects
335+
if (instr.effects != null) {
336+
for (const effect of instr.effects) {
337+
switch (effect.kind) {
338+
case 'Assign':
339+
case 'Alias':
340+
case 'MaybeAlias':
341+
case 'Capture': {
342+
const source = effectDerivedFromProps.get(
343+
effect.from.identifier.id,
344+
);
345+
if (source != null) {
346+
effectDerivedFromProps.set(effect.into.identifier.id, source);
347+
}
348+
break;
349+
}
350+
}
351+
}
352+
}
210353
}
211354
for (const operand of eachTerminalOperand(block.terminal)) {
212355
if (values.has(operand.identifier.id)) {
@@ -217,14 +360,29 @@ function validateEffect(
217360
seenBlocks.add(block.id);
218361
}
219362

220-
for (const loc of setStateLocations) {
221-
errors.push({
222-
reason:
223-
'Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)',
224-
description: null,
225-
severity: ErrorSeverity.InvalidReact,
226-
loc,
227-
suggestions: null,
228-
});
363+
for (const call of setStateCalls) {
364+
if (call.propsSource != null) {
365+
const propName = call.propsSource.identifier.name?.value;
366+
const propInfo = propName != null ? ` (from prop '${propName}')` : '';
367+
368+
errors.push({
369+
reason: `Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)`,
370+
description: `You are using props${propInfo} to update local state in an effect.`,
371+
severity: ErrorSeverity.InvalidReact,
372+
loc: call.loc,
373+
suggestions: null,
374+
});
375+
} else {
376+
errors.push({
377+
reason:
378+
'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)',
379+
description:
380+
'This effect updates state based on other state values. ' +
381+
'Consider calculating this value directly during render',
382+
severity: ErrorSeverity.InvalidReact,
383+
loc: call.loc,
384+
suggestions: null,
385+
});
386+
}
229387
}
230388
}

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ function BadExample() {
2424
```
2525
Found 1 error:
2626
27-
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
27+
Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
28+
29+
This effect updates state based on other state values. Consider calculating this value directly during render.
2830
2931
error.invalid-derived-computation-in-effect.ts:9:4
3032
7 | const [fullName, setFullName] = useState('');
3133
8 | useEffect(() => {
3234
> 9 | setFullName(capitalize(firstName + ' ' + lastName));
33-
| ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
35+
| ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
3436
10 | }, [firstName, lastName]);
3537
11 |
3638
12 | return <div>{fullName}</div>;

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,15 @@ export const FIXTURE_ENTRYPOINT = {
3434
```
3535
Found 1 error:
3636
37-
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
37+
Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
3838
39-
error.derived-state-from-mixed-deps-no-error.ts:9:4
39+
This effect updates state based on other state values. Consider calculating this value directly during render.
40+
41+
error.bug-derived-state-from-mixed-deps.ts:9:4
4042
7 |
4143
8 | useEffect(() => {
4244
> 9 | setDisplayName(prefix + name);
43-
| ^^^^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
45+
| ^^^^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
4446
10 | }, [prefix, name]);
4547
11 |
4648
12 | return (

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@ export const FIXTURE_ENTRYPOINT = {
2828
```
2929
Found 1 error:
3030
31-
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
31+
Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
32+
33+
This effect updates state based on other state values. Consider calculating this value directly during render.
3234
3335
error.invalid-derived-state-from-props-destructured.ts:8:4
3436
6 |
3537
7 | useEffect(() => {
3638
> 8 | setFullName(firstName + ' ' + lastName);
37-
| ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
39+
| ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
3840
9 | }, [firstName, lastName]);
3941
10 |
4042
11 | return <div>{fullName}</div>;

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @validateNoDerivedComputationsInEffects
22
import {useEffect, useState} from 'react';
33

4-
function Component({user: {firstName, lastName}}) {
4+
function Component({firstName, lastName}) {
55
const [fullName, setFullName] = useState('');
66

77
useEffect(() => {
@@ -13,5 +13,5 @@ function Component({user: {firstName, lastName}}) {
1313

1414
export const FIXTURE_ENTRYPOINT = {
1515
fn: Component,
16-
params: [{user: {firstName: 'John', lastName: 'Doe'}}],
16+
params: [{firstName: 'John', lastName: 'Doe'}],
1717
};

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@ export const FIXTURE_ENTRYPOINT = {
2828
```
2929
Found 1 error:
3030
31-
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
31+
Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
32+
33+
This effect updates state based on other state values. Consider calculating this value directly during render.
3234
3335
error.invalid-derived-state-from-props-in-effect.ts:8:4
3436
6 |
3537
7 | useEffect(() => {
3638
> 8 | setFullName(firstName + ' ' + lastName);
37-
| ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
39+
| ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
3840
9 | }, [firstName, lastName]);
3941
10 |
4042
11 | return <div>{fullName}</div>;

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ export const FIXTURE_ENTRYPOINT = {
3636
```
3737
Found 1 error:
3838
39-
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
39+
Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
40+
41+
This effect updates state based on other state values. Consider calculating this value directly during render.
4042
4143
error.invalid-derived-state-from-state-in-effect.ts:10:4
4244
8 |
4345
9 | useEffect(() => {
4446
> 10 | setFullName(firstName + ' ' + lastName);
45-
| ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
47+
| ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
4648
11 | }, [firstName, lastName]);
4749
12 |
4850
13 | return (

0 commit comments

Comments
 (0)