Skip to content

Commit 2dc0a2a

Browse files
committed
[compiler] Allow setStates in use{Layout,Insertion}Effect where the set value is derived from a ref
@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 886b3d3 commit 2dc0a2a

File tree

5 files changed

+212
-0
lines changed

5 files changed

+212
-0
lines changed

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
isUseEffectHookType,
1818
isUseInsertionEffectHookType,
1919
isUseLayoutEffectHookType,
20+
isUseRefType,
21+
isRefValueType,
2022
Place,
2123
} from '../HIR';
2224
import {eachInstructionValueOperand} from '../HIR/visitors';
@@ -130,6 +132,7 @@ function getSetStateCall(
130132
fn: HIRFunction,
131133
setStateFunctions: Map<IdentifierId, Place>,
132134
): Place | null {
135+
const refDerivedValues: Set<IdentifierId> = new Set();
133136
for (const [, block] of fn.body.blocks) {
134137
for (const instr of block.instructions) {
135138
switch (instr.value.kind) {
@@ -140,6 +143,9 @@ function getSetStateCall(
140143
instr.value.place,
141144
);
142145
}
146+
if (refDerivedValues.has(instr.value.place.identifier.id)) {
147+
refDerivedValues.add(instr.lvalue.identifier.id);
148+
}
143149
break;
144150
}
145151
case 'StoreLocal': {
@@ -153,6 +159,37 @@ function getSetStateCall(
153159
instr.value.value,
154160
);
155161
}
162+
if (refDerivedValues.has(instr.value.value.identifier.id)) {
163+
refDerivedValues.add(instr.value.lvalue.place.identifier.id);
164+
refDerivedValues.add(instr.lvalue.identifier.id);
165+
}
166+
break;
167+
}
168+
case 'PropertyLoad': {
169+
if (
170+
instr.value.property === 'current' &&
171+
(isUseRefType(instr.value.object.identifier) ||
172+
isRefValueType(instr.value.object.identifier))
173+
) {
174+
refDerivedValues.add(instr.lvalue.identifier.id);
175+
} else if (refDerivedValues.has(instr.value.object.identifier.id)) {
176+
refDerivedValues.add(instr.lvalue.identifier.id);
177+
}
178+
break;
179+
}
180+
case 'Destructure': {
181+
if (refDerivedValues.has(instr.value.value.identifier.id)) {
182+
for (const place of eachInstructionValueOperand(instr.value)) {
183+
refDerivedValues.add(place.identifier.id);
184+
}
185+
refDerivedValues.add(instr.lvalue.identifier.id);
186+
}
187+
break;
188+
}
189+
case 'MethodCall': {
190+
if (refDerivedValues.has(instr.value.receiver.identifier.id)) {
191+
refDerivedValues.add(instr.lvalue.identifier.id);
192+
}
156193
break;
157194
}
158195
case 'CallExpression': {
@@ -161,6 +198,18 @@ function getSetStateCall(
161198
isSetStateType(callee.identifier) ||
162199
setStateFunctions.has(callee.identifier.id)
163200
) {
201+
const arg = instr.value.args.at(0);
202+
if (
203+
arg !== undefined &&
204+
arg.kind === 'Identifier' &&
205+
refDerivedValues.has(arg.identifier.id)
206+
) {
207+
/**
208+
* The one special case where we allow setStates in effects is in the very specific
209+
* scenario where the value being set is derived from a ref.
210+
*/
211+
return null;
212+
}
164213
/*
165214
* TODO: once we support multiple locations per error, we should link to the
166215
* 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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoSetStateInEffects
6+
import { useState, useRef, useLayoutEffect } from 'react';
7+
8+
function Tooltip() {
9+
const ref = useRef(null);
10+
const [tooltipHeight, setTooltipHeight] = useState(0);
11+
12+
useLayoutEffect(() => {
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+
## Code
27+
28+
```javascript
29+
import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects
30+
import { useState, useRef, useLayoutEffect } from "react";
31+
32+
function Tooltip() {
33+
const $ = _c(2);
34+
const ref = useRef(null);
35+
const [tooltipHeight, setTooltipHeight] = useState(0);
36+
let t0;
37+
let t1;
38+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
39+
t0 = () => {
40+
const { height } = ref.current.getBoundingClientRect();
41+
setTooltipHeight(height);
42+
};
43+
t1 = [];
44+
$[0] = t0;
45+
$[1] = t1;
46+
} else {
47+
t0 = $[0];
48+
t1 = $[1];
49+
}
50+
useLayoutEffect(t0, t1);
51+
return tooltipHeight;
52+
}
53+
54+
export const FIXTURE_ENTRYPOINT = {
55+
fn: Tooltip,
56+
params: [],
57+
};
58+
59+
```
60+
61+
### Eval output
62+
(kind: exception) Cannot read properties of null (reading 'getBoundingClientRect')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// @validateNoSetStateInEffects
2+
import { useState, useRef, useLayoutEffect } from 'react';
3+
4+
function Tooltip() {
5+
const ref = useRef(null);
6+
const [tooltipHeight, setTooltipHeight] = useState(0);
7+
8+
useLayoutEffect(() => {
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+
};

0 commit comments

Comments
 (0)