Skip to content

Commit 189efa4

Browse files
committed
[compiler] Improve setState-in-effects rule to account for ref-gated conditionals
Conditionally calling setState in an effect is sometimes necessary, but should generally follow the pattern of using a "previous vaue" ref to manually compare and ensure that the setState is idempotent. See fixture for an example.
1 parent 92cac2d commit 189efa4

File tree

4 files changed

+229
-6
lines changed

4 files changed

+229
-6
lines changed

compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -672,9 +672,14 @@ export const EnvironmentConfigSchema = z.object({
672672
validateNoDynamicallyCreatedComponentsOrHooks: z.boolean().default(false),
673673

674674
/**
675-
* When enabled, allows setState calls in effects when the value being set is
676-
* derived from a ref. This is useful for patterns where initial layout measurements
677-
* from refs need to be stored in state during mount.
675+
* When enabled, allows setState calls in effects based on valid patterns involving refs:
676+
* - Allow setState where the value being set is derived from a ref. This is useful where
677+
* state needs to take into account layer information, and a layout effect reads layout
678+
* data from a ref and sets state.
679+
* - Allow conditionally calling setState after manually comparing previous/new values
680+
* for changes via a ref. Relying on effect deps is insufficient for non-primitive values,
681+
* so a ref is generally required to manually track previous values and compare prev/next
682+
* for meaningful changes before setting state.
678683
*/
679684
enableAllowSetStateFromRefsInEffects: z.boolean().default(true),
680685

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

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@ import {
2121
isUseRefType,
2222
isRefValueType,
2323
Place,
24+
Effect,
25+
BlockId,
2426
} from '../HIR';
2527
import {
2628
eachInstructionLValue,
2729
eachInstructionValueOperand,
2830
} from '../HIR/visitors';
31+
import {createControlDominators} from '../Inference/ControlDominators';
32+
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
2933
import {Result} from '../Utils/Result';
30-
import {Iterable_some} from '../Utils/utils';
34+
import {assertExhaustive, Iterable_some} from '../Utils/utils';
3135

3236
/**
3337
* Validates against calling setState in the body of an effect (useEffect and friends),
@@ -140,6 +144,8 @@ function getSetStateCall(
140144
setStateFunctions: Map<IdentifierId, Place>,
141145
env: Environment,
142146
): Place | null {
147+
const enableAllowSetStateFromRefsInEffects =
148+
env.config.enableAllowSetStateFromRefsInEffects;
143149
const refDerivedValues: Set<IdentifierId> = new Set();
144150

145151
const isDerivedFromRef = (place: Place): boolean => {
@@ -150,9 +156,39 @@ function getSetStateCall(
150156
);
151157
};
152158

159+
const isReactiveControlledBlock: (id: BlockId) => boolean =
160+
enableAllowSetStateFromRefsInEffects
161+
? createControlDominators(fn, place => isDerivedFromRef(place))
162+
: () => false;
163+
153164
for (const [, block] of fn.body.blocks) {
165+
if (enableAllowSetStateFromRefsInEffects) {
166+
for (const phi of block.phis) {
167+
if (isDerivedFromRef(phi.place)) {
168+
// Already marked reactive on a previous pass
169+
continue;
170+
}
171+
let isPhiReactive = false;
172+
for (const [, operand] of phi.operands) {
173+
if (isDerivedFromRef(operand)) {
174+
isPhiReactive = true;
175+
break;
176+
}
177+
}
178+
if (isPhiReactive) {
179+
refDerivedValues.add(phi.place.identifier.id);
180+
} else {
181+
for (const [pred] of phi.operands) {
182+
if (isReactiveControlledBlock(pred)) {
183+
refDerivedValues.add(phi.place.identifier.id);
184+
break;
185+
}
186+
}
187+
}
188+
}
189+
}
154190
for (const instr of block.instructions) {
155-
if (env.config.enableAllowSetStateFromRefsInEffects) {
191+
if (enableAllowSetStateFromRefsInEffects) {
156192
const hasRefOperand = Iterable_some(
157193
eachInstructionValueOperand(instr.value),
158194
isDerivedFromRef,
@@ -162,6 +198,46 @@ function getSetStateCall(
162198
for (const lvalue of eachInstructionLValue(instr)) {
163199
refDerivedValues.add(lvalue.identifier.id);
164200
}
201+
// Ref-derived values can also propagate through mutation
202+
for (const operand of eachInstructionValueOperand(instr.value)) {
203+
switch (operand.effect) {
204+
case Effect.Capture:
205+
case Effect.Store:
206+
case Effect.ConditionallyMutate:
207+
case Effect.ConditionallyMutateIterator:
208+
case Effect.Mutate: {
209+
if (isMutable(instr, operand)) {
210+
refDerivedValues.add(operand.identifier.id);
211+
}
212+
break;
213+
}
214+
case Effect.Freeze:
215+
case Effect.Read: {
216+
// no-op
217+
break;
218+
}
219+
case Effect.Unknown: {
220+
CompilerError.invariant(false, {
221+
reason: 'Unexpected unknown effect',
222+
description: null,
223+
details: [
224+
{
225+
kind: 'error',
226+
loc: operand.loc,
227+
message: null,
228+
},
229+
],
230+
suggestions: null,
231+
});
232+
}
233+
default: {
234+
assertExhaustive(
235+
operand.effect,
236+
`Unexpected effect kind \`${operand.effect}\``,
237+
);
238+
}
239+
}
240+
}
165241
}
166242

167243
if (
@@ -203,7 +279,7 @@ function getSetStateCall(
203279
isSetStateType(callee.identifier) ||
204280
setStateFunctions.has(callee.identifier.id)
205281
) {
206-
if (env.config.enableAllowSetStateFromRefsInEffects) {
282+
if (enableAllowSetStateFromRefsInEffects) {
207283
const arg = instr.value.args.at(0);
208284
if (
209285
arg !== undefined &&
@@ -216,6 +292,8 @@ function getSetStateCall(
216292
* be needed when initial layout measurements from refs need to be stored in state.
217293
*/
218294
return null;
295+
} else if (isReactiveControlledBlock(block.id)) {
296+
continue;
219297
}
220298
}
221299
/*
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer"
6+
import {useState, useRef, useEffect} from 'react';
7+
8+
function Component({x, y}) {
9+
const previousXRef = useRef(null);
10+
const previousYRef = useRef(null);
11+
12+
const [data, setData] = useState(null);
13+
14+
useEffect(() => {
15+
const previousX = previousXRef.current;
16+
previousXRef.current = x;
17+
const previousY = previousYRef.current;
18+
previousYRef.current = y;
19+
if (!areEqual(x, previousX) || !areEqual(y, previousY)) {
20+
const data = load({x, y});
21+
setData(data);
22+
}
23+
}, [x, y]);
24+
25+
return tooltipHeight;
26+
}
27+
28+
function areEqual(a, b) {
29+
return a === b;
30+
}
31+
32+
function load({x, y}) {
33+
return x * y;
34+
}
35+
36+
export const FIXTURE_ENTRYPOINT = {
37+
fn: Component,
38+
params: [{x: 0, y: 0}],
39+
};
40+
41+
```
42+
43+
## Code
44+
45+
```javascript
46+
import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer"
47+
import { useState, useRef, useEffect } from "react";
48+
49+
function Component(t0) {
50+
const $ = _c(4);
51+
const { x, y } = t0;
52+
const previousXRef = useRef(null);
53+
const previousYRef = useRef(null);
54+
55+
const [, setData] = useState(null);
56+
let t1;
57+
let t2;
58+
if ($[0] !== x || $[1] !== y) {
59+
t1 = () => {
60+
const previousX = previousXRef.current;
61+
previousXRef.current = x;
62+
const previousY = previousYRef.current;
63+
previousYRef.current = y;
64+
if (!areEqual(x, previousX) || !areEqual(y, previousY)) {
65+
const data_0 = load({ x, y });
66+
setData(data_0);
67+
}
68+
};
69+
70+
t2 = [x, y];
71+
$[0] = x;
72+
$[1] = y;
73+
$[2] = t1;
74+
$[3] = t2;
75+
} else {
76+
t1 = $[2];
77+
t2 = $[3];
78+
}
79+
useEffect(t1, t2);
80+
return tooltipHeight;
81+
}
82+
83+
function areEqual(a, b) {
84+
return a === b;
85+
}
86+
87+
function load({ x, y }) {
88+
return x * y;
89+
}
90+
91+
export const FIXTURE_ENTRYPOINT = {
92+
fn: Component,
93+
params: [{ x: 0, y: 0 }],
94+
};
95+
96+
```
97+
98+
## Logs
99+
100+
```
101+
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":163},"end":{"line":22,"column":1,"index":640},"filename":"valid-setState-in-useEffect-controlled-by-ref-value.ts"},"fnName":"Component","memoSlots":4,"memoBlocks":1,"memoValues":2,"prunedMemoBlocks":0,"prunedMemoValues":0}
102+
```
103+
104+
### Eval output
105+
(kind: exception) tooltipHeight is not defined
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer"
2+
import {useState, useRef, useEffect} from 'react';
3+
4+
function Component({x, y}) {
5+
const previousXRef = useRef(null);
6+
const previousYRef = useRef(null);
7+
8+
const [data, setData] = useState(null);
9+
10+
useEffect(() => {
11+
const previousX = previousXRef.current;
12+
previousXRef.current = x;
13+
const previousY = previousYRef.current;
14+
previousYRef.current = y;
15+
if (!areEqual(x, previousX) || !areEqual(y, previousY)) {
16+
const data = load({x, y});
17+
setData(data);
18+
}
19+
}, [x, y]);
20+
21+
return tooltipHeight;
22+
}
23+
24+
function areEqual(a, b) {
25+
return a === b;
26+
}
27+
28+
function load({x, y}) {
29+
return x * y;
30+
}
31+
32+
export const FIXTURE_ENTRYPOINT = {
33+
fn: Component,
34+
params: [{x: 0, y: 0}],
35+
};

0 commit comments

Comments
 (0)