Skip to content

Commit a16bcf9

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 437cae2 commit a16bcf9

File tree

4 files changed

+228
-6
lines changed

4 files changed

+228
-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: 80 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,38 @@ function getSetStateCall(
150156
);
151157
};
152158

159+
const isRefControlledBlock: (id: BlockId) => boolean =
160+
enableAllowSetStateFromRefsInEffects
161+
? createControlDominators(fn, place => isDerivedFromRef(place))
162+
: (): boolean => 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+
continue;
169+
}
170+
let isPhiDerivedFromRef = false;
171+
for (const [, operand] of phi.operands) {
172+
if (isDerivedFromRef(operand)) {
173+
isPhiDerivedFromRef = true;
174+
break;
175+
}
176+
}
177+
if (isPhiDerivedFromRef) {
178+
refDerivedValues.add(phi.place.identifier.id);
179+
} else {
180+
for (const [pred] of phi.operands) {
181+
if (isRefControlledBlock(pred)) {
182+
refDerivedValues.add(phi.place.identifier.id);
183+
break;
184+
}
185+
}
186+
}
187+
}
188+
}
154189
for (const instr of block.instructions) {
155-
if (env.config.enableAllowSetStateFromRefsInEffects) {
190+
if (enableAllowSetStateFromRefsInEffects) {
156191
const hasRefOperand = Iterable_some(
157192
eachInstructionValueOperand(instr.value),
158193
isDerivedFromRef,
@@ -162,6 +197,46 @@ function getSetStateCall(
162197
for (const lvalue of eachInstructionLValue(instr)) {
163198
refDerivedValues.add(lvalue.identifier.id);
164199
}
200+
// Ref-derived values can also propagate through mutation
201+
for (const operand of eachInstructionValueOperand(instr.value)) {
202+
switch (operand.effect) {
203+
case Effect.Capture:
204+
case Effect.Store:
205+
case Effect.ConditionallyMutate:
206+
case Effect.ConditionallyMutateIterator:
207+
case Effect.Mutate: {
208+
if (isMutable(instr, operand)) {
209+
refDerivedValues.add(operand.identifier.id);
210+
}
211+
break;
212+
}
213+
case Effect.Freeze:
214+
case Effect.Read: {
215+
// no-op
216+
break;
217+
}
218+
case Effect.Unknown: {
219+
CompilerError.invariant(false, {
220+
reason: 'Unexpected unknown effect',
221+
description: null,
222+
details: [
223+
{
224+
kind: 'error',
225+
loc: operand.loc,
226+
message: null,
227+
},
228+
],
229+
suggestions: null,
230+
});
231+
}
232+
default: {
233+
assertExhaustive(
234+
operand.effect,
235+
`Unexpected effect kind \`${operand.effect}\``,
236+
);
237+
}
238+
}
239+
}
165240
}
166241

167242
if (
@@ -203,7 +278,7 @@ function getSetStateCall(
203278
isSetStateType(callee.identifier) ||
204279
setStateFunctions.has(callee.identifier.id)
205280
) {
206-
if (env.config.enableAllowSetStateFromRefsInEffects) {
281+
if (enableAllowSetStateFromRefsInEffects) {
207282
const arg = instr.value.args.at(0);
208283
if (
209284
arg !== undefined &&
@@ -216,6 +291,8 @@ function getSetStateCall(
216291
* be needed when initial layout measurements from refs need to be stored in state.
217292
*/
218293
return null;
294+
} else if (isRefControlledBlock(block.id)) {
295+
continue;
219296
}
220297
}
221298
/*
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)