Skip to content

Commit ac2c9b5

Browse files
committed
[compiler] Validate against deriving data in an effect instead of render
Validates against cases described in https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state ghstack-source-id: 3f34ddf Pull Request resolved: #32285
1 parent efd5017 commit ac2c9b5

File tree

4 files changed

+260
-1
lines changed

4 files changed

+260
-1
lines changed

compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ import {
9191
} from '../Validation';
9292
import {validateLocalsNotReassignedAfterRender} from '../Validation/ValidateLocalsNotReassignedAfterRender';
9393
import {outlineFunctions} from '../Optimization/OutlineFunctions';
94-
import {propagatePhiTypes} from '../TypeInference/PropagatePhiTypes';
9594
import {lowerContextAccess} from '../Optimization/LowerContextAccess';
9695
import {validateNoSetStateInPassiveEffects} from '../Validation/ValidateNoSetStateInPassiveEffects';
9796
import {validateNoJSXInTryStatement} from '../Validation/ValidateNoJSXInTryStatement';
@@ -100,6 +99,7 @@ import {outlineJSX} from '../Optimization/OutlineJsx';
10099
import {optimizePropsMethodCalls} from '../Optimization/OptimizePropsMethodCalls';
101100
import {transformFire} from '../Transform';
102101
import {validateNoImpureFunctionsInRender} from '../Validation/ValiateNoImpureFunctionsInRender';
102+
import {validateNoDerivedComputationsInEffects} from '../Validation/ValidateNoDerivedComputationsInEffects';
103103

104104
export type CompilerPipelineValue =
105105
| {kind: 'ast'; name: string; value: CodegenFunction}
@@ -251,6 +251,7 @@ function runWithEnvironment(
251251
validateNoSetStateInRender(hir);
252252
}
253253

254+
validateNoDerivedComputationsInEffects(hir);
254255
if (env.config.validateNoSetStateInPassiveEffects) {
255256
validateNoSetStateInPassiveEffects(hir);
256257
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {CompilerError, ErrorSeverity, SourceLocation} from '..';
9+
import {
10+
ArrayExpression,
11+
BlockId,
12+
FunctionExpression,
13+
HIRFunction,
14+
IdentifierId,
15+
isSetStateType,
16+
isUseEffectHookType,
17+
} from '../HIR';
18+
import {
19+
eachInstructionValueOperand,
20+
eachTerminalOperand,
21+
} from '../HIR/visitors';
22+
23+
/**
24+
* Validates that useEffect is not used for derived computations which could/should
25+
* be performed in render.
26+
*
27+
* See https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state
28+
*
29+
* Example:
30+
*
31+
* ```
32+
* // 🔴 Avoid: redundant state and unnecessary Effect
33+
* const [fullName, setFullName] = useState('');
34+
* useEffect(() => {
35+
* setFullName(firstName + ' ' + lastName);
36+
* }, [firstName, lastName]);
37+
* ```
38+
*
39+
* Instead use:
40+
*
41+
* ```
42+
* // ✅ Good: calculated during rendering
43+
* const fullName = firstName + ' ' + lastName;
44+
* ```
45+
*/
46+
export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
47+
const candidateDependencies: Map<IdentifierId, ArrayExpression> = new Map();
48+
const functions: Map<IdentifierId, FunctionExpression> = new Map();
49+
const locals: Map<IdentifierId, IdentifierId> = new Map();
50+
51+
const errors = new CompilerError();
52+
53+
for (const block of fn.body.blocks.values()) {
54+
for (const instr of block.instructions) {
55+
const {lvalue, value} = instr;
56+
if (value.kind === 'LoadLocal') {
57+
locals.set(lvalue.identifier.id, value.place.identifier.id);
58+
} else if (value.kind === 'ArrayExpression') {
59+
candidateDependencies.set(lvalue.identifier.id, value);
60+
} else if (value.kind === 'FunctionExpression') {
61+
functions.set(lvalue.identifier.id, value);
62+
} else if (
63+
value.kind === 'CallExpression' ||
64+
value.kind === 'MethodCall'
65+
) {
66+
const callee =
67+
value.kind === 'CallExpression' ? value.callee : value.property;
68+
if (
69+
isUseEffectHookType(callee.identifier) &&
70+
value.args.length === 2 &&
71+
value.args[0].kind === 'Identifier' &&
72+
value.args[1].kind === 'Identifier'
73+
) {
74+
const effectFunction = functions.get(value.args[0].identifier.id);
75+
const deps = candidateDependencies.get(value.args[1].identifier.id);
76+
if (
77+
effectFunction != null &&
78+
deps != null &&
79+
deps.elements.length !== 0 &&
80+
deps.elements.every(element => element.kind === 'Identifier')
81+
) {
82+
const dependencies: Array<IdentifierId> = deps.elements.map(dep => {
83+
CompilerError.invariant(dep.kind === 'Identifier', {
84+
reason: `Dependency is checked as a place above`,
85+
loc: value.loc,
86+
});
87+
return locals.get(dep.identifier.id) ?? dep.identifier.id;
88+
});
89+
validateEffect(
90+
effectFunction.loweredFunc.func,
91+
dependencies,
92+
errors,
93+
);
94+
}
95+
}
96+
}
97+
}
98+
}
99+
if (errors.hasErrors()) {
100+
throw errors;
101+
}
102+
}
103+
104+
function validateEffect(
105+
effectFunction: HIRFunction,
106+
effectDeps: Array<IdentifierId>,
107+
errors: CompilerError,
108+
): void {
109+
for (const operand of effectFunction.context) {
110+
if (isSetStateType(operand.identifier)) {
111+
continue;
112+
} else if (effectDeps.find(dep => dep === operand.identifier.id) != null) {
113+
continue;
114+
} else {
115+
// Captured something other than the effect dep or setState
116+
return;
117+
}
118+
}
119+
for (const dep of effectDeps) {
120+
if (
121+
effectFunction.context.find(operand => operand.identifier.id === dep) ==
122+
null
123+
) {
124+
// effect dep wasn't actually used in the function
125+
return;
126+
}
127+
}
128+
129+
const seenBlocks: Set<BlockId> = new Set();
130+
const values: Map<IdentifierId, Array<IdentifierId>> = new Map();
131+
for (const dep of effectDeps) {
132+
values.set(dep, [dep]);
133+
}
134+
135+
const setStateLocations: Array<SourceLocation> = [];
136+
for (const block of effectFunction.body.blocks.values()) {
137+
for (const pred of block.preds) {
138+
if (!seenBlocks.has(pred)) {
139+
// skip if block has a back edge
140+
return;
141+
}
142+
}
143+
for (const instr of block.instructions) {
144+
switch (instr.value.kind) {
145+
case 'LoadLocal': {
146+
const deps = values.get(instr.value.place.identifier.id);
147+
if (deps != null) {
148+
values.set(instr.lvalue.identifier.id, deps);
149+
}
150+
break;
151+
}
152+
case 'Primitive':
153+
case 'JSXText':
154+
case 'LoadGlobal':
155+
case 'BinaryExpression':
156+
case 'TemplateLiteral': {
157+
const aggregateDeps: Set<IdentifierId> = new Set();
158+
for (const operand of eachInstructionValueOperand(instr.value)) {
159+
const deps = values.get(operand.identifier.id);
160+
if (deps != null) {
161+
for (const dep of deps) {
162+
aggregateDeps.add(dep);
163+
}
164+
}
165+
}
166+
if (aggregateDeps.size !== 0) {
167+
values.set(instr.lvalue.identifier.id, Array.from(aggregateDeps));
168+
}
169+
break;
170+
}
171+
case 'CallExpression': {
172+
if (
173+
isSetStateType(instr.value.callee.identifier) &&
174+
instr.value.args.length === 1 &&
175+
instr.value.args[0].kind === 'Identifier'
176+
) {
177+
const deps = values.get(instr.value.args[0].identifier.id);
178+
if (deps != null && new Set(deps).size === effectDeps.length) {
179+
setStateLocations.push(instr.value.callee.loc);
180+
} else {
181+
// doesn't depend on any deps
182+
return;
183+
}
184+
} else {
185+
// some other call
186+
return;
187+
}
188+
break;
189+
}
190+
default: {
191+
return;
192+
}
193+
}
194+
}
195+
for (const operand of eachTerminalOperand(block.terminal)) {
196+
if (values.has(operand.identifier.id)) {
197+
return;
198+
}
199+
}
200+
seenBlocks.add(block.id);
201+
}
202+
203+
for (const loc of setStateLocations) {
204+
errors.push({
205+
reason:
206+
'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)',
207+
description: null,
208+
severity: ErrorSeverity.InvalidReact,
209+
loc,
210+
suggestions: null,
211+
});
212+
}
213+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
2+
## Input
3+
4+
```javascript
5+
function Form() {
6+
const [firstName, setFirstName] = useState('Taylor');
7+
const [lastName, setLastName] = useState('Swift');
8+
9+
// 🔴 Avoid: redundant state and unnecessary Effect
10+
const [fullName, setFullName] = useState('');
11+
useEffect(() => {
12+
setFullName(firstName + ' ' + lastName);
13+
}, [firstName, lastName]);
14+
15+
return <div>{fullName}</div>;
16+
}
17+
18+
```
19+
20+
21+
## Error
22+
23+
```
24+
6 | const [fullName, setFullName] = useState('');
25+
7 | useEffect(() => {
26+
> 8 | setFullName(firstName + ' ' + lastName);
27+
| ^^^^^^^^^^^ InvalidReact: 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) (8:8)
28+
9 | }, [firstName, lastName]);
29+
10 |
30+
11 | return <div>{fullName}</div>;
31+
```
32+
33+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
function Form() {
2+
const [firstName, setFirstName] = useState('Taylor');
3+
const [lastName, setLastName] = useState('Swift');
4+
5+
// 🔴 Avoid: redundant state and unnecessary Effect
6+
const [fullName, setFullName] = useState('');
7+
useEffect(() => {
8+
setFullName(firstName + ' ' + lastName);
9+
}, [firstName, lastName]);
10+
11+
return <div>{fullName}</div>;
12+
}

0 commit comments

Comments
 (0)