Skip to content

Commit 7b38acc

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 1d9c392 commit 7b38acc

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
@@ -13,14 +13,21 @@ import {
1313
FunctionExpression,
1414
HIRFunction,
1515
IdentifierId,
16+
Place,
1617
isSetStateType,
1718
isUseEffectHookType,
1819
} from '../HIR';
20+
import {printInstruction, printPlace} from '../HIR/PrintHIR';
1921
import {
2022
eachInstructionValueOperand,
2123
eachTerminalOperand,
2224
} from '../HIR/visitors';
2325

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

5260
const errors = new CompilerError();
5361

62+
if (fn.fnType === 'Hook') {
63+
for (const param of fn.params) {
64+
if (param.kind === 'Identifier') {
65+
derivedFromProps.set(param.identifier.id, param);
66+
}
67+
}
68+
} else if (fn.fnType === 'Component') {
69+
const props = fn.params[0];
70+
if (props != null && props.kind === 'Identifier') {
71+
derivedFromProps.set(props.identifier.id, props);
72+
}
73+
}
74+
5475
for (const block of fn.body.blocks.values()) {
5576
for (const instr of block.instructions) {
5677
const {lvalue, value} = instr;
78+
79+
// Track props derivation through instruction effects
80+
if (instr.effects != null) {
81+
for (const effect of instr.effects) {
82+
switch (effect.kind) {
83+
case 'Assign':
84+
case 'Alias':
85+
case 'MaybeAlias':
86+
case 'Capture': {
87+
const source = derivedFromProps.get(effect.from.identifier.id);
88+
if (source != null) {
89+
derivedFromProps.set(effect.into.identifier.id, source);
90+
}
91+
break;
92+
}
93+
}
94+
}
95+
}
96+
97+
/**
98+
* TODO: figure out why property access off of props does not create an Assign or Alias/Maybe
99+
* Alias
100+
*
101+
* import {useEffect, useState} from 'react'
102+
*
103+
* function Component(props) {
104+
* const [displayValue, setDisplayValue] = useState('');
105+
*
106+
* useEffect(() => {
107+
* const computed = props.prefix + props.value + props.suffix;
108+
* ^^^^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^
109+
* we want to track that these are from props
110+
* setDisplayValue(computed);
111+
* }, [props.prefix, props.value, props.suffix]);
112+
*
113+
* return <div>{displayValue}</div>;
114+
* }
115+
*/
116+
if (value.kind === 'FunctionExpression') {
117+
for (const [, block] of value.loweredFunc.func.body.blocks) {
118+
for (const instr of block.instructions) {
119+
if (instr.effects != null) {
120+
console.group(printInstruction(instr));
121+
for (const effect of instr.effects) {
122+
console.log(effect);
123+
switch (effect.kind) {
124+
case 'Assign':
125+
case 'Alias':
126+
case 'MaybeAlias':
127+
case 'Capture': {
128+
const source = derivedFromProps.get(
129+
effect.from.identifier.id,
130+
);
131+
if (source != null) {
132+
derivedFromProps.set(effect.into.identifier.id, source);
133+
}
134+
break;
135+
}
136+
}
137+
}
138+
}
139+
console.groupEnd();
140+
}
141+
}
142+
}
143+
144+
for (const [, place] of derivedFromProps) {
145+
console.log(printPlace(place));
146+
}
147+
57148
if (value.kind === 'LoadLocal') {
58149
locals.set(lvalue.identifier.id, value.place.identifier.id);
59150
} else if (value.kind === 'ArrayExpression') {
@@ -97,6 +188,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
97188
validateEffect(
98189
effectFunction.loweredFunc.func,
99190
dependencies,
191+
derivedFromProps,
100192
errors,
101193
);
102194
}
@@ -112,35 +204,49 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
112204
function validateEffect(
113205
effectFunction: HIRFunction,
114206
effectDeps: Array<IdentifierId>,
207+
derivedFromProps: Map<IdentifierId, Place>,
115208
errors: CompilerError,
116209
): void {
117210
for (const operand of effectFunction.context) {
118211
if (isSetStateType(operand.identifier)) {
119212
continue;
120213
} else if (effectDeps.find(dep => dep === operand.identifier.id) != null) {
121214
continue;
215+
} else if (derivedFromProps.has(operand.identifier.id)) {
216+
continue;
122217
} else {
123218
// Captured something other than the effect dep or setState
219+
console.log('early return 1');
124220
return;
125221
}
126222
}
127223
for (const dep of effectDeps) {
224+
console.log({dep});
128225
if (
129226
effectFunction.context.find(operand => operand.identifier.id === dep) ==
130-
null
227+
null ||
228+
derivedFromProps.has(dep) === false
131229
) {
230+
console.log('early return 2');
132231
// effect dep wasn't actually used in the function
133232
return;
134233
}
135234
}
136235

137236
const seenBlocks: Set<BlockId> = new Set();
138237
const values: Map<IdentifierId, Array<IdentifierId>> = new Map();
238+
const effectDerivedFromProps: Map<IdentifierId, Place> = new Map();
239+
139240
for (const dep of effectDeps) {
241+
console.log({dep});
140242
values.set(dep, [dep]);
243+
const propsSource = derivedFromProps.get(dep);
244+
if (propsSource != null) {
245+
effectDerivedFromProps.set(dep, propsSource);
246+
}
141247
}
142248

143-
const setStateLocations: Array<SourceLocation> = [];
249+
const setStateCalls: Array<SetStateCall> = [];
144250
for (const block of effectFunction.body.blocks.values()) {
145251
for (const pred of block.preds) {
146252
if (!seenBlocks.has(pred)) {
@@ -150,17 +256,27 @@ function validateEffect(
150256
}
151257
for (const phi of block.phis) {
152258
const aggregateDeps: Set<IdentifierId> = new Set();
259+
let propsSource: Place | null = null;
260+
153261
for (const operand of phi.operands.values()) {
154262
const deps = values.get(operand.identifier.id);
155263
if (deps != null) {
156264
for (const dep of deps) {
157265
aggregateDeps.add(dep);
158266
}
159267
}
268+
const source = effectDerivedFromProps.get(operand.identifier.id);
269+
if (source != null) {
270+
propsSource = source;
271+
}
160272
}
273+
161274
if (aggregateDeps.size !== 0) {
162275
values.set(phi.place.identifier.id, Array.from(aggregateDeps));
163276
}
277+
if (propsSource != null) {
278+
effectDerivedFromProps.set(phi.place.identifier.id, propsSource);
279+
}
164280
}
165281
for (const instr of block.instructions) {
166282
switch (instr.value.kind) {
@@ -203,9 +319,16 @@ function validateEffect(
203319
) {
204320
const deps = values.get(instr.value.args[0].identifier.id);
205321
if (deps != null && new Set(deps).size === effectDeps.length) {
206-
setStateLocations.push(instr.value.callee.loc);
322+
const propsSource = effectDerivedFromProps.get(
323+
instr.value.args[0].identifier.id,
324+
);
325+
326+
setStateCalls.push({
327+
loc: instr.value.callee.loc,
328+
propsSource: propsSource ?? null,
329+
});
207330
} else {
208-
// doesn't depend on any deps
331+
// doesn't depend on all deps
209332
return;
210333
}
211334
}
@@ -215,6 +338,26 @@ function validateEffect(
215338
return;
216339
}
217340
}
341+
342+
// Track props derivation through instruction effects
343+
if (instr.effects != null) {
344+
for (const effect of instr.effects) {
345+
switch (effect.kind) {
346+
case 'Assign':
347+
case 'Alias':
348+
case 'MaybeAlias':
349+
case 'Capture': {
350+
const source = effectDerivedFromProps.get(
351+
effect.from.identifier.id,
352+
);
353+
if (source != null) {
354+
effectDerivedFromProps.set(effect.into.identifier.id, source);
355+
}
356+
break;
357+
}
358+
}
359+
}
360+
}
218361
}
219362
for (const operand of eachTerminalOperand(block.terminal)) {
220363
if (values.has(operand.identifier.id)) {
@@ -225,14 +368,29 @@ function validateEffect(
225368
seenBlocks.add(block.id);
226369
}
227370

228-
for (const loc of setStateLocations) {
229-
errors.push({
230-
category: ErrorCategory.EffectDerivationsOfState,
231-
reason:
232-
'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)',
233-
description: null,
234-
loc,
235-
suggestions: null,
236-
});
371+
for (const call of setStateCalls) {
372+
if (call.propsSource != null) {
373+
const propName = call.propsSource.identifier.name?.value;
374+
const propInfo = propName != null ? ` (from prop '${propName}')` : '';
375+
376+
errors.push({
377+
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)`,
378+
description: `You are using props${propInfo} to update local state in an effect.`,
379+
severity: ErrorSeverity.InvalidReact,
380+
loc: call.loc,
381+
suggestions: null,
382+
});
383+
} else {
384+
errors.push({
385+
reason:
386+
'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)',
387+
description:
388+
'This effect updates state based on other state values. ' +
389+
'Consider calculating this value directly during render',
390+
severity: ErrorSeverity.InvalidReact,
391+
loc: call.loc,
392+
suggestions: null,
393+
});
394+
}
237395
}
238396
}

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)