Skip to content

Commit 63599d6

Browse files
committed
[compiler] Add enableUseKeyedState flag and update setState validation
This commit adds a new compiler environment flag `enableUseKeyedState`, and updates ValidateNoSetStateInRender to no longer throw on setState calls with primitive values. When `enableUseKeyedState` is enabled, calling setState with a primitive instead recommends using `useKeyedState` instead.
1 parent 74fa166 commit 63599d6

File tree

37 files changed

+751
-418
lines changed

37 files changed

+751
-418
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,12 @@ export const EnvironmentConfigSchema = z.object({
318318
*/
319319
validateNoSetStateInRender: z.boolean().default(true),
320320

321+
/**
322+
* When enabled, changes the behavior of validateNoSetStateInRender to recommend
323+
* using useKeyedState instead of calling setState directly in render.
324+
*/
325+
enableUseKeyedState: z.boolean().default(false),
326+
321327
/**
322328
* Validates that setState is not called synchronously within an effect (useEffect and friends).
323329
* Scheduling a setState (with an event listener, subscription, etc) is valid.

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

Lines changed: 149 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,116 @@ import {
1010
CompilerError,
1111
ErrorCategory,
1212
} from '../CompilerError';
13-
import {HIRFunction, IdentifierId, isSetStateType} from '../HIR';
13+
import {
14+
HIRFunction,
15+
Identifier,
16+
IdentifierId,
17+
Instruction,
18+
InstructionValue,
19+
isPrimitiveType,
20+
isSetStateType,
21+
Phi,
22+
Place,
23+
SpreadPattern,
24+
} from '../HIR';
1425
import {computeUnconditionalBlocks} from '../HIR/ComputeUnconditionalBlocks';
1526
import {eachInstructionValueOperand} from '../HIR/visitors';
1627
import {Result} from '../Utils/Result';
1728

29+
function isPrimitiveSetArg(
30+
arg: Place | SpreadPattern,
31+
fn: HIRFunction,
32+
): boolean {
33+
if (arg.kind !== 'Identifier') {
34+
return false;
35+
}
36+
37+
const visited = new Set<IdentifierId>();
38+
const defs = buildDefinitionMap(fn);
39+
return isPrimitiveIdentifier(arg.identifier, defs, visited);
40+
}
41+
42+
type DefinitionMap = Map<IdentifierId, Instruction | Phi>;
43+
44+
function buildDefinitionMap(fn: HIRFunction): DefinitionMap {
45+
const defs: DefinitionMap = new Map();
46+
47+
for (const [, block] of fn.body.blocks) {
48+
for (const phi of block.phis) {
49+
defs.set(phi.place.identifier.id, phi);
50+
}
51+
for (const instr of block.instructions) {
52+
defs.set(instr.lvalue.identifier.id, instr);
53+
}
54+
}
55+
56+
return defs;
57+
}
58+
59+
function isPrimitiveIdentifier(
60+
identifier: Identifier,
61+
defs: DefinitionMap,
62+
visited: Set<IdentifierId>,
63+
): boolean {
64+
if (isPrimitiveType(identifier)) {
65+
return true;
66+
}
67+
68+
if (visited.has(identifier.id)) {
69+
return false;
70+
}
71+
visited.add(identifier.id);
72+
73+
const def = defs.get(identifier.id);
74+
if (def == null) {
75+
return false;
76+
}
77+
78+
if (isPhi(def)) {
79+
return Array.from(def.operands.values()).every(operand =>
80+
isPrimitiveIdentifier(operand.identifier, defs, visited),
81+
);
82+
}
83+
84+
return isPrimitiveInstruction(def.value, defs, visited);
85+
}
86+
87+
function isPhi(def: Instruction | Phi): def is Phi {
88+
return 'kind' in def && def.kind === 'Phi';
89+
}
90+
91+
function isPrimitiveInstruction(
92+
value: InstructionValue,
93+
defs: DefinitionMap,
94+
visited: Set<IdentifierId>,
95+
): boolean {
96+
switch (value.kind) {
97+
case 'Primitive':
98+
case 'TemplateLiteral':
99+
case 'JSXText':
100+
case 'UnaryExpression':
101+
case 'BinaryExpression':
102+
return true;
103+
104+
case 'TypeCastExpression':
105+
return isPrimitiveIdentifier(value.value.identifier, defs, visited);
106+
107+
case 'LoadLocal':
108+
case 'LoadContext':
109+
return isPrimitiveIdentifier(value.place.identifier, defs, visited);
110+
111+
case 'StoreLocal':
112+
case 'StoreContext':
113+
return isPrimitiveIdentifier(value.value.identifier, defs, visited);
114+
115+
case 'Await':
116+
return isPrimitiveIdentifier(value.value.identifier, defs, visited);
117+
118+
default:
119+
return false;
120+
}
121+
}
122+
18123
/**
19124
* Validates that the given function does not have an infinite update loop
20125
* caused by unconditionally calling setState during render. This validation
@@ -55,6 +160,7 @@ function validateNoSetStateInRenderImpl(
55160
unconditionalSetStateFunctions: Set<IdentifierId>,
56161
): Result<void, CompilerError> {
57162
const unconditionalBlocks = computeUnconditionalBlocks(fn);
163+
const enableUseKeyedState = fn.env.config.enableUseKeyedState;
58164
let activeManualMemoId: number | null = null;
59165
const errors = new CompilerError();
60166
for (const [, block] of fn.body.blocks) {
@@ -155,20 +261,48 @@ function validateNoSetStateInRenderImpl(
155261
}),
156262
);
157263
} else if (unconditionalBlocks.has(block.id)) {
158-
errors.pushDiagnostic(
159-
CompilerDiagnostic.create({
160-
category: ErrorCategory.RenderSetState,
161-
reason:
162-
'Calling setState during render may trigger an infinite loop',
163-
description:
164-
'Calling setState during render will trigger another render, and can lead to infinite loops. (https://react.dev/reference/react/useState)',
165-
suggestions: null,
166-
}).withDetails({
167-
kind: 'error',
168-
loc: callee.loc,
169-
message: 'Found setState() in render',
170-
}),
171-
);
264+
let isArgPrimitive = false;
265+
266+
if (instr.value.args.length > 0) {
267+
const arg = instr.value.args[0];
268+
if (arg.kind === 'Identifier') {
269+
isArgPrimitive = isPrimitiveSetArg(arg, fn);
270+
}
271+
}
272+
273+
if (isArgPrimitive) {
274+
if (enableUseKeyedState) {
275+
errors.pushDiagnostic(
276+
CompilerDiagnostic.create({
277+
category: ErrorCategory.RenderSetState,
278+
reason:
279+
'Calling setState during render may trigger an infinite loop',
280+
description:
281+
'Use useKeyedState instead of calling setState directly in render. Example: const [value, setValue] = useKeyedState(initialValue, key)',
282+
suggestions: null,
283+
}).withDetails({
284+
kind: 'error',
285+
loc: callee.loc,
286+
message: 'Found setState() in render',
287+
}),
288+
);
289+
}
290+
} else {
291+
errors.pushDiagnostic(
292+
CompilerDiagnostic.create({
293+
category: ErrorCategory.RenderSetState,
294+
reason:
295+
'Calling setState during render may trigger an infinite loop',
296+
description:
297+
'Calling setState during render will trigger another render, and can lead to infinite loops. (https://react.dev/reference/react/useState)',
298+
suggestions: null,
299+
}).withDetails({
300+
kind: 'error',
301+
loc: callee.loc,
302+
message: 'Found setState() in render',
303+
}),
304+
);
305+
}
172306
}
173307
}
174308
break;

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-setState-in-render-unbound-state.expect.md

Lines changed: 0 additions & 41 deletions
This file was deleted.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoSetStateInRender @enableUseKeyedState
6+
import {useState} from 'react';
7+
8+
function Component() {
9+
const [total, setTotal] = useState(0);
10+
setTotal(42);
11+
return total;
12+
}
13+
14+
export const FIXTURE_ENTRYPOINT = {
15+
fn: Component,
16+
params: [],
17+
isComponent: true,
18+
};
19+
20+
```
21+
22+
23+
## Error
24+
25+
```
26+
Found 1 error:
27+
28+
Error: Calling setState during render may trigger an infinite loop
29+
30+
Use useKeyedState instead of calling setState directly in render. Example: const [value, setValue] = useKeyedState(initialValue, key).
31+
32+
error.invalid-setstate-enabled-use-keyed-state.ts:6:2
33+
4 | function Component() {
34+
5 | const [total, setTotal] = useState(0);
35+
> 6 | setTotal(42);
36+
| ^^^^^^^^ Found setState() in render
37+
7 | return total;
38+
8 | }
39+
9 |
40+
```
41+
42+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// @validateNoSetStateInRender @enableUseKeyedState
2+
import {useState} from 'react';
3+
4+
function Component() {
5+
const [total, setTotal] = useState(0);
6+
setTotal(42);
7+
return total;
8+
}
9+
10+
export const FIXTURE_ENTRYPOINT = {
11+
fn: Component,
12+
params: [],
13+
isComponent: true,
14+
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validateNoSetStateInRender
6+
import {useState} from 'react';
7+
8+
function Component() {
9+
const [total, setTotal] = useState(0);
10+
setTotal({count: 42});
11+
return total;
12+
}
13+
14+
export const FIXTURE_ENTRYPOINT = {
15+
fn: Component,
16+
params: [],
17+
isComponent: true,
18+
};
19+
20+
```
21+
22+
23+
## Error
24+
25+
```
26+
Found 1 error:
27+
28+
Error: Calling setState during render may trigger an infinite loop
29+
30+
Calling setState during render will trigger another render, and can lead to infinite loops. (https://react.dev/reference/react/useState).
31+
32+
error.invalid-setstate-object-no-keyed-state.ts:6:2
33+
4 | function Component() {
34+
5 | const [total, setTotal] = useState(0);
35+
> 6 | setTotal({count: 42});
36+
| ^^^^^^^^ Found setState() in render
37+
7 | return total;
38+
8 | }
39+
9 |
40+
```
41+
42+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// @validateNoSetStateInRender
2+
import {useState} from 'react';
3+
4+
function Component() {
5+
const [total, setTotal] = useState(0);
6+
setTotal({count: 42});
7+
return total;
8+
}
9+
10+
export const FIXTURE_ENTRYPOINT = {
11+
fn: Component,
12+
params: [],
13+
isComponent: true,
14+
};

0 commit comments

Comments
 (0)