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 55bcd0bc5ce89..c0a183f103626 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -2021,6 +2021,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 795969b3f3bba..3de8c8b590851 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,6 +99,21 @@ export function validateNoSetStateInEffects( instr.value.kind === 'MethodCall' ? instr.value.receiver : instr.value.callee; + + 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); + } + } + } + if ( isUseEffectHookType(callee.identifier) || isUseLayoutEffectHookType(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 0000000000000..946169205f0a0 --- /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 0000000000000..a838e58564ecc --- /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 0000000000000..d426c74a2da38 --- /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 0000000000000..e2ebd7f58e957 --- /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 0000000000000..f24c39c678d23 --- /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 0000000000000..1436edf290b04 --- /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}, + ], +};