From 09f05694a25578c46e846e4ff9495f7c1352c29c Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Mon, 1 Dec 2025 14:55:42 -0500 Subject: [PATCH] [compiler] Extend setState in effect validation to useEffectEvent (#35214) ValidateNoSetStateInEffects already supports transitive setter functions. This PR marks any synchonous state setter useEffectEvent function so we can validate that uEE isn't being used only as misdirection to avoid the validation within an effect body. The error points to the call of the effect event. Example: ```js export default function MyApp() { const [count, setCount] = useState(0) const effectEvent = useEffectEvent(() => { setCount(10) }) useEffect(() => { effectEvent() }, []) return
{count}
; ``` ``` Found 1 error: Error: Calling setState synchronously within an effect can trigger cascading renders Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following: * Update external systems with the latest state from React. * Subscribe for updates from some external system, calling setState in a callback function when external state changes. Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect). 5 | }) 6 | useEffect(() => { > 7 | effectEvent() | ^^^^^^^^^^^ Avoid calling setState() directly within an effect 8 | }, []) 9 | return
{count}
; 10 | } ``` --- .../src/HIR/HIR.ts | 5 + .../Validation/ValidateNoSetStateInEffects.ts | 16 +- ...-in-useEffect-via-useEffectEvent.expect.md | 71 +++++++ ...etState-in-useEffect-via-useEffectEvent.js | 13 ++ ...fect-via-useEffectEvent-listener.expect.md | 116 +++++++++++ ...n-useEffect-via-useEffectEvent-listener.js | 29 +++ ...fect-via-useEffectEvent-with-ref.expect.md | 192 ++++++++++++++++++ ...n-useEffect-via-useEffectEvent-with-ref.js | 59 ++++++ 8 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-setState-in-useEffect-via-useEffectEvent.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-setState-in-useEffect-via-useEffectEvent.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-listener.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-listener.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-with-ref.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-with-ref.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts index 3e4cbb93b44..fe850f9a538 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -2023,6 +2023,11 @@ export function isUseInsertionEffectHookType(id: Identifier): boolean { id.type.shapeId === 'BuiltInUseInsertionEffectHook' ); } +export function isUseEffectEventType(id: Identifier): boolean { + return ( + id.type.kind === 'Function' && id.type.shapeId === 'BuiltInUseEffectEvent' + ); +} export function isUseContextHookType(id: Identifier): boolean { return ( diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInEffects.ts index 795969b3f3b..86070e2973e 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInEffects.ts @@ -16,6 +16,7 @@ import { IdentifierId, isSetStateType, isUseEffectHookType, + isUseEffectEventType, isUseInsertionEffectHookType, isUseLayoutEffectHookType, isUseRefType, @@ -98,7 +99,20 @@ export function validateNoSetStateInEffects( instr.value.kind === 'MethodCall' ? instr.value.receiver : instr.value.callee; - if ( + + if (isUseEffectEventType(callee.identifier)) { + const arg = instr.value.args[0]; + if (arg !== undefined && arg.kind === 'Identifier') { + const setState = setStateFunctions.get(arg.identifier.id); + if (setState !== undefined) { + /** + * This effect event function calls setState synchonously, + * treat it as a setState function for transitive tracking + */ + setStateFunctions.set(instr.lvalue.identifier.id, setState); + } + } + } else if ( isUseEffectHookType(callee.identifier) || isUseLayoutEffectHookType(callee.identifier) || isUseInsertionEffectHookType(callee.identifier) diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-setState-in-useEffect-via-useEffectEvent.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-setState-in-useEffect-via-useEffectEvent.expect.md new file mode 100644 index 00000000000..946169205f0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-setState-in-useEffect-via-useEffectEvent.expect.md @@ -0,0 +1,71 @@ + +## Input + +```javascript +// @loggerTestOnly @validateNoSetStateInEffects +import {useEffect, useEffectEvent, useState} from 'react'; + +function Component() { + const [state, setState] = useState(0); + const effectEvent = useEffectEvent(() => { + setState(true); + }); + useEffect(() => { + effectEvent(); + }, []); + return state; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @loggerTestOnly @validateNoSetStateInEffects +import { useEffect, useEffectEvent, useState } from "react"; + +function Component() { + const $ = _c(4); + const [state, setState] = useState(0); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = () => { + setState(true); + }; + $[0] = t0; + } else { + t0 = $[0]; + } + const effectEvent = useEffectEvent(t0); + let t1; + if ($[1] !== effectEvent) { + t1 = () => { + effectEvent(); + }; + $[1] = effectEvent; + $[2] = t1; + } else { + t1 = $[2]; + } + let t2; + if ($[3] === Symbol.for("react.memo_cache_sentinel")) { + t2 = []; + $[3] = t2; + } else { + t2 = $[3]; + } + useEffect(t1, t2); + return state; +} + +``` + +## Logs + +``` +{"kind":"CompileError","detail":{"options":{"category":"EffectSetState","reason":"Calling setState synchronously within an effect can trigger cascading renders","description":"Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:\n* Update external systems with the latest state from React.\n* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\nCalling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect)","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":10,"column":4,"index":267},"end":{"line":10,"column":15,"index":278},"filename":"invalid-setState-in-useEffect-via-useEffectEvent.ts","identifierName":"effectEvent"},"message":"Avoid calling setState() directly within an effect"}]}},"fnLoc":null} +{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":108},"end":{"line":13,"column":1,"index":309},"filename":"invalid-setState-in-useEffect-via-useEffectEvent.ts"},"fnName":"Component","memoSlots":4,"memoBlocks":3,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0} +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-setState-in-useEffect-via-useEffectEvent.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-setState-in-useEffect-via-useEffectEvent.js new file mode 100644 index 00000000000..a838e58564e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-setState-in-useEffect-via-useEffectEvent.js @@ -0,0 +1,13 @@ +// @loggerTestOnly @validateNoSetStateInEffects +import {useEffect, useEffectEvent, useState} from 'react'; + +function Component() { + const [state, setState] = useState(0); + const effectEvent = useEffectEvent(() => { + setState(true); + }); + useEffect(() => { + effectEvent(); + }, []); + return state; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-listener.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-listener.expect.md new file mode 100644 index 00000000000..d426c74a2da --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-listener.expect.md @@ -0,0 +1,116 @@ + +## Input + +```javascript +// @validateNoSetStateInEffects @loggerTestOnly @compilationMode:"infer" +import {useEffect, useEffectEvent, useState} from 'react'; + +const shouldSetState = false; + +function Component() { + const [state, setState] = useState(0); + const effectEvent = useEffectEvent(() => { + setState(10); + }); + useEffect(() => { + setTimeout(effectEvent, 10); + }); + + const effectEventWithTimeout = useEffectEvent(() => { + setTimeout(() => { + setState(20); + }, 10); + }); + useEffect(() => { + effectEventWithTimeout(); + }, []); + return state; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @loggerTestOnly @compilationMode:"infer" +import { useEffect, useEffectEvent, useState } from "react"; + +const shouldSetState = false; + +function Component() { + const $ = _c(7); + const [state, setState] = useState(0); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = () => { + setState(10); + }; + $[0] = t0; + } else { + t0 = $[0]; + } + const effectEvent = useEffectEvent(t0); + let t1; + if ($[1] !== effectEvent) { + t1 = () => { + setTimeout(effectEvent, 10); + }; + $[1] = effectEvent; + $[2] = t1; + } else { + t1 = $[2]; + } + useEffect(t1); + let t2; + if ($[3] === Symbol.for("react.memo_cache_sentinel")) { + t2 = () => { + setTimeout(() => { + setState(20); + }, 10); + }; + $[3] = t2; + } else { + t2 = $[3]; + } + const effectEventWithTimeout = useEffectEvent(t2); + let t3; + if ($[4] !== effectEventWithTimeout) { + t3 = () => { + effectEventWithTimeout(); + }; + $[4] = effectEventWithTimeout; + $[5] = t3; + } else { + t3 = $[5]; + } + let t4; + if ($[6] === Symbol.for("react.memo_cache_sentinel")) { + t4 = []; + $[6] = t4; + } else { + t4 = $[6]; + } + useEffect(t3, t4); + return state; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + +## Logs + +``` +{"kind":"CompileSuccess","fnLoc":{"start":{"line":6,"column":0,"index":164},"end":{"line":24,"column":1,"index":551},"filename":"valid-setState-in-useEffect-via-useEffectEvent-listener.ts"},"fnName":"Component","memoSlots":7,"memoBlocks":5,"memoValues":5,"prunedMemoBlocks":0,"prunedMemoValues":0} +``` + +### Eval output +(kind: exception) (0 , _react.useEffectEvent) is not a function \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-listener.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-listener.js new file mode 100644 index 00000000000..e2ebd7f58e9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-listener.js @@ -0,0 +1,29 @@ +// @validateNoSetStateInEffects @loggerTestOnly @compilationMode:"infer" +import {useEffect, useEffectEvent, useState} from 'react'; + +const shouldSetState = false; + +function Component() { + const [state, setState] = useState(0); + const effectEvent = useEffectEvent(() => { + setState(10); + }); + useEffect(() => { + setTimeout(effectEvent, 10); + }); + + const effectEventWithTimeout = useEffectEvent(() => { + setTimeout(() => { + setState(20); + }, 10); + }); + useEffect(() => { + effectEventWithTimeout(); + }, []); + return state; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-with-ref.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-with-ref.expect.md new file mode 100644 index 00000000000..f24c39c678d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-with-ref.expect.md @@ -0,0 +1,192 @@ + +## Input + +```javascript +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer" +import {useState, useRef, useEffect, useEffectEvent} from 'react'; + +function Component({x, y}) { + const previousXRef = useRef(null); + const previousYRef = useRef(null); + + const [data, setData] = useState(null); + + const effectEvent = useEffectEvent(() => { + const data = load({x, y}); + setData(data); + }); + + useEffect(() => { + const previousX = previousXRef.current; + previousXRef.current = x; + const previousY = previousYRef.current; + previousYRef.current = y; + if (!areEqual(x, previousX) || !areEqual(y, previousY)) { + effectEvent(); + } + }, [x, y]); + + const effectEvent2 = useEffectEvent((xx, yy) => { + const previousX = previousXRef.current; + previousXRef.current = xx; + const previousY = previousYRef.current; + previousYRef.current = yy; + if (!areEqual(xx, previousX) || !areEqual(yy, previousY)) { + const data = load({x: xx, y: yy}); + setData(data); + } + }); + + useEffect(() => { + effectEvent2(x, y); + }, [x, y]); + + return data; +} + +function areEqual(a, b) { + return a === b; +} + +function load({x, y}) { + return x * y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{x: 0, y: 0}], + sequentialRenders: [ + {x: 0, y: 0}, + {x: 1, y: 0}, + {x: 1, y: 1}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer" +import { useState, useRef, useEffect, useEffectEvent } from "react"; + +function Component(t0) { + const $ = _c(18); + const { x, y } = t0; + const previousXRef = useRef(null); + const previousYRef = useRef(null); + + const [data, setData] = useState(null); + let t1; + if ($[0] !== x || $[1] !== y) { + t1 = () => { + const data_0 = load({ x, y }); + setData(data_0); + }; + $[0] = x; + $[1] = y; + $[2] = t1; + } else { + t1 = $[2]; + } + const effectEvent = useEffectEvent(t1); + let t2; + if ($[3] !== effectEvent || $[4] !== x || $[5] !== y) { + t2 = () => { + const previousX = previousXRef.current; + previousXRef.current = x; + const previousY = previousYRef.current; + previousYRef.current = y; + if (!areEqual(x, previousX) || !areEqual(y, previousY)) { + effectEvent(); + } + }; + $[3] = effectEvent; + $[4] = x; + $[5] = y; + $[6] = t2; + } else { + t2 = $[6]; + } + let t3; + if ($[7] !== x || $[8] !== y) { + t3 = [x, y]; + $[7] = x; + $[8] = y; + $[9] = t3; + } else { + t3 = $[9]; + } + useEffect(t2, t3); + let t4; + if ($[10] === Symbol.for("react.memo_cache_sentinel")) { + t4 = (xx, yy) => { + const previousX_0 = previousXRef.current; + previousXRef.current = xx; + const previousY_0 = previousYRef.current; + previousYRef.current = yy; + if (!areEqual(xx, previousX_0) || !areEqual(yy, previousY_0)) { + const data_1 = load({ x: xx, y: yy }); + setData(data_1); + } + }; + $[10] = t4; + } else { + t4 = $[10]; + } + const effectEvent2 = useEffectEvent(t4); + let t5; + if ($[11] !== effectEvent2 || $[12] !== x || $[13] !== y) { + t5 = () => { + effectEvent2(x, y); + }; + $[11] = effectEvent2; + $[12] = x; + $[13] = y; + $[14] = t5; + } else { + t5 = $[14]; + } + let t6; + if ($[15] !== x || $[16] !== y) { + t6 = [x, y]; + $[15] = x; + $[16] = y; + $[17] = t6; + } else { + t6 = $[17]; + } + useEffect(t5, t6); + return data; +} + +function areEqual(a, b) { + return a === b; +} + +function load({ x, y }) { + return x * y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 0, y: 0 }], + sequentialRenders: [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + ], +}; + +``` + +## Logs + +``` +{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":179},"end":{"line":41,"column":1,"index":1116},"filename":"valid-setState-in-useEffect-via-useEffectEvent-with-ref.ts"},"fnName":"Component","memoSlots":18,"memoBlocks":6,"memoValues":6,"prunedMemoBlocks":0,"prunedMemoValues":0} +``` + +### Eval output +(kind: ok) [[ (exception in render) TypeError: (0 , _react.useEffectEvent) is not a function ]] +[[ (exception in render) TypeError: (0 , _react.useEffectEvent) is not a function ]] +[[ (exception in render) TypeError: (0 , _react.useEffectEvent) is not a function ]] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-with-ref.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-with-ref.js new file mode 100644 index 00000000000..1436edf290b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-with-ref.js @@ -0,0 +1,59 @@ +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer" +import {useState, useRef, useEffect, useEffectEvent} from 'react'; + +function Component({x, y}) { + const previousXRef = useRef(null); + const previousYRef = useRef(null); + + const [data, setData] = useState(null); + + const effectEvent = useEffectEvent(() => { + const data = load({x, y}); + setData(data); + }); + + useEffect(() => { + const previousX = previousXRef.current; + previousXRef.current = x; + const previousY = previousYRef.current; + previousYRef.current = y; + if (!areEqual(x, previousX) || !areEqual(y, previousY)) { + effectEvent(); + } + }, [x, y]); + + const effectEvent2 = useEffectEvent((xx, yy) => { + const previousX = previousXRef.current; + previousXRef.current = xx; + const previousY = previousYRef.current; + previousYRef.current = yy; + if (!areEqual(xx, previousX) || !areEqual(yy, previousY)) { + const data = load({x: xx, y: yy}); + setData(data); + } + }); + + useEffect(() => { + effectEvent2(x, y); + }, [x, y]); + + return data; +} + +function areEqual(a, b) { + return a === b; +} + +function load({x, y}) { + return x * y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{x: 0, y: 0}], + sequentialRenders: [ + {x: 0, y: 0}, + {x: 1, y: 0}, + {x: 1, y: 1}, + ], +};