Skip to content

Commit b946a24

Browse files
authored
[compiler] Improve setState-in-effects rule to account for ref-gated conditionals (#35147)
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. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35147). * #35148 * __->__ #35147
1 parent d6b1a05 commit b946a24

File tree

4 files changed

+245
-6
lines changed

4 files changed

+245
-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: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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 data;
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+
sequentialRenders: [
40+
{x: 0, y: 0},
41+
{x: 1, y: 0},
42+
{x: 1, y: 1},
43+
],
44+
};
45+
46+
```
47+
48+
## Code
49+
50+
```javascript
51+
import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer"
52+
import { useState, useRef, useEffect } from "react";
53+
54+
function Component(t0) {
55+
const $ = _c(4);
56+
const { x, y } = t0;
57+
const previousXRef = useRef(null);
58+
const previousYRef = useRef(null);
59+
60+
const [data, setData] = useState(null);
61+
let t1;
62+
let t2;
63+
if ($[0] !== x || $[1] !== y) {
64+
t1 = () => {
65+
const previousX = previousXRef.current;
66+
previousXRef.current = x;
67+
const previousY = previousYRef.current;
68+
previousYRef.current = y;
69+
if (!areEqual(x, previousX) || !areEqual(y, previousY)) {
70+
const data_0 = load({ x, y });
71+
setData(data_0);
72+
}
73+
};
74+
75+
t2 = [x, y];
76+
$[0] = x;
77+
$[1] = y;
78+
$[2] = t1;
79+
$[3] = t2;
80+
} else {
81+
t1 = $[2];
82+
t2 = $[3];
83+
}
84+
useEffect(t1, t2);
85+
return data;
86+
}
87+
88+
function areEqual(a, b) {
89+
return a === b;
90+
}
91+
92+
function load({ x, y }) {
93+
return x * y;
94+
}
95+
96+
export const FIXTURE_ENTRYPOINT = {
97+
fn: Component,
98+
params: [{ x: 0, y: 0 }],
99+
sequentialRenders: [
100+
{ x: 0, y: 0 },
101+
{ x: 1, y: 0 },
102+
{ x: 1, y: 1 },
103+
],
104+
};
105+
106+
```
107+
108+
## Logs
109+
110+
```
111+
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":163},"end":{"line":22,"column":1,"index":631},"filename":"valid-setState-in-useEffect-controlled-by-ref-value.ts"},"fnName":"Component","memoSlots":4,"memoBlocks":1,"memoValues":2,"prunedMemoBlocks":0,"prunedMemoValues":0}
112+
```
113+
114+
### Eval output
115+
(kind: ok) 0
116+
0
117+
1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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 data;
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+
sequentialRenders: [
36+
{x: 0, y: 0},
37+
{x: 1, y: 0},
38+
{x: 1, y: 1},
39+
],
40+
};

0 commit comments

Comments
 (0)