Skip to content

Commit 634107b

Browse files
committed
[Compiler] Don't throw calculate in render if the blamed setter is used outside of the effect
Summary: If the setter is used both inside and outside the effect then usually the solution is more complex and requires hoisting state up to a parent component since we can't just remove the local state. To do this, we now have 2 caches that track setState usages (not just calls) since if the effect is passed as an argument or called outside the effect the solution gets more complex which we are trying to avoid for now
1 parent 807b1aa commit 634107b

7 files changed

+245
-106
lines changed

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

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
Instruction,
2020
isUseStateType,
2121
isUseRefType,
22+
GeneratedSource,
23+
SourceLocation,
2224
} from '../HIR';
2325
import {eachInstructionLValue, eachInstructionOperand} from '../HIR/visitors';
2426
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
@@ -59,6 +61,10 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
5961
const functions: Map<IdentifierId, FunctionExpression> = new Map();
6062

6163
const derivationCache: Map<IdentifierId, DerivationMetadata> = new Map();
64+
const setStateCache: Map<string | undefined | null, Array<Place>> = new Map();
65+
66+
const effects: Array<HIRFunction> = [];
67+
6268
if (fn.fnType === 'Hook') {
6369
for (const param of fn.params) {
6470
if (param.kind === 'Identifier') {
@@ -127,11 +133,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
127133
) {
128134
const effectFunction = functions.get(value.args[0].identifier.id);
129135
if (effectFunction != null) {
130-
validateEffect(
131-
effectFunction.loweredFunc.func,
132-
errors,
133-
derivationCache,
134-
);
136+
effects.push(effectFunction.loweredFunc.func);
135137
}
136138
} else if (
137139
isUseStateType(lvalue.identifier) &&
@@ -146,6 +148,25 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
146148
}
147149

148150
for (const operand of eachInstructionOperand(instr)) {
151+
// Record setState usages everywhere
152+
switch (instr.value.kind) {
153+
case 'JsxExpression':
154+
case 'CallExpression':
155+
case 'MethodCall':
156+
if (
157+
isSetStateType(operand.identifier) &&
158+
operand.loc !== GeneratedSource
159+
) {
160+
if (setStateCache.has(operand.loc.identifierName)) {
161+
setStateCache.get(operand.loc.identifierName)!.push(operand);
162+
} else {
163+
setStateCache.set(operand.loc.identifierName, [operand]);
164+
}
165+
}
166+
break;
167+
default:
168+
}
169+
149170
const operandMetadata = derivationCache.get(operand.identifier.id);
150171

151172
if (operandMetadata === undefined) {
@@ -214,6 +235,10 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
214235
}
215236
}
216237

238+
for (const effect of effects) {
239+
validateEffect(effect, errors, derivationCache, setStateCache);
240+
}
241+
217242
if (errors.hasAnyErrors()) {
218243
throw errors;
219244
}
@@ -275,11 +300,17 @@ function validateEffect(
275300
effectFunction: HIRFunction,
276301
errors: CompilerError,
277302
derivationCache: Map<IdentifierId, DerivationMetadata>,
303+
setStateCache: Map<string | undefined | null, Array<Place>>,
278304
): void {
305+
const effectSetStateCache: Map<
306+
string | undefined | null,
307+
Array<Place>
308+
> = new Map();
279309
const seenBlocks: Set<BlockId> = new Set();
280310

281311
const effectDerivedSetStateCalls: Array<{
282312
value: CallExpression;
313+
loc: SourceLocation;
283314
sourceIds: Set<IdentifierId>;
284315
}> = [];
285316

@@ -298,6 +329,28 @@ function validateEffect(
298329
return;
299330
}
300331

332+
for (const operand of eachInstructionOperand(instr)) {
333+
switch (instr.value.kind) {
334+
case 'JsxExpression':
335+
case 'CallExpression':
336+
case 'MethodCall':
337+
if (
338+
isSetStateType(operand.identifier) &&
339+
operand.loc !== GeneratedSource
340+
) {
341+
if (effectSetStateCache.has(operand.loc.identifierName)) {
342+
effectSetStateCache
343+
.get(operand.loc.identifierName)!
344+
.push(operand);
345+
} else {
346+
effectSetStateCache.set(operand.loc.identifierName, [operand]);
347+
}
348+
}
349+
break;
350+
default:
351+
}
352+
}
353+
301354
if (
302355
instr.value.kind === 'CallExpression' &&
303356
isSetStateType(instr.value.callee.identifier) &&
@@ -311,6 +364,7 @@ function validateEffect(
311364
if (argMetadata !== undefined) {
312365
effectDerivedSetStateCalls.push({
313366
value: instr.value,
367+
loc: instr.value.callee.loc,
314368
sourceIds: argMetadata.sourcesIds,
315369
});
316370
}
@@ -343,13 +397,22 @@ function validateEffect(
343397
}
344398

345399
for (const derivedSetStateCall of effectDerivedSetStateCalls) {
346-
errors.push({
347-
category: ErrorCategory.EffectDerivationsOfState,
348-
reason:
349-
'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)',
350-
description: null,
351-
loc: derivedSetStateCall.value.callee.loc,
352-
suggestions: null,
353-
});
400+
if (
401+
derivedSetStateCall.loc !== GeneratedSource &&
402+
effectSetStateCache.has(derivedSetStateCall.loc.identifierName) &&
403+
setStateCache.has(derivedSetStateCall.loc.identifierName) &&
404+
effectSetStateCache.get(derivedSetStateCall.loc.identifierName)!
405+
.length ===
406+
setStateCache.get(derivedSetStateCall.loc.identifierName)!.length
407+
) {
408+
errors.push({
409+
category: ErrorCategory.EffectDerivationsOfState,
410+
reason:
411+
'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)',
412+
description: null,
413+
loc: derivedSetStateCall.value.callee.loc,
414+
suggestions: null,
415+
});
416+
}
354417
}
355418
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoDerivedComputationsInEffects
6+
import {useEffect, useState} from 'react';
7+
8+
function Component({initialName}) {
9+
const [name, setName] = useState('');
10+
11+
useEffect(() => {
12+
setName(initialName);
13+
}, [initialName]);
14+
15+
return (
16+
<div>
17+
<input value={name} onChange={e => setName(e.target.value)} />
18+
</div>
19+
);
20+
}
21+
22+
export const FIXTURE_ENTRYPOINT = {
23+
fn: Component,
24+
params: [{initialName: 'John'}],
25+
};
26+
27+
```
28+
29+
## Code
30+
31+
```javascript
32+
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects
33+
import { useEffect, useState } from "react";
34+
35+
function Component(t0) {
36+
const $ = _c(6);
37+
const { initialName } = t0;
38+
const [name, setName] = useState("");
39+
let t1;
40+
let t2;
41+
if ($[0] !== initialName) {
42+
t1 = () => {
43+
setName(initialName);
44+
};
45+
t2 = [initialName];
46+
$[0] = initialName;
47+
$[1] = t1;
48+
$[2] = t2;
49+
} else {
50+
t1 = $[1];
51+
t2 = $[2];
52+
}
53+
useEffect(t1, t2);
54+
let t3;
55+
if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
56+
t3 = (e) => setName(e.target.value);
57+
$[3] = t3;
58+
} else {
59+
t3 = $[3];
60+
}
61+
let t4;
62+
if ($[4] !== name) {
63+
t4 = (
64+
<div>
65+
<input value={name} onChange={t3} />
66+
</div>
67+
);
68+
$[4] = name;
69+
$[5] = t4;
70+
} else {
71+
t4 = $[5];
72+
}
73+
return t4;
74+
}
75+
76+
export const FIXTURE_ENTRYPOINT = {
77+
fn: Component,
78+
params: [{ initialName: "John" }],
79+
};
80+
81+
```
82+
83+
### Eval output
84+
(kind: ok) <div><input value="John"></div>
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoDerivedComputationsInEffects
6+
import {useEffect, useState} from 'react';
7+
8+
function MockComponent({onSet}) {
9+
return <div onClick={() => onSet('clicked')}>Mock Component</div>;
10+
}
11+
12+
function Component({propValue}) {
13+
const [value, setValue] = useState(null);
14+
useEffect(() => {
15+
setValue(propValue);
16+
}, [propValue]);
17+
18+
return <MockComponent onSet={setValue} />;
19+
}
20+
21+
export const FIXTURE_ENTRYPOINT = {
22+
fn: Component,
23+
params: [{propValue: 'test'}],
24+
};
25+
26+
```
27+
28+
## Code
29+
30+
```javascript
31+
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects
32+
import { useEffect, useState } from "react";
33+
34+
function MockComponent(t0) {
35+
const $ = _c(2);
36+
const { onSet } = t0;
37+
let t1;
38+
if ($[0] !== onSet) {
39+
t1 = <div onClick={() => onSet("clicked")}>Mock Component</div>;
40+
$[0] = onSet;
41+
$[1] = t1;
42+
} else {
43+
t1 = $[1];
44+
}
45+
return t1;
46+
}
47+
48+
function Component(t0) {
49+
const $ = _c(4);
50+
const { propValue } = t0;
51+
const [, setValue] = useState(null);
52+
let t1;
53+
let t2;
54+
if ($[0] !== propValue) {
55+
t1 = () => {
56+
setValue(propValue);
57+
};
58+
t2 = [propValue];
59+
$[0] = propValue;
60+
$[1] = t1;
61+
$[2] = t2;
62+
} else {
63+
t1 = $[1];
64+
t2 = $[2];
65+
}
66+
useEffect(t1, t2);
67+
let t3;
68+
if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
69+
t3 = <MockComponent onSet={setValue} />;
70+
$[3] = t3;
71+
} else {
72+
t3 = $[3];
73+
}
74+
return t3;
75+
}
76+
77+
export const FIXTURE_ENTRYPOINT = {
78+
fn: Component,
79+
params: [{ propValue: "test" }],
80+
};
81+
82+
```
83+
84+
### Eval output
85+
(kind: ok) <div>Mock Component</div>

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/effect-derived-computations/error.derived-state-from-prop-setter-call-outside-effect-no-error.expect.md

Lines changed: 0 additions & 47 deletions
This file was deleted.

0 commit comments

Comments
 (0)