Skip to content

Commit 835b009

Browse files
authored
[compiler] Allow setStates in use{Layout,Insertion}Effect where the set value is derived from a ref (facebook#34462)
@stipsan found this issue where the compiler would bailout on the `useLayoutEffect` examples in the React docs. While setState in an effect is typically an anti-pattern due to the fact that it hurts performance through cascading renders, the one scenario where it _is_ allowed is if the value being set flows from a ref.
1 parent e2ba45b commit 835b009

13 files changed

+504
-2
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ function runWithEnvironment(
276276
}
277277

278278
if (env.config.validateNoSetStateInEffects) {
279-
env.logErrors(validateNoSetStateInEffects(hir));
279+
env.logErrors(validateNoSetStateInEffects(hir, env));
280280
}
281281

282282
if (env.config.validateNoJSXInTryStatements) {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,13 @@ export const EnvironmentConfigSchema = z.object({
660660
* while its parent function remains uncompiled.
661661
*/
662662
validateNoDynamicallyCreatedComponentsOrHooks: z.boolean().default(false),
663+
664+
/**
665+
* When enabled, allows setState calls in effects when the value being set is
666+
* derived from a ref. This is useful for patterns where initial layout measurements
667+
* from refs need to be stored in state during mount.
668+
*/
669+
enableAllowSetStateFromRefsInEffects: z.boolean().default(true),
663670
});
664671

665672
export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,23 @@ import {
1111
ErrorCategory,
1212
} from '../CompilerError';
1313
import {
14+
Environment,
1415
HIRFunction,
1516
IdentifierId,
1617
isSetStateType,
1718
isUseEffectHookType,
1819
isUseInsertionEffectHookType,
1920
isUseLayoutEffectHookType,
21+
isUseRefType,
22+
isRefValueType,
2023
Place,
2124
} from '../HIR';
22-
import {eachInstructionValueOperand} from '../HIR/visitors';
25+
import {
26+
eachInstructionLValue,
27+
eachInstructionValueOperand,
28+
} from '../HIR/visitors';
2329
import {Result} from '../Utils/Result';
30+
import {Iterable_some} from '../Utils/utils';
2431

2532
/**
2633
* Validates against calling setState in the body of an effect (useEffect and friends),
@@ -32,6 +39,7 @@ import {Result} from '../Utils/Result';
3239
*/
3340
export function validateNoSetStateInEffects(
3441
fn: HIRFunction,
42+
env: Environment,
3543
): Result<void, CompilerError> {
3644
const setStateFunctions: Map<IdentifierId, Place> = new Map();
3745
const errors = new CompilerError();
@@ -72,6 +80,7 @@ export function validateNoSetStateInEffects(
7280
const callee = getSetStateCall(
7381
instr.value.loweredFunc.func,
7482
setStateFunctions,
83+
env,
7584
);
7685
if (callee !== null) {
7786
setStateFunctions.set(instr.lvalue.identifier.id, callee);
@@ -129,9 +138,42 @@ export function validateNoSetStateInEffects(
129138
function getSetStateCall(
130139
fn: HIRFunction,
131140
setStateFunctions: Map<IdentifierId, Place>,
141+
env: Environment,
132142
): Place | null {
143+
const refDerivedValues: Set<IdentifierId> = new Set();
144+
145+
const isDerivedFromRef = (place: Place): boolean => {
146+
return (
147+
refDerivedValues.has(place.identifier.id) ||
148+
isUseRefType(place.identifier) ||
149+
isRefValueType(place.identifier)
150+
);
151+
};
152+
133153
for (const [, block] of fn.body.blocks) {
134154
for (const instr of block.instructions) {
155+
if (env.config.enableAllowSetStateFromRefsInEffects) {
156+
const hasRefOperand = Iterable_some(
157+
eachInstructionValueOperand(instr.value),
158+
isDerivedFromRef,
159+
);
160+
161+
if (hasRefOperand) {
162+
for (const lvalue of eachInstructionLValue(instr)) {
163+
refDerivedValues.add(lvalue.identifier.id);
164+
}
165+
}
166+
167+
if (
168+
instr.value.kind === 'PropertyLoad' &&
169+
instr.value.property === 'current' &&
170+
(isUseRefType(instr.value.object.identifier) ||
171+
isRefValueType(instr.value.object.identifier))
172+
) {
173+
refDerivedValues.add(instr.lvalue.identifier.id);
174+
}
175+
}
176+
135177
switch (instr.value.kind) {
136178
case 'LoadLocal': {
137179
if (setStateFunctions.has(instr.value.place.identifier.id)) {
@@ -161,6 +203,21 @@ function getSetStateCall(
161203
isSetStateType(callee.identifier) ||
162204
setStateFunctions.has(callee.identifier.id)
163205
) {
206+
if (env.config.enableAllowSetStateFromRefsInEffects) {
207+
const arg = instr.value.args.at(0);
208+
if (
209+
arg !== undefined &&
210+
arg.kind === 'Identifier' &&
211+
refDerivedValues.has(arg.identifier.id)
212+
) {
213+
/**
214+
* The one special case where we allow setStates in effects is in the very specific
215+
* scenario where the value being set is derived from a ref. For example this may
216+
* be needed when initial layout measurements from refs need to be stored in state.
217+
*/
218+
return null;
219+
}
220+
}
164221
/*
165222
* TODO: once we support multiple locations per error, we should link to the
166223
* original Place in the case that setStateFunction.has(callee)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoSetStateInEffects
6+
import {useState, useRef, useEffect} from 'react';
7+
8+
function Tooltip() {
9+
const ref = useRef(null);
10+
const [tooltipHeight, setTooltipHeight] = useState(0);
11+
12+
useEffect(() => {
13+
const {height} = ref.current.getBoundingClientRect();
14+
setTooltipHeight(height);
15+
}, []);
16+
17+
return tooltipHeight;
18+
}
19+
20+
export const FIXTURE_ENTRYPOINT = {
21+
fn: Tooltip,
22+
params: [],
23+
};
24+
25+
```
26+
27+
## Code
28+
29+
```javascript
30+
import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects
31+
import { useState, useRef, useEffect } from "react";
32+
33+
function Tooltip() {
34+
const $ = _c(2);
35+
const ref = useRef(null);
36+
const [tooltipHeight, setTooltipHeight] = useState(0);
37+
let t0;
38+
let t1;
39+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
40+
t0 = () => {
41+
const { height } = ref.current.getBoundingClientRect();
42+
setTooltipHeight(height);
43+
};
44+
t1 = [];
45+
$[0] = t0;
46+
$[1] = t1;
47+
} else {
48+
t0 = $[0];
49+
t1 = $[1];
50+
}
51+
useEffect(t0, t1);
52+
return tooltipHeight;
53+
}
54+
55+
export const FIXTURE_ENTRYPOINT = {
56+
fn: Tooltip,
57+
params: [],
58+
};
59+
60+
```
61+
62+
### Eval output
63+
(kind: exception) Cannot read properties of null (reading 'getBoundingClientRect')
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// @validateNoSetStateInEffects
2+
import {useState, useRef, useEffect} from 'react';
3+
4+
function Tooltip() {
5+
const ref = useRef(null);
6+
const [tooltipHeight, setTooltipHeight] = useState(0);
7+
8+
useEffect(() => {
9+
const {height} = ref.current.getBoundingClientRect();
10+
setTooltipHeight(height);
11+
}, []);
12+
13+
return tooltipHeight;
14+
}
15+
16+
export const FIXTURE_ENTRYPOINT = {
17+
fn: Tooltip,
18+
params: [],
19+
};
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
6+
import {useState, useRef, useLayoutEffect} from 'react';
7+
8+
function Component() {
9+
const ref = useRef({size: 5});
10+
const [computedSize, setComputedSize] = useState(0);
11+
12+
useLayoutEffect(() => {
13+
setComputedSize(ref.current.size * 10);
14+
}, []);
15+
16+
return computedSize;
17+
}
18+
19+
export const FIXTURE_ENTRYPOINT = {
20+
fn: Component,
21+
params: [],
22+
};
23+
24+
```
25+
26+
## Code
27+
28+
```javascript
29+
import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
30+
import { useState, useRef, useLayoutEffect } from "react";
31+
32+
function Component() {
33+
const $ = _c(3);
34+
let t0;
35+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
36+
t0 = { size: 5 };
37+
$[0] = t0;
38+
} else {
39+
t0 = $[0];
40+
}
41+
const ref = useRef(t0);
42+
const [computedSize, setComputedSize] = useState(0);
43+
let t1;
44+
let t2;
45+
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
46+
t1 = () => {
47+
setComputedSize(ref.current.size * 10);
48+
};
49+
t2 = [];
50+
$[1] = t1;
51+
$[2] = t2;
52+
} else {
53+
t1 = $[1];
54+
t2 = $[2];
55+
}
56+
useLayoutEffect(t1, t2);
57+
return computedSize;
58+
}
59+
60+
export const FIXTURE_ENTRYPOINT = {
61+
fn: Component,
62+
params: [],
63+
};
64+
65+
```
66+
67+
### Eval output
68+
(kind: ok) 50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
2+
import {useState, useRef, useLayoutEffect} from 'react';
3+
4+
function Component() {
5+
const ref = useRef({size: 5});
6+
const [computedSize, setComputedSize] = useState(0);
7+
8+
useLayoutEffect(() => {
9+
setComputedSize(ref.current.size * 10);
10+
}, []);
11+
12+
return computedSize;
13+
}
14+
15+
export const FIXTURE_ENTRYPOINT = {
16+
fn: Component,
17+
params: [],
18+
};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
6+
import {useState, useRef, useEffect} from 'react';
7+
8+
function Component() {
9+
const ref = useRef([1, 2, 3, 4, 5]);
10+
const [value, setValue] = useState(0);
11+
12+
useEffect(() => {
13+
const index = 2;
14+
setValue(ref.current[index]);
15+
}, []);
16+
17+
return value;
18+
}
19+
20+
export const FIXTURE_ENTRYPOINT = {
21+
fn: Component,
22+
params: [],
23+
};
24+
25+
```
26+
27+
## Code
28+
29+
```javascript
30+
import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
31+
import { useState, useRef, useEffect } from "react";
32+
33+
function Component() {
34+
const $ = _c(3);
35+
let t0;
36+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
37+
t0 = [1, 2, 3, 4, 5];
38+
$[0] = t0;
39+
} else {
40+
t0 = $[0];
41+
}
42+
const ref = useRef(t0);
43+
const [value, setValue] = useState(0);
44+
let t1;
45+
let t2;
46+
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
47+
t1 = () => {
48+
setValue(ref.current[2]);
49+
};
50+
t2 = [];
51+
$[1] = t1;
52+
$[2] = t2;
53+
} else {
54+
t1 = $[1];
55+
t2 = $[2];
56+
}
57+
useEffect(t1, t2);
58+
return value;
59+
}
60+
61+
export const FIXTURE_ENTRYPOINT = {
62+
fn: Component,
63+
params: [],
64+
};
65+
66+
```
67+
68+
### Eval output
69+
(kind: ok) 3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
2+
import {useState, useRef, useEffect} from 'react';
3+
4+
function Component() {
5+
const ref = useRef([1, 2, 3, 4, 5]);
6+
const [value, setValue] = useState(0);
7+
8+
useEffect(() => {
9+
const index = 2;
10+
setValue(ref.current[index]);
11+
}, []);
12+
13+
return value;
14+
}
15+
16+
export const FIXTURE_ENTRYPOINT = {
17+
fn: Component,
18+
params: [],
19+
};

0 commit comments

Comments
 (0)