From 44f6c7c815a32bb12c2fa221b5ed9d2d717b3b6d Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Thu, 4 Sep 2025 11:17:20 -0700 Subject: [PATCH 01/25] [compiler] new tests for props derived Adds some new test cases for ValidateNoDerivedComputationsInEffects. --- ...ved-state-one-time-init-no-error.expect.md | 87 +++++++++++++++++++ .../derived-state-one-time-init-no-error.js | 21 +++++ ...-state-with-conditional-no-error.expect.md | 79 +++++++++++++++++ ...derived-state-with-conditional-no-error.js | 21 +++++ ...state-with-side-effects-no-error.expect.md | 74 ++++++++++++++++ ...erived-state-with-side-effects-no-error.js | 19 ++++ ...ug-derived-state-from-mixed-deps.expect.md | 49 +++++++++++ ...error.bug-derived-state-from-mixed-deps.js | 23 +++++ ...ed-state-from-props-destructured.expect.md | 43 +++++++++ ...d-derived-state-from-props-destructured.js | 17 ++++ ...rived-state-from-props-in-effect.expect.md | 43 +++++++++ ...alid-derived-state-from-props-in-effect.js | 17 ++++ ...rived-state-from-state-in-effect.expect.md | 51 +++++++++++ ...alid-derived-state-from-state-in-effect.js | 25 ++++++ ...erived-state-from-props-computed.expect.md | 72 +++++++++++++++ ...valid-derived-state-from-props-computed.js | 18 ++++ 16 files changed, 659 insertions(+) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.expect.md new file mode 100644 index 0000000000..07a58aeef3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.expect.md @@ -0,0 +1,87 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({initialName}) { + const [name, setName] = useState(''); + + useEffect(() => { + setName(initialName); + }, []); + + return ( +
+ setName(e.target.value)} /> +
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{initialName: 'John'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects +import { useEffect, useState } from "react"; + +function Component(t0) { + const $ = _c(6); + const { initialName } = t0; + const [name, setName] = useState(""); + let t1; + if ($[0] !== initialName) { + t1 = () => { + setName(initialName); + }; + $[0] = initialName; + $[1] = t1; + } else { + t1 = $[1]; + } + let t2; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t2 = []; + $[2] = t2; + } else { + t2 = $[2]; + } + useEffect(t1, t2); + let t3; + if ($[3] === Symbol.for("react.memo_cache_sentinel")) { + t3 = (e) => setName(e.target.value); + $[3] = t3; + } else { + t3 = $[3]; + } + let t4; + if ($[4] !== name) { + t4 = ( +
+ +
+ ); + $[4] = name; + $[5] = t4; + } else { + t4 = $[5]; + } + return t4; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ initialName: "John" }], +}; + +``` + +### Eval output +(kind: ok)
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.js new file mode 100644 index 0000000000..c6705378a5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.js @@ -0,0 +1,21 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({initialName}) { + const [name, setName] = useState(''); + + useEffect(() => { + setName(initialName); + }, []); + + return ( +
+ setName(e.target.value)} /> +
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{initialName: 'John'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md new file mode 100644 index 0000000000..b7a1c85d52 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md @@ -0,0 +1,79 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value, enabled}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + if (enabled) { + setLocalValue(value); + } else { + setLocalValue('disabled'); + } + }, [value, enabled]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test', enabled: true}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects +import { useEffect, useState } from "react"; + +function Component(t0) { + const $ = _c(6); + const { value, enabled } = t0; + const [localValue, setLocalValue] = useState(""); + let t1; + let t2; + if ($[0] !== enabled || $[1] !== value) { + t1 = () => { + if (enabled) { + setLocalValue(value); + } else { + setLocalValue("disabled"); + } + }; + + t2 = [value, enabled]; + $[0] = enabled; + $[1] = value; + $[2] = t1; + $[3] = t2; + } else { + t1 = $[2]; + t2 = $[3]; + } + useEffect(t1, t2); + let t3; + if ($[4] !== localValue) { + t3 =
{localValue}
; + $[4] = localValue; + $[5] = t3; + } else { + t3 = $[5]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: "test", enabled: true }], +}; + +``` + +### Eval output +(kind: ok)
test
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js new file mode 100644 index 0000000000..79d83b8925 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js @@ -0,0 +1,21 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value, enabled}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + if (enabled) { + setLocalValue(value); + } else { + setLocalValue('disabled'); + } + }, [value, enabled]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test', enabled: true}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md new file mode 100644 index 0000000000..e0708dd1f7 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md @@ -0,0 +1,74 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + console.log('Value changed:', value); + setLocalValue(value); + document.title = `Value: ${value}`; + }, [value]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects +import { useEffect, useState } from "react"; + +function Component(t0) { + const $ = _c(5); + const { value } = t0; + const [localValue, setLocalValue] = useState(""); + let t1; + let t2; + if ($[0] !== value) { + t1 = () => { + console.log("Value changed:", value); + setLocalValue(value); + document.title = `Value: ${value}`; + }; + t2 = [value]; + $[0] = value; + $[1] = t1; + $[2] = t2; + } else { + t1 = $[1]; + t2 = $[2]; + } + useEffect(t1, t2); + let t3; + if ($[3] !== localValue) { + t3 =
{localValue}
; + $[3] = localValue; + $[4] = t3; + } else { + t3 = $[4]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: "test" }], +}; + +``` + +### Eval output +(kind: ok)
test
+logs: ['Value changed:','test'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js new file mode 100644 index 0000000000..b948dda6cb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js @@ -0,0 +1,19 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + console.log('Value changed:', value); + setLocalValue(value); + document.title = `Value: ${value}`; + }, [value]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md new file mode 100644 index 0000000000..54c95d68e3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -0,0 +1,49 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({prefix}) { + const [name, setName] = useState(''); + const [displayName, setDisplayName] = useState(''); + + useEffect(() => { + setDisplayName(prefix + name); + }, [prefix, name]); + + return ( +
+ setName(e.target.value)} /> +
{displayName}
+
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prefix: 'Hello, '}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +error.derived-state-from-mixed-deps-no-error.ts:9:4 + 7 | + 8 | useEffect(() => { +> 9 | setDisplayName(prefix + name); + | ^^^^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + 10 | }, [prefix, name]); + 11 | + 12 | return ( +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.js new file mode 100644 index 0000000000..0004ab0ebf --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.js @@ -0,0 +1,23 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({prefix}) { + const [name, setName] = useState(''); + const [displayName, setDisplayName] = useState(''); + + useEffect(() => { + setDisplayName(prefix + name); + }, [prefix, name]); + + return ( +
+ setName(e.target.value)} /> +
{displayName}
+
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prefix: 'Hello, '}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md new file mode 100644 index 0000000000..cb18bd12a3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -0,0 +1,43 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({user: {firstName, lastName}}) { + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return
{fullName}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{user: {firstName: 'John', lastName: 'Doe'}}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +error.invalid-derived-state-from-props-destructured.ts:8:4 + 6 | + 7 | useEffect(() => { +> 8 | setFullName(firstName + ' ' + lastName); + | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + 9 | }, [firstName, lastName]); + 10 | + 11 | return
{fullName}
; +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js new file mode 100644 index 0000000000..130d31c11a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js @@ -0,0 +1,17 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({user: {firstName, lastName}}) { + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return
{fullName}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{user: {firstName: 'John', lastName: 'Doe'}}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md new file mode 100644 index 0000000000..15d94c39ad --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -0,0 +1,43 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({firstName, lastName}) { + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return
{fullName}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{firstName: 'John', lastName: 'Doe'}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +error.invalid-derived-state-from-props-in-effect.ts:8:4 + 6 | + 7 | useEffect(() => { +> 8 | setFullName(firstName + ' ' + lastName); + | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + 9 | }, [firstName, lastName]); + 10 | + 11 | return
{fullName}
; +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.js new file mode 100644 index 0000000000..966f09ea89 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.js @@ -0,0 +1,17 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({firstName, lastName}) { + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return
{fullName}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{firstName: 'John', lastName: 'Doe'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md new file mode 100644 index 0000000000..7466edb3c5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -0,0 +1,51 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component() { + const [firstName, setFirstName] = useState('John'); + const [lastName, setLastName] = useState('Doe'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return ( +
+ setFirstName(e.target.value)} /> + setLastName(e.target.value)} /> +
{fullName}
+
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +error.invalid-derived-state-from-state-in-effect.ts:10:4 + 8 | + 9 | useEffect(() => { +> 10 | setFullName(firstName + ' ' + lastName); + | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + 11 | }, [firstName, lastName]); + 12 | + 13 | return ( +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.js new file mode 100644 index 0000000000..2b4f9f7066 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.js @@ -0,0 +1,25 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component() { + const [firstName, setFirstName] = useState('John'); + const [lastName, setLastName] = useState('Doe'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return ( +
+ setFirstName(e.target.value)} /> + setLastName(e.target.value)} /> +
{fullName}
+
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md new file mode 100644 index 0000000000..3d0c4fe9c8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md @@ -0,0 +1,72 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component(props) { + const [displayValue, setDisplayValue] = useState(''); + + useEffect(() => { + const computed = props.prefix + props.value + props.suffix; + setDisplayValue(computed); + }, [props.prefix, props.value, props.suffix]); + + return
{displayValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prefix: '[', value: 'test', suffix: ']'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects +import { useEffect, useState } from "react"; + +function Component(props) { + const $ = _c(7); + const [displayValue, setDisplayValue] = useState(""); + let t0; + let t1; + if ($[0] !== props.prefix || $[1] !== props.suffix || $[2] !== props.value) { + t0 = () => { + const computed = props.prefix + props.value + props.suffix; + setDisplayValue(computed); + }; + t1 = [props.prefix, props.value, props.suffix]; + $[0] = props.prefix; + $[1] = props.suffix; + $[2] = props.value; + $[3] = t0; + $[4] = t1; + } else { + t0 = $[3]; + t1 = $[4]; + } + useEffect(t0, t1); + let t2; + if ($[5] !== displayValue) { + t2 =
{displayValue}
; + $[5] = displayValue; + $[6] = t2; + } else { + t2 = $[6]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ prefix: "[", value: "test", suffix: "]" }], +}; + +``` + +### Eval output +(kind: ok)
[test]
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js new file mode 100644 index 0000000000..0e726f86ab --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js @@ -0,0 +1,18 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component(props) { + const [displayValue, setDisplayValue] = useState(''); + + useEffect(() => { + const computed = props.prefix + props.value + props.suffix; + setDisplayValue(computed); + }, [props.prefix, props.value, props.suffix]); + + return
{displayValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prefix: '[', value: 'test', suffix: ']'}], +}; From 190adb1ce177fbad88d63d49ac62221ef70f2f85 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Thu, 4 Sep 2025 11:17:20 -0700 Subject: [PATCH 02/25] [compiler][wip] Extend ValidateNoDerivedComputationsInEffects for props derived effects This PR adds infra to disambiguate between two types of derived state in effects: 1. State derived from props 2. State derived from other state TODO: - [ ] Props tracking through destructuring and property access does not seem to be propagated correctly inside of Functions' instructions (or i might be misunderstanding how we track aliasing effects) - [ ] compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js should be failing - [ ] Handle "mixed" case where deps flow from at least one prop AND state. Should probably have a different error reason, to aid with categorization --- .../ValidateNoDerivedComputationsInEffects.ts | 184 ++++++++++++++++-- ...id-derived-computation-in-effect.expect.md | 6 +- ...ug-derived-state-from-mixed-deps.expect.md | 8 +- ...ed-state-from-props-destructured.expect.md | 6 +- ...d-derived-state-from-props-destructured.js | 4 +- ...rived-state-from-props-in-effect.expect.md | 6 +- ...rived-state-from-state-in-effect.expect.md | 6 +- 7 files changed, 194 insertions(+), 26 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index d026a94ed4..d45e18c448 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -12,14 +12,21 @@ import { FunctionExpression, HIRFunction, IdentifierId, + Place, isSetStateType, isUseEffectHookType, } from '../HIR'; +import {printInstruction, printPlace} from '../HIR/PrintHIR'; import { eachInstructionValueOperand, eachTerminalOperand, } from '../HIR/visitors'; +type SetStateCall = { + loc: SourceLocation; + propsSource: Place | null; // null means state-derived, non-null means props-derived +}; + /** * Validates that useEffect is not used for derived computations which could/should * be performed in render. @@ -47,12 +54,96 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const candidateDependencies: Map = new Map(); const functions: Map = new Map(); const locals: Map = new Map(); + const derivedFromProps: Map = new Map(); const errors = new CompilerError(); + if (fn.fnType === 'Hook') { + for (const param of fn.params) { + if (param.kind === 'Identifier') { + derivedFromProps.set(param.identifier.id, param); + } + } + } else if (fn.fnType === 'Component') { + const props = fn.params[0]; + if (props != null && props.kind === 'Identifier') { + derivedFromProps.set(props.identifier.id, props); + } + } + for (const block of fn.body.blocks.values()) { for (const instr of block.instructions) { const {lvalue, value} = instr; + + // Track props derivation through instruction effects + if (instr.effects != null) { + for (const effect of instr.effects) { + switch (effect.kind) { + case 'Assign': + case 'Alias': + case 'MaybeAlias': + case 'Capture': { + const source = derivedFromProps.get(effect.from.identifier.id); + if (source != null) { + derivedFromProps.set(effect.into.identifier.id, source); + } + break; + } + } + } + } + + /** + * TODO: figure out why property access off of props does not create an Assign or Alias/Maybe + * Alias + * + * import {useEffect, useState} from 'react' + * + * function Component(props) { + * const [displayValue, setDisplayValue] = useState(''); + * + * useEffect(() => { + * const computed = props.prefix + props.value + props.suffix; + * ^^^^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^ + * we want to track that these are from props + * setDisplayValue(computed); + * }, [props.prefix, props.value, props.suffix]); + * + * return
{displayValue}
; + * } + */ + if (value.kind === 'FunctionExpression') { + for (const [, block] of value.loweredFunc.func.body.blocks) { + for (const instr of block.instructions) { + if (instr.effects != null) { + console.group(printInstruction(instr)); + for (const effect of instr.effects) { + console.log(effect); + switch (effect.kind) { + case 'Assign': + case 'Alias': + case 'MaybeAlias': + case 'Capture': { + const source = derivedFromProps.get( + effect.from.identifier.id, + ); + if (source != null) { + derivedFromProps.set(effect.into.identifier.id, source); + } + break; + } + } + } + } + console.groupEnd(); + } + } + } + + for (const [, place] of derivedFromProps) { + console.log(printPlace(place)); + } + if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); } else if (value.kind === 'ArrayExpression') { @@ -89,6 +180,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { validateEffect( effectFunction.loweredFunc.func, dependencies, + derivedFromProps, errors, ); } @@ -104,6 +196,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function validateEffect( effectFunction: HIRFunction, effectDeps: Array, + derivedFromProps: Map, errors: CompilerError, ): void { for (const operand of effectFunction.context) { @@ -111,16 +204,22 @@ function validateEffect( continue; } else if (effectDeps.find(dep => dep === operand.identifier.id) != null) { continue; + } else if (derivedFromProps.has(operand.identifier.id)) { + continue; } else { // Captured something other than the effect dep or setState + console.log('early return 1'); return; } } for (const dep of effectDeps) { + console.log({dep}); if ( effectFunction.context.find(operand => operand.identifier.id === dep) == - null + null || + derivedFromProps.has(dep) === false ) { + console.log('early return 2'); // effect dep wasn't actually used in the function return; } @@ -128,11 +227,18 @@ function validateEffect( const seenBlocks: Set = new Set(); const values: Map> = new Map(); + const effectDerivedFromProps: Map = new Map(); + for (const dep of effectDeps) { + console.log({dep}); values.set(dep, [dep]); + const propsSource = derivedFromProps.get(dep); + if (propsSource != null) { + effectDerivedFromProps.set(dep, propsSource); + } } - const setStateLocations: Array = []; + const setStateCalls: Array = []; for (const block of effectFunction.body.blocks.values()) { for (const pred of block.preds) { if (!seenBlocks.has(pred)) { @@ -142,6 +248,8 @@ function validateEffect( } for (const phi of block.phis) { const aggregateDeps: Set = new Set(); + let propsSource: Place | null = null; + for (const operand of phi.operands.values()) { const deps = values.get(operand.identifier.id); if (deps != null) { @@ -149,10 +257,18 @@ function validateEffect( aggregateDeps.add(dep); } } + const source = effectDerivedFromProps.get(operand.identifier.id); + if (source != null) { + propsSource = source; + } } + if (aggregateDeps.size !== 0) { values.set(phi.place.identifier.id, Array.from(aggregateDeps)); } + if (propsSource != null) { + effectDerivedFromProps.set(phi.place.identifier.id, propsSource); + } } for (const instr of block.instructions) { switch (instr.value.kind) { @@ -195,9 +311,16 @@ function validateEffect( ) { const deps = values.get(instr.value.args[0].identifier.id); if (deps != null && new Set(deps).size === effectDeps.length) { - setStateLocations.push(instr.value.callee.loc); + const propsSource = effectDerivedFromProps.get( + instr.value.args[0].identifier.id, + ); + + setStateCalls.push({ + loc: instr.value.callee.loc, + propsSource: propsSource ?? null, + }); } else { - // doesn't depend on any deps + // doesn't depend on all deps return; } } @@ -207,6 +330,26 @@ function validateEffect( return; } } + + // Track props derivation through instruction effects + if (instr.effects != null) { + for (const effect of instr.effects) { + switch (effect.kind) { + case 'Assign': + case 'Alias': + case 'MaybeAlias': + case 'Capture': { + const source = effectDerivedFromProps.get( + effect.from.identifier.id, + ); + if (source != null) { + effectDerivedFromProps.set(effect.into.identifier.id, source); + } + break; + } + } + } + } } for (const operand of eachTerminalOperand(block.terminal)) { if (values.has(operand.identifier.id)) { @@ -217,14 +360,29 @@ function validateEffect( seenBlocks.add(block.id); } - for (const loc of setStateLocations) { - errors.push({ - reason: - 'Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)', - description: null, - severity: ErrorSeverity.InvalidReact, - loc, - suggestions: null, - }); + for (const call of setStateCalls) { + if (call.propsSource != null) { + const propName = call.propsSource.identifier.name?.value; + const propInfo = propName != null ? ` (from prop '${propName}')` : ''; + + errors.push({ + reason: `Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)`, + description: `You are using props${propInfo} to update local state in an effect.`, + severity: ErrorSeverity.InvalidReact, + loc: call.loc, + suggestions: null, + }); + } else { + errors.push({ + reason: + 'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)', + description: + 'This effect updates state based on other state values. ' + + 'Consider calculating this value directly during render', + severity: ErrorSeverity.InvalidReact, + loc: call.loc, + suggestions: null, + }); + } } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md index d97a665ae6..1d7e24b3ef 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md @@ -24,13 +24,15 @@ function BadExample() { ``` Found 1 error: -Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +This effect updates state based on other state values. Consider calculating this value directly during render. error.invalid-derived-computation-in-effect.ts:9:4 7 | const [fullName, setFullName] = useState(''); 8 | useEffect(() => { > 9 | setFullName(capitalize(firstName + ' ' + lastName)); - | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) 10 | }, [firstName, lastName]); 11 | 12 | return
{fullName}
; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md index 54c95d68e3..8124f4b3f3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -34,13 +34,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) -error.derived-state-from-mixed-deps-no-error.ts:9:4 +This effect updates state based on other state values. Consider calculating this value directly during render. + +error.bug-derived-state-from-mixed-deps.ts:9:4 7 | 8 | useEffect(() => { > 9 | setDisplayName(prefix + name); - | ^^^^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) 10 | }, [prefix, name]); 11 | 12 | return ( diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index cb18bd12a3..26b8b7930b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -28,13 +28,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +This effect updates state based on other state values. Consider calculating this value directly during render. error.invalid-derived-state-from-props-destructured.ts:8:4 6 | 7 | useEffect(() => { > 8 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) 9 | }, [firstName, lastName]); 10 | 11 | return
{fullName}
; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js index 130d31c11a..966f09ea89 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js @@ -1,7 +1,7 @@ // @validateNoDerivedComputationsInEffects import {useEffect, useState} from 'react'; -function Component({user: {firstName, lastName}}) { +function Component({firstName, lastName}) { const [fullName, setFullName] = useState(''); useEffect(() => { @@ -13,5 +13,5 @@ function Component({user: {firstName, lastName}}) { export const FIXTURE_ENTRYPOINT = { fn: Component, - params: [{user: {firstName: 'John', lastName: 'Doe'}}], + params: [{firstName: 'John', lastName: 'Doe'}], }; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md index 15d94c39ad..1f7ff8dc5d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -28,13 +28,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +This effect updates state based on other state values. Consider calculating this value directly during render. error.invalid-derived-state-from-props-in-effect.ts:8:4 6 | 7 | useEffect(() => { > 8 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) 9 | }, [firstName, lastName]); 10 | 11 | return
{fullName}
; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md index 7466edb3c5..c5548c970b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -36,13 +36,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +This effect updates state based on other state values. Consider calculating this value directly during render. error.invalid-derived-state-from-state-in-effect.ts:10:4 8 | 9 | useEffect(() => { > 10 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) 11 | }, [firstName, lastName]); 12 | 13 | return ( From 8107871be95540321f940a0361a51c32bfdabfd5 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes <57368278+jorge-cab@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:20:02 -0700 Subject: [PATCH 03/25] [compiler] Basic solution for instruction based prop derivation validation --- .../ValidateNoDerivedComputationsInEffects.ts | 345 ++++++++++++------ 1 file changed, 229 insertions(+), 116 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index d45e18c448..0d6b7bd25d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -5,13 +5,15 @@ * LICENSE file in the root directory of this source tree. */ -import {CompilerError, ErrorSeverity, SourceLocation} from '..'; +import {TypeOf} from 'zod'; +import {CompilerError, Effect, ErrorSeverity, SourceLocation} from '..'; import { ArrayExpression, BlockId, FunctionExpression, HIRFunction, IdentifierId, + InstructionValue, Place, isSetStateType, isUseEffectHookType, @@ -19,13 +21,74 @@ import { import {printInstruction, printPlace} from '../HIR/PrintHIR'; import { eachInstructionValueOperand, + eachInstructionOperand, eachTerminalOperand, + eachInstructionLValue, } from '../HIR/visitors'; +import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables'; +import {assertExhaustive} from '../Utils/utils'; type SetStateCall = { loc: SourceLocation; - propsSource: Place | null; // null means state-derived, non-null means props-derived + propsSources: Place[] | undefined; // undefined means state-derived, defined means props-derived }; +type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; + +type DerivationMetadata = { + identifierPlace: Place; + sources: Place[]; + typeOfValue: TypeOfValue; +}; + +function joinValue( + lvalueType: TypeOfValue, + valueType: TypeOfValue, +): TypeOfValue { + if (lvalueType === 'ignored') return valueType; + if (valueType === 'ignored') return lvalueType; + if (lvalueType === valueType) return lvalueType; + return 'fromPropsOrState'; +} + +function propagateDerivation( + dest: Place, + source: Place | undefined, + derivedFromProps: Map, +) { + if (source === undefined) { + return; + } + + if (source.identifier.name?.kind === 'promoted') { + derivedFromProps.set(dest.identifier.id, dest); + } else { + derivedFromProps.set(dest.identifier.id, source); + } +} + +function updateDerivationMetadata( + target: Place, + sources: DerivationMetadata[], + typeOfValue: TypeOfValue, + derivedTuple: Map, +): void { + let newValue: DerivationMetadata = { + identifierPlace: target, + sources: [], + typeOfValue: typeOfValue, + }; + + for (const source of sources) { + // If the identifier of the source is a promoted identifier, then + // we should set the source as the first named identifier. + if (source.identifierPlace.identifier.name?.kind === 'promoted') { + newValue.sources.push(target); + } else { + newValue.sources.push(...source.sources); + } + } + derivedTuple.set(target.identifier.id, newValue); +} /** * Validates that useEffect is not used for derived computations which could/should @@ -54,96 +117,138 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const candidateDependencies: Map = new Map(); const functions: Map = new Map(); const locals: Map = new Map(); - const derivedFromProps: Map = new Map(); + + // MY take on this + const valueToType: Map = new Map(); + const valueToSourceProps: Map> = new Map(); + const valueToSourceStates: Map> = new Map(); + const valueToSources: Map> = new Map(); + + // Sources are still probably not correct + const derivedTuple: Map = new Map(); const errors = new CompilerError(); if (fn.fnType === 'Hook') { for (const param of fn.params) { if (param.kind === 'Identifier') { - derivedFromProps.set(param.identifier.id, param); + derivedTuple.set(param.identifier.id, { + identifierPlace: param, + sources: [param], + typeOfValue: 'fromProps', + }); } } } else if (fn.fnType === 'Component') { const props = fn.params[0]; if (props != null && props.kind === 'Identifier') { - derivedFromProps.set(props.identifier.id, props); + derivedTuple.set(props.identifier.id, { + identifierPlace: props, + sources: [props], + typeOfValue: 'fromProps', + }); } } for (const block of fn.body.blocks.values()) { + for (const phi of block.phis) { + for (const operand of phi.operands.values()) { + const source = derivedTuple.get(operand.identifier.id); + if (source !== undefined && source.typeOfValue === 'fromProps') { + if ( + source.identifierPlace.identifier.name === null || + source.identifierPlace.identifier.name?.kind === 'promoted' + ) { + derivedTuple.set(phi.place.identifier.id, { + identifierPlace: phi.place, + sources: [phi.place], + typeOfValue: 'fromProps', + }); + } else { + derivedTuple.set(phi.place.identifier.id, { + identifierPlace: phi.place, + sources: source.sources, + typeOfValue: 'fromProps', + }); + } + } + } + } + for (const instr of block.instructions) { const {lvalue, value} = instr; - // Track props derivation through instruction effects - if (instr.effects != null) { - for (const effect of instr.effects) { - switch (effect.kind) { - case 'Assign': - case 'Alias': - case 'MaybeAlias': - case 'Capture': { - const source = derivedFromProps.get(effect.from.identifier.id); - if (source != null) { - derivedFromProps.set(effect.into.identifier.id, source); + // This needs to be repeated "recursively" on FunctionExpressions + // HERE >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + // DERIVATION LOGIC----------------------------------------------------- + console.log('instr', printInstruction(instr)); + console.log('instr', instr); + // console.log('instr lValue', instr.lvalue); + + let typeOfValue: TypeOfValue = 'ignored'; + + // TODO: Add handling for state derived props + let sources: DerivationMetadata[] = []; + for (const operand of eachInstructionValueOperand(value)) { + const opSource = derivedTuple.get(operand.identifier.id); + if (opSource === undefined) { + continue; + } + + typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); + sources.push(opSource); + } + + // TODO: Add handling for state derived props + if (typeOfValue !== 'ignored') { + for (const lvalue of eachInstructionLValue(instr)) { + updateDerivationMetadata(lvalue, sources, typeOfValue, derivedTuple); + } + + for (const operand of eachInstructionValueOperand(value)) { + switch (operand.effect) { + case Effect.Capture: + case Effect.Store: + case Effect.ConditionallyMutate: + case Effect.ConditionallyMutateIterator: + case Effect.Mutate: { + if (isMutable(instr, operand)) { + updateDerivationMetadata( + operand, + sources, + typeOfValue, + derivedTuple, + ); } break; } - } - } - } - - /** - * TODO: figure out why property access off of props does not create an Assign or Alias/Maybe - * Alias - * - * import {useEffect, useState} from 'react' - * - * function Component(props) { - * const [displayValue, setDisplayValue] = useState(''); - * - * useEffect(() => { - * const computed = props.prefix + props.value + props.suffix; - * ^^^^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^ - * we want to track that these are from props - * setDisplayValue(computed); - * }, [props.prefix, props.value, props.suffix]); - * - * return
{displayValue}
; - * } - */ - if (value.kind === 'FunctionExpression') { - for (const [, block] of value.loweredFunc.func.body.blocks) { - for (const instr of block.instructions) { - if (instr.effects != null) { - console.group(printInstruction(instr)); - for (const effect of instr.effects) { - console.log(effect); - switch (effect.kind) { - case 'Assign': - case 'Alias': - case 'MaybeAlias': - case 'Capture': { - const source = derivedFromProps.get( - effect.from.identifier.id, - ); - if (source != null) { - derivedFromProps.set(effect.into.identifier.id, source); - } - break; - } - } - } + case Effect.Freeze: + case Effect.Read: { + // no-op + break; + } + case Effect.Unknown: { + CompilerError.invariant(false, { + reason: 'Unexpected unknown effect', + description: null, + loc: operand.loc, + suggestions: null, + }); + } + default: { + assertExhaustive( + operand.effect, + `Unexpected effect kind \`${operand.effect}\``, + ); } - console.groupEnd(); } } } + console.log('derivedTuple', derivedTuple); + // HERE >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - for (const [, place] of derivedFromProps) { - console.log(printPlace(place)); - } - + // console.log('derivedTuple', derivedTuple); + // DERIVATION LOGIC----------------------------------------------------- if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); } else if (value.kind === 'ArrayExpression') { @@ -156,6 +261,8 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { ) { const callee = value.kind === 'CallExpression' ? value.callee : value.property; + + // This is a useEffect hook if ( isUseEffectHookType(callee.identifier) && value.args.length === 2 && @@ -180,7 +287,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { validateEffect( effectFunction.loweredFunc.func, dependencies, - derivedFromProps, + derivedTuple, errors, ); } @@ -196,7 +303,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function validateEffect( effectFunction: HIRFunction, effectDeps: Array, - derivedFromProps: Map, + derivedTuple: Map, errors: CompilerError, ): void { for (const operand of effectFunction.context) { @@ -204,7 +311,7 @@ function validateEffect( continue; } else if (effectDeps.find(dep => dep === operand.identifier.id) != null) { continue; - } else if (derivedFromProps.has(operand.identifier.id)) { + } else if (derivedTuple.has(operand.identifier.id)) { continue; } else { // Captured something other than the effect dep or setState @@ -212,29 +319,36 @@ function validateEffect( return; } } + + // This might be wrong gotta double check + let hasInvalidDep = false; for (const dep of effectDeps) { - console.log({dep}); + const depMetadata = derivedTuple.get(dep); if ( - effectFunction.context.find(operand => operand.identifier.id === dep) == + effectFunction.context.find(operand => operand.identifier.id === dep) != null || - derivedFromProps.has(dep) === false + (depMetadata !== undefined && depMetadata.typeOfValue !== 'ignored') ) { - console.log('early return 2'); - // effect dep wasn't actually used in the function - return; + hasInvalidDep = true; } } + if (!hasInvalidDep) { + console.log('early return 2'); + // effect dep wasn't actually used in the function + return; + } + const seenBlocks: Set = new Set(); + // This variable is suspicious maybe we don't need it? const values: Map> = new Map(); - const effectDerivedFromProps: Map = new Map(); + const effectInvalidlyDerived: Map = new Map(); for (const dep of effectDeps) { - console.log({dep}); values.set(dep, [dep]); - const propsSource = derivedFromProps.get(dep); - if (propsSource != null) { - effectDerivedFromProps.set(dep, propsSource); + const depMetadata = derivedTuple.get(dep); + if (depMetadata !== undefined) { + effectInvalidlyDerived.set(dep, depMetadata.sources); } } @@ -246,9 +360,11 @@ function validateEffect( return; } } + + // TODO: This might need editing for (const phi of block.phis) { const aggregateDeps: Set = new Set(); - let propsSource: Place | null = null; + let propsSources: Place[] | null = null; for (const operand of phi.operands.values()) { const deps = values.get(operand.identifier.id); @@ -257,19 +373,20 @@ function validateEffect( aggregateDeps.add(dep); } } - const source = effectDerivedFromProps.get(operand.identifier.id); - if (source != null) { - propsSource = source; + const sources = effectInvalidlyDerived.get(operand.identifier.id); + if (sources != null) { + propsSources = sources; } } if (aggregateDeps.size !== 0) { values.set(phi.place.identifier.id, Array.from(aggregateDeps)); } - if (propsSource != null) { - effectDerivedFromProps.set(phi.place.identifier.id, propsSource); + if (propsSources != null) { + effectInvalidlyDerived.set(phi.place.identifier.id, propsSources); } } + for (const instr of block.instructions) { switch (instr.value.kind) { case 'Primitive': @@ -291,7 +408,7 @@ function validateEffect( case 'CallExpression': case 'MethodCall': { const aggregateDeps: Set = new Set(); - for (const operand of eachInstructionValueOperand(instr.value)) { + for (const operand of eachInstructionOperand(instr)) { const deps = values.get(operand.identifier.id); if (deps != null) { for (const dep of deps) { @@ -310,60 +427,56 @@ function validateEffect( instr.value.args[0].kind === 'Identifier' ) { const deps = values.get(instr.value.args[0].identifier.id); + console.log('deps', deps); if (deps != null && new Set(deps).size === effectDeps.length) { - const propsSource = effectDerivedFromProps.get( + // console.log('setState arg', instr.value.args[0].identifier.id); + // console.log('effectInvalidlyDerived', effectInvalidlyDerived); + // console.log('derivedTuple', derivedTuple); + const propSources = derivedTuple.get( instr.value.args[0].identifier.id, ); - setStateCalls.push({ - loc: instr.value.callee.loc, - propsSource: propsSource ?? null, - }); + console.log('Final reference', propSources); + if (propSources !== undefined) { + setStateCalls.push({ + loc: instr.value.callee.loc, + propsSources: propSources.sources, + }); + } else { + setStateCalls.push({ + loc: instr.value.callee.loc, + propsSources: undefined, + }); + } } else { // doesn't depend on all deps + console.log('early return 3'); return; } } break; } default: { + console.log('early return 4'); return; } } - - // Track props derivation through instruction effects - if (instr.effects != null) { - for (const effect of instr.effects) { - switch (effect.kind) { - case 'Assign': - case 'Alias': - case 'MaybeAlias': - case 'Capture': { - const source = effectDerivedFromProps.get( - effect.from.identifier.id, - ); - if (source != null) { - effectDerivedFromProps.set(effect.into.identifier.id, source); - } - break; - } - } - } - } } for (const operand of eachTerminalOperand(block.terminal)) { if (values.has(operand.identifier.id)) { - // return; } } seenBlocks.add(block.id); } + console.log('setStateCalls', setStateCalls); for (const call of setStateCalls) { - if (call.propsSource != null) { - const propName = call.propsSource.identifier.name?.value; - const propInfo = propName != null ? ` (from prop '${propName}')` : ''; + if (call.propsSources != null) { + const propNames = call.propsSources + .map(place => place.identifier.name?.value) + .join(', '); + const propInfo = propNames != null ? ` (from props '${propNames}')` : ''; errors.push({ reason: `Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)`, From 89bec62a2267aa7f9e91274cf87f89d5797cc5f7 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes <57368278+jorge-cab@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:22:39 -0700 Subject: [PATCH 04/25] [compiler] Validation for values derived from props in useEffect ready --- .../ValidateNoDerivedComputationsInEffects.ts | 444 ++++++++++-------- 1 file changed, 248 insertions(+), 196 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index 0d6b7bd25d..bcd252294c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -5,39 +5,54 @@ * LICENSE file in the root directory of this source tree. */ -import {TypeOf} from 'zod'; +import {effect} from 'zod'; import {CompilerError, Effect, ErrorSeverity, SourceLocation} from '..'; import { ArrayExpression, + BasicBlock, BlockId, + Identifier, FunctionExpression, HIRFunction, IdentifierId, - InstructionValue, + Instruction, Place, isSetStateType, isUseEffectHookType, + isUseStateType, + IdentifierName, + GeneratedSource, } from '../HIR'; -import {printInstruction, printPlace} from '../HIR/PrintHIR'; +import {printInstruction} from '../HIR/PrintHIR'; import { - eachInstructionValueOperand, eachInstructionOperand, eachTerminalOperand, eachInstructionLValue, + eachPatternOperand, } from '../HIR/visitors'; import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables'; import {assertExhaustive} from '../Utils/utils'; type SetStateCall = { loc: SourceLocation; - propsSources: Place[] | undefined; // undefined means state-derived, defined means props-derived + invalidDeps: Map | undefined; + setStateId: IdentifierId; }; type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type DerivationMetadata = { + typeOfValue: TypeOfValue; + // TODO: Rename to place identifierPlace: Place; sources: Place[]; - typeOfValue: TypeOfValue; +}; + +// TODO: This needs refining +type ErrorMetadata = { + errorType: 'HoistState' | 'CalculateInRender'; + propInfo: string | undefined; + loc: SourceLocation; + setStateId: IdentifierId; }; function joinValue( @@ -50,22 +65,6 @@ function joinValue( return 'fromPropsOrState'; } -function propagateDerivation( - dest: Place, - source: Place | undefined, - derivedFromProps: Map, -) { - if (source === undefined) { - return; - } - - if (source.identifier.name?.kind === 'promoted') { - derivedFromProps.set(dest.identifier.id, dest); - } else { - derivedFromProps.set(dest.identifier.id, source); - } -} - function updateDerivationMetadata( target: Place, sources: DerivationMetadata[], @@ -80,7 +79,7 @@ function updateDerivationMetadata( for (const source of sources) { // If the identifier of the source is a promoted identifier, then - // we should set the source as the first named identifier. + // we should set the target as the source. if (source.identifierPlace.identifier.name?.kind === 'promoted') { newValue.sources.push(target); } else { @@ -90,6 +89,133 @@ function updateDerivationMetadata( derivedTuple.set(target.identifier.id, newValue); } +function parseInstr( + instr: Instruction, + derivedTuple: Map, + setStateCalls: Map, +) { + // console.log(printInstruction(instr)); + // console.log(instr); + let typeOfValue: TypeOfValue = 'ignored'; + + // If the instruction is destructuring a useState hook call + if ( + instr.value.kind === 'Destructure' && + instr.value.lvalue.pattern.kind === 'ArrayPattern' && + isUseStateType(instr.value.value.identifier) + ) { + const value = instr.value.lvalue.pattern.items[0]; + if (value.kind === 'Identifier') { + derivedTuple.set(value.identifier.id, { + identifierPlace: value, + sources: [value], + typeOfValue: 'fromState', + }); + } + } + + // If the instruction is calling a setState + if ( + instr.value.kind === 'CallExpression' && + isSetStateType(instr.value.callee.identifier) && + instr.value.args.length === 1 && + instr.value.args[0].kind === 'Identifier' && + instr.value.callee.loc !== GeneratedSource && + instr.value.callee.loc.identifierName !== undefined && + instr.value.callee.loc.identifierName !== null + ) { + setStateCalls.set( + instr.value.callee.loc.identifierName, + instr.value.callee, + ); + } + + let sources: DerivationMetadata[] = []; + for (const operand of eachInstructionOperand(instr)) { + const opSource = derivedTuple.get(operand.identifier.id); + if (opSource === undefined) { + continue; + } + + typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); + sources.push(opSource); + } + + if (typeOfValue !== 'ignored') { + for (const lvalue of eachInstructionLValue(instr)) { + updateDerivationMetadata(lvalue, sources, typeOfValue, derivedTuple); + } + + for (const operand of eachInstructionOperand(instr)) { + switch (operand.effect) { + case Effect.Capture: + case Effect.Store: + case Effect.ConditionallyMutate: + case Effect.ConditionallyMutateIterator: + case Effect.Mutate: { + if (isMutable(instr, operand)) { + updateDerivationMetadata( + operand, + sources, + typeOfValue, + derivedTuple, + ); + } + break; + } + case Effect.Freeze: + case Effect.Read: { + // no-op + break; + } + case Effect.Unknown: { + CompilerError.invariant(false, { + reason: 'Unexpected unknown effect', + description: null, + loc: operand.loc, + suggestions: null, + }); + } + default: { + assertExhaustive( + operand.effect, + `Unexpected effect kind \`${operand.effect}\``, + ); + } + } + } + } +} + +function parseBlockPhi( + block: BasicBlock, + derivedTuple: Map, +) { + for (const phi of block.phis) { + for (const operand of phi.operands.values()) { + const source = derivedTuple.get(operand.identifier.id); + if (source !== undefined && source.typeOfValue === 'fromProps') { + if ( + source.identifierPlace.identifier.name === null || + source.identifierPlace.identifier.name?.kind === 'promoted' + ) { + derivedTuple.set(phi.place.identifier.id, { + identifierPlace: phi.place, + sources: [phi.place], + typeOfValue: 'fromProps', + }); + } else { + derivedTuple.set(phi.place.identifier.id, { + identifierPlace: phi.place, + sources: source.sources, + typeOfValue: 'fromProps', + }); + } + } + } + } +} + /** * Validates that useEffect is not used for derived computations which could/should * be performed in render. @@ -117,17 +243,15 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const candidateDependencies: Map = new Map(); const functions: Map = new Map(); const locals: Map = new Map(); - - // MY take on this - const valueToType: Map = new Map(); - const valueToSourceProps: Map> = new Map(); - const valueToSourceStates: Map> = new Map(); - const valueToSources: Map> = new Map(); - - // Sources are still probably not correct const derivedTuple: Map = new Map(); - const errors = new CompilerError(); + // Investigating + const effectSetStates: Map = new Map(); + const setStateCalls: Map = new Map(); + + // let shouldCalculateInRender: boolean = true; + + const errors: ErrorMetadata[] = []; if (fn.fnType === 'Hook') { for (const param of fn.params) { @@ -151,104 +275,26 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } for (const block of fn.body.blocks.values()) { - for (const phi of block.phis) { - for (const operand of phi.operands.values()) { - const source = derivedTuple.get(operand.identifier.id); - if (source !== undefined && source.typeOfValue === 'fromProps') { - if ( - source.identifierPlace.identifier.name === null || - source.identifierPlace.identifier.name?.kind === 'promoted' - ) { - derivedTuple.set(phi.place.identifier.id, { - identifierPlace: phi.place, - sources: [phi.place], - typeOfValue: 'fromProps', - }); - } else { - derivedTuple.set(phi.place.identifier.id, { - identifierPlace: phi.place, - sources: source.sources, - typeOfValue: 'fromProps', - }); - } - } - } - } + parseBlockPhi(block, derivedTuple); for (const instr of block.instructions) { const {lvalue, value} = instr; - // This needs to be repeated "recursively" on FunctionExpressions - // HERE >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - // DERIVATION LOGIC----------------------------------------------------- - console.log('instr', printInstruction(instr)); - console.log('instr', instr); - // console.log('instr lValue', instr.lvalue); + parseInstr(instr, derivedTuple, setStateCalls); - let typeOfValue: TypeOfValue = 'ignored'; - - // TODO: Add handling for state derived props - let sources: DerivationMetadata[] = []; - for (const operand of eachInstructionValueOperand(value)) { - const opSource = derivedTuple.get(operand.identifier.id); - if (opSource === undefined) { - continue; - } - - typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); - sources.push(opSource); - } - - // TODO: Add handling for state derived props - if (typeOfValue !== 'ignored') { - for (const lvalue of eachInstructionLValue(instr)) { - updateDerivationMetadata(lvalue, sources, typeOfValue, derivedTuple); - } - - for (const operand of eachInstructionValueOperand(value)) { - switch (operand.effect) { - case Effect.Capture: - case Effect.Store: - case Effect.ConditionallyMutate: - case Effect.ConditionallyMutateIterator: - case Effect.Mutate: { - if (isMutable(instr, operand)) { - updateDerivationMetadata( - operand, - sources, - typeOfValue, - derivedTuple, - ); - } - break; - } - case Effect.Freeze: - case Effect.Read: { - // no-op - break; - } - case Effect.Unknown: { - CompilerError.invariant(false, { - reason: 'Unexpected unknown effect', - description: null, - loc: operand.loc, - suggestions: null, - }); - } - default: { - assertExhaustive( - operand.effect, - `Unexpected effect kind \`${operand.effect}\``, - ); - } + /* + * Special case for function expressions, we need to parse nested instructions + * TODO: Can there be more recursive levels? + */ + if (value.kind === 'FunctionExpression') { + for (const [, block] of value.loweredFunc.func.body.blocks) { + for (const instr of block.instructions) { + parseInstr(instr, derivedTuple, setStateCalls); } } } - console.log('derivedTuple', derivedTuple); - // HERE >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - // console.log('derivedTuple', derivedTuple); - // DERIVATION LOGIC----------------------------------------------------- + // Maybe this should run for every instruction being parsed if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); } else if (value.kind === 'ArrayExpression') { @@ -262,7 +308,6 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const callee = value.kind === 'CallExpression' ? value.callee : value.property; - // This is a useEffect hook if ( isUseEffectHookType(callee.identifier) && value.args.length === 2 && @@ -288,6 +333,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { effectFunction.loweredFunc.func, dependencies, derivedTuple, + effectSetStates, errors, ); } @@ -295,8 +341,21 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } } } - if (errors.hasErrors()) { - throw errors; + + console.log('setStateCalls: ', setStateCalls); + console.log('effectSetStates: ', effectSetStates); + const throwableErrors = new CompilerError(); + for (const error of errors) { + throwableErrors.push({ + reason: `You may not need an effect. Values derived from state should be calculated in render, not in an effect. `, + description: `You are using a value derived from props${error.propInfo} to update local state in an effect.`, + severity: ErrorSeverity.InvalidReact, + loc: error.loc, + }); + } + + if (throwableErrors.hasErrors()) { + throw throwableErrors; } } @@ -304,8 +363,13 @@ function validateEffect( effectFunction: HIRFunction, effectDeps: Array, derivedTuple: Map, - errors: CompilerError, + effectSetStates: Map, + errors: ErrorMetadata[], ): void { + /* + * TODO: This makes it so we only capture single line useEffects. + * We should be able to capture multiline as well + */ for (const operand of effectFunction.context) { if (isSetStateType(operand.identifier)) { continue; @@ -315,7 +379,6 @@ function validateEffect( continue; } else { // Captured something other than the effect dep or setState - console.log('early return 1'); return; } } @@ -342,17 +405,18 @@ function validateEffect( const seenBlocks: Set = new Set(); // This variable is suspicious maybe we don't need it? const values: Map> = new Map(); - const effectInvalidlyDerived: Map = new Map(); + const effectInvalidlyDerived: Map = + new Map(); for (const dep of effectDeps) { values.set(dep, [dep]); const depMetadata = derivedTuple.get(dep); if (depMetadata !== undefined) { - effectInvalidlyDerived.set(dep, depMetadata.sources); + effectInvalidlyDerived.set(dep, depMetadata); } } - const setStateCalls: Array = []; + const setStateCallsInEffect: Array = []; for (const block of effectFunction.body.blocks.values()) { for (const pred of block.preds) { if (!seenBlocks.has(pred)) { @@ -361,33 +425,23 @@ function validateEffect( } } - // TODO: This might need editing - for (const phi of block.phis) { - const aggregateDeps: Set = new Set(); - let propsSources: Place[] | null = null; - - for (const operand of phi.operands.values()) { - const deps = values.get(operand.identifier.id); - if (deps != null) { - for (const dep of deps) { - aggregateDeps.add(dep); - } - } - const sources = effectInvalidlyDerived.get(operand.identifier.id); - if (sources != null) { - propsSources = sources; - } - } - - if (aggregateDeps.size !== 0) { - values.set(phi.place.identifier.id, Array.from(aggregateDeps)); - } - if (propsSources != null) { - effectInvalidlyDerived.set(phi.place.identifier.id, propsSources); - } - } + parseBlockPhi(block, effectInvalidlyDerived); for (const instr of block.instructions) { + if ( + instr.value.kind === 'CallExpression' && + isSetStateType(instr.value.callee.identifier) && + instr.value.args.length === 1 && + instr.value.args[0].kind === 'Identifier' && + instr.value.callee.loc !== GeneratedSource && + instr.value.callee.loc.identifierName !== undefined && + instr.value.callee.loc.identifierName !== null + ) { + effectSetStates.set( + instr.value.callee.loc.identifierName, + instr.value.callee, + ); + } switch (instr.value.kind) { case 'Primitive': case 'JSXText': @@ -426,32 +480,24 @@ function validateEffect( instr.value.args.length === 1 && instr.value.args[0].kind === 'Identifier' ) { - const deps = values.get(instr.value.args[0].identifier.id); - console.log('deps', deps); - if (deps != null && new Set(deps).size === effectDeps.length) { - // console.log('setState arg', instr.value.args[0].identifier.id); - // console.log('effectInvalidlyDerived', effectInvalidlyDerived); - // console.log('derivedTuple', derivedTuple); - const propSources = derivedTuple.get( - instr.value.args[0].identifier.id, - ); + const propSources = derivedTuple.get( + instr.value.args[0].identifier.id, + ); - console.log('Final reference', propSources); - if (propSources !== undefined) { - setStateCalls.push({ - loc: instr.value.callee.loc, - propsSources: propSources.sources, - }); - } else { - setStateCalls.push({ - loc: instr.value.callee.loc, - propsSources: undefined, - }); - } + if (propSources !== undefined) { + setStateCallsInEffect.push({ + loc: instr.value.callee.loc, + setStateId: instr.value.callee.identifier.id, + invalidDeps: new Map([ + [instr.value.args[0].identifier, propSources.sources], + ]), + }); } else { - // doesn't depend on all deps - console.log('early return 3'); - return; + setStateCallsInEffect.push({ + loc: instr.value.callee.loc, + setStateId: instr.value.callee.identifier.id, + invalidDeps: undefined, + }); } } break; @@ -462,6 +508,7 @@ function validateEffect( } } } + for (const operand of eachTerminalOperand(block.terminal)) { if (values.has(operand.identifier.id)) { return; @@ -470,31 +517,36 @@ function validateEffect( seenBlocks.add(block.id); } - console.log('setStateCalls', setStateCalls); - for (const call of setStateCalls) { - if (call.propsSources != null) { - const propNames = call.propsSources - .map(place => place.identifier.name?.value) - .join(', '); - const propInfo = propNames != null ? ` (from props '${propNames}')` : ''; + // need to track if the setState call has been used elsewhere + // if it is then the solution should be to lift the state up to the parent component + // if not the solution should be to calculate the value in rende + // + // If the same setState is used both inside and outside the effect + + for (const call of setStateCallsInEffect) { + if (call.invalidDeps != null) { + let propNames = ''; + for (const [, places] of call.invalidDeps.entries()) { + const placeNames = places + .map(place => place.identifier.name?.value) + .join(', '); + propNames += `[${placeNames}], `; + } + propNames = propNames.slice(0, -2); + const propInfo = propNames ? ` (from props '${propNames}')` : ''; errors.push({ - reason: `Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)`, - description: `You are using props${propInfo} to update local state in an effect.`, - severity: ErrorSeverity.InvalidReact, + errorType: 'HoistState', + propInfo: propInfo, loc: call.loc, - suggestions: null, + setStateId: call.setStateId, }); } else { errors.push({ - reason: - 'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)', - description: - 'This effect updates state based on other state values. ' + - 'Consider calculating this value directly during render', - severity: ErrorSeverity.InvalidReact, + errorType: 'CalculateInRender', + propInfo: undefined, loc: call.loc, - suggestions: null, + setStateId: call.setStateId, }); } } From 41c1e1c9d70a6f811a450baf504aa27bd1b6b432 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes <57368278+jorge-cab@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:22:39 -0700 Subject: [PATCH 05/25] [compiler] Added check for if the same invalid setSate within an effect is used elsewhere --- .../ValidateNoDerivedComputationsInEffects.ts | 92 ++++++++++++------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index bcd252294c..0a79bc35d2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -40,6 +40,8 @@ type SetStateCall = { }; type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; +type SetStateName = string | undefined | null; + type DerivationMetadata = { typeOfValue: TypeOfValue; // TODO: Rename to place @@ -51,8 +53,9 @@ type DerivationMetadata = { type ErrorMetadata = { errorType: 'HoistState' | 'CalculateInRender'; propInfo: string | undefined; + localStateInfo: string | undefined; loc: SourceLocation; - setStateId: IdentifierId; + setStateName: SetStateName; }; function joinValue( @@ -92,7 +95,7 @@ function updateDerivationMetadata( function parseInstr( instr: Instruction, derivedTuple: Map, - setStateCalls: Map, + setStateCalls: Map, ) { // console.log(printInstruction(instr)); // console.log(instr); @@ -120,14 +123,17 @@ function parseInstr( isSetStateType(instr.value.callee.identifier) && instr.value.args.length === 1 && instr.value.args[0].kind === 'Identifier' && - instr.value.callee.loc !== GeneratedSource && - instr.value.callee.loc.identifierName !== undefined && - instr.value.callee.loc.identifierName !== null + instr.value.callee.loc !== GeneratedSource ) { - setStateCalls.set( - instr.value.callee.loc.identifierName, - instr.value.callee, - ); + if (setStateCalls.has(instr.value.callee.loc.identifierName)) { + setStateCalls + .get(instr.value.callee.loc.identifierName)! + .push(instr.value.callee); + } else { + setStateCalls.set(instr.value.callee.loc.identifierName, [ + instr.value.callee, + ]); + } } let sources: DerivationMetadata[] = []; @@ -245,11 +251,8 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const locals: Map = new Map(); const derivedTuple: Map = new Map(); - // Investigating - const effectSetStates: Map = new Map(); - const setStateCalls: Map = new Map(); - - // let shouldCalculateInRender: boolean = true; + const effectSetStates: Map = new Map(); + const setStateCalls: Map = new Map(); const errors: ErrorMetadata[] = []; @@ -342,13 +345,37 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } } - console.log('setStateCalls: ', setStateCalls); - console.log('effectSetStates: ', effectSetStates); const throwableErrors = new CompilerError(); for (const error of errors) { + let reason; + let description = ''; + // TODO: Not sure if this is robust enough. + /* + * If we use a setState from an invalid useEffect elsewhere then we probably have to + * hoist state up, else we should calculate in render + */ + if ( + setStateCalls.get(error.setStateName)?.length != + effectSetStates.get(error.setStateName)?.length + ) { + reason = + 'Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)'; + } else { + reason = + 'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)'; + } + + if (error.propInfo !== undefined) { + description += error.propInfo; + } + + if (error.localStateInfo !== undefined) { + description += error.localStateInfo; + } + throwableErrors.push({ - reason: `You may not need an effect. Values derived from state should be calculated in render, not in an effect. `, - description: `You are using a value derived from props${error.propInfo} to update local state in an effect.`, + reason: reason, + description: description, severity: ErrorSeverity.InvalidReact, loc: error.loc, }); @@ -363,7 +390,7 @@ function validateEffect( effectFunction: HIRFunction, effectDeps: Array, derivedTuple: Map, - effectSetStates: Map, + effectSetStates: Map, errors: ErrorMetadata[], ): void { /* @@ -437,10 +464,15 @@ function validateEffect( instr.value.callee.loc.identifierName !== undefined && instr.value.callee.loc.identifierName !== null ) { - effectSetStates.set( - instr.value.callee.loc.identifierName, - instr.value.callee, - ); + if (effectSetStates.has(instr.value.callee.loc.identifierName)) { + effectSetStates + .get(instr.value.callee.loc.identifierName)! + .push(instr.value.callee); + } else { + effectSetStates.set(instr.value.callee.loc.identifierName, [ + instr.value.callee, + ]); + } } switch (instr.value.kind) { case 'Primitive': @@ -517,12 +549,6 @@ function validateEffect( seenBlocks.add(block.id); } - // need to track if the setState call has been used elsewhere - // if it is then the solution should be to lift the state up to the parent component - // if not the solution should be to calculate the value in rende - // - // If the same setState is used both inside and outside the effect - for (const call of setStateCallsInEffect) { if (call.invalidDeps != null) { let propNames = ''; @@ -538,15 +564,19 @@ function validateEffect( errors.push({ errorType: 'HoistState', propInfo: propInfo, + localStateInfo: undefined, loc: call.loc, - setStateId: call.setStateId, + setStateName: + call.loc !== GeneratedSource ? call.loc.identifierName : undefined, }); } else { errors.push({ errorType: 'CalculateInRender', propInfo: undefined, + localStateInfo: undefined, loc: call.loc, - setStateId: call.setStateId, + setStateName: + call.loc !== GeneratedSource ? call.loc.identifierName : undefined, }); } } From e0e356a980b3a4de8d9c2577519332783403d664 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes <57368278+jorge-cab@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:22:39 -0700 Subject: [PATCH 06/25] [compiler] Added validation for local state and refined error messages --- .../ValidateNoDerivedComputationsInEffects.ts | 95 ++++++++----------- 1 file changed, 39 insertions(+), 56 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index 0a79bc35d2..84b33d37c5 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -33,11 +33,13 @@ import { import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables'; import {assertExhaustive} from '../Utils/utils'; +// TODO: Maybe I can consolidate some types type SetStateCall = { loc: SourceLocation; - invalidDeps: Map | undefined; + invalidDeps: DerivationMetadata; setStateId: IdentifierId; }; + type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type SetStateName = string | undefined | null; @@ -51,9 +53,8 @@ type DerivationMetadata = { // TODO: This needs refining type ErrorMetadata = { - errorType: 'HoistState' | 'CalculateInRender'; - propInfo: string | undefined; - localStateInfo: string | undefined; + errorType: TypeOfValue; + invalidDepInfo: string | undefined; loc: SourceLocation; setStateName: SetStateName; }; @@ -101,7 +102,7 @@ function parseInstr( // console.log(instr); let typeOfValue: TypeOfValue = 'ignored'; - // If the instruction is destructuring a useState hook call + // TODO: Not sure if this will catch every time we create a new useState if ( instr.value.kind === 'Destructure' && instr.value.lvalue.pattern.kind === 'ArrayPattern' && @@ -117,7 +118,6 @@ function parseInstr( } } - // If the instruction is calling a setState if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && @@ -297,7 +297,6 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } } - // Maybe this should run for every instruction being parsed if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); } else if (value.kind === 'ArrayExpression') { @@ -356,7 +355,8 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { */ if ( setStateCalls.get(error.setStateName)?.length != - effectSetStates.get(error.setStateName)?.length + effectSetStates.get(error.setStateName)?.length && + error.errorType !== 'fromState' ) { reason = 'Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)'; @@ -365,17 +365,9 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { 'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)'; } - if (error.propInfo !== undefined) { - description += error.propInfo; - } - - if (error.localStateInfo !== undefined) { - description += error.localStateInfo; - } - throwableErrors.push({ reason: reason, - description: description, + description: `You are using invalid dependencies: \n\n${error.invalidDepInfo}`, severity: ErrorSeverity.InvalidReact, loc: error.loc, }); @@ -410,7 +402,7 @@ function validateEffect( } } - // This might be wrong gotta double check + // TODO: This might be wrong gotta double check let hasInvalidDep = false; for (const dep of effectDeps) { const depMetadata = derivedTuple.get(dep); @@ -512,23 +504,15 @@ function validateEffect( instr.value.args.length === 1 && instr.value.args[0].kind === 'Identifier' ) { - const propSources = derivedTuple.get( + const invalidDeps = derivedTuple.get( instr.value.args[0].identifier.id, ); - if (propSources !== undefined) { + if (invalidDeps !== undefined) { setStateCallsInEffect.push({ loc: instr.value.callee.loc, setStateId: instr.value.callee.identifier.id, - invalidDeps: new Map([ - [instr.value.args[0].identifier, propSources.sources], - ]), - }); - } else { - setStateCallsInEffect.push({ - loc: instr.value.callee.loc, - setStateId: instr.value.callee.identifier.id, - invalidDeps: undefined, + invalidDeps: invalidDeps, }); } } @@ -550,34 +534,33 @@ function validateEffect( } for (const call of setStateCallsInEffect) { - if (call.invalidDeps != null) { - let propNames = ''; - for (const [, places] of call.invalidDeps.entries()) { - const placeNames = places - .map(place => place.identifier.name?.value) - .join(', '); - propNames += `[${placeNames}], `; - } - propNames = propNames.slice(0, -2); - const propInfo = propNames ? ` (from props '${propNames}')` : ''; + const placeNames = call.invalidDeps.sources + .map(place => place.identifier.name?.value) + .join(', '); - errors.push({ - errorType: 'HoistState', - propInfo: propInfo, - localStateInfo: undefined, - loc: call.loc, - setStateName: - call.loc !== GeneratedSource ? call.loc.identifierName : undefined, - }); - } else { - errors.push({ - errorType: 'CalculateInRender', - propInfo: undefined, - localStateInfo: undefined, - loc: call.loc, - setStateName: - call.loc !== GeneratedSource ? call.loc.identifierName : undefined, - }); + let sourceNames = ''; + let invalidDepInfo = ''; + console.log(call.invalidDeps); + if (call.invalidDeps.typeOfValue === 'fromProps') { + sourceNames += `[${placeNames}], `; + sourceNames = sourceNames.slice(0, -2); + invalidDepInfo = sourceNames + ? `Invalid deps from props ${sourceNames}` + : ''; + } else if (call.invalidDeps.typeOfValue === 'fromState') { + sourceNames += `[${placeNames}], `; + sourceNames = sourceNames.slice(0, -2); + invalidDepInfo = sourceNames + ? `Invalid deps from local state: ${sourceNames}` + : ''; } + + errors.push({ + errorType: call.invalidDeps.typeOfValue, + invalidDepInfo: invalidDepInfo, + loc: call.loc, + setStateName: + call.loc !== GeneratedSource ? call.loc.identifierName : undefined, + }); } } From 00ddf2819ac3c1f69bf5b3783146efff12c1535d Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes <57368278+jorge-cab@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:22:39 -0700 Subject: [PATCH 07/25] [compiler] First functional disambiguated single line validation of no derived computations in effects --- .../ValidateNoDerivedComputationsInEffects.ts | 64 +++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index 84b33d37c5..b14b9cdc3c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -5,13 +5,11 @@ * LICENSE file in the root directory of this source tree. */ -import {effect} from 'zod'; import {CompilerError, Effect, ErrorSeverity, SourceLocation} from '..'; import { ArrayExpression, BasicBlock, BlockId, - Identifier, FunctionExpression, HIRFunction, IdentifierId, @@ -20,15 +18,12 @@ import { isSetStateType, isUseEffectHookType, isUseStateType, - IdentifierName, GeneratedSource, } from '../HIR'; -import {printInstruction} from '../HIR/PrintHIR'; import { eachInstructionOperand, eachTerminalOperand, eachInstructionLValue, - eachPatternOperand, } from '../HIR/visitors'; import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables'; import {assertExhaustive} from '../Utils/utils'; @@ -46,12 +41,10 @@ type SetStateName = string | undefined | null; type DerivationMetadata = { typeOfValue: TypeOfValue; - // TODO: Rename to place - identifierPlace: Place; - sources: Place[]; + place: Place; + sources: Array; }; -// TODO: This needs refining type ErrorMetadata = { errorType: TypeOfValue; invalidDepInfo: string | undefined; @@ -71,20 +64,22 @@ function joinValue( function updateDerivationMetadata( target: Place, - sources: DerivationMetadata[], + sources: Array, typeOfValue: TypeOfValue, derivedTuple: Map, ): void { let newValue: DerivationMetadata = { - identifierPlace: target, + place: target, sources: [], typeOfValue: typeOfValue, }; for (const source of sources) { - // If the identifier of the source is a promoted identifier, then - // we should set the target as the source. - if (source.identifierPlace.identifier.name?.kind === 'promoted') { + /* + * If the identifier of the source is a promoted identifier, then + * we should set the target as the source. + */ + if (source.place.identifier.name?.kind === 'promoted') { newValue.sources.push(target); } else { newValue.sources.push(...source.sources); @@ -96,10 +91,8 @@ function updateDerivationMetadata( function parseInstr( instr: Instruction, derivedTuple: Map, - setStateCalls: Map, -) { - // console.log(printInstruction(instr)); - // console.log(instr); + setStateCalls: Map>, +): void { let typeOfValue: TypeOfValue = 'ignored'; // TODO: Not sure if this will catch every time we create a new useState @@ -111,7 +104,7 @@ function parseInstr( const value = instr.value.lvalue.pattern.items[0]; if (value.kind === 'Identifier') { derivedTuple.set(value.identifier.id, { - identifierPlace: value, + place: value, sources: [value], typeOfValue: 'fromState', }); @@ -136,7 +129,7 @@ function parseInstr( } } - let sources: DerivationMetadata[] = []; + let sources: Array = []; for (const operand of eachInstructionOperand(instr)) { const opSource = derivedTuple.get(operand.identifier.id); if (opSource === undefined) { @@ -196,23 +189,23 @@ function parseInstr( function parseBlockPhi( block: BasicBlock, derivedTuple: Map, -) { +): void { for (const phi of block.phis) { for (const operand of phi.operands.values()) { const source = derivedTuple.get(operand.identifier.id); if (source !== undefined && source.typeOfValue === 'fromProps') { if ( - source.identifierPlace.identifier.name === null || - source.identifierPlace.identifier.name?.kind === 'promoted' + source.place.identifier.name === null || + source.place.identifier.name?.kind === 'promoted' ) { derivedTuple.set(phi.place.identifier.id, { - identifierPlace: phi.place, + place: phi.place, sources: [phi.place], typeOfValue: 'fromProps', }); } else { derivedTuple.set(phi.place.identifier.id, { - identifierPlace: phi.place, + place: phi.place, sources: source.sources, typeOfValue: 'fromProps', }); @@ -251,16 +244,16 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const locals: Map = new Map(); const derivedTuple: Map = new Map(); - const effectSetStates: Map = new Map(); - const setStateCalls: Map = new Map(); + const effectSetStates: Map> = new Map(); + const setStateCalls: Map> = new Map(); - const errors: ErrorMetadata[] = []; + const errors: Array = []; if (fn.fnType === 'Hook') { for (const param of fn.params) { if (param.kind === 'Identifier') { derivedTuple.set(param.identifier.id, { - identifierPlace: param, + place: param, sources: [param], typeOfValue: 'fromProps', }); @@ -270,7 +263,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const props = fn.params[0]; if (props != null && props.kind === 'Identifier') { derivedTuple.set(props.identifier.id, { - identifierPlace: props, + place: props, sources: [props], typeOfValue: 'fromProps', }); @@ -347,7 +340,6 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const throwableErrors = new CompilerError(); for (const error of errors) { let reason; - let description = ''; // TODO: Not sure if this is robust enough. /* * If we use a setState from an invalid useEffect elsewhere then we probably have to @@ -382,8 +374,8 @@ function validateEffect( effectFunction: HIRFunction, effectDeps: Array, derivedTuple: Map, - effectSetStates: Map, - errors: ErrorMetadata[], + effectSetStates: Map>, + errors: Array, ): void { /* * TODO: This makes it so we only capture single line useEffects. @@ -553,6 +545,12 @@ function validateEffect( invalidDepInfo = sourceNames ? `Invalid deps from local state: ${sourceNames}` : ''; + } else { + sourceNames += `[${placeNames}], `; + sourceNames = sourceNames.slice(0, -2); + invalidDepInfo = sourceNames + ? `Invalid deps from both props and local state: ${sourceNames}` + : ''; } errors.push({ From 2ae27a55d04e4914384a7aa4a8669213649642bb Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Thu, 4 Sep 2025 11:25:18 -0700 Subject: [PATCH 08/25] [compiler] Remove single line constraint and improve overall capturing logic --- .../ValidateNoDerivedComputationsInEffects.ts | 605 +++++++++--------- ...-state-with-conditional-no-error.expect.md | 79 --- ...state-with-side-effects-no-error.expect.md | 74 --- ...ug-derived-state-from-mixed-deps.expect.md | 6 +- ...erived-state-from-shadowed-props.expect.md | 58 ++ ...error.derived-state-from-shadowed-props.js | 21 + ...r.derived-state-with-conditional.expect.md | 49 ++ ...> error.derived-state-with-conditional.js} | 0 ....derived-state-with-side-effects.expect.md | 47 ++ ... error.derived-state-with-side-effects.js} | 0 ...id-derived-computation-in-effect.expect.md | 6 +- ...r.invalid-derived-computation-in-effect.js | 0 ...erived-state-from-props-computed.expect.md | 46 ++ ...alid-derived-state-from-props-computed.js} | 0 ...ed-state-from-props-destructured.expect.md | 20 +- ...d-derived-state-from-props-destructured.js | 8 +- ...rived-state-from-props-in-effect.expect.md | 6 +- ...te-from-props-with-default-value.expect.md | 43 ++ ...ved-state-from-props-with-default-value.js | 15 + ...rived-state-from-state-in-effect.expect.md | 6 +- ...erived-state-from-props-computed.expect.md | 72 --- 21 files changed, 601 insertions(+), 560 deletions(-) delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/{derived-state-with-conditional-no-error.js => error.derived-state-with-conditional.js} (100%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/{derived-state-with-side-effects-no-error.js => error.derived-state-with-side-effects.js} (100%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{ => useEffect}/error.invalid-derived-computation-in-effect.expect.md (58%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{ => useEffect}/error.invalid-derived-computation-in-effect.js (100%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/{invalid-derived-state-from-props-computed.js => error.invalid-derived-state-from-props-computed.js} (100%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index b14b9cdc3c..eca16f44f2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -5,7 +5,13 @@ * LICENSE file in the root directory of this source tree. */ -import {CompilerError, Effect, ErrorSeverity, SourceLocation} from '..'; +import { + CompilerDiagnostic, + CompilerError, + Effect, + ErrorSeverity, + SourceLocation, +} from '..'; import { ArrayExpression, BasicBlock, @@ -20,201 +26,31 @@ import { isUseStateType, GeneratedSource, } from '../HIR'; -import { - eachInstructionOperand, - eachTerminalOperand, - eachInstructionLValue, -} from '../HIR/visitors'; +import {eachInstructionOperand, eachInstructionLValue} from '../HIR/visitors'; import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables'; import {assertExhaustive} from '../Utils/utils'; -// TODO: Maybe I can consolidate some types type SetStateCall = { loc: SourceLocation; - invalidDeps: DerivationMetadata; + derivedDep: DerivationMetadata; setStateId: IdentifierId; }; type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; -type SetStateName = string | undefined | null; - type DerivationMetadata = { typeOfValue: TypeOfValue; place: Place; - sources: Array; + sources: Set; }; type ErrorMetadata = { - errorType: TypeOfValue; - invalidDepInfo: string | undefined; + type: TypeOfValue; + description: string | undefined; loc: SourceLocation; - setStateName: SetStateName; + setStateName: string | undefined | null; }; -function joinValue( - lvalueType: TypeOfValue, - valueType: TypeOfValue, -): TypeOfValue { - if (lvalueType === 'ignored') return valueType; - if (valueType === 'ignored') return lvalueType; - if (lvalueType === valueType) return lvalueType; - return 'fromPropsOrState'; -} - -function updateDerivationMetadata( - target: Place, - sources: Array, - typeOfValue: TypeOfValue, - derivedTuple: Map, -): void { - let newValue: DerivationMetadata = { - place: target, - sources: [], - typeOfValue: typeOfValue, - }; - - for (const source of sources) { - /* - * If the identifier of the source is a promoted identifier, then - * we should set the target as the source. - */ - if (source.place.identifier.name?.kind === 'promoted') { - newValue.sources.push(target); - } else { - newValue.sources.push(...source.sources); - } - } - derivedTuple.set(target.identifier.id, newValue); -} - -function parseInstr( - instr: Instruction, - derivedTuple: Map, - setStateCalls: Map>, -): void { - let typeOfValue: TypeOfValue = 'ignored'; - - // TODO: Not sure if this will catch every time we create a new useState - if ( - instr.value.kind === 'Destructure' && - instr.value.lvalue.pattern.kind === 'ArrayPattern' && - isUseStateType(instr.value.value.identifier) - ) { - const value = instr.value.lvalue.pattern.items[0]; - if (value.kind === 'Identifier') { - derivedTuple.set(value.identifier.id, { - place: value, - sources: [value], - typeOfValue: 'fromState', - }); - } - } - - if ( - instr.value.kind === 'CallExpression' && - isSetStateType(instr.value.callee.identifier) && - instr.value.args.length === 1 && - instr.value.args[0].kind === 'Identifier' && - instr.value.callee.loc !== GeneratedSource - ) { - if (setStateCalls.has(instr.value.callee.loc.identifierName)) { - setStateCalls - .get(instr.value.callee.loc.identifierName)! - .push(instr.value.callee); - } else { - setStateCalls.set(instr.value.callee.loc.identifierName, [ - instr.value.callee, - ]); - } - } - - let sources: Array = []; - for (const operand of eachInstructionOperand(instr)) { - const opSource = derivedTuple.get(operand.identifier.id); - if (opSource === undefined) { - continue; - } - - typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); - sources.push(opSource); - } - - if (typeOfValue !== 'ignored') { - for (const lvalue of eachInstructionLValue(instr)) { - updateDerivationMetadata(lvalue, sources, typeOfValue, derivedTuple); - } - - for (const operand of eachInstructionOperand(instr)) { - switch (operand.effect) { - case Effect.Capture: - case Effect.Store: - case Effect.ConditionallyMutate: - case Effect.ConditionallyMutateIterator: - case Effect.Mutate: { - if (isMutable(instr, operand)) { - updateDerivationMetadata( - operand, - sources, - typeOfValue, - derivedTuple, - ); - } - break; - } - case Effect.Freeze: - case Effect.Read: { - // no-op - break; - } - case Effect.Unknown: { - CompilerError.invariant(false, { - reason: 'Unexpected unknown effect', - description: null, - loc: operand.loc, - suggestions: null, - }); - } - default: { - assertExhaustive( - operand.effect, - `Unexpected effect kind \`${operand.effect}\``, - ); - } - } - } - } -} - -function parseBlockPhi( - block: BasicBlock, - derivedTuple: Map, -): void { - for (const phi of block.phis) { - for (const operand of phi.operands.values()) { - const source = derivedTuple.get(operand.identifier.id); - if (source !== undefined && source.typeOfValue === 'fromProps') { - if ( - source.place.identifier.name === null || - source.place.identifier.name?.kind === 'promoted' - ) { - derivedTuple.set(phi.place.identifier.id, { - place: phi.place, - sources: [phi.place], - typeOfValue: 'fromProps', - }); - } else { - derivedTuple.set(phi.place.identifier.id, { - place: phi.place, - sources: source.sources, - typeOfValue: 'fromProps', - }); - } - } - } - } -} - /** * Validates that useEffect is not used for derived computations which could/should * be performed in render. @@ -242,19 +78,22 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const candidateDependencies: Map = new Map(); const functions: Map = new Map(); const locals: Map = new Map(); - const derivedTuple: Map = new Map(); + const derivationCache: Map = new Map(); - const effectSetStates: Map> = new Map(); - const setStateCalls: Map> = new Map(); + const effectSetStates: Map< + string | undefined | null, + Array + > = new Map(); + const setStateCalls: Map> = new Map(); const errors: Array = []; if (fn.fnType === 'Hook') { for (const param of fn.params) { if (param.kind === 'Identifier') { - derivedTuple.set(param.identifier.id, { + derivationCache.set(param.identifier.id, { place: param, - sources: [param], + sources: new Set([param]), typeOfValue: 'fromProps', }); } @@ -262,33 +101,21 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } else if (fn.fnType === 'Component') { const props = fn.params[0]; if (props != null && props.kind === 'Identifier') { - derivedTuple.set(props.identifier.id, { + derivationCache.set(props.identifier.id, { place: props, - sources: [props], + sources: new Set([props]), typeOfValue: 'fromProps', }); } } for (const block of fn.body.blocks.values()) { - parseBlockPhi(block, derivedTuple); + parseBlockPhi(block, derivationCache); for (const instr of block.instructions) { const {lvalue, value} = instr; - parseInstr(instr, derivedTuple, setStateCalls); - - /* - * Special case for function expressions, we need to parse nested instructions - * TODO: Can there be more recursive levels? - */ - if (value.kind === 'FunctionExpression') { - for (const [, block] of value.loweredFunc.func.body.blocks) { - for (const instr of block.instructions) { - parseInstr(instr, derivedTuple, setStateCalls); - } - } - } + parseInstr(instr, derivationCache, setStateCalls); if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); @@ -327,7 +154,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { validateEffect( effectFunction.loweredFunc.func, dependencies, - derivedTuple, + derivationCache, effectSetStates, errors, ); @@ -337,10 +164,36 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } } + const compilerError = generateCompilerError( + setStateCalls, + effectSetStates, + errors, + ); + + if (compilerError.hasErrors()) { + throw compilerError; + } +} + +function generateCompilerError( + setStateCalls: Map>, + effectSetStates: Map>, + errors: Array, +): CompilerError { const throwableErrors = new CompilerError(); for (const error of errors) { - let reason; - // TODO: Not sure if this is robust enough. + let compilerDiagnostic: CompilerDiagnostic | undefined = undefined; + let detailMessage = ''; + switch (error.type) { + case 'fromProps': + detailMessage = 'This state value shadows a value passed as a prop.'; + break; + case 'fromPropsOrState': + detailMessage = + 'This state value shadows a value passed as a prop or a value from state.'; + break; + } + /* * If we use a setState from an invalid useEffect elsewhere then we probably have to * hoist state up, else we should calculate in render @@ -348,86 +201,256 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if ( setStateCalls.get(error.setStateName)?.length != effectSetStates.get(error.setStateName)?.length && - error.errorType !== 'fromState' + error.type !== 'fromState' ) { - reason = - 'Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)'; + compilerDiagnostic = CompilerDiagnostic.create({ + description: `${error.description} This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.`, + category: `Local state shadows parent state.`, + severity: ErrorSeverity.InvalidReact, + }).withDetail({ + kind: 'error', + loc: error.loc, + message: 'this setState synchronizes the state', + }); + + for (const [key, setStateCallArray] of effectSetStates) { + if (setStateCallArray.length === 0) { + continue; + } + + const nonUseEffectSetStateCalls = setStateCalls.get(key); + if (nonUseEffectSetStateCalls) { + for (const place of nonUseEffectSetStateCalls) { + if (!setStateCallArray.includes(place)) { + compilerDiagnostic.withDetail({ + kind: 'error', + loc: place.loc, + message: + 'this setState updates the shadowed state, but should call an onChange event from the parent', + }); + } + } + } + } } else { - reason = - 'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)'; + compilerDiagnostic = CompilerDiagnostic.create({ + description: `${error.description} Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, + category: `Derive values in render, not effects.`, + severity: ErrorSeverity.InvalidReact, + }).withDetail({ + kind: 'error', + loc: error.loc, + message: 'This should be computed during render, not in an effect', + }); } - throwableErrors.push({ - reason: reason, - description: `You are using invalid dependencies: \n\n${error.invalidDepInfo}`, - severity: ErrorSeverity.InvalidReact, - loc: error.loc, - }); + if (compilerDiagnostic) { + throwableErrors.pushDiagnostic(compilerDiagnostic); + } } - if (throwableErrors.hasErrors()) { - throw throwableErrors; + return throwableErrors; +} + +function joinValue( + lvalueType: TypeOfValue, + valueType: TypeOfValue, +): TypeOfValue { + if (lvalueType === 'ignored') return valueType; + if (valueType === 'ignored') return lvalueType; + if (lvalueType === valueType) return lvalueType; + return 'fromPropsOrState'; +} + +function updateDerivationMetadata( + target: Place, + sources: Array | undefined, + typeOfValue: TypeOfValue | undefined, + derivationCache: Map, +): void { + let newValue: DerivationMetadata = { + place: target, + sources: new Set(), + typeOfValue: typeOfValue ?? 'ignored', + }; + + if (sources !== undefined) { + for (const source of sources) { + /* + * If the identifier of the source is a promoted identifier, then + * we should set the target as the source. + */ + for (const place of source.sources) { + if ( + place.identifier.name === null || + place.identifier.name?.kind === 'promoted' + ) { + newValue.sources.add(target); + } else { + newValue.sources.add(place); + } + } + } + } + + derivationCache.set(target.identifier.id, newValue); +} + +function parseInstr( + instr: Instruction, + derivationCache: Map, + setStateCalls: Map>, +): void { + // Recursively parse function expressions + if (instr.value.kind === 'FunctionExpression') { + for (const [, block] of instr.value.loweredFunc.func.body.blocks) { + for (const instr of block.instructions) { + parseInstr(instr, derivationCache, setStateCalls); + } + } + } + + let typeOfValue: TypeOfValue = 'ignored'; + + // Catch any useState hook calls + let sources: Array = []; + if ( + instr.value.kind === 'Destructure' && + instr.value.lvalue.pattern.kind === 'ArrayPattern' && + isUseStateType(instr.value.value.identifier) + ) { + typeOfValue = 'fromState'; + + const stateValueSource = instr.value.lvalue.pattern.items[0]; + if (stateValueSource.kind === 'Identifier') { + sources.push({ + place: stateValueSource, + typeOfValue: typeOfValue, + sources: new Set([stateValueSource]), + }); + } + } + + if ( + instr.value.kind === 'CallExpression' && + isSetStateType(instr.value.callee.identifier) && + instr.value.args.length === 1 && + instr.value.args[0].kind === 'Identifier' && + instr.value.callee.loc !== GeneratedSource + ) { + if (setStateCalls.has(instr.value.callee.loc.identifierName)) { + setStateCalls + .get(instr.value.callee.loc.identifierName)! + .push(instr.value.callee); + } else { + setStateCalls.set(instr.value.callee.loc.identifierName, [ + instr.value.callee, + ]); + } + } + + for (const operand of eachInstructionOperand(instr)) { + const opSource = derivationCache.get(operand.identifier.id); + if (opSource === undefined) { + continue; + } + + typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); + sources.push(opSource); + } + + if (typeOfValue !== 'ignored') { + for (const lvalue of eachInstructionLValue(instr)) { + updateDerivationMetadata(lvalue, sources, typeOfValue, derivationCache); + } + + for (const operand of eachInstructionOperand(instr)) { + switch (operand.effect) { + case Effect.Capture: + case Effect.Store: + case Effect.ConditionallyMutate: + case Effect.ConditionallyMutateIterator: + case Effect.Mutate: { + if (isMutable(instr, operand)) { + updateDerivationMetadata( + operand, + sources, + typeOfValue, + derivationCache, + ); + } + break; + } + case Effect.Freeze: + case Effect.Read: { + // no-op + break; + } + case Effect.Unknown: { + CompilerError.invariant(false, { + reason: 'Unexpected unknown effect', + description: null, + loc: operand.loc, + suggestions: null, + }); + } + default: { + assertExhaustive( + operand.effect, + `Unexpected effect kind \`${operand.effect}\``, + ); + } + } + } + } +} + +function parseBlockPhi( + block: BasicBlock, + derivationCache: Map, +): void { + for (const phi of block.phis) { + for (const operand of phi.operands.values()) { + const phiSource = derivationCache.get(operand.identifier.id); + if (phiSource !== undefined) { + updateDerivationMetadata( + phi.place, + [phiSource], + phiSource?.typeOfValue, + derivationCache, + ); + } + } } } function validateEffect( effectFunction: HIRFunction, effectDeps: Array, - derivedTuple: Map, - effectSetStates: Map>, + derivationCache: Map, + effectSetStates: Map>, errors: Array, ): void { - /* - * TODO: This makes it so we only capture single line useEffects. - * We should be able to capture multiline as well - */ - for (const operand of effectFunction.context) { - if (isSetStateType(operand.identifier)) { - continue; - } else if (effectDeps.find(dep => dep === operand.identifier.id) != null) { - continue; - } else if (derivedTuple.has(operand.identifier.id)) { - continue; - } else { - // Captured something other than the effect dep or setState - return; - } - } - - // TODO: This might be wrong gotta double check - let hasInvalidDep = false; + let isUsingDerivedDeps = false; for (const dep of effectDeps) { - const depMetadata = derivedTuple.get(dep); + const depMetadata = derivationCache.get(dep); if ( effectFunction.context.find(operand => operand.identifier.id === dep) != null || (depMetadata !== undefined && depMetadata.typeOfValue !== 'ignored') ) { - hasInvalidDep = true; + isUsingDerivedDeps = true; } } - if (!hasInvalidDep) { - console.log('early return 2'); - // effect dep wasn't actually used in the function + if (!isUsingDerivedDeps) { + // no prop/state derived deps were used in the body of the effect return; } const seenBlocks: Set = new Set(); - // This variable is suspicious maybe we don't need it? - const values: Map> = new Map(); - const effectInvalidlyDerived: Map = - new Map(); - for (const dep of effectDeps) { - values.set(dep, [dep]); - const depMetadata = derivedTuple.get(dep); - if (depMetadata !== undefined) { - effectInvalidlyDerived.set(dep, depMetadata); - } - } - - const setStateCallsInEffect: Array = []; + const derivedSetStateCall: Array = []; for (const block of effectFunction.body.blocks.values()) { for (const pred of block.preds) { if (!seenBlocks.has(pred)) { @@ -436,7 +459,7 @@ function validateEffect( } } - parseBlockPhi(block, effectInvalidlyDerived); + parseBlockPhi(block, derivationCache); for (const instr of block.instructions) { if ( @@ -465,10 +488,6 @@ function validateEffect( break; } case 'LoadLocal': { - const deps = values.get(instr.value.place.identifier.id); - if (deps != null) { - values.set(instr.lvalue.identifier.id, deps); - } break; } case 'ComputedLoad': @@ -477,85 +496,53 @@ function validateEffect( case 'TemplateLiteral': case 'CallExpression': case 'MethodCall': { - const aggregateDeps: Set = new Set(); - for (const operand of eachInstructionOperand(instr)) { - const deps = values.get(operand.identifier.id); - if (deps != null) { - for (const dep of deps) { - aggregateDeps.add(dep); - } - } - } - if (aggregateDeps.size !== 0) { - values.set(instr.lvalue.identifier.id, Array.from(aggregateDeps)); - } - if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && instr.value.args.length === 1 && instr.value.args[0].kind === 'Identifier' ) { - const invalidDeps = derivedTuple.get( + const derivedDep = derivationCache.get( instr.value.args[0].identifier.id, ); - if (invalidDeps !== undefined) { - setStateCallsInEffect.push({ + if (derivedDep !== undefined) { + derivedSetStateCall.push({ loc: instr.value.callee.loc, setStateId: instr.value.callee.identifier.id, - invalidDeps: invalidDeps, + derivedDep: derivedDep, }); } } break; } - default: { - console.log('early return 4'); - return; - } } } - for (const operand of eachTerminalOperand(block.terminal)) { - if (values.has(operand.identifier.id)) { - return; - } - } seenBlocks.add(block.id); } - for (const call of setStateCallsInEffect) { - const placeNames = call.invalidDeps.sources - .map(place => place.identifier.name?.value) + for (const call of derivedSetStateCall) { + const placeNames = Array.from(call.derivedDep.sources) + .map(place => { + return place.identifier.name?.value; + }) + .filter(Boolean) .join(', '); - let sourceNames = ''; - let invalidDepInfo = ''; - console.log(call.invalidDeps); - if (call.invalidDeps.typeOfValue === 'fromProps') { - sourceNames += `[${placeNames}], `; - sourceNames = sourceNames.slice(0, -2); - invalidDepInfo = sourceNames - ? `Invalid deps from props ${sourceNames}` - : ''; - } else if (call.invalidDeps.typeOfValue === 'fromState') { - sourceNames += `[${placeNames}], `; - sourceNames = sourceNames.slice(0, -2); - invalidDepInfo = sourceNames - ? `Invalid deps from local state: ${sourceNames}` - : ''; + let errorDescription = ''; + + if (call.derivedDep.typeOfValue === 'fromProps') { + errorDescription = `props [${placeNames}].`; + } else if (call.derivedDep.typeOfValue === 'fromState') { + errorDescription = `local state [${placeNames}].`; } else { - sourceNames += `[${placeNames}], `; - sourceNames = sourceNames.slice(0, -2); - invalidDepInfo = sourceNames - ? `Invalid deps from both props and local state: ${sourceNames}` - : ''; + errorDescription = `both props and local state [${placeNames}].`; } errors.push({ - errorType: call.invalidDeps.typeOfValue, - invalidDepInfo: invalidDepInfo, + type: call.derivedDep.typeOfValue, + description: `This setState() appears to derive a value from ${errorDescription}`, loc: call.loc, setStateName: call.loc !== GeneratedSource ? call.loc.identifierName : undefined, diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md deleted file mode 100644 index b7a1c85d52..0000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md +++ /dev/null @@ -1,79 +0,0 @@ - -## Input - -```javascript -// @validateNoDerivedComputationsInEffects -import {useEffect, useState} from 'react'; - -function Component({value, enabled}) { - const [localValue, setLocalValue] = useState(''); - - useEffect(() => { - if (enabled) { - setLocalValue(value); - } else { - setLocalValue('disabled'); - } - }, [value, enabled]); - - return
{localValue}
; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{value: 'test', enabled: true}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects -import { useEffect, useState } from "react"; - -function Component(t0) { - const $ = _c(6); - const { value, enabled } = t0; - const [localValue, setLocalValue] = useState(""); - let t1; - let t2; - if ($[0] !== enabled || $[1] !== value) { - t1 = () => { - if (enabled) { - setLocalValue(value); - } else { - setLocalValue("disabled"); - } - }; - - t2 = [value, enabled]; - $[0] = enabled; - $[1] = value; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); - let t3; - if ($[4] !== localValue) { - t3 =
{localValue}
; - $[4] = localValue; - $[5] = t3; - } else { - t3 = $[5]; - } - return t3; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ value: "test", enabled: true }], -}; - -``` - -### Eval output -(kind: ok)
test
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md deleted file mode 100644 index e0708dd1f7..0000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md +++ /dev/null @@ -1,74 +0,0 @@ - -## Input - -```javascript -// @validateNoDerivedComputationsInEffects -import {useEffect, useState} from 'react'; - -function Component({value}) { - const [localValue, setLocalValue] = useState(''); - - useEffect(() => { - console.log('Value changed:', value); - setLocalValue(value); - document.title = `Value: ${value}`; - }, [value]); - - return
{localValue}
; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{value: 'test'}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects -import { useEffect, useState } from "react"; - -function Component(t0) { - const $ = _c(5); - const { value } = t0; - const [localValue, setLocalValue] = useState(""); - let t1; - let t2; - if ($[0] !== value) { - t1 = () => { - console.log("Value changed:", value); - setLocalValue(value); - document.title = `Value: ${value}`; - }; - t2 = [value]; - $[0] = value; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useEffect(t1, t2); - let t3; - if ($[3] !== localValue) { - t3 =
{localValue}
; - $[3] = localValue; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ value: "test" }], -}; - -``` - -### Eval output -(kind: ok)
test
-logs: ['Value changed:','test'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md index 8124f4b3f3..2588a014af 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -34,15 +34,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: Derive values in render, not effects. -This effect updates state based on other state values. Consider calculating this value directly during render. +This setState() appears to derive a value from both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.bug-derived-state-from-mixed-deps.ts:9:4 7 | 8 | useEffect(() => { > 9 | setDisplayName(prefix + name); - | ^^^^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^^^^ This should be computed during render, not in an effect 10 | }, [prefix, name]); 11 | 12 | return ( diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md new file mode 100644 index 0000000000..66079d40bb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md @@ -0,0 +1,58 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useState, useEffect} from 'react'; + +function Component({props, number}) { + const nothing = 0; + const missDirection = number; + const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + + useEffect(() => { + setDisplayValue(props.prefix + missDirection + nothing); + }, [props.prefix, missDirection, nothing]); + + return ( +
{ + setDisplayValue('clicked'); + }}> + {displayValue} +
+ ); +} + +``` + + +## Error + +``` +Found 1 error: + +Error: Local state shadows parent state. + +This setState() appears to derive a value from props [props, number]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. + +error.derived-state-from-shadowed-props.ts:10:4 + 8 | + 9 | useEffect(() => { +> 10 | setDisplayValue(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^ this setState synchronizes the state + 11 | }, [props.prefix, missDirection, nothing]); + 12 | + 13 | return ( + +error.derived-state-from-shadowed-props.ts:16:8 + 14 |
{ +> 16 | setDisplayValue('clicked'); + | ^^^^^^^^^^^^^^^ this setState updates the shadowed state, but should call an onChange event from the parent + 17 | }}> + 18 | {displayValue} + 19 |
+``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js new file mode 100644 index 0000000000..6b4cefedf5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js @@ -0,0 +1,21 @@ +// @validateNoDerivedComputationsInEffects +import {useState, useEffect} from 'react'; + +function Component({props, number}) { + const nothing = 0; + const missDirection = number; + const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + + useEffect(() => { + setDisplayValue(props.prefix + missDirection + nothing); + }, [props.prefix, missDirection, nothing]); + + return ( +
{ + setDisplayValue('clicked'); + }}> + {displayValue} +
+ ); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md new file mode 100644 index 0000000000..0643af7722 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md @@ -0,0 +1,49 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value, enabled}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + if (enabled) { + setLocalValue(value); + } else { + setLocalValue('disabled'); + } + }, [value, enabled]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test', enabled: true}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Derive values in render, not effects. + +This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. + +error.derived-state-with-conditional.ts:9:6 + 7 | useEffect(() => { + 8 | if (enabled) { +> 9 | setLocalValue(value); + | ^^^^^^^^^^^^^ This should be computed during render, not in an effect + 10 | } else { + 11 | setLocalValue('disabled'); + 12 | } +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md new file mode 100644 index 0000000000..0f25b76660 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md @@ -0,0 +1,47 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + console.log('Value changed:', value); + setLocalValue(value); + document.title = `Value: ${value}`; + }, [value]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test'}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Derive values in render, not effects. + +This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. + +error.derived-state-with-side-effects.ts:9:4 + 7 | useEffect(() => { + 8 | console.log('Value changed:', value); +> 9 | setLocalValue(value); + | ^^^^^^^^^^^^^ This should be computed during render, not in an effect + 10 | document.title = `Value: ${value}`; + 11 | }, [value]); + 12 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md similarity index 58% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md index 1d7e24b3ef..bdf7a9b209 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md @@ -24,15 +24,15 @@ function BadExample() { ``` Found 1 error: -Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: Derive values in render, not effects. -This effect updates state based on other state values. Consider calculating this value directly during render. +This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-computation-in-effect.ts:9:4 7 | const [fullName, setFullName] = useState(''); 8 | useEffect(() => { > 9 | setFullName(capitalize(firstName + ' ' + lastName)); - | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ This should be computed during render, not in an effect 10 | }, [firstName, lastName]); 11 | 12 | return
{fullName}
; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md new file mode 100644 index 0000000000..7773a2cc8d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md @@ -0,0 +1,46 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component(props) { + const [displayValue, setDisplayValue] = useState(''); + + useEffect(() => { + const computed = props.prefix + props.value + props.suffix; + setDisplayValue(computed); + }, [props.prefix, props.value, props.suffix]); + + return
{displayValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prefix: '[', value: 'test', suffix: ']'}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Derive values in render, not effects. + +This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. + +error.invalid-derived-state-from-props-computed.ts:9:4 + 7 | useEffect(() => { + 8 | const computed = props.prefix + props.value + props.suffix; +> 9 | setDisplayValue(computed); + | ^^^^^^^^^^^^^^^ This should be computed during render, not in an effect + 10 | }, [props.prefix, props.value, props.suffix]); + 11 | + 12 | return
{displayValue}
; +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index 26b8b7930b..99b596c4ce 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -5,19 +5,19 @@ // @validateNoDerivedComputationsInEffects import {useEffect, useState} from 'react'; -function Component({user: {firstName, lastName}}) { - const [fullName, setFullName] = useState(''); +function Component({props}) { + const [fullName, setFullName] = useState(props.firstName + ' ' + props.lastName); useEffect(() => { - setFullName(firstName + ' ' + lastName); - }, [firstName, lastName]); + setFullName(props.firstName + ' ' + props.lastName); + }, [props.firstName, props.lastName]); return
{fullName}
; } export const FIXTURE_ENTRYPOINT = { fn: Component, - params: [{user: {firstName: 'John', lastName: 'Doe'}}], + params: [{firstName: 'John', lastName: 'Doe'}], }; ``` @@ -28,16 +28,16 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: Derive values in render, not effects. -This effect updates state based on other state values. Consider calculating this value directly during render. +This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-destructured.ts:8:4 6 | 7 | useEffect(() => { -> 8 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) - 9 | }, [firstName, lastName]); +> 8 | setFullName(props.firstName + ' ' + props.lastName); + | ^^^^^^^^^^^ This should be computed during render, not in an effect + 9 | }, [props.firstName, props.lastName]); 10 | 11 | return
{fullName}
; ``` diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js index 966f09ea89..78f7c910ff 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js @@ -1,12 +1,12 @@ // @validateNoDerivedComputationsInEffects import {useEffect, useState} from 'react'; -function Component({firstName, lastName}) { - const [fullName, setFullName] = useState(''); +function Component({props}) { + const [fullName, setFullName] = useState(props.firstName + ' ' + props.lastName); useEffect(() => { - setFullName(firstName + ' ' + lastName); - }, [firstName, lastName]); + setFullName(props.firstName + ' ' + props.lastName); + }, [props.firstName, props.lastName]); return
{fullName}
; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md index 1f7ff8dc5d..88c722b8f6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -28,15 +28,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: Derive values in render, not effects. -This effect updates state based on other state values. Consider calculating this value directly during render. +This setState() appears to derive a value from props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-in-effect.ts:8:4 6 | 7 | useEffect(() => { > 8 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ This should be computed during render, not in an effect 9 | }, [firstName, lastName]); 10 | 11 | return
{fullName}
; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md new file mode 100644 index 0000000000..3af0c00ecc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md @@ -0,0 +1,43 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects + +export default function InProductLobbyGeminiCard( + input = 'empty', +) { + const [currInput, setCurrInput] = useState(input); + + useEffect(() => { + setCurrInput(input) + }, [input]); + + return ( +
{currInput}
+ ) +} + +``` + + +## Error + +``` +Found 1 error: + +Error: Derive values in render, not effects. + +This setState() appears to derive a value from props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. + +error.invalid-derived-state-from-props-with-default-value.ts:9:4 + 7 | + 8 | useEffect(() => { +> 9 | setCurrInput(input) + | ^^^^^^^^^^^^ This should be computed during render, not in an effect + 10 | }, [input]); + 11 | + 12 | return ( +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js new file mode 100644 index 0000000000..a2ad3de584 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js @@ -0,0 +1,15 @@ +// @validateNoDerivedComputationsInEffects + +export default function InProductLobbyGeminiCard( + input = 'empty', +) { + const [currInput, setCurrInput] = useState(input); + + useEffect(() => { + setCurrInput(input) + }, [input]); + + return ( +
{currInput}
+ ) +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md index c5548c970b..5a029cb0cc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -36,15 +36,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: Derive values in render, not effects. -This effect updates state based on other state values. Consider calculating this value directly during render. +This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-state-in-effect.ts:10:4 8 | 9 | useEffect(() => { > 10 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ This should be computed during render, not in an effect 11 | }, [firstName, lastName]); 12 | 13 | return ( diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md deleted file mode 100644 index 3d0c4fe9c8..0000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md +++ /dev/null @@ -1,72 +0,0 @@ - -## Input - -```javascript -// @validateNoDerivedComputationsInEffects -import {useEffect, useState} from 'react'; - -function Component(props) { - const [displayValue, setDisplayValue] = useState(''); - - useEffect(() => { - const computed = props.prefix + props.value + props.suffix; - setDisplayValue(computed); - }, [props.prefix, props.value, props.suffix]); - - return
{displayValue}
; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{prefix: '[', value: 'test', suffix: ']'}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects -import { useEffect, useState } from "react"; - -function Component(props) { - const $ = _c(7); - const [displayValue, setDisplayValue] = useState(""); - let t0; - let t1; - if ($[0] !== props.prefix || $[1] !== props.suffix || $[2] !== props.value) { - t0 = () => { - const computed = props.prefix + props.value + props.suffix; - setDisplayValue(computed); - }; - t1 = [props.prefix, props.value, props.suffix]; - $[0] = props.prefix; - $[1] = props.suffix; - $[2] = props.value; - $[3] = t0; - $[4] = t1; - } else { - t0 = $[3]; - t1 = $[4]; - } - useEffect(t0, t1); - let t2; - if ($[5] !== displayValue) { - t2 =
{displayValue}
; - $[5] = displayValue; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ prefix: "[", value: "test", suffix: "]" }], -}; - -``` - -### Eval output -(kind: ok)
[test]
\ No newline at end of file From 0ce2ba181cb6ce35c4c0947d75951a861e811765 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Thu, 4 Sep 2025 11:27:25 -0700 Subject: [PATCH 09/25] [compiler] Add catching useStates that shadow a reactive value --- .../ValidateNoDerivedComputationsInEffects.ts | 128 ++++++++++++------ ...erived-state-from-shadowed-props.expect.md | 18 +++ ...erived-state-from-props-computed.expect.md | 2 +- ...ed-state-from-props-destructured.expect.md | 2 +- ...ror.shadowed-props-with-onchange.expect.md | 61 +++++++++ .../error.shadowed-props-with-onchange.js | 15 ++ 6 files changed, 179 insertions(+), 47 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index eca16f44f2..4a788b07dd 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -41,7 +41,7 @@ type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type DerivationMetadata = { typeOfValue: TypeOfValue; place: Place; - sources: Set; + sources: Array; }; type ErrorMetadata = { @@ -49,6 +49,7 @@ type ErrorMetadata = { description: string | undefined; loc: SourceLocation; setStateName: string | undefined | null; + derivedDepsNames: Array; }; /** @@ -79,6 +80,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const functions: Map = new Map(); const locals: Map = new Map(); const derivationCache: Map = new Map(); + const shadowingUseState: Map> = new Map(); const effectSetStates: Map< string | undefined | null, @@ -93,7 +95,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (param.kind === 'Identifier') { derivationCache.set(param.identifier.id, { place: param, - sources: new Set([param]), + sources: [param], typeOfValue: 'fromProps', }); } @@ -103,7 +105,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (props != null && props.kind === 'Identifier') { derivationCache.set(props.identifier.id, { place: props, - sources: new Set([props]), + sources: [props], typeOfValue: 'fromProps', }); } @@ -115,7 +117,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { for (const instr of block.instructions) { const {lvalue, value} = instr; - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); @@ -167,6 +169,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const compilerError = generateCompilerError( setStateCalls, effectSetStates, + shadowingUseState, errors, ); @@ -178,21 +181,12 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function generateCompilerError( setStateCalls: Map>, effectSetStates: Map>, + shadowingUseState: Map>, errors: Array, ): CompilerError { const throwableErrors = new CompilerError(); for (const error of errors) { let compilerDiagnostic: CompilerDiagnostic | undefined = undefined; - let detailMessage = ''; - switch (error.type) { - case 'fromProps': - detailMessage = 'This state value shadows a value passed as a prop.'; - break; - case 'fromPropsOrState': - detailMessage = - 'This state value shadows a value passed as a prop or a value from state.'; - break; - } /* * If we use a setState from an invalid useEffect elsewhere then we probably have to @@ -204,15 +198,27 @@ function generateCompilerError( error.type !== 'fromState' ) { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.`, - category: `Local state shadows parent state.`, + description: `The setState within a useEffect is deriving from ${error.description} Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render`, + category: `You might not need an effect. Local state shadows parent state.`, severity: ErrorSeverity.InvalidReact, }).withDetail({ kind: 'error', loc: error.loc, - message: 'this setState synchronizes the state', + message: `this derives values from props ${error.type === 'fromPropsOrState' ? 'and local state ' : ''}to synchronize state`, }); + for (const derivedDep of error.derivedDepsNames) { + if (shadowingUseState.has(derivedDep)) { + for (const loc of shadowingUseState.get(derivedDep)!) { + compilerDiagnostic.withDetail({ + kind: 'error', + loc: loc, + message: `this useState shadows ${derivedDep}`, + }); + } + } + } + for (const [key, setStateCallArray] of effectSetStates) { if (setStateCallArray.length === 0) { continue; @@ -235,7 +241,7 @@ function generateCompilerError( } else { compilerDiagnostic = CompilerDiagnostic.create({ description: `${error.description} Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, - category: `Derive values in render, not effects.`, + category: `You might not need an effect. Derive values in render, not effects.`, severity: ErrorSeverity.InvalidReact, }).withDetail({ kind: 'error', @@ -270,7 +276,7 @@ function updateDerivationMetadata( ): void { let newValue: DerivationMetadata = { place: target, - sources: new Set(), + sources: [], typeOfValue: typeOfValue ?? 'ignored', }; @@ -285,9 +291,9 @@ function updateDerivationMetadata( place.identifier.name === null || place.identifier.name?.kind === 'promoted' ) { - newValue.sources.add(target); + newValue.sources.push(target); } else { - newValue.sources.add(place); + newValue.sources.push(place); } } } @@ -300,37 +306,21 @@ function parseInstr( instr: Instruction, derivationCache: Map, setStateCalls: Map>, + shadowingUseState: Map>, ): void { // Recursively parse function expressions if (instr.value.kind === 'FunctionExpression') { for (const [, block] of instr.value.loweredFunc.func.body.blocks) { for (const instr of block.instructions) { - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); } } } let typeOfValue: TypeOfValue = 'ignored'; - // Catch any useState hook calls let sources: Array = []; - if ( - instr.value.kind === 'Destructure' && - instr.value.lvalue.pattern.kind === 'ArrayPattern' && - isUseStateType(instr.value.value.identifier) - ) { - typeOfValue = 'fromState'; - - const stateValueSource = instr.value.lvalue.pattern.items[0]; - if (stateValueSource.kind === 'Identifier') { - sources.push({ - place: stateValueSource, - typeOfValue: typeOfValue, - sources: new Set([stateValueSource]), - }); - } - } - + // Catch setState calls if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && @@ -357,6 +347,49 @@ function parseInstr( typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); sources.push(opSource); + + if ( + instr.value.kind === 'Destructure' && + instr.value.lvalue.pattern.kind === 'ArrayPattern' && + isUseStateType(instr.value.value.identifier) && + opSource.typeOfValue === 'fromProps' + ) { + opSource.sources.forEach(source => { + if (instr.value.kind !== 'Destructure') { + return; + } + + if (source.identifier.name !== null) { + if (shadowingUseState.has(source.identifier.name.value)) { + shadowingUseState + .get(source.identifier.name.value) + ?.push(instr.value.value.loc); + } else { + shadowingUseState.set(source.identifier.name.value, [ + instr.value.value.loc, + ]); + } + } + }); + } + } + + // Catch useState hook calls + if ( + instr.value.kind === 'Destructure' && + instr.value.lvalue.pattern.kind === 'ArrayPattern' && + isUseStateType(instr.value.value.identifier) + ) { + const stateValueSource = instr.value.lvalue.pattern.items[0]; + if (stateValueSource.kind === 'Identifier') { + sources.push({ + place: stateValueSource, + typeOfValue: typeOfValue, + sources: [stateValueSource], + }); + } + + typeOfValue = joinValue(typeOfValue, 'fromState'); } if (typeOfValue !== 'ignored') { @@ -523,7 +556,7 @@ function validateEffect( } for (const call of derivedSetStateCall) { - const placeNames = Array.from(call.derivedDep.sources) + const derivedDepsStr = Array.from(call.derivedDep.sources) .map(place => { return place.identifier.name?.value; }) @@ -533,19 +566,24 @@ function validateEffect( let errorDescription = ''; if (call.derivedDep.typeOfValue === 'fromProps') { - errorDescription = `props [${placeNames}].`; + errorDescription = `props [${derivedDepsStr}].`; } else if (call.derivedDep.typeOfValue === 'fromState') { - errorDescription = `local state [${placeNames}].`; + errorDescription = `local state [${derivedDepsStr}].`; } else { - errorDescription = `both props and local state [${placeNames}].`; + errorDescription = `both props and local state [${derivedDepsStr}].`; } errors.push({ type: call.derivedDep.typeOfValue, - description: `This setState() appears to derive a value from ${errorDescription}`, + description: `${errorDescription}`, loc: call.loc, setStateName: call.loc !== GeneratedSource ? call.loc.identifierName : undefined, + derivedDepsNames: Array.from(call.derivedDep.sources) + .map(place => { + return place.identifier.name?.value ?? ''; + }) + .filter(Boolean), }); } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md index 66079d40bb..034c387a74 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md @@ -36,6 +36,24 @@ Error: Local state shadows parent state. This setState() appears to derive a value from props [props, number]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows props + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows number + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + error.derived-state-from-shadowed-props.ts:10:4 8 | 9 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md index 7773a2cc8d..f4c0bdcbb9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md @@ -31,7 +31,7 @@ Found 1 error: Error: Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +This setState() appears to derive a value from props [props, props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-computed.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index 99b596c4ce..aa169ead3e 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -30,7 +30,7 @@ Found 1 error: Error: Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +This setState() appears to derive a value from props [props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-destructured.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md new file mode 100644 index 0000000000..406a54d1e2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md @@ -0,0 +1,61 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} + +``` + + +## Error + +``` +Found 1 error: + +Error: Local state shadows parent state. + +This setState() appears to derive a value from props [startDate]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. + +error.shadowed-props-with-onchange.ts:4:47 + 2 | + 3 | function EndDate({startDate, endDate, onStartDateChange}) { +> 4 | const [localStartDate, setLocalStartDate] = useState(startDate); + | ^^^^^^^^^^^^^^^^^^^ this useState shadows startDate + 5 | + 6 | useEffect(() => { + 7 | setLocalStartDate(startDate); + +error.shadowed-props-with-onchange.ts:7:8 + 5 | + 6 | useEffect(() => { +> 7 | setLocalStartDate(startDate); + | ^^^^^^^^^^^^^^^^^ this setState synchronizes the state + 8 | }, [startDate]); + 9 | + 10 | const onChange = (date) => { + +error.shadowed-props-with-onchange.ts:11:8 + 9 | + 10 | const onChange = (date) => { +> 11 | setLocalStartDate(date); + | ^^^^^^^^^^^^^^^^^ this setState updates the shadowed state, but should call an onChange event from the parent + 12 | onStartDateChange(date); + 13 | } + 14 | return +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js new file mode 100644 index 0000000000..52e74312e6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js @@ -0,0 +1,15 @@ +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} From 43c5c68b9ecbd97b8a6ae79ad1e21c35a8bcd38b Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Thu, 4 Sep 2025 14:13:44 -0700 Subject: [PATCH 10/25] [compiler] Add catching useStates that shadow a reactive value --- .../ValidateNoDerivedComputationsInEffects.ts | 154 ++++++++++++------ ...ug-derived-state-from-mixed-deps.expect.md | 4 +- ...erived-state-from-shadowed-props.expect.md | 24 ++- ...r.derived-state-with-conditional.expect.md | 4 +- ....derived-state-with-side-effects.expect.md | 4 +- ...id-derived-computation-in-effect.expect.md | 4 +- ...erived-state-from-props-computed.expect.md | 4 +- ...ed-state-from-props-destructured.expect.md | 4 +- ...rived-state-from-props-in-effect.expect.md | 4 +- ...te-from-props-with-default-value.expect.md | 4 +- ...rived-state-from-state-in-effect.expect.md | 4 +- ...ror.shadowed-props-with-onchange.expect.md | 61 +++++++ .../error.shadowed-props-with-onchange.js | 15 ++ compiler/yarn.lock | 46 +----- 14 files changed, 220 insertions(+), 116 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index eca16f44f2..a82000d5c2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -41,7 +41,7 @@ type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type DerivationMetadata = { typeOfValue: TypeOfValue; place: Place; - sources: Set; + sources: Array; }; type ErrorMetadata = { @@ -49,6 +49,7 @@ type ErrorMetadata = { description: string | undefined; loc: SourceLocation; setStateName: string | undefined | null; + derivedDepsNames: Array; }; /** @@ -79,6 +80,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const functions: Map = new Map(); const locals: Map = new Map(); const derivationCache: Map = new Map(); + const shadowingUseState: Map> = new Map(); const effectSetStates: Map< string | undefined | null, @@ -93,7 +95,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (param.kind === 'Identifier') { derivationCache.set(param.identifier.id, { place: param, - sources: new Set([param]), + sources: [param], typeOfValue: 'fromProps', }); } @@ -103,7 +105,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (props != null && props.kind === 'Identifier') { derivationCache.set(props.identifier.id, { place: props, - sources: new Set([props]), + sources: [props], typeOfValue: 'fromProps', }); } @@ -115,7 +117,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { for (const instr of block.instructions) { const {lvalue, value} = instr; - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); @@ -167,6 +169,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const compilerError = generateCompilerError( setStateCalls, effectSetStates, + shadowingUseState, errors, ); @@ -178,21 +181,12 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function generateCompilerError( setStateCalls: Map>, effectSetStates: Map>, + shadowingUseState: Map>, errors: Array, ): CompilerError { const throwableErrors = new CompilerError(); for (const error of errors) { let compilerDiagnostic: CompilerDiagnostic | undefined = undefined; - let detailMessage = ''; - switch (error.type) { - case 'fromProps': - detailMessage = 'This state value shadows a value passed as a prop.'; - break; - case 'fromPropsOrState': - detailMessage = - 'This state value shadows a value passed as a prop or a value from state.'; - break; - } /* * If we use a setState from an invalid useEffect elsewhere then we probably have to @@ -204,15 +198,27 @@ function generateCompilerError( error.type !== 'fromState' ) { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.`, - category: `Local state shadows parent state.`, + description: `The setState within a useEffect is deriving from ${error.description} Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render`, + category: `You might not need an effect. Local state shadows parent state.`, severity: ErrorSeverity.InvalidReact, }).withDetail({ kind: 'error', loc: error.loc, - message: 'this setState synchronizes the state', + message: `this derives values from props ${error.type === 'fromPropsOrState' ? 'and local state ' : ''}to synchronize state`, }); + for (const derivedDep of error.derivedDepsNames) { + if (shadowingUseState.has(derivedDep)) { + for (const loc of shadowingUseState.get(derivedDep)!) { + compilerDiagnostic.withDetail({ + kind: 'error', + loc: loc, + message: `this useState shadows ${derivedDep}`, + }); + } + } + } + for (const [key, setStateCallArray] of effectSetStates) { if (setStateCallArray.length === 0) { continue; @@ -235,7 +241,7 @@ function generateCompilerError( } else { compilerDiagnostic = CompilerDiagnostic.create({ description: `${error.description} Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, - category: `Derive values in render, not effects.`, + category: `You might not need an effect. Derive values in render, not effects.`, severity: ErrorSeverity.InvalidReact, }).withDetail({ kind: 'error', @@ -270,7 +276,7 @@ function updateDerivationMetadata( ): void { let newValue: DerivationMetadata = { place: target, - sources: new Set(), + sources: [], typeOfValue: typeOfValue ?? 'ignored', }; @@ -285,9 +291,9 @@ function updateDerivationMetadata( place.identifier.name === null || place.identifier.name?.kind === 'promoted' ) { - newValue.sources.add(target); + newValue.sources.push(target); } else { - newValue.sources.add(place); + newValue.sources.push(place); } } } @@ -300,37 +306,21 @@ function parseInstr( instr: Instruction, derivationCache: Map, setStateCalls: Map>, + shadowingUseState: Map>, ): void { // Recursively parse function expressions if (instr.value.kind === 'FunctionExpression') { for (const [, block] of instr.value.loweredFunc.func.body.blocks) { for (const instr of block.instructions) { - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); } } } let typeOfValue: TypeOfValue = 'ignored'; - // Catch any useState hook calls let sources: Array = []; - if ( - instr.value.kind === 'Destructure' && - instr.value.lvalue.pattern.kind === 'ArrayPattern' && - isUseStateType(instr.value.value.identifier) - ) { - typeOfValue = 'fromState'; - - const stateValueSource = instr.value.lvalue.pattern.items[0]; - if (stateValueSource.kind === 'Identifier') { - sources.push({ - place: stateValueSource, - typeOfValue: typeOfValue, - sources: new Set([stateValueSource]), - }); - } - } - + // Catch setState calls if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && @@ -357,6 +347,49 @@ function parseInstr( typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); sources.push(opSource); + + if ( + instr.value.kind === 'Destructure' && + instr.value.lvalue.pattern.kind === 'ArrayPattern' && + isUseStateType(instr.value.value.identifier) && + opSource.typeOfValue === 'fromProps' + ) { + opSource.sources.forEach(source => { + if (instr.value.kind !== 'Destructure') { + return; + } + + if (source.identifier.name !== null) { + if (shadowingUseState.has(source.identifier.name.value)) { + shadowingUseState + .get(source.identifier.name.value) + ?.push(instr.value.value.loc); + } else { + shadowingUseState.set(source.identifier.name.value, [ + instr.value.value.loc, + ]); + } + } + }); + } + } + + // Catch useState hook calls + if ( + instr.value.kind === 'Destructure' && + instr.value.lvalue.pattern.kind === 'ArrayPattern' && + isUseStateType(instr.value.value.identifier) + ) { + const stateValueSource = instr.value.lvalue.pattern.items[0]; + if (stateValueSource.kind === 'Identifier') { + sources.push({ + place: stateValueSource, + typeOfValue: typeOfValue, + sources: [stateValueSource], + }); + } + + typeOfValue = joinValue(typeOfValue, 'fromState'); } if (typeOfValue !== 'ignored') { @@ -410,16 +443,26 @@ function parseBlockPhi( derivationCache: Map, ): void { for (const phi of block.phis) { + let typeOfValue: TypeOfValue = 'ignored'; + let sources: Array = []; for (const operand of phi.operands.values()) { - const phiSource = derivationCache.get(operand.identifier.id); - if (phiSource !== undefined) { - updateDerivationMetadata( - phi.place, - [phiSource], - phiSource?.typeOfValue, - derivationCache, - ); + const opSource = derivationCache.get(operand.identifier.id); + + if (opSource === undefined) { + continue; } + + typeOfValue = joinValue(typeOfValue, opSource?.typeOfValue ?? 'ignored'); + sources.push(opSource); + } + + if (typeOfValue !== 'ignored') { + updateDerivationMetadata( + phi.place, + sources, + typeOfValue, + derivationCache, + ); } } } @@ -523,7 +566,7 @@ function validateEffect( } for (const call of derivedSetStateCall) { - const placeNames = Array.from(call.derivedDep.sources) + const derivedDepsStr = Array.from(call.derivedDep.sources) .map(place => { return place.identifier.name?.value; }) @@ -533,19 +576,24 @@ function validateEffect( let errorDescription = ''; if (call.derivedDep.typeOfValue === 'fromProps') { - errorDescription = `props [${placeNames}].`; + errorDescription = `props [${derivedDepsStr}].`; } else if (call.derivedDep.typeOfValue === 'fromState') { - errorDescription = `local state [${placeNames}].`; + errorDescription = `local state [${derivedDepsStr}].`; } else { - errorDescription = `both props and local state [${placeNames}].`; + errorDescription = `both props and local state [${derivedDepsStr}].`; } errors.push({ type: call.derivedDep.typeOfValue, - description: `This setState() appears to derive a value from ${errorDescription}`, + description: `${errorDescription}`, loc: call.loc, setStateName: call.loc !== GeneratedSource ? call.loc.identifierName : undefined, + derivedDepsNames: Array.from(call.derivedDep.sources) + .map(place => { + return place.identifier.name?.value ?? ''; + }) + .filter(Boolean), }); } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md index 2588a014af..31ac0cdf88 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -34,9 +34,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.bug-derived-state-from-mixed-deps.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md index 66079d40bb..cd7c024fe9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md @@ -32,19 +32,37 @@ function Component({props, number}) { ``` Found 1 error: -Error: Local state shadows parent state. +Error: You might not need an effect. Local state shadows parent state. -This setState() appears to derive a value from props [props, number]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. +The setState within a useEffect is deriving from props [props, number]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render error.derived-state-from-shadowed-props.ts:10:4 8 | 9 | useEffect(() => { > 10 | setDisplayValue(props.prefix + missDirection + nothing); - | ^^^^^^^^^^^^^^^ this setState synchronizes the state + | ^^^^^^^^^^^^^^^ this derives values from props to synchronize state 11 | }, [props.prefix, missDirection, nothing]); 12 | 13 | return ( +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows props + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows number + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + error.derived-state-from-shadowed-props.ts:16:8 14 |
{ diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md index 0643af7722..21d8a60362 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md @@ -32,9 +32,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-conditional.ts:9:6 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md index 0f25b76660..cae437f125 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md @@ -30,9 +30,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-side-effects.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md index bdf7a9b209..b5f47fb14a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md @@ -24,9 +24,9 @@ function BadExample() { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-computation-in-effect.ts:9:4 7 | const [fullName, setFullName] = useState(''); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md index 7773a2cc8d..532154248a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md @@ -29,9 +29,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +props [props, props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-computed.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index 99b596c4ce..e5e06e8d9f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +props [props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-destructured.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md index 88c722b8f6..64b5e02783 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-in-effect.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md index 3af0c00ecc..be2f714938 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md @@ -26,9 +26,9 @@ export default function InProductLobbyGeminiCard( ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-with-default-value.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md index 5a029cb0cc..591eb4cae3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -36,9 +36,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-state-in-effect.ts:10:4 8 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md new file mode 100644 index 0000000000..f08a65dad2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md @@ -0,0 +1,61 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} + +``` + + +## Error + +``` +Found 1 error: + +Error: You might not need an effect. Local state shadows parent state. + +The setState within a useEffect is deriving from props [startDate]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render + +error.shadowed-props-with-onchange.ts:7:8 + 5 | + 6 | useEffect(() => { +> 7 | setLocalStartDate(startDate); + | ^^^^^^^^^^^^^^^^^ this derives values from props to synchronize state + 8 | }, [startDate]); + 9 | + 10 | const onChange = (date) => { + +error.shadowed-props-with-onchange.ts:4:47 + 2 | + 3 | function EndDate({startDate, endDate, onStartDateChange}) { +> 4 | const [localStartDate, setLocalStartDate] = useState(startDate); + | ^^^^^^^^^^^^^^^^^^^ this useState shadows startDate + 5 | + 6 | useEffect(() => { + 7 | setLocalStartDate(startDate); + +error.shadowed-props-with-onchange.ts:11:8 + 9 | + 10 | const onChange = (date) => { +> 11 | setLocalStartDate(date); + | ^^^^^^^^^^^^^^^^^ this setState updates the shadowed state, but should call an onChange event from the parent + 12 | onStartDateChange(date); + 13 | } + 14 | return +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js new file mode 100644 index 0000000000..52e74312e6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js @@ -0,0 +1,15 @@ +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} diff --git a/compiler/yarn.lock b/compiler/yarn.lock index 6e1bc7feeb..f4eb92b966 100644 --- a/compiler/yarn.lock +++ b/compiler/yarn.lock @@ -542,11 +542,6 @@ resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== -"@babel/helper-string-parser@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" - integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== - "@babel/helper-validator-identifier@^7.19.1", "@babel/helper-validator-identifier@^7.25.9": version "7.25.9" resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz" @@ -1605,7 +1600,7 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.19.0", "@babel/types@^7.2.0", "@babel/types@^7.2.2", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.2", "@babel/types@^7.24.7", "@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.26.3", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.4": +"@babel/types@7.26.3", "@babel/types@^7.0.0", "@babel/types@^7.19.0", "@babel/types@^7.2.0", "@babel/types@^7.2.2", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.2", "@babel/types@^7.24.7", "@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.26.10", "@babel/types@^7.26.3", "@babel/types@^7.27.0", "@babel/types@^7.27.1", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.4": version "7.26.3" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.3.tgz#37e79830f04c2b5687acc77db97fbc75fb81f3c0" integrity sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA== @@ -1613,14 +1608,6 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" -"@babel/types@^7.26.10", "@babel/types@^7.27.0", "@babel/types@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.1.tgz#9defc53c16fc899e46941fc6901a9eea1c9d8560" - integrity sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" - "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" @@ -9789,16 +9776,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9839,14 +9817,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10515,7 +10486,7 @@ workerpool@^6.5.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -10533,15 +10504,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" From 47452447a1261fd47fdb9a3b05e44b297d23223f Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Thu, 4 Sep 2025 14:13:44 -0700 Subject: [PATCH 11/25] [compiler] Add catching useStates that shadow a reactive value --- .../ValidateNoDerivedComputationsInEffects.ts | 158 +++++++++++------- ...ug-derived-state-from-mixed-deps.expect.md | 4 +- ...erived-state-from-shadowed-props.expect.md | 24 ++- ...r.derived-state-with-conditional.expect.md | 4 +- ....derived-state-with-side-effects.expect.md | 4 +- ...id-derived-computation-in-effect.expect.md | 4 +- ...erived-state-from-props-computed.expect.md | 4 +- ...ed-state-from-props-destructured.expect.md | 4 +- ...rived-state-from-props-in-effect.expect.md | 4 +- ...te-from-props-with-default-value.expect.md | 4 +- ...rived-state-from-state-in-effect.expect.md | 4 +- ...ror.shadowed-props-with-onchange.expect.md | 61 +++++++ .../error.shadowed-props-with-onchange.js | 15 ++ compiler/yarn.lock | 46 +---- 14 files changed, 217 insertions(+), 123 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index eca16f44f2..8362e9b439 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -41,7 +41,7 @@ type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type DerivationMetadata = { typeOfValue: TypeOfValue; place: Place; - sources: Set; + sources: Array; }; type ErrorMetadata = { @@ -49,6 +49,7 @@ type ErrorMetadata = { description: string | undefined; loc: SourceLocation; setStateName: string | undefined | null; + derivedDepsNames: Array; }; /** @@ -79,6 +80,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const functions: Map = new Map(); const locals: Map = new Map(); const derivationCache: Map = new Map(); + const shadowingUseState: Map> = new Map(); const effectSetStates: Map< string | undefined | null, @@ -93,7 +95,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (param.kind === 'Identifier') { derivationCache.set(param.identifier.id, { place: param, - sources: new Set([param]), + sources: [param], typeOfValue: 'fromProps', }); } @@ -103,7 +105,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (props != null && props.kind === 'Identifier') { derivationCache.set(props.identifier.id, { place: props, - sources: new Set([props]), + sources: [props], typeOfValue: 'fromProps', }); } @@ -115,7 +117,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { for (const instr of block.instructions) { const {lvalue, value} = instr; - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); @@ -167,6 +169,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const compilerError = generateCompilerError( setStateCalls, effectSetStates, + shadowingUseState, errors, ); @@ -178,21 +181,12 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function generateCompilerError( setStateCalls: Map>, effectSetStates: Map>, + shadowingUseState: Map>, errors: Array, ): CompilerError { const throwableErrors = new CompilerError(); for (const error of errors) { let compilerDiagnostic: CompilerDiagnostic | undefined = undefined; - let detailMessage = ''; - switch (error.type) { - case 'fromProps': - detailMessage = 'This state value shadows a value passed as a prop.'; - break; - case 'fromPropsOrState': - detailMessage = - 'This state value shadows a value passed as a prop or a value from state.'; - break; - } /* * If we use a setState from an invalid useEffect elsewhere then we probably have to @@ -204,15 +198,27 @@ function generateCompilerError( error.type !== 'fromState' ) { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.`, - category: `Local state shadows parent state.`, + description: `The setState within a useEffect is deriving from ${error.description}. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render`, + category: `You might not need an effect. Local state shadows parent state.`, severity: ErrorSeverity.InvalidReact, }).withDetail({ kind: 'error', loc: error.loc, - message: 'this setState synchronizes the state', + message: `this derives values from props ${error.type === 'fromPropsOrState' ? 'and local state ' : ''}to synchronize state`, }); + for (const derivedDep of error.derivedDepsNames) { + if (shadowingUseState.has(derivedDep)) { + for (const loc of shadowingUseState.get(derivedDep)!) { + compilerDiagnostic.withDetail({ + kind: 'error', + loc: loc, + message: `this useState shadows ${derivedDep}`, + }); + } + } + } + for (const [key, setStateCallArray] of effectSetStates) { if (setStateCallArray.length === 0) { continue; @@ -234,8 +240,8 @@ function generateCompilerError( } } else { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, - category: `Derive values in render, not effects.`, + description: `${error.description ? error.description.charAt(0).toUpperCase() + error.description.slice(1) : ''}. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, + category: `You might not need an effect. Derive values in render, not effects.`, severity: ErrorSeverity.InvalidReact, }).withDetail({ kind: 'error', @@ -270,7 +276,7 @@ function updateDerivationMetadata( ): void { let newValue: DerivationMetadata = { place: target, - sources: new Set(), + sources: [], typeOfValue: typeOfValue ?? 'ignored', }; @@ -285,9 +291,9 @@ function updateDerivationMetadata( place.identifier.name === null || place.identifier.name?.kind === 'promoted' ) { - newValue.sources.add(target); + newValue.sources.push(target); } else { - newValue.sources.add(place); + newValue.sources.push(place); } } } @@ -300,38 +306,19 @@ function parseInstr( instr: Instruction, derivationCache: Map, setStateCalls: Map>, + shadowingUseState: Map>, ): void { // Recursively parse function expressions + let typeOfValue: TypeOfValue = 'ignored'; + + let sources: Array = []; if (instr.value.kind === 'FunctionExpression') { for (const [, block] of instr.value.loweredFunc.func.body.blocks) { for (const instr of block.instructions) { - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); } } - } - - let typeOfValue: TypeOfValue = 'ignored'; - - // Catch any useState hook calls - let sources: Array = []; - if ( - instr.value.kind === 'Destructure' && - instr.value.lvalue.pattern.kind === 'ArrayPattern' && - isUseStateType(instr.value.value.identifier) - ) { - typeOfValue = 'fromState'; - - const stateValueSource = instr.value.lvalue.pattern.items[0]; - if (stateValueSource.kind === 'Identifier') { - sources.push({ - place: stateValueSource, - typeOfValue: typeOfValue, - sources: new Set([stateValueSource]), - }); - } - } - - if ( + } else if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && instr.value.args.length === 1 && @@ -347,6 +334,21 @@ function parseInstr( instr.value.callee, ]); } + } else if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + isUseStateType(instr.lvalue.identifier) + ) { + const stateValueSource = instr.value.args[0]; + if (stateValueSource.kind === 'Identifier') { + sources.push({ + place: stateValueSource, + typeOfValue: typeOfValue, + sources: [stateValueSource], + }); + } + + typeOfValue = joinValue(typeOfValue, 'fromState'); } for (const operand of eachInstructionOperand(instr)) { @@ -357,6 +359,27 @@ function parseInstr( typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); sources.push(opSource); + + if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + opSource.typeOfValue === 'fromProps' && + isUseStateType(instr.lvalue.identifier) + ) { + opSource.sources.forEach(source => { + if (source.identifier.name !== null) { + if (shadowingUseState.has(source.identifier.name.value)) { + shadowingUseState + .get(source.identifier.name.value) + ?.push(instr.lvalue.loc); + } else { + shadowingUseState.set(source.identifier.name.value, [ + instr.lvalue.loc, + ]); + } + } + }); + } } if (typeOfValue !== 'ignored') { @@ -410,16 +433,26 @@ function parseBlockPhi( derivationCache: Map, ): void { for (const phi of block.phis) { + let typeOfValue: TypeOfValue = 'ignored'; + let sources: Array = []; for (const operand of phi.operands.values()) { - const phiSource = derivationCache.get(operand.identifier.id); - if (phiSource !== undefined) { - updateDerivationMetadata( - phi.place, - [phiSource], - phiSource?.typeOfValue, - derivationCache, - ); + const opSource = derivationCache.get(operand.identifier.id); + + if (opSource === undefined) { + continue; } + + typeOfValue = joinValue(typeOfValue, opSource?.typeOfValue ?? 'ignored'); + sources.push(opSource); + } + + if (typeOfValue !== 'ignored') { + updateDerivationMetadata( + phi.place, + sources, + typeOfValue, + derivationCache, + ); } } } @@ -523,7 +556,7 @@ function validateEffect( } for (const call of derivedSetStateCall) { - const placeNames = Array.from(call.derivedDep.sources) + const derivedDepsStr = Array.from(call.derivedDep.sources) .map(place => { return place.identifier.name?.value; }) @@ -533,19 +566,24 @@ function validateEffect( let errorDescription = ''; if (call.derivedDep.typeOfValue === 'fromProps') { - errorDescription = `props [${placeNames}].`; + errorDescription = `props [${derivedDepsStr}]`; } else if (call.derivedDep.typeOfValue === 'fromState') { - errorDescription = `local state [${placeNames}].`; + errorDescription = `local state [${derivedDepsStr}]`; } else { - errorDescription = `both props and local state [${placeNames}].`; + errorDescription = `both props and local state [${derivedDepsStr}]`; } errors.push({ type: call.derivedDep.typeOfValue, - description: `This setState() appears to derive a value from ${errorDescription}`, + description: `${errorDescription}`, loc: call.loc, setStateName: call.loc !== GeneratedSource ? call.loc.identifierName : undefined, + derivedDepsNames: Array.from(call.derivedDep.sources) + .map(place => { + return place.identifier.name?.value ?? ''; + }) + .filter(Boolean), }); } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md index 2588a014af..5255636da7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -34,9 +34,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.bug-derived-state-from-mixed-deps.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md index 66079d40bb..cd7c024fe9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md @@ -32,19 +32,37 @@ function Component({props, number}) { ``` Found 1 error: -Error: Local state shadows parent state. +Error: You might not need an effect. Local state shadows parent state. -This setState() appears to derive a value from props [props, number]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. +The setState within a useEffect is deriving from props [props, number]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render error.derived-state-from-shadowed-props.ts:10:4 8 | 9 | useEffect(() => { > 10 | setDisplayValue(props.prefix + missDirection + nothing); - | ^^^^^^^^^^^^^^^ this setState synchronizes the state + | ^^^^^^^^^^^^^^^ this derives values from props to synchronize state 11 | }, [props.prefix, missDirection, nothing]); 12 | 13 | return ( +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows props + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows number + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + error.derived-state-from-shadowed-props.ts:16:8 14 |
{ diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md index 0643af7722..5cf7a99730 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md @@ -32,9 +32,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-conditional.ts:9:6 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md index 0f25b76660..ba8d835199 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md @@ -30,9 +30,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-side-effects.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md index bdf7a9b209..61ae320eec 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md @@ -24,9 +24,9 @@ function BadExample() { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-computation-in-effect.ts:9:4 7 | const [fullName, setFullName] = useState(''); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md index 7773a2cc8d..daf74031d9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md @@ -29,9 +29,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-computed.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index 99b596c4ce..c5509ceae3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-destructured.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md index 88c722b8f6..9cb8a7427f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-in-effect.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md index 3af0c00ecc..8136511e6f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md @@ -26,9 +26,9 @@ export default function InProductLobbyGeminiCard( ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-with-default-value.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md index 5a029cb0cc..e7f8cd4584 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -36,9 +36,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-state-in-effect.ts:10:4 8 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md new file mode 100644 index 0000000000..f08a65dad2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md @@ -0,0 +1,61 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} + +``` + + +## Error + +``` +Found 1 error: + +Error: You might not need an effect. Local state shadows parent state. + +The setState within a useEffect is deriving from props [startDate]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render + +error.shadowed-props-with-onchange.ts:7:8 + 5 | + 6 | useEffect(() => { +> 7 | setLocalStartDate(startDate); + | ^^^^^^^^^^^^^^^^^ this derives values from props to synchronize state + 8 | }, [startDate]); + 9 | + 10 | const onChange = (date) => { + +error.shadowed-props-with-onchange.ts:4:47 + 2 | + 3 | function EndDate({startDate, endDate, onStartDateChange}) { +> 4 | const [localStartDate, setLocalStartDate] = useState(startDate); + | ^^^^^^^^^^^^^^^^^^^ this useState shadows startDate + 5 | + 6 | useEffect(() => { + 7 | setLocalStartDate(startDate); + +error.shadowed-props-with-onchange.ts:11:8 + 9 | + 10 | const onChange = (date) => { +> 11 | setLocalStartDate(date); + | ^^^^^^^^^^^^^^^^^ this setState updates the shadowed state, but should call an onChange event from the parent + 12 | onStartDateChange(date); + 13 | } + 14 | return +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js new file mode 100644 index 0000000000..52e74312e6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js @@ -0,0 +1,15 @@ +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} diff --git a/compiler/yarn.lock b/compiler/yarn.lock index 6e1bc7feeb..f4eb92b966 100644 --- a/compiler/yarn.lock +++ b/compiler/yarn.lock @@ -542,11 +542,6 @@ resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== -"@babel/helper-string-parser@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" - integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== - "@babel/helper-validator-identifier@^7.19.1", "@babel/helper-validator-identifier@^7.25.9": version "7.25.9" resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz" @@ -1605,7 +1600,7 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.19.0", "@babel/types@^7.2.0", "@babel/types@^7.2.2", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.2", "@babel/types@^7.24.7", "@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.26.3", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.4": +"@babel/types@7.26.3", "@babel/types@^7.0.0", "@babel/types@^7.19.0", "@babel/types@^7.2.0", "@babel/types@^7.2.2", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.2", "@babel/types@^7.24.7", "@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.26.10", "@babel/types@^7.26.3", "@babel/types@^7.27.0", "@babel/types@^7.27.1", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.4": version "7.26.3" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.3.tgz#37e79830f04c2b5687acc77db97fbc75fb81f3c0" integrity sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA== @@ -1613,14 +1608,6 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" -"@babel/types@^7.26.10", "@babel/types@^7.27.0", "@babel/types@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.1.tgz#9defc53c16fc899e46941fc6901a9eea1c9d8560" - integrity sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" - "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" @@ -9789,16 +9776,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9839,14 +9817,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10515,7 +10486,7 @@ workerpool@^6.5.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -10533,15 +10504,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" From 030a610ccb64f28588eeb91509fc966cf259139a Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Thu, 4 Sep 2025 15:34:35 -0700 Subject: [PATCH 12/25] [compiler] new tests for props derived Adds some new test cases for ValidateNoDerivedComputationsInEffects. --- ...ved-state-one-time-init-no-error.expect.md | 87 +++++++++++++++++++ .../derived-state-one-time-init-no-error.js | 21 +++++ ...-state-with-conditional-no-error.expect.md | 79 +++++++++++++++++ ...derived-state-with-conditional-no-error.js | 21 +++++ ...state-with-side-effects-no-error.expect.md | 74 ++++++++++++++++ ...erived-state-with-side-effects-no-error.js | 19 ++++ ...ug-derived-state-from-mixed-deps.expect.md | 49 +++++++++++ ...error.bug-derived-state-from-mixed-deps.js | 23 +++++ ...ed-state-from-props-destructured.expect.md | 43 +++++++++ ...d-derived-state-from-props-destructured.js | 17 ++++ ...rived-state-from-props-in-effect.expect.md | 43 +++++++++ ...alid-derived-state-from-props-in-effect.js | 17 ++++ ...rived-state-from-state-in-effect.expect.md | 51 +++++++++++ ...alid-derived-state-from-state-in-effect.js | 25 ++++++ ...erived-state-from-props-computed.expect.md | 72 +++++++++++++++ ...valid-derived-state-from-props-computed.js | 18 ++++ 16 files changed, 659 insertions(+) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.expect.md new file mode 100644 index 0000000000..07a58aeef3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.expect.md @@ -0,0 +1,87 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({initialName}) { + const [name, setName] = useState(''); + + useEffect(() => { + setName(initialName); + }, []); + + return ( +
+ setName(e.target.value)} /> +
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{initialName: 'John'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects +import { useEffect, useState } from "react"; + +function Component(t0) { + const $ = _c(6); + const { initialName } = t0; + const [name, setName] = useState(""); + let t1; + if ($[0] !== initialName) { + t1 = () => { + setName(initialName); + }; + $[0] = initialName; + $[1] = t1; + } else { + t1 = $[1]; + } + let t2; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t2 = []; + $[2] = t2; + } else { + t2 = $[2]; + } + useEffect(t1, t2); + let t3; + if ($[3] === Symbol.for("react.memo_cache_sentinel")) { + t3 = (e) => setName(e.target.value); + $[3] = t3; + } else { + t3 = $[3]; + } + let t4; + if ($[4] !== name) { + t4 = ( +
+ +
+ ); + $[4] = name; + $[5] = t4; + } else { + t4 = $[5]; + } + return t4; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ initialName: "John" }], +}; + +``` + +### Eval output +(kind: ok)
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.js new file mode 100644 index 0000000000..c6705378a5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-one-time-init-no-error.js @@ -0,0 +1,21 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({initialName}) { + const [name, setName] = useState(''); + + useEffect(() => { + setName(initialName); + }, []); + + return ( +
+ setName(e.target.value)} /> +
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{initialName: 'John'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md new file mode 100644 index 0000000000..b7a1c85d52 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md @@ -0,0 +1,79 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value, enabled}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + if (enabled) { + setLocalValue(value); + } else { + setLocalValue('disabled'); + } + }, [value, enabled]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test', enabled: true}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects +import { useEffect, useState } from "react"; + +function Component(t0) { + const $ = _c(6); + const { value, enabled } = t0; + const [localValue, setLocalValue] = useState(""); + let t1; + let t2; + if ($[0] !== enabled || $[1] !== value) { + t1 = () => { + if (enabled) { + setLocalValue(value); + } else { + setLocalValue("disabled"); + } + }; + + t2 = [value, enabled]; + $[0] = enabled; + $[1] = value; + $[2] = t1; + $[3] = t2; + } else { + t1 = $[2]; + t2 = $[3]; + } + useEffect(t1, t2); + let t3; + if ($[4] !== localValue) { + t3 =
{localValue}
; + $[4] = localValue; + $[5] = t3; + } else { + t3 = $[5]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: "test", enabled: true }], +}; + +``` + +### Eval output +(kind: ok)
test
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js new file mode 100644 index 0000000000..79d83b8925 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js @@ -0,0 +1,21 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value, enabled}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + if (enabled) { + setLocalValue(value); + } else { + setLocalValue('disabled'); + } + }, [value, enabled]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test', enabled: true}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md new file mode 100644 index 0000000000..e0708dd1f7 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md @@ -0,0 +1,74 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + console.log('Value changed:', value); + setLocalValue(value); + document.title = `Value: ${value}`; + }, [value]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects +import { useEffect, useState } from "react"; + +function Component(t0) { + const $ = _c(5); + const { value } = t0; + const [localValue, setLocalValue] = useState(""); + let t1; + let t2; + if ($[0] !== value) { + t1 = () => { + console.log("Value changed:", value); + setLocalValue(value); + document.title = `Value: ${value}`; + }; + t2 = [value]; + $[0] = value; + $[1] = t1; + $[2] = t2; + } else { + t1 = $[1]; + t2 = $[2]; + } + useEffect(t1, t2); + let t3; + if ($[3] !== localValue) { + t3 =
{localValue}
; + $[3] = localValue; + $[4] = t3; + } else { + t3 = $[4]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: "test" }], +}; + +``` + +### Eval output +(kind: ok)
test
+logs: ['Value changed:','test'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js new file mode 100644 index 0000000000..b948dda6cb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js @@ -0,0 +1,19 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + console.log('Value changed:', value); + setLocalValue(value); + document.title = `Value: ${value}`; + }, [value]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md new file mode 100644 index 0000000000..54c95d68e3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -0,0 +1,49 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({prefix}) { + const [name, setName] = useState(''); + const [displayName, setDisplayName] = useState(''); + + useEffect(() => { + setDisplayName(prefix + name); + }, [prefix, name]); + + return ( +
+ setName(e.target.value)} /> +
{displayName}
+
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prefix: 'Hello, '}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +error.derived-state-from-mixed-deps-no-error.ts:9:4 + 7 | + 8 | useEffect(() => { +> 9 | setDisplayName(prefix + name); + | ^^^^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + 10 | }, [prefix, name]); + 11 | + 12 | return ( +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.js new file mode 100644 index 0000000000..0004ab0ebf --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.js @@ -0,0 +1,23 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({prefix}) { + const [name, setName] = useState(''); + const [displayName, setDisplayName] = useState(''); + + useEffect(() => { + setDisplayName(prefix + name); + }, [prefix, name]); + + return ( +
+ setName(e.target.value)} /> +
{displayName}
+
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prefix: 'Hello, '}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md new file mode 100644 index 0000000000..cb18bd12a3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -0,0 +1,43 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({user: {firstName, lastName}}) { + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return
{fullName}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{user: {firstName: 'John', lastName: 'Doe'}}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +error.invalid-derived-state-from-props-destructured.ts:8:4 + 6 | + 7 | useEffect(() => { +> 8 | setFullName(firstName + ' ' + lastName); + | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + 9 | }, [firstName, lastName]); + 10 | + 11 | return
{fullName}
; +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js new file mode 100644 index 0000000000..130d31c11a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js @@ -0,0 +1,17 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({user: {firstName, lastName}}) { + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return
{fullName}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{user: {firstName: 'John', lastName: 'Doe'}}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md new file mode 100644 index 0000000000..15d94c39ad --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -0,0 +1,43 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({firstName, lastName}) { + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return
{fullName}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{firstName: 'John', lastName: 'Doe'}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +error.invalid-derived-state-from-props-in-effect.ts:8:4 + 6 | + 7 | useEffect(() => { +> 8 | setFullName(firstName + ' ' + lastName); + | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + 9 | }, [firstName, lastName]); + 10 | + 11 | return
{fullName}
; +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.js new file mode 100644 index 0000000000..966f09ea89 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.js @@ -0,0 +1,17 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({firstName, lastName}) { + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return
{fullName}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{firstName: 'John', lastName: 'Doe'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md new file mode 100644 index 0000000000..7466edb3c5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -0,0 +1,51 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component() { + const [firstName, setFirstName] = useState('John'); + const [lastName, setLastName] = useState('Doe'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return ( +
+ setFirstName(e.target.value)} /> + setLastName(e.target.value)} /> +
{fullName}
+
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +error.invalid-derived-state-from-state-in-effect.ts:10:4 + 8 | + 9 | useEffect(() => { +> 10 | setFullName(firstName + ' ' + lastName); + | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + 11 | }, [firstName, lastName]); + 12 | + 13 | return ( +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.js new file mode 100644 index 0000000000..2b4f9f7066 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.js @@ -0,0 +1,25 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component() { + const [firstName, setFirstName] = useState('John'); + const [lastName, setLastName] = useState('Doe'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); + + return ( +
+ setFirstName(e.target.value)} /> + setLastName(e.target.value)} /> +
{fullName}
+
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md new file mode 100644 index 0000000000..3d0c4fe9c8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md @@ -0,0 +1,72 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component(props) { + const [displayValue, setDisplayValue] = useState(''); + + useEffect(() => { + const computed = props.prefix + props.value + props.suffix; + setDisplayValue(computed); + }, [props.prefix, props.value, props.suffix]); + + return
{displayValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prefix: '[', value: 'test', suffix: ']'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects +import { useEffect, useState } from "react"; + +function Component(props) { + const $ = _c(7); + const [displayValue, setDisplayValue] = useState(""); + let t0; + let t1; + if ($[0] !== props.prefix || $[1] !== props.suffix || $[2] !== props.value) { + t0 = () => { + const computed = props.prefix + props.value + props.suffix; + setDisplayValue(computed); + }; + t1 = [props.prefix, props.value, props.suffix]; + $[0] = props.prefix; + $[1] = props.suffix; + $[2] = props.value; + $[3] = t0; + $[4] = t1; + } else { + t0 = $[3]; + t1 = $[4]; + } + useEffect(t0, t1); + let t2; + if ($[5] !== displayValue) { + t2 =
{displayValue}
; + $[5] = displayValue; + $[6] = t2; + } else { + t2 = $[6]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ prefix: "[", value: "test", suffix: "]" }], +}; + +``` + +### Eval output +(kind: ok)
[test]
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js new file mode 100644 index 0000000000..0e726f86ab --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js @@ -0,0 +1,18 @@ +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component(props) { + const [displayValue, setDisplayValue] = useState(''); + + useEffect(() => { + const computed = props.prefix + props.value + props.suffix; + setDisplayValue(computed); + }, [props.prefix, props.value, props.suffix]); + + return
{displayValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prefix: '[', value: 'test', suffix: ']'}], +}; From 948bf95bd986bd2e573bd45299f807b199c84333 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Thu, 4 Sep 2025 15:35:04 -0700 Subject: [PATCH 13/25] [compiler][wip] Extend ValidateNoDerivedComputationsInEffects for props derived effects This PR adds infra to disambiguate between two types of derived state in effects: 1. State derived from props 2. State derived from other state TODO: - [ ] Props tracking through destructuring and property access does not seem to be propagated correctly inside of Functions' instructions (or i might be misunderstanding how we track aliasing effects) - [ ] compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js should be failing - [ ] Handle "mixed" case where deps flow from at least one prop AND state. Should probably have a different error reason, to aid with categorization --- .../ValidateNoDerivedComputationsInEffects.ts | 185 ++++++++++++++++-- ...id-derived-computation-in-effect.expect.md | 6 +- ...ug-derived-state-from-mixed-deps.expect.md | 8 +- ...ed-state-from-props-destructured.expect.md | 6 +- ...d-derived-state-from-props-destructured.js | 4 +- ...rived-state-from-props-in-effect.expect.md | 6 +- ...rived-state-from-state-in-effect.expect.md | 6 +- 7 files changed, 194 insertions(+), 27 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index f1fa5aec40..f8a48a8021 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -13,14 +13,21 @@ import { FunctionExpression, HIRFunction, IdentifierId, + Place, isSetStateType, isUseEffectHookType, } from '../HIR'; +import {printInstruction, printPlace} from '../HIR/PrintHIR'; import { eachInstructionValueOperand, eachTerminalOperand, } from '../HIR/visitors'; +type SetStateCall = { + loc: SourceLocation; + propsSource: Place | null; // null means state-derived, non-null means props-derived +}; + /** * Validates that useEffect is not used for derived computations which could/should * be performed in render. @@ -48,12 +55,96 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const candidateDependencies: Map = new Map(); const functions: Map = new Map(); const locals: Map = new Map(); + const derivedFromProps: Map = new Map(); const errors = new CompilerError(); + if (fn.fnType === 'Hook') { + for (const param of fn.params) { + if (param.kind === 'Identifier') { + derivedFromProps.set(param.identifier.id, param); + } + } + } else if (fn.fnType === 'Component') { + const props = fn.params[0]; + if (props != null && props.kind === 'Identifier') { + derivedFromProps.set(props.identifier.id, props); + } + } + for (const block of fn.body.blocks.values()) { for (const instr of block.instructions) { const {lvalue, value} = instr; + + // Track props derivation through instruction effects + if (instr.effects != null) { + for (const effect of instr.effects) { + switch (effect.kind) { + case 'Assign': + case 'Alias': + case 'MaybeAlias': + case 'Capture': { + const source = derivedFromProps.get(effect.from.identifier.id); + if (source != null) { + derivedFromProps.set(effect.into.identifier.id, source); + } + break; + } + } + } + } + + /** + * TODO: figure out why property access off of props does not create an Assign or Alias/Maybe + * Alias + * + * import {useEffect, useState} from 'react' + * + * function Component(props) { + * const [displayValue, setDisplayValue] = useState(''); + * + * useEffect(() => { + * const computed = props.prefix + props.value + props.suffix; + * ^^^^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^ + * we want to track that these are from props + * setDisplayValue(computed); + * }, [props.prefix, props.value, props.suffix]); + * + * return
{displayValue}
; + * } + */ + if (value.kind === 'FunctionExpression') { + for (const [, block] of value.loweredFunc.func.body.blocks) { + for (const instr of block.instructions) { + if (instr.effects != null) { + console.group(printInstruction(instr)); + for (const effect of instr.effects) { + console.log(effect); + switch (effect.kind) { + case 'Assign': + case 'Alias': + case 'MaybeAlias': + case 'Capture': { + const source = derivedFromProps.get( + effect.from.identifier.id, + ); + if (source != null) { + derivedFromProps.set(effect.into.identifier.id, source); + } + break; + } + } + } + } + console.groupEnd(); + } + } + } + + for (const [, place] of derivedFromProps) { + console.log(printPlace(place)); + } + if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); } else if (value.kind === 'ArrayExpression') { @@ -90,6 +181,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { validateEffect( effectFunction.loweredFunc.func, dependencies, + derivedFromProps, errors, ); } @@ -105,6 +197,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function validateEffect( effectFunction: HIRFunction, effectDeps: Array, + derivedFromProps: Map, errors: CompilerError, ): void { for (const operand of effectFunction.context) { @@ -112,16 +205,22 @@ function validateEffect( continue; } else if (effectDeps.find(dep => dep === operand.identifier.id) != null) { continue; + } else if (derivedFromProps.has(operand.identifier.id)) { + continue; } else { // Captured something other than the effect dep or setState + console.log('early return 1'); return; } } for (const dep of effectDeps) { + console.log({dep}); if ( effectFunction.context.find(operand => operand.identifier.id === dep) == - null + null || + derivedFromProps.has(dep) === false ) { + console.log('early return 2'); // effect dep wasn't actually used in the function return; } @@ -129,11 +228,18 @@ function validateEffect( const seenBlocks: Set = new Set(); const values: Map> = new Map(); + const effectDerivedFromProps: Map = new Map(); + for (const dep of effectDeps) { + console.log({dep}); values.set(dep, [dep]); + const propsSource = derivedFromProps.get(dep); + if (propsSource != null) { + effectDerivedFromProps.set(dep, propsSource); + } } - const setStateLocations: Array = []; + const setStateCalls: Array = []; for (const block of effectFunction.body.blocks.values()) { for (const pred of block.preds) { if (!seenBlocks.has(pred)) { @@ -143,6 +249,8 @@ function validateEffect( } for (const phi of block.phis) { const aggregateDeps: Set = new Set(); + let propsSource: Place | null = null; + for (const operand of phi.operands.values()) { const deps = values.get(operand.identifier.id); if (deps != null) { @@ -150,10 +258,18 @@ function validateEffect( aggregateDeps.add(dep); } } + const source = effectDerivedFromProps.get(operand.identifier.id); + if (source != null) { + propsSource = source; + } } + if (aggregateDeps.size !== 0) { values.set(phi.place.identifier.id, Array.from(aggregateDeps)); } + if (propsSource != null) { + effectDerivedFromProps.set(phi.place.identifier.id, propsSource); + } } for (const instr of block.instructions) { switch (instr.value.kind) { @@ -196,9 +312,16 @@ function validateEffect( ) { const deps = values.get(instr.value.args[0].identifier.id); if (deps != null && new Set(deps).size === effectDeps.length) { - setStateLocations.push(instr.value.callee.loc); + const propsSource = effectDerivedFromProps.get( + instr.value.args[0].identifier.id, + ); + + setStateCalls.push({ + loc: instr.value.callee.loc, + propsSource: propsSource ?? null, + }); } else { - // doesn't depend on any deps + // doesn't depend on all deps return; } } @@ -208,6 +331,26 @@ function validateEffect( return; } } + + // Track props derivation through instruction effects + if (instr.effects != null) { + for (const effect of instr.effects) { + switch (effect.kind) { + case 'Assign': + case 'Alias': + case 'MaybeAlias': + case 'Capture': { + const source = effectDerivedFromProps.get( + effect.from.identifier.id, + ); + if (source != null) { + effectDerivedFromProps.set(effect.into.identifier.id, source); + } + break; + } + } + } + } } for (const operand of eachTerminalOperand(block.terminal)) { if (values.has(operand.identifier.id)) { @@ -218,15 +361,29 @@ function validateEffect( seenBlocks.add(block.id); } - for (const loc of setStateLocations) { - errors.push({ - category: ErrorCategory.EffectDerivationsOfState, - reason: - 'Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)', - description: null, - severity: ErrorSeverity.InvalidReact, - loc, - suggestions: null, - }); + for (const call of setStateCalls) { + if (call.propsSource != null) { + const propName = call.propsSource.identifier.name?.value; + const propInfo = propName != null ? ` (from prop '${propName}')` : ''; + + errors.push({ + reason: `Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)`, + description: `You are using props${propInfo} to update local state in an effect.`, + severity: ErrorSeverity.InvalidReact, + loc: call.loc, + suggestions: null, + }); + } else { + errors.push({ + reason: + 'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)', + description: + 'This effect updates state based on other state values. ' + + 'Consider calculating this value directly during render', + severity: ErrorSeverity.InvalidReact, + loc: call.loc, + suggestions: null, + }); + } } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md index d97a665ae6..1d7e24b3ef 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md @@ -24,13 +24,15 @@ function BadExample() { ``` Found 1 error: -Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +This effect updates state based on other state values. Consider calculating this value directly during render. error.invalid-derived-computation-in-effect.ts:9:4 7 | const [fullName, setFullName] = useState(''); 8 | useEffect(() => { > 9 | setFullName(capitalize(firstName + ' ' + lastName)); - | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) 10 | }, [firstName, lastName]); 11 | 12 | return
{fullName}
; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md index 54c95d68e3..8124f4b3f3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -34,13 +34,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) -error.derived-state-from-mixed-deps-no-error.ts:9:4 +This effect updates state based on other state values. Consider calculating this value directly during render. + +error.bug-derived-state-from-mixed-deps.ts:9:4 7 | 8 | useEffect(() => { > 9 | setDisplayName(prefix + name); - | ^^^^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) 10 | }, [prefix, name]); 11 | 12 | return ( diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index cb18bd12a3..26b8b7930b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -28,13 +28,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +This effect updates state based on other state values. Consider calculating this value directly during render. error.invalid-derived-state-from-props-destructured.ts:8:4 6 | 7 | useEffect(() => { > 8 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) 9 | }, [firstName, lastName]); 10 | 11 | return
{fullName}
; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js index 130d31c11a..966f09ea89 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js @@ -1,7 +1,7 @@ // @validateNoDerivedComputationsInEffects import {useEffect, useState} from 'react'; -function Component({user: {firstName, lastName}}) { +function Component({firstName, lastName}) { const [fullName, setFullName] = useState(''); useEffect(() => { @@ -13,5 +13,5 @@ function Component({user: {firstName, lastName}}) { export const FIXTURE_ENTRYPOINT = { fn: Component, - params: [{user: {firstName: 'John', lastName: 'Doe'}}], + params: [{firstName: 'John', lastName: 'Doe'}], }; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md index 15d94c39ad..1f7ff8dc5d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -28,13 +28,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +This effect updates state based on other state values. Consider calculating this value directly during render. error.invalid-derived-state-from-props-in-effect.ts:8:4 6 | 7 | useEffect(() => { > 8 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) 9 | }, [firstName, lastName]); 10 | 11 | return
{fullName}
; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md index 7466edb3c5..c5548c970b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -36,13 +36,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + +This effect updates state based on other state values. Consider calculating this value directly during render. error.invalid-derived-state-from-state-in-effect.ts:10:4 8 | 9 | useEffect(() => { > 10 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) 11 | }, [firstName, lastName]); 12 | 13 | return ( From 0c90ac2e28de2b22642ff9ee9de21aa0bb3fdcab Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes <57368278+jorge-cab@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:35:04 -0700 Subject: [PATCH 14/25] [compiler] Basic solution for instruction based prop derivation validation --- .../ValidateNoDerivedComputationsInEffects.ts | 345 ++++++++++++------ 1 file changed, 229 insertions(+), 116 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index f8a48a8021..78174c656b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -5,7 +5,8 @@ * LICENSE file in the root directory of this source tree. */ -import {CompilerError, ErrorSeverity, SourceLocation} from '..'; +import {TypeOf} from 'zod'; +import {CompilerError, Effect, ErrorSeverity, SourceLocation} from '..'; import {ErrorCategory} from '../CompilerError'; import { ArrayExpression, @@ -13,6 +14,7 @@ import { FunctionExpression, HIRFunction, IdentifierId, + InstructionValue, Place, isSetStateType, isUseEffectHookType, @@ -20,13 +22,74 @@ import { import {printInstruction, printPlace} from '../HIR/PrintHIR'; import { eachInstructionValueOperand, + eachInstructionOperand, eachTerminalOperand, + eachInstructionLValue, } from '../HIR/visitors'; +import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables'; +import {assertExhaustive} from '../Utils/utils'; type SetStateCall = { loc: SourceLocation; - propsSource: Place | null; // null means state-derived, non-null means props-derived + propsSources: Place[] | undefined; // undefined means state-derived, defined means props-derived }; +type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; + +type DerivationMetadata = { + identifierPlace: Place; + sources: Place[]; + typeOfValue: TypeOfValue; +}; + +function joinValue( + lvalueType: TypeOfValue, + valueType: TypeOfValue, +): TypeOfValue { + if (lvalueType === 'ignored') return valueType; + if (valueType === 'ignored') return lvalueType; + if (lvalueType === valueType) return lvalueType; + return 'fromPropsOrState'; +} + +function propagateDerivation( + dest: Place, + source: Place | undefined, + derivedFromProps: Map, +) { + if (source === undefined) { + return; + } + + if (source.identifier.name?.kind === 'promoted') { + derivedFromProps.set(dest.identifier.id, dest); + } else { + derivedFromProps.set(dest.identifier.id, source); + } +} + +function updateDerivationMetadata( + target: Place, + sources: DerivationMetadata[], + typeOfValue: TypeOfValue, + derivedTuple: Map, +): void { + let newValue: DerivationMetadata = { + identifierPlace: target, + sources: [], + typeOfValue: typeOfValue, + }; + + for (const source of sources) { + // If the identifier of the source is a promoted identifier, then + // we should set the source as the first named identifier. + if (source.identifierPlace.identifier.name?.kind === 'promoted') { + newValue.sources.push(target); + } else { + newValue.sources.push(...source.sources); + } + } + derivedTuple.set(target.identifier.id, newValue); +} /** * Validates that useEffect is not used for derived computations which could/should @@ -55,96 +118,138 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const candidateDependencies: Map = new Map(); const functions: Map = new Map(); const locals: Map = new Map(); - const derivedFromProps: Map = new Map(); + + // MY take on this + const valueToType: Map = new Map(); + const valueToSourceProps: Map> = new Map(); + const valueToSourceStates: Map> = new Map(); + const valueToSources: Map> = new Map(); + + // Sources are still probably not correct + const derivedTuple: Map = new Map(); const errors = new CompilerError(); if (fn.fnType === 'Hook') { for (const param of fn.params) { if (param.kind === 'Identifier') { - derivedFromProps.set(param.identifier.id, param); + derivedTuple.set(param.identifier.id, { + identifierPlace: param, + sources: [param], + typeOfValue: 'fromProps', + }); } } } else if (fn.fnType === 'Component') { const props = fn.params[0]; if (props != null && props.kind === 'Identifier') { - derivedFromProps.set(props.identifier.id, props); + derivedTuple.set(props.identifier.id, { + identifierPlace: props, + sources: [props], + typeOfValue: 'fromProps', + }); } } for (const block of fn.body.blocks.values()) { + for (const phi of block.phis) { + for (const operand of phi.operands.values()) { + const source = derivedTuple.get(operand.identifier.id); + if (source !== undefined && source.typeOfValue === 'fromProps') { + if ( + source.identifierPlace.identifier.name === null || + source.identifierPlace.identifier.name?.kind === 'promoted' + ) { + derivedTuple.set(phi.place.identifier.id, { + identifierPlace: phi.place, + sources: [phi.place], + typeOfValue: 'fromProps', + }); + } else { + derivedTuple.set(phi.place.identifier.id, { + identifierPlace: phi.place, + sources: source.sources, + typeOfValue: 'fromProps', + }); + } + } + } + } + for (const instr of block.instructions) { const {lvalue, value} = instr; - // Track props derivation through instruction effects - if (instr.effects != null) { - for (const effect of instr.effects) { - switch (effect.kind) { - case 'Assign': - case 'Alias': - case 'MaybeAlias': - case 'Capture': { - const source = derivedFromProps.get(effect.from.identifier.id); - if (source != null) { - derivedFromProps.set(effect.into.identifier.id, source); + // This needs to be repeated "recursively" on FunctionExpressions + // HERE >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + // DERIVATION LOGIC----------------------------------------------------- + console.log('instr', printInstruction(instr)); + console.log('instr', instr); + // console.log('instr lValue', instr.lvalue); + + let typeOfValue: TypeOfValue = 'ignored'; + + // TODO: Add handling for state derived props + let sources: DerivationMetadata[] = []; + for (const operand of eachInstructionValueOperand(value)) { + const opSource = derivedTuple.get(operand.identifier.id); + if (opSource === undefined) { + continue; + } + + typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); + sources.push(opSource); + } + + // TODO: Add handling for state derived props + if (typeOfValue !== 'ignored') { + for (const lvalue of eachInstructionLValue(instr)) { + updateDerivationMetadata(lvalue, sources, typeOfValue, derivedTuple); + } + + for (const operand of eachInstructionValueOperand(value)) { + switch (operand.effect) { + case Effect.Capture: + case Effect.Store: + case Effect.ConditionallyMutate: + case Effect.ConditionallyMutateIterator: + case Effect.Mutate: { + if (isMutable(instr, operand)) { + updateDerivationMetadata( + operand, + sources, + typeOfValue, + derivedTuple, + ); } break; } - } - } - } - - /** - * TODO: figure out why property access off of props does not create an Assign or Alias/Maybe - * Alias - * - * import {useEffect, useState} from 'react' - * - * function Component(props) { - * const [displayValue, setDisplayValue] = useState(''); - * - * useEffect(() => { - * const computed = props.prefix + props.value + props.suffix; - * ^^^^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^ - * we want to track that these are from props - * setDisplayValue(computed); - * }, [props.prefix, props.value, props.suffix]); - * - * return
{displayValue}
; - * } - */ - if (value.kind === 'FunctionExpression') { - for (const [, block] of value.loweredFunc.func.body.blocks) { - for (const instr of block.instructions) { - if (instr.effects != null) { - console.group(printInstruction(instr)); - for (const effect of instr.effects) { - console.log(effect); - switch (effect.kind) { - case 'Assign': - case 'Alias': - case 'MaybeAlias': - case 'Capture': { - const source = derivedFromProps.get( - effect.from.identifier.id, - ); - if (source != null) { - derivedFromProps.set(effect.into.identifier.id, source); - } - break; - } - } - } + case Effect.Freeze: + case Effect.Read: { + // no-op + break; + } + case Effect.Unknown: { + CompilerError.invariant(false, { + reason: 'Unexpected unknown effect', + description: null, + loc: operand.loc, + suggestions: null, + }); + } + default: { + assertExhaustive( + operand.effect, + `Unexpected effect kind \`${operand.effect}\``, + ); } - console.groupEnd(); } } } + console.log('derivedTuple', derivedTuple); + // HERE >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - for (const [, place] of derivedFromProps) { - console.log(printPlace(place)); - } - + // console.log('derivedTuple', derivedTuple); + // DERIVATION LOGIC----------------------------------------------------- if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); } else if (value.kind === 'ArrayExpression') { @@ -157,6 +262,8 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { ) { const callee = value.kind === 'CallExpression' ? value.callee : value.property; + + // This is a useEffect hook if ( isUseEffectHookType(callee.identifier) && value.args.length === 2 && @@ -181,7 +288,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { validateEffect( effectFunction.loweredFunc.func, dependencies, - derivedFromProps, + derivedTuple, errors, ); } @@ -197,7 +304,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function validateEffect( effectFunction: HIRFunction, effectDeps: Array, - derivedFromProps: Map, + derivedTuple: Map, errors: CompilerError, ): void { for (const operand of effectFunction.context) { @@ -205,7 +312,7 @@ function validateEffect( continue; } else if (effectDeps.find(dep => dep === operand.identifier.id) != null) { continue; - } else if (derivedFromProps.has(operand.identifier.id)) { + } else if (derivedTuple.has(operand.identifier.id)) { continue; } else { // Captured something other than the effect dep or setState @@ -213,29 +320,36 @@ function validateEffect( return; } } + + // This might be wrong gotta double check + let hasInvalidDep = false; for (const dep of effectDeps) { - console.log({dep}); + const depMetadata = derivedTuple.get(dep); if ( - effectFunction.context.find(operand => operand.identifier.id === dep) == + effectFunction.context.find(operand => operand.identifier.id === dep) != null || - derivedFromProps.has(dep) === false + (depMetadata !== undefined && depMetadata.typeOfValue !== 'ignored') ) { - console.log('early return 2'); - // effect dep wasn't actually used in the function - return; + hasInvalidDep = true; } } + if (!hasInvalidDep) { + console.log('early return 2'); + // effect dep wasn't actually used in the function + return; + } + const seenBlocks: Set = new Set(); + // This variable is suspicious maybe we don't need it? const values: Map> = new Map(); - const effectDerivedFromProps: Map = new Map(); + const effectInvalidlyDerived: Map = new Map(); for (const dep of effectDeps) { - console.log({dep}); values.set(dep, [dep]); - const propsSource = derivedFromProps.get(dep); - if (propsSource != null) { - effectDerivedFromProps.set(dep, propsSource); + const depMetadata = derivedTuple.get(dep); + if (depMetadata !== undefined) { + effectInvalidlyDerived.set(dep, depMetadata.sources); } } @@ -247,9 +361,11 @@ function validateEffect( return; } } + + // TODO: This might need editing for (const phi of block.phis) { const aggregateDeps: Set = new Set(); - let propsSource: Place | null = null; + let propsSources: Place[] | null = null; for (const operand of phi.operands.values()) { const deps = values.get(operand.identifier.id); @@ -258,19 +374,20 @@ function validateEffect( aggregateDeps.add(dep); } } - const source = effectDerivedFromProps.get(operand.identifier.id); - if (source != null) { - propsSource = source; + const sources = effectInvalidlyDerived.get(operand.identifier.id); + if (sources != null) { + propsSources = sources; } } if (aggregateDeps.size !== 0) { values.set(phi.place.identifier.id, Array.from(aggregateDeps)); } - if (propsSource != null) { - effectDerivedFromProps.set(phi.place.identifier.id, propsSource); + if (propsSources != null) { + effectInvalidlyDerived.set(phi.place.identifier.id, propsSources); } } + for (const instr of block.instructions) { switch (instr.value.kind) { case 'Primitive': @@ -292,7 +409,7 @@ function validateEffect( case 'CallExpression': case 'MethodCall': { const aggregateDeps: Set = new Set(); - for (const operand of eachInstructionValueOperand(instr.value)) { + for (const operand of eachInstructionOperand(instr)) { const deps = values.get(operand.identifier.id); if (deps != null) { for (const dep of deps) { @@ -311,60 +428,56 @@ function validateEffect( instr.value.args[0].kind === 'Identifier' ) { const deps = values.get(instr.value.args[0].identifier.id); + console.log('deps', deps); if (deps != null && new Set(deps).size === effectDeps.length) { - const propsSource = effectDerivedFromProps.get( + // console.log('setState arg', instr.value.args[0].identifier.id); + // console.log('effectInvalidlyDerived', effectInvalidlyDerived); + // console.log('derivedTuple', derivedTuple); + const propSources = derivedTuple.get( instr.value.args[0].identifier.id, ); - setStateCalls.push({ - loc: instr.value.callee.loc, - propsSource: propsSource ?? null, - }); + console.log('Final reference', propSources); + if (propSources !== undefined) { + setStateCalls.push({ + loc: instr.value.callee.loc, + propsSources: propSources.sources, + }); + } else { + setStateCalls.push({ + loc: instr.value.callee.loc, + propsSources: undefined, + }); + } } else { // doesn't depend on all deps + console.log('early return 3'); return; } } break; } default: { + console.log('early return 4'); return; } } - - // Track props derivation through instruction effects - if (instr.effects != null) { - for (const effect of instr.effects) { - switch (effect.kind) { - case 'Assign': - case 'Alias': - case 'MaybeAlias': - case 'Capture': { - const source = effectDerivedFromProps.get( - effect.from.identifier.id, - ); - if (source != null) { - effectDerivedFromProps.set(effect.into.identifier.id, source); - } - break; - } - } - } - } } for (const operand of eachTerminalOperand(block.terminal)) { if (values.has(operand.identifier.id)) { - // return; } } seenBlocks.add(block.id); } + console.log('setStateCalls', setStateCalls); for (const call of setStateCalls) { - if (call.propsSource != null) { - const propName = call.propsSource.identifier.name?.value; - const propInfo = propName != null ? ` (from prop '${propName}')` : ''; + if (call.propsSources != null) { + const propNames = call.propsSources + .map(place => place.identifier.name?.value) + .join(', '); + const propInfo = propNames != null ? ` (from props '${propNames}')` : ''; errors.push({ reason: `Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)`, From 38d50d7376c80b8e1ca4f6b0453affbcb4de774b Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes <57368278+jorge-cab@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:35:04 -0700 Subject: [PATCH 15/25] [compiler] Validation for values derived from props in useEffect ready --- .../ValidateNoDerivedComputationsInEffects.ts | 444 ++++++++++-------- 1 file changed, 248 insertions(+), 196 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index 78174c656b..46b5ed59bc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -5,40 +5,55 @@ * LICENSE file in the root directory of this source tree. */ -import {TypeOf} from 'zod'; +import {effect} from 'zod'; import {CompilerError, Effect, ErrorSeverity, SourceLocation} from '..'; import {ErrorCategory} from '../CompilerError'; import { ArrayExpression, + BasicBlock, BlockId, + Identifier, FunctionExpression, HIRFunction, IdentifierId, - InstructionValue, + Instruction, Place, isSetStateType, isUseEffectHookType, + isUseStateType, + IdentifierName, + GeneratedSource, } from '../HIR'; -import {printInstruction, printPlace} from '../HIR/PrintHIR'; +import {printInstruction} from '../HIR/PrintHIR'; import { - eachInstructionValueOperand, eachInstructionOperand, eachTerminalOperand, eachInstructionLValue, + eachPatternOperand, } from '../HIR/visitors'; import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables'; import {assertExhaustive} from '../Utils/utils'; type SetStateCall = { loc: SourceLocation; - propsSources: Place[] | undefined; // undefined means state-derived, defined means props-derived + invalidDeps: Map | undefined; + setStateId: IdentifierId; }; type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type DerivationMetadata = { + typeOfValue: TypeOfValue; + // TODO: Rename to place identifierPlace: Place; sources: Place[]; - typeOfValue: TypeOfValue; +}; + +// TODO: This needs refining +type ErrorMetadata = { + errorType: 'HoistState' | 'CalculateInRender'; + propInfo: string | undefined; + loc: SourceLocation; + setStateId: IdentifierId; }; function joinValue( @@ -51,22 +66,6 @@ function joinValue( return 'fromPropsOrState'; } -function propagateDerivation( - dest: Place, - source: Place | undefined, - derivedFromProps: Map, -) { - if (source === undefined) { - return; - } - - if (source.identifier.name?.kind === 'promoted') { - derivedFromProps.set(dest.identifier.id, dest); - } else { - derivedFromProps.set(dest.identifier.id, source); - } -} - function updateDerivationMetadata( target: Place, sources: DerivationMetadata[], @@ -81,7 +80,7 @@ function updateDerivationMetadata( for (const source of sources) { // If the identifier of the source is a promoted identifier, then - // we should set the source as the first named identifier. + // we should set the target as the source. if (source.identifierPlace.identifier.name?.kind === 'promoted') { newValue.sources.push(target); } else { @@ -91,6 +90,133 @@ function updateDerivationMetadata( derivedTuple.set(target.identifier.id, newValue); } +function parseInstr( + instr: Instruction, + derivedTuple: Map, + setStateCalls: Map, +) { + // console.log(printInstruction(instr)); + // console.log(instr); + let typeOfValue: TypeOfValue = 'ignored'; + + // If the instruction is destructuring a useState hook call + if ( + instr.value.kind === 'Destructure' && + instr.value.lvalue.pattern.kind === 'ArrayPattern' && + isUseStateType(instr.value.value.identifier) + ) { + const value = instr.value.lvalue.pattern.items[0]; + if (value.kind === 'Identifier') { + derivedTuple.set(value.identifier.id, { + identifierPlace: value, + sources: [value], + typeOfValue: 'fromState', + }); + } + } + + // If the instruction is calling a setState + if ( + instr.value.kind === 'CallExpression' && + isSetStateType(instr.value.callee.identifier) && + instr.value.args.length === 1 && + instr.value.args[0].kind === 'Identifier' && + instr.value.callee.loc !== GeneratedSource && + instr.value.callee.loc.identifierName !== undefined && + instr.value.callee.loc.identifierName !== null + ) { + setStateCalls.set( + instr.value.callee.loc.identifierName, + instr.value.callee, + ); + } + + let sources: DerivationMetadata[] = []; + for (const operand of eachInstructionOperand(instr)) { + const opSource = derivedTuple.get(operand.identifier.id); + if (opSource === undefined) { + continue; + } + + typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); + sources.push(opSource); + } + + if (typeOfValue !== 'ignored') { + for (const lvalue of eachInstructionLValue(instr)) { + updateDerivationMetadata(lvalue, sources, typeOfValue, derivedTuple); + } + + for (const operand of eachInstructionOperand(instr)) { + switch (operand.effect) { + case Effect.Capture: + case Effect.Store: + case Effect.ConditionallyMutate: + case Effect.ConditionallyMutateIterator: + case Effect.Mutate: { + if (isMutable(instr, operand)) { + updateDerivationMetadata( + operand, + sources, + typeOfValue, + derivedTuple, + ); + } + break; + } + case Effect.Freeze: + case Effect.Read: { + // no-op + break; + } + case Effect.Unknown: { + CompilerError.invariant(false, { + reason: 'Unexpected unknown effect', + description: null, + loc: operand.loc, + suggestions: null, + }); + } + default: { + assertExhaustive( + operand.effect, + `Unexpected effect kind \`${operand.effect}\``, + ); + } + } + } + } +} + +function parseBlockPhi( + block: BasicBlock, + derivedTuple: Map, +) { + for (const phi of block.phis) { + for (const operand of phi.operands.values()) { + const source = derivedTuple.get(operand.identifier.id); + if (source !== undefined && source.typeOfValue === 'fromProps') { + if ( + source.identifierPlace.identifier.name === null || + source.identifierPlace.identifier.name?.kind === 'promoted' + ) { + derivedTuple.set(phi.place.identifier.id, { + identifierPlace: phi.place, + sources: [phi.place], + typeOfValue: 'fromProps', + }); + } else { + derivedTuple.set(phi.place.identifier.id, { + identifierPlace: phi.place, + sources: source.sources, + typeOfValue: 'fromProps', + }); + } + } + } + } +} + /** * Validates that useEffect is not used for derived computations which could/should * be performed in render. @@ -118,17 +244,15 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const candidateDependencies: Map = new Map(); const functions: Map = new Map(); const locals: Map = new Map(); - - // MY take on this - const valueToType: Map = new Map(); - const valueToSourceProps: Map> = new Map(); - const valueToSourceStates: Map> = new Map(); - const valueToSources: Map> = new Map(); - - // Sources are still probably not correct const derivedTuple: Map = new Map(); - const errors = new CompilerError(); + // Investigating + const effectSetStates: Map = new Map(); + const setStateCalls: Map = new Map(); + + // let shouldCalculateInRender: boolean = true; + + const errors: ErrorMetadata[] = []; if (fn.fnType === 'Hook') { for (const param of fn.params) { @@ -152,104 +276,26 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } for (const block of fn.body.blocks.values()) { - for (const phi of block.phis) { - for (const operand of phi.operands.values()) { - const source = derivedTuple.get(operand.identifier.id); - if (source !== undefined && source.typeOfValue === 'fromProps') { - if ( - source.identifierPlace.identifier.name === null || - source.identifierPlace.identifier.name?.kind === 'promoted' - ) { - derivedTuple.set(phi.place.identifier.id, { - identifierPlace: phi.place, - sources: [phi.place], - typeOfValue: 'fromProps', - }); - } else { - derivedTuple.set(phi.place.identifier.id, { - identifierPlace: phi.place, - sources: source.sources, - typeOfValue: 'fromProps', - }); - } - } - } - } + parseBlockPhi(block, derivedTuple); for (const instr of block.instructions) { const {lvalue, value} = instr; - // This needs to be repeated "recursively" on FunctionExpressions - // HERE >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - // DERIVATION LOGIC----------------------------------------------------- - console.log('instr', printInstruction(instr)); - console.log('instr', instr); - // console.log('instr lValue', instr.lvalue); + parseInstr(instr, derivedTuple, setStateCalls); - let typeOfValue: TypeOfValue = 'ignored'; - - // TODO: Add handling for state derived props - let sources: DerivationMetadata[] = []; - for (const operand of eachInstructionValueOperand(value)) { - const opSource = derivedTuple.get(operand.identifier.id); - if (opSource === undefined) { - continue; - } - - typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); - sources.push(opSource); - } - - // TODO: Add handling for state derived props - if (typeOfValue !== 'ignored') { - for (const lvalue of eachInstructionLValue(instr)) { - updateDerivationMetadata(lvalue, sources, typeOfValue, derivedTuple); - } - - for (const operand of eachInstructionValueOperand(value)) { - switch (operand.effect) { - case Effect.Capture: - case Effect.Store: - case Effect.ConditionallyMutate: - case Effect.ConditionallyMutateIterator: - case Effect.Mutate: { - if (isMutable(instr, operand)) { - updateDerivationMetadata( - operand, - sources, - typeOfValue, - derivedTuple, - ); - } - break; - } - case Effect.Freeze: - case Effect.Read: { - // no-op - break; - } - case Effect.Unknown: { - CompilerError.invariant(false, { - reason: 'Unexpected unknown effect', - description: null, - loc: operand.loc, - suggestions: null, - }); - } - default: { - assertExhaustive( - operand.effect, - `Unexpected effect kind \`${operand.effect}\``, - ); - } + /* + * Special case for function expressions, we need to parse nested instructions + * TODO: Can there be more recursive levels? + */ + if (value.kind === 'FunctionExpression') { + for (const [, block] of value.loweredFunc.func.body.blocks) { + for (const instr of block.instructions) { + parseInstr(instr, derivedTuple, setStateCalls); } } } - console.log('derivedTuple', derivedTuple); - // HERE >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - // console.log('derivedTuple', derivedTuple); - // DERIVATION LOGIC----------------------------------------------------- + // Maybe this should run for every instruction being parsed if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); } else if (value.kind === 'ArrayExpression') { @@ -263,7 +309,6 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const callee = value.kind === 'CallExpression' ? value.callee : value.property; - // This is a useEffect hook if ( isUseEffectHookType(callee.identifier) && value.args.length === 2 && @@ -289,6 +334,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { effectFunction.loweredFunc.func, dependencies, derivedTuple, + effectSetStates, errors, ); } @@ -296,8 +342,21 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } } } - if (errors.hasErrors()) { - throw errors; + + console.log('setStateCalls: ', setStateCalls); + console.log('effectSetStates: ', effectSetStates); + const throwableErrors = new CompilerError(); + for (const error of errors) { + throwableErrors.push({ + reason: `You may not need an effect. Values derived from state should be calculated in render, not in an effect. `, + description: `You are using a value derived from props${error.propInfo} to update local state in an effect.`, + severity: ErrorSeverity.InvalidReact, + loc: error.loc, + }); + } + + if (throwableErrors.hasErrors()) { + throw throwableErrors; } } @@ -305,8 +364,13 @@ function validateEffect( effectFunction: HIRFunction, effectDeps: Array, derivedTuple: Map, - errors: CompilerError, + effectSetStates: Map, + errors: ErrorMetadata[], ): void { + /* + * TODO: This makes it so we only capture single line useEffects. + * We should be able to capture multiline as well + */ for (const operand of effectFunction.context) { if (isSetStateType(operand.identifier)) { continue; @@ -316,7 +380,6 @@ function validateEffect( continue; } else { // Captured something other than the effect dep or setState - console.log('early return 1'); return; } } @@ -343,17 +406,18 @@ function validateEffect( const seenBlocks: Set = new Set(); // This variable is suspicious maybe we don't need it? const values: Map> = new Map(); - const effectInvalidlyDerived: Map = new Map(); + const effectInvalidlyDerived: Map = + new Map(); for (const dep of effectDeps) { values.set(dep, [dep]); const depMetadata = derivedTuple.get(dep); if (depMetadata !== undefined) { - effectInvalidlyDerived.set(dep, depMetadata.sources); + effectInvalidlyDerived.set(dep, depMetadata); } } - const setStateCalls: Array = []; + const setStateCallsInEffect: Array = []; for (const block of effectFunction.body.blocks.values()) { for (const pred of block.preds) { if (!seenBlocks.has(pred)) { @@ -362,33 +426,23 @@ function validateEffect( } } - // TODO: This might need editing - for (const phi of block.phis) { - const aggregateDeps: Set = new Set(); - let propsSources: Place[] | null = null; - - for (const operand of phi.operands.values()) { - const deps = values.get(operand.identifier.id); - if (deps != null) { - for (const dep of deps) { - aggregateDeps.add(dep); - } - } - const sources = effectInvalidlyDerived.get(operand.identifier.id); - if (sources != null) { - propsSources = sources; - } - } - - if (aggregateDeps.size !== 0) { - values.set(phi.place.identifier.id, Array.from(aggregateDeps)); - } - if (propsSources != null) { - effectInvalidlyDerived.set(phi.place.identifier.id, propsSources); - } - } + parseBlockPhi(block, effectInvalidlyDerived); for (const instr of block.instructions) { + if ( + instr.value.kind === 'CallExpression' && + isSetStateType(instr.value.callee.identifier) && + instr.value.args.length === 1 && + instr.value.args[0].kind === 'Identifier' && + instr.value.callee.loc !== GeneratedSource && + instr.value.callee.loc.identifierName !== undefined && + instr.value.callee.loc.identifierName !== null + ) { + effectSetStates.set( + instr.value.callee.loc.identifierName, + instr.value.callee, + ); + } switch (instr.value.kind) { case 'Primitive': case 'JSXText': @@ -427,32 +481,24 @@ function validateEffect( instr.value.args.length === 1 && instr.value.args[0].kind === 'Identifier' ) { - const deps = values.get(instr.value.args[0].identifier.id); - console.log('deps', deps); - if (deps != null && new Set(deps).size === effectDeps.length) { - // console.log('setState arg', instr.value.args[0].identifier.id); - // console.log('effectInvalidlyDerived', effectInvalidlyDerived); - // console.log('derivedTuple', derivedTuple); - const propSources = derivedTuple.get( - instr.value.args[0].identifier.id, - ); + const propSources = derivedTuple.get( + instr.value.args[0].identifier.id, + ); - console.log('Final reference', propSources); - if (propSources !== undefined) { - setStateCalls.push({ - loc: instr.value.callee.loc, - propsSources: propSources.sources, - }); - } else { - setStateCalls.push({ - loc: instr.value.callee.loc, - propsSources: undefined, - }); - } + if (propSources !== undefined) { + setStateCallsInEffect.push({ + loc: instr.value.callee.loc, + setStateId: instr.value.callee.identifier.id, + invalidDeps: new Map([ + [instr.value.args[0].identifier, propSources.sources], + ]), + }); } else { - // doesn't depend on all deps - console.log('early return 3'); - return; + setStateCallsInEffect.push({ + loc: instr.value.callee.loc, + setStateId: instr.value.callee.identifier.id, + invalidDeps: undefined, + }); } } break; @@ -463,6 +509,7 @@ function validateEffect( } } } + for (const operand of eachTerminalOperand(block.terminal)) { if (values.has(operand.identifier.id)) { return; @@ -471,31 +518,36 @@ function validateEffect( seenBlocks.add(block.id); } - console.log('setStateCalls', setStateCalls); - for (const call of setStateCalls) { - if (call.propsSources != null) { - const propNames = call.propsSources - .map(place => place.identifier.name?.value) - .join(', '); - const propInfo = propNames != null ? ` (from props '${propNames}')` : ''; + // need to track if the setState call has been used elsewhere + // if it is then the solution should be to lift the state up to the parent component + // if not the solution should be to calculate the value in rende + // + // If the same setState is used both inside and outside the effect + + for (const call of setStateCallsInEffect) { + if (call.invalidDeps != null) { + let propNames = ''; + for (const [, places] of call.invalidDeps.entries()) { + const placeNames = places + .map(place => place.identifier.name?.value) + .join(', '); + propNames += `[${placeNames}], `; + } + propNames = propNames.slice(0, -2); + const propInfo = propNames ? ` (from props '${propNames}')` : ''; errors.push({ - reason: `Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)`, - description: `You are using props${propInfo} to update local state in an effect.`, - severity: ErrorSeverity.InvalidReact, + errorType: 'HoistState', + propInfo: propInfo, loc: call.loc, - suggestions: null, + setStateId: call.setStateId, }); } else { errors.push({ - reason: - 'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)', - description: - 'This effect updates state based on other state values. ' + - 'Consider calculating this value directly during render', - severity: ErrorSeverity.InvalidReact, + errorType: 'CalculateInRender', + propInfo: undefined, loc: call.loc, - suggestions: null, + setStateId: call.setStateId, }); } } From ac6de69494b021005ad3cc07b40e40bc8aef4ec6 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes <57368278+jorge-cab@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:35:04 -0700 Subject: [PATCH 16/25] [compiler] Added check for if the same invalid setSate within an effect is used elsewhere --- .../ValidateNoDerivedComputationsInEffects.ts | 92 ++++++++++++------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index 46b5ed59bc..b2cae1f9a2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -41,6 +41,8 @@ type SetStateCall = { }; type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; +type SetStateName = string | undefined | null; + type DerivationMetadata = { typeOfValue: TypeOfValue; // TODO: Rename to place @@ -52,8 +54,9 @@ type DerivationMetadata = { type ErrorMetadata = { errorType: 'HoistState' | 'CalculateInRender'; propInfo: string | undefined; + localStateInfo: string | undefined; loc: SourceLocation; - setStateId: IdentifierId; + setStateName: SetStateName; }; function joinValue( @@ -93,7 +96,7 @@ function updateDerivationMetadata( function parseInstr( instr: Instruction, derivedTuple: Map, - setStateCalls: Map, + setStateCalls: Map, ) { // console.log(printInstruction(instr)); // console.log(instr); @@ -121,14 +124,17 @@ function parseInstr( isSetStateType(instr.value.callee.identifier) && instr.value.args.length === 1 && instr.value.args[0].kind === 'Identifier' && - instr.value.callee.loc !== GeneratedSource && - instr.value.callee.loc.identifierName !== undefined && - instr.value.callee.loc.identifierName !== null + instr.value.callee.loc !== GeneratedSource ) { - setStateCalls.set( - instr.value.callee.loc.identifierName, - instr.value.callee, - ); + if (setStateCalls.has(instr.value.callee.loc.identifierName)) { + setStateCalls + .get(instr.value.callee.loc.identifierName)! + .push(instr.value.callee); + } else { + setStateCalls.set(instr.value.callee.loc.identifierName, [ + instr.value.callee, + ]); + } } let sources: DerivationMetadata[] = []; @@ -246,11 +252,8 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const locals: Map = new Map(); const derivedTuple: Map = new Map(); - // Investigating - const effectSetStates: Map = new Map(); - const setStateCalls: Map = new Map(); - - // let shouldCalculateInRender: boolean = true; + const effectSetStates: Map = new Map(); + const setStateCalls: Map = new Map(); const errors: ErrorMetadata[] = []; @@ -343,13 +346,37 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } } - console.log('setStateCalls: ', setStateCalls); - console.log('effectSetStates: ', effectSetStates); const throwableErrors = new CompilerError(); for (const error of errors) { + let reason; + let description = ''; + // TODO: Not sure if this is robust enough. + /* + * If we use a setState from an invalid useEffect elsewhere then we probably have to + * hoist state up, else we should calculate in render + */ + if ( + setStateCalls.get(error.setStateName)?.length != + effectSetStates.get(error.setStateName)?.length + ) { + reason = + 'Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)'; + } else { + reason = + 'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)'; + } + + if (error.propInfo !== undefined) { + description += error.propInfo; + } + + if (error.localStateInfo !== undefined) { + description += error.localStateInfo; + } + throwableErrors.push({ - reason: `You may not need an effect. Values derived from state should be calculated in render, not in an effect. `, - description: `You are using a value derived from props${error.propInfo} to update local state in an effect.`, + reason: reason, + description: description, severity: ErrorSeverity.InvalidReact, loc: error.loc, }); @@ -364,7 +391,7 @@ function validateEffect( effectFunction: HIRFunction, effectDeps: Array, derivedTuple: Map, - effectSetStates: Map, + effectSetStates: Map, errors: ErrorMetadata[], ): void { /* @@ -438,10 +465,15 @@ function validateEffect( instr.value.callee.loc.identifierName !== undefined && instr.value.callee.loc.identifierName !== null ) { - effectSetStates.set( - instr.value.callee.loc.identifierName, - instr.value.callee, - ); + if (effectSetStates.has(instr.value.callee.loc.identifierName)) { + effectSetStates + .get(instr.value.callee.loc.identifierName)! + .push(instr.value.callee); + } else { + effectSetStates.set(instr.value.callee.loc.identifierName, [ + instr.value.callee, + ]); + } } switch (instr.value.kind) { case 'Primitive': @@ -518,12 +550,6 @@ function validateEffect( seenBlocks.add(block.id); } - // need to track if the setState call has been used elsewhere - // if it is then the solution should be to lift the state up to the parent component - // if not the solution should be to calculate the value in rende - // - // If the same setState is used both inside and outside the effect - for (const call of setStateCallsInEffect) { if (call.invalidDeps != null) { let propNames = ''; @@ -539,15 +565,19 @@ function validateEffect( errors.push({ errorType: 'HoistState', propInfo: propInfo, + localStateInfo: undefined, loc: call.loc, - setStateId: call.setStateId, + setStateName: + call.loc !== GeneratedSource ? call.loc.identifierName : undefined, }); } else { errors.push({ errorType: 'CalculateInRender', propInfo: undefined, + localStateInfo: undefined, loc: call.loc, - setStateId: call.setStateId, + setStateName: + call.loc !== GeneratedSource ? call.loc.identifierName : undefined, }); } } From 689e3a61fe89ee36f07e637999aa105bc93f86df Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes <57368278+jorge-cab@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:35:04 -0700 Subject: [PATCH 17/25] [compiler] Added validation for local state and refined error messages --- .../ValidateNoDerivedComputationsInEffects.ts | 95 ++++++++----------- 1 file changed, 39 insertions(+), 56 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index b2cae1f9a2..5f9611081c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -34,11 +34,13 @@ import { import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables'; import {assertExhaustive} from '../Utils/utils'; +// TODO: Maybe I can consolidate some types type SetStateCall = { loc: SourceLocation; - invalidDeps: Map | undefined; + invalidDeps: DerivationMetadata; setStateId: IdentifierId; }; + type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type SetStateName = string | undefined | null; @@ -52,9 +54,8 @@ type DerivationMetadata = { // TODO: This needs refining type ErrorMetadata = { - errorType: 'HoistState' | 'CalculateInRender'; - propInfo: string | undefined; - localStateInfo: string | undefined; + errorType: TypeOfValue; + invalidDepInfo: string | undefined; loc: SourceLocation; setStateName: SetStateName; }; @@ -102,7 +103,7 @@ function parseInstr( // console.log(instr); let typeOfValue: TypeOfValue = 'ignored'; - // If the instruction is destructuring a useState hook call + // TODO: Not sure if this will catch every time we create a new useState if ( instr.value.kind === 'Destructure' && instr.value.lvalue.pattern.kind === 'ArrayPattern' && @@ -118,7 +119,6 @@ function parseInstr( } } - // If the instruction is calling a setState if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && @@ -298,7 +298,6 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } } - // Maybe this should run for every instruction being parsed if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); } else if (value.kind === 'ArrayExpression') { @@ -357,7 +356,8 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { */ if ( setStateCalls.get(error.setStateName)?.length != - effectSetStates.get(error.setStateName)?.length + effectSetStates.get(error.setStateName)?.length && + error.errorType !== 'fromState' ) { reason = 'Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)'; @@ -366,17 +366,9 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { 'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)'; } - if (error.propInfo !== undefined) { - description += error.propInfo; - } - - if (error.localStateInfo !== undefined) { - description += error.localStateInfo; - } - throwableErrors.push({ reason: reason, - description: description, + description: `You are using invalid dependencies: \n\n${error.invalidDepInfo}`, severity: ErrorSeverity.InvalidReact, loc: error.loc, }); @@ -411,7 +403,7 @@ function validateEffect( } } - // This might be wrong gotta double check + // TODO: This might be wrong gotta double check let hasInvalidDep = false; for (const dep of effectDeps) { const depMetadata = derivedTuple.get(dep); @@ -513,23 +505,15 @@ function validateEffect( instr.value.args.length === 1 && instr.value.args[0].kind === 'Identifier' ) { - const propSources = derivedTuple.get( + const invalidDeps = derivedTuple.get( instr.value.args[0].identifier.id, ); - if (propSources !== undefined) { + if (invalidDeps !== undefined) { setStateCallsInEffect.push({ loc: instr.value.callee.loc, setStateId: instr.value.callee.identifier.id, - invalidDeps: new Map([ - [instr.value.args[0].identifier, propSources.sources], - ]), - }); - } else { - setStateCallsInEffect.push({ - loc: instr.value.callee.loc, - setStateId: instr.value.callee.identifier.id, - invalidDeps: undefined, + invalidDeps: invalidDeps, }); } } @@ -551,34 +535,33 @@ function validateEffect( } for (const call of setStateCallsInEffect) { - if (call.invalidDeps != null) { - let propNames = ''; - for (const [, places] of call.invalidDeps.entries()) { - const placeNames = places - .map(place => place.identifier.name?.value) - .join(', '); - propNames += `[${placeNames}], `; - } - propNames = propNames.slice(0, -2); - const propInfo = propNames ? ` (from props '${propNames}')` : ''; + const placeNames = call.invalidDeps.sources + .map(place => place.identifier.name?.value) + .join(', '); - errors.push({ - errorType: 'HoistState', - propInfo: propInfo, - localStateInfo: undefined, - loc: call.loc, - setStateName: - call.loc !== GeneratedSource ? call.loc.identifierName : undefined, - }); - } else { - errors.push({ - errorType: 'CalculateInRender', - propInfo: undefined, - localStateInfo: undefined, - loc: call.loc, - setStateName: - call.loc !== GeneratedSource ? call.loc.identifierName : undefined, - }); + let sourceNames = ''; + let invalidDepInfo = ''; + console.log(call.invalidDeps); + if (call.invalidDeps.typeOfValue === 'fromProps') { + sourceNames += `[${placeNames}], `; + sourceNames = sourceNames.slice(0, -2); + invalidDepInfo = sourceNames + ? `Invalid deps from props ${sourceNames}` + : ''; + } else if (call.invalidDeps.typeOfValue === 'fromState') { + sourceNames += `[${placeNames}], `; + sourceNames = sourceNames.slice(0, -2); + invalidDepInfo = sourceNames + ? `Invalid deps from local state: ${sourceNames}` + : ''; } + + errors.push({ + errorType: call.invalidDeps.typeOfValue, + invalidDepInfo: invalidDepInfo, + loc: call.loc, + setStateName: + call.loc !== GeneratedSource ? call.loc.identifierName : undefined, + }); } } From 3fd58cfd36f2b6b69d157069e2cf263b2a842a07 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes <57368278+jorge-cab@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:35:04 -0700 Subject: [PATCH 18/25] [compiler] First functional disambiguated single line validation of no derived computations in effects --- .../ValidateNoDerivedComputationsInEffects.ts | 64 +++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index 5f9611081c..77f02c4d14 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -5,14 +5,12 @@ * LICENSE file in the root directory of this source tree. */ -import {effect} from 'zod'; import {CompilerError, Effect, ErrorSeverity, SourceLocation} from '..'; import {ErrorCategory} from '../CompilerError'; import { ArrayExpression, BasicBlock, BlockId, - Identifier, FunctionExpression, HIRFunction, IdentifierId, @@ -21,15 +19,12 @@ import { isSetStateType, isUseEffectHookType, isUseStateType, - IdentifierName, GeneratedSource, } from '../HIR'; -import {printInstruction} from '../HIR/PrintHIR'; import { eachInstructionOperand, eachTerminalOperand, eachInstructionLValue, - eachPatternOperand, } from '../HIR/visitors'; import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables'; import {assertExhaustive} from '../Utils/utils'; @@ -47,12 +42,10 @@ type SetStateName = string | undefined | null; type DerivationMetadata = { typeOfValue: TypeOfValue; - // TODO: Rename to place - identifierPlace: Place; - sources: Place[]; + place: Place; + sources: Array; }; -// TODO: This needs refining type ErrorMetadata = { errorType: TypeOfValue; invalidDepInfo: string | undefined; @@ -72,20 +65,22 @@ function joinValue( function updateDerivationMetadata( target: Place, - sources: DerivationMetadata[], + sources: Array, typeOfValue: TypeOfValue, derivedTuple: Map, ): void { let newValue: DerivationMetadata = { - identifierPlace: target, + place: target, sources: [], typeOfValue: typeOfValue, }; for (const source of sources) { - // If the identifier of the source is a promoted identifier, then - // we should set the target as the source. - if (source.identifierPlace.identifier.name?.kind === 'promoted') { + /* + * If the identifier of the source is a promoted identifier, then + * we should set the target as the source. + */ + if (source.place.identifier.name?.kind === 'promoted') { newValue.sources.push(target); } else { newValue.sources.push(...source.sources); @@ -97,10 +92,8 @@ function updateDerivationMetadata( function parseInstr( instr: Instruction, derivedTuple: Map, - setStateCalls: Map, -) { - // console.log(printInstruction(instr)); - // console.log(instr); + setStateCalls: Map>, +): void { let typeOfValue: TypeOfValue = 'ignored'; // TODO: Not sure if this will catch every time we create a new useState @@ -112,7 +105,7 @@ function parseInstr( const value = instr.value.lvalue.pattern.items[0]; if (value.kind === 'Identifier') { derivedTuple.set(value.identifier.id, { - identifierPlace: value, + place: value, sources: [value], typeOfValue: 'fromState', }); @@ -137,7 +130,7 @@ function parseInstr( } } - let sources: DerivationMetadata[] = []; + let sources: Array = []; for (const operand of eachInstructionOperand(instr)) { const opSource = derivedTuple.get(operand.identifier.id); if (opSource === undefined) { @@ -197,23 +190,23 @@ function parseInstr( function parseBlockPhi( block: BasicBlock, derivedTuple: Map, -) { +): void { for (const phi of block.phis) { for (const operand of phi.operands.values()) { const source = derivedTuple.get(operand.identifier.id); if (source !== undefined && source.typeOfValue === 'fromProps') { if ( - source.identifierPlace.identifier.name === null || - source.identifierPlace.identifier.name?.kind === 'promoted' + source.place.identifier.name === null || + source.place.identifier.name?.kind === 'promoted' ) { derivedTuple.set(phi.place.identifier.id, { - identifierPlace: phi.place, + place: phi.place, sources: [phi.place], typeOfValue: 'fromProps', }); } else { derivedTuple.set(phi.place.identifier.id, { - identifierPlace: phi.place, + place: phi.place, sources: source.sources, typeOfValue: 'fromProps', }); @@ -252,16 +245,16 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const locals: Map = new Map(); const derivedTuple: Map = new Map(); - const effectSetStates: Map = new Map(); - const setStateCalls: Map = new Map(); + const effectSetStates: Map> = new Map(); + const setStateCalls: Map> = new Map(); - const errors: ErrorMetadata[] = []; + const errors: Array = []; if (fn.fnType === 'Hook') { for (const param of fn.params) { if (param.kind === 'Identifier') { derivedTuple.set(param.identifier.id, { - identifierPlace: param, + place: param, sources: [param], typeOfValue: 'fromProps', }); @@ -271,7 +264,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const props = fn.params[0]; if (props != null && props.kind === 'Identifier') { derivedTuple.set(props.identifier.id, { - identifierPlace: props, + place: props, sources: [props], typeOfValue: 'fromProps', }); @@ -348,7 +341,6 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const throwableErrors = new CompilerError(); for (const error of errors) { let reason; - let description = ''; // TODO: Not sure if this is robust enough. /* * If we use a setState from an invalid useEffect elsewhere then we probably have to @@ -383,8 +375,8 @@ function validateEffect( effectFunction: HIRFunction, effectDeps: Array, derivedTuple: Map, - effectSetStates: Map, - errors: ErrorMetadata[], + effectSetStates: Map>, + errors: Array, ): void { /* * TODO: This makes it so we only capture single line useEffects. @@ -554,6 +546,12 @@ function validateEffect( invalidDepInfo = sourceNames ? `Invalid deps from local state: ${sourceNames}` : ''; + } else { + sourceNames += `[${placeNames}], `; + sourceNames = sourceNames.slice(0, -2); + invalidDepInfo = sourceNames + ? `Invalid deps from both props and local state: ${sourceNames}` + : ''; } errors.push({ From 361c22a966646e40ae02ff7105f2d6c0f7438203 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Thu, 4 Sep 2025 15:35:04 -0700 Subject: [PATCH 19/25] [compiler] Remove single line constraint and improve overall capturing logic --- .../ValidateNoDerivedComputationsInEffects.ts | 605 +++++++++--------- ...-state-with-conditional-no-error.expect.md | 79 --- ...state-with-side-effects-no-error.expect.md | 74 --- ...ug-derived-state-from-mixed-deps.expect.md | 6 +- ...erived-state-from-shadowed-props.expect.md | 58 ++ ...error.derived-state-from-shadowed-props.js | 21 + ...r.derived-state-with-conditional.expect.md | 49 ++ ...> error.derived-state-with-conditional.js} | 0 ....derived-state-with-side-effects.expect.md | 47 ++ ... error.derived-state-with-side-effects.js} | 0 ...id-derived-computation-in-effect.expect.md | 6 +- ...r.invalid-derived-computation-in-effect.js | 0 ...erived-state-from-props-computed.expect.md | 46 ++ ...alid-derived-state-from-props-computed.js} | 0 ...ed-state-from-props-destructured.expect.md | 20 +- ...d-derived-state-from-props-destructured.js | 8 +- ...rived-state-from-props-in-effect.expect.md | 6 +- ...te-from-props-with-default-value.expect.md | 43 ++ ...ved-state-from-props-with-default-value.js | 15 + ...rived-state-from-state-in-effect.expect.md | 6 +- ...erived-state-from-props-computed.expect.md | 72 --- 21 files changed, 601 insertions(+), 560 deletions(-) delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/{derived-state-with-conditional-no-error.js => error.derived-state-with-conditional.js} (100%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/{derived-state-with-side-effects-no-error.js => error.derived-state-with-side-effects.js} (100%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{ => useEffect}/error.invalid-derived-computation-in-effect.expect.md (58%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{ => useEffect}/error.invalid-derived-computation-in-effect.js (100%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/{invalid-derived-state-from-props-computed.js => error.invalid-derived-state-from-props-computed.js} (100%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index 77f02c4d14..bcae209aa2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -5,7 +5,13 @@ * LICENSE file in the root directory of this source tree. */ -import {CompilerError, Effect, ErrorSeverity, SourceLocation} from '..'; +import { + CompilerDiagnostic, + CompilerError, + Effect, + ErrorSeverity, + SourceLocation, +} from '..'; import {ErrorCategory} from '../CompilerError'; import { ArrayExpression, @@ -21,201 +27,31 @@ import { isUseStateType, GeneratedSource, } from '../HIR'; -import { - eachInstructionOperand, - eachTerminalOperand, - eachInstructionLValue, -} from '../HIR/visitors'; +import {eachInstructionOperand, eachInstructionLValue} from '../HIR/visitors'; import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables'; import {assertExhaustive} from '../Utils/utils'; -// TODO: Maybe I can consolidate some types type SetStateCall = { loc: SourceLocation; - invalidDeps: DerivationMetadata; + derivedDep: DerivationMetadata; setStateId: IdentifierId; }; type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; -type SetStateName = string | undefined | null; - type DerivationMetadata = { typeOfValue: TypeOfValue; place: Place; - sources: Array; + sources: Set; }; type ErrorMetadata = { - errorType: TypeOfValue; - invalidDepInfo: string | undefined; + type: TypeOfValue; + description: string | undefined; loc: SourceLocation; - setStateName: SetStateName; + setStateName: string | undefined | null; }; -function joinValue( - lvalueType: TypeOfValue, - valueType: TypeOfValue, -): TypeOfValue { - if (lvalueType === 'ignored') return valueType; - if (valueType === 'ignored') return lvalueType; - if (lvalueType === valueType) return lvalueType; - return 'fromPropsOrState'; -} - -function updateDerivationMetadata( - target: Place, - sources: Array, - typeOfValue: TypeOfValue, - derivedTuple: Map, -): void { - let newValue: DerivationMetadata = { - place: target, - sources: [], - typeOfValue: typeOfValue, - }; - - for (const source of sources) { - /* - * If the identifier of the source is a promoted identifier, then - * we should set the target as the source. - */ - if (source.place.identifier.name?.kind === 'promoted') { - newValue.sources.push(target); - } else { - newValue.sources.push(...source.sources); - } - } - derivedTuple.set(target.identifier.id, newValue); -} - -function parseInstr( - instr: Instruction, - derivedTuple: Map, - setStateCalls: Map>, -): void { - let typeOfValue: TypeOfValue = 'ignored'; - - // TODO: Not sure if this will catch every time we create a new useState - if ( - instr.value.kind === 'Destructure' && - instr.value.lvalue.pattern.kind === 'ArrayPattern' && - isUseStateType(instr.value.value.identifier) - ) { - const value = instr.value.lvalue.pattern.items[0]; - if (value.kind === 'Identifier') { - derivedTuple.set(value.identifier.id, { - place: value, - sources: [value], - typeOfValue: 'fromState', - }); - } - } - - if ( - instr.value.kind === 'CallExpression' && - isSetStateType(instr.value.callee.identifier) && - instr.value.args.length === 1 && - instr.value.args[0].kind === 'Identifier' && - instr.value.callee.loc !== GeneratedSource - ) { - if (setStateCalls.has(instr.value.callee.loc.identifierName)) { - setStateCalls - .get(instr.value.callee.loc.identifierName)! - .push(instr.value.callee); - } else { - setStateCalls.set(instr.value.callee.loc.identifierName, [ - instr.value.callee, - ]); - } - } - - let sources: Array = []; - for (const operand of eachInstructionOperand(instr)) { - const opSource = derivedTuple.get(operand.identifier.id); - if (opSource === undefined) { - continue; - } - - typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); - sources.push(opSource); - } - - if (typeOfValue !== 'ignored') { - for (const lvalue of eachInstructionLValue(instr)) { - updateDerivationMetadata(lvalue, sources, typeOfValue, derivedTuple); - } - - for (const operand of eachInstructionOperand(instr)) { - switch (operand.effect) { - case Effect.Capture: - case Effect.Store: - case Effect.ConditionallyMutate: - case Effect.ConditionallyMutateIterator: - case Effect.Mutate: { - if (isMutable(instr, operand)) { - updateDerivationMetadata( - operand, - sources, - typeOfValue, - derivedTuple, - ); - } - break; - } - case Effect.Freeze: - case Effect.Read: { - // no-op - break; - } - case Effect.Unknown: { - CompilerError.invariant(false, { - reason: 'Unexpected unknown effect', - description: null, - loc: operand.loc, - suggestions: null, - }); - } - default: { - assertExhaustive( - operand.effect, - `Unexpected effect kind \`${operand.effect}\``, - ); - } - } - } - } -} - -function parseBlockPhi( - block: BasicBlock, - derivedTuple: Map, -): void { - for (const phi of block.phis) { - for (const operand of phi.operands.values()) { - const source = derivedTuple.get(operand.identifier.id); - if (source !== undefined && source.typeOfValue === 'fromProps') { - if ( - source.place.identifier.name === null || - source.place.identifier.name?.kind === 'promoted' - ) { - derivedTuple.set(phi.place.identifier.id, { - place: phi.place, - sources: [phi.place], - typeOfValue: 'fromProps', - }); - } else { - derivedTuple.set(phi.place.identifier.id, { - place: phi.place, - sources: source.sources, - typeOfValue: 'fromProps', - }); - } - } - } - } -} - /** * Validates that useEffect is not used for derived computations which could/should * be performed in render. @@ -243,19 +79,22 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const candidateDependencies: Map = new Map(); const functions: Map = new Map(); const locals: Map = new Map(); - const derivedTuple: Map = new Map(); + const derivationCache: Map = new Map(); - const effectSetStates: Map> = new Map(); - const setStateCalls: Map> = new Map(); + const effectSetStates: Map< + string | undefined | null, + Array + > = new Map(); + const setStateCalls: Map> = new Map(); const errors: Array = []; if (fn.fnType === 'Hook') { for (const param of fn.params) { if (param.kind === 'Identifier') { - derivedTuple.set(param.identifier.id, { + derivationCache.set(param.identifier.id, { place: param, - sources: [param], + sources: new Set([param]), typeOfValue: 'fromProps', }); } @@ -263,33 +102,21 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } else if (fn.fnType === 'Component') { const props = fn.params[0]; if (props != null && props.kind === 'Identifier') { - derivedTuple.set(props.identifier.id, { + derivationCache.set(props.identifier.id, { place: props, - sources: [props], + sources: new Set([props]), typeOfValue: 'fromProps', }); } } for (const block of fn.body.blocks.values()) { - parseBlockPhi(block, derivedTuple); + parseBlockPhi(block, derivationCache); for (const instr of block.instructions) { const {lvalue, value} = instr; - parseInstr(instr, derivedTuple, setStateCalls); - - /* - * Special case for function expressions, we need to parse nested instructions - * TODO: Can there be more recursive levels? - */ - if (value.kind === 'FunctionExpression') { - for (const [, block] of value.loweredFunc.func.body.blocks) { - for (const instr of block.instructions) { - parseInstr(instr, derivedTuple, setStateCalls); - } - } - } + parseInstr(instr, derivationCache, setStateCalls); if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); @@ -328,7 +155,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { validateEffect( effectFunction.loweredFunc.func, dependencies, - derivedTuple, + derivationCache, effectSetStates, errors, ); @@ -338,10 +165,36 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { } } + const compilerError = generateCompilerError( + setStateCalls, + effectSetStates, + errors, + ); + + if (compilerError.hasErrors()) { + throw compilerError; + } +} + +function generateCompilerError( + setStateCalls: Map>, + effectSetStates: Map>, + errors: Array, +): CompilerError { const throwableErrors = new CompilerError(); for (const error of errors) { - let reason; - // TODO: Not sure if this is robust enough. + let compilerDiagnostic: CompilerDiagnostic | undefined = undefined; + let detailMessage = ''; + switch (error.type) { + case 'fromProps': + detailMessage = 'This state value shadows a value passed as a prop.'; + break; + case 'fromPropsOrState': + detailMessage = + 'This state value shadows a value passed as a prop or a value from state.'; + break; + } + /* * If we use a setState from an invalid useEffect elsewhere then we probably have to * hoist state up, else we should calculate in render @@ -349,86 +202,256 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if ( setStateCalls.get(error.setStateName)?.length != effectSetStates.get(error.setStateName)?.length && - error.errorType !== 'fromState' + error.type !== 'fromState' ) { - reason = - 'Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)'; + compilerDiagnostic = CompilerDiagnostic.create({ + description: `${error.description} This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.`, + category: `Local state shadows parent state.`, + severity: ErrorSeverity.InvalidReact, + }).withDetail({ + kind: 'error', + loc: error.loc, + message: 'this setState synchronizes the state', + }); + + for (const [key, setStateCallArray] of effectSetStates) { + if (setStateCallArray.length === 0) { + continue; + } + + const nonUseEffectSetStateCalls = setStateCalls.get(key); + if (nonUseEffectSetStateCalls) { + for (const place of nonUseEffectSetStateCalls) { + if (!setStateCallArray.includes(place)) { + compilerDiagnostic.withDetail({ + kind: 'error', + loc: place.loc, + message: + 'this setState updates the shadowed state, but should call an onChange event from the parent', + }); + } + } + } + } } else { - reason = - 'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)'; + compilerDiagnostic = CompilerDiagnostic.create({ + description: `${error.description} Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, + category: `Derive values in render, not effects.`, + severity: ErrorSeverity.InvalidReact, + }).withDetail({ + kind: 'error', + loc: error.loc, + message: 'This should be computed during render, not in an effect', + }); } - throwableErrors.push({ - reason: reason, - description: `You are using invalid dependencies: \n\n${error.invalidDepInfo}`, - severity: ErrorSeverity.InvalidReact, - loc: error.loc, - }); + if (compilerDiagnostic) { + throwableErrors.pushDiagnostic(compilerDiagnostic); + } } - if (throwableErrors.hasErrors()) { - throw throwableErrors; + return throwableErrors; +} + +function joinValue( + lvalueType: TypeOfValue, + valueType: TypeOfValue, +): TypeOfValue { + if (lvalueType === 'ignored') return valueType; + if (valueType === 'ignored') return lvalueType; + if (lvalueType === valueType) return lvalueType; + return 'fromPropsOrState'; +} + +function updateDerivationMetadata( + target: Place, + sources: Array | undefined, + typeOfValue: TypeOfValue | undefined, + derivationCache: Map, +): void { + let newValue: DerivationMetadata = { + place: target, + sources: new Set(), + typeOfValue: typeOfValue ?? 'ignored', + }; + + if (sources !== undefined) { + for (const source of sources) { + /* + * If the identifier of the source is a promoted identifier, then + * we should set the target as the source. + */ + for (const place of source.sources) { + if ( + place.identifier.name === null || + place.identifier.name?.kind === 'promoted' + ) { + newValue.sources.add(target); + } else { + newValue.sources.add(place); + } + } + } + } + + derivationCache.set(target.identifier.id, newValue); +} + +function parseInstr( + instr: Instruction, + derivationCache: Map, + setStateCalls: Map>, +): void { + // Recursively parse function expressions + if (instr.value.kind === 'FunctionExpression') { + for (const [, block] of instr.value.loweredFunc.func.body.blocks) { + for (const instr of block.instructions) { + parseInstr(instr, derivationCache, setStateCalls); + } + } + } + + let typeOfValue: TypeOfValue = 'ignored'; + + // Catch any useState hook calls + let sources: Array = []; + if ( + instr.value.kind === 'Destructure' && + instr.value.lvalue.pattern.kind === 'ArrayPattern' && + isUseStateType(instr.value.value.identifier) + ) { + typeOfValue = 'fromState'; + + const stateValueSource = instr.value.lvalue.pattern.items[0]; + if (stateValueSource.kind === 'Identifier') { + sources.push({ + place: stateValueSource, + typeOfValue: typeOfValue, + sources: new Set([stateValueSource]), + }); + } + } + + if ( + instr.value.kind === 'CallExpression' && + isSetStateType(instr.value.callee.identifier) && + instr.value.args.length === 1 && + instr.value.args[0].kind === 'Identifier' && + instr.value.callee.loc !== GeneratedSource + ) { + if (setStateCalls.has(instr.value.callee.loc.identifierName)) { + setStateCalls + .get(instr.value.callee.loc.identifierName)! + .push(instr.value.callee); + } else { + setStateCalls.set(instr.value.callee.loc.identifierName, [ + instr.value.callee, + ]); + } + } + + for (const operand of eachInstructionOperand(instr)) { + const opSource = derivationCache.get(operand.identifier.id); + if (opSource === undefined) { + continue; + } + + typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); + sources.push(opSource); + } + + if (typeOfValue !== 'ignored') { + for (const lvalue of eachInstructionLValue(instr)) { + updateDerivationMetadata(lvalue, sources, typeOfValue, derivationCache); + } + + for (const operand of eachInstructionOperand(instr)) { + switch (operand.effect) { + case Effect.Capture: + case Effect.Store: + case Effect.ConditionallyMutate: + case Effect.ConditionallyMutateIterator: + case Effect.Mutate: { + if (isMutable(instr, operand)) { + updateDerivationMetadata( + operand, + sources, + typeOfValue, + derivationCache, + ); + } + break; + } + case Effect.Freeze: + case Effect.Read: { + // no-op + break; + } + case Effect.Unknown: { + CompilerError.invariant(false, { + reason: 'Unexpected unknown effect', + description: null, + loc: operand.loc, + suggestions: null, + }); + } + default: { + assertExhaustive( + operand.effect, + `Unexpected effect kind \`${operand.effect}\``, + ); + } + } + } + } +} + +function parseBlockPhi( + block: BasicBlock, + derivationCache: Map, +): void { + for (const phi of block.phis) { + for (const operand of phi.operands.values()) { + const phiSource = derivationCache.get(operand.identifier.id); + if (phiSource !== undefined) { + updateDerivationMetadata( + phi.place, + [phiSource], + phiSource?.typeOfValue, + derivationCache, + ); + } + } } } function validateEffect( effectFunction: HIRFunction, effectDeps: Array, - derivedTuple: Map, - effectSetStates: Map>, + derivationCache: Map, + effectSetStates: Map>, errors: Array, ): void { - /* - * TODO: This makes it so we only capture single line useEffects. - * We should be able to capture multiline as well - */ - for (const operand of effectFunction.context) { - if (isSetStateType(operand.identifier)) { - continue; - } else if (effectDeps.find(dep => dep === operand.identifier.id) != null) { - continue; - } else if (derivedTuple.has(operand.identifier.id)) { - continue; - } else { - // Captured something other than the effect dep or setState - return; - } - } - - // TODO: This might be wrong gotta double check - let hasInvalidDep = false; + let isUsingDerivedDeps = false; for (const dep of effectDeps) { - const depMetadata = derivedTuple.get(dep); + const depMetadata = derivationCache.get(dep); if ( effectFunction.context.find(operand => operand.identifier.id === dep) != null || (depMetadata !== undefined && depMetadata.typeOfValue !== 'ignored') ) { - hasInvalidDep = true; + isUsingDerivedDeps = true; } } - if (!hasInvalidDep) { - console.log('early return 2'); - // effect dep wasn't actually used in the function + if (!isUsingDerivedDeps) { + // no prop/state derived deps were used in the body of the effect return; } const seenBlocks: Set = new Set(); - // This variable is suspicious maybe we don't need it? - const values: Map> = new Map(); - const effectInvalidlyDerived: Map = - new Map(); - for (const dep of effectDeps) { - values.set(dep, [dep]); - const depMetadata = derivedTuple.get(dep); - if (depMetadata !== undefined) { - effectInvalidlyDerived.set(dep, depMetadata); - } - } - - const setStateCallsInEffect: Array = []; + const derivedSetStateCall: Array = []; for (const block of effectFunction.body.blocks.values()) { for (const pred of block.preds) { if (!seenBlocks.has(pred)) { @@ -437,7 +460,7 @@ function validateEffect( } } - parseBlockPhi(block, effectInvalidlyDerived); + parseBlockPhi(block, derivationCache); for (const instr of block.instructions) { if ( @@ -466,10 +489,6 @@ function validateEffect( break; } case 'LoadLocal': { - const deps = values.get(instr.value.place.identifier.id); - if (deps != null) { - values.set(instr.lvalue.identifier.id, deps); - } break; } case 'ComputedLoad': @@ -478,85 +497,53 @@ function validateEffect( case 'TemplateLiteral': case 'CallExpression': case 'MethodCall': { - const aggregateDeps: Set = new Set(); - for (const operand of eachInstructionOperand(instr)) { - const deps = values.get(operand.identifier.id); - if (deps != null) { - for (const dep of deps) { - aggregateDeps.add(dep); - } - } - } - if (aggregateDeps.size !== 0) { - values.set(instr.lvalue.identifier.id, Array.from(aggregateDeps)); - } - if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && instr.value.args.length === 1 && instr.value.args[0].kind === 'Identifier' ) { - const invalidDeps = derivedTuple.get( + const derivedDep = derivationCache.get( instr.value.args[0].identifier.id, ); - if (invalidDeps !== undefined) { - setStateCallsInEffect.push({ + if (derivedDep !== undefined) { + derivedSetStateCall.push({ loc: instr.value.callee.loc, setStateId: instr.value.callee.identifier.id, - invalidDeps: invalidDeps, + derivedDep: derivedDep, }); } } break; } - default: { - console.log('early return 4'); - return; - } } } - for (const operand of eachTerminalOperand(block.terminal)) { - if (values.has(operand.identifier.id)) { - return; - } - } seenBlocks.add(block.id); } - for (const call of setStateCallsInEffect) { - const placeNames = call.invalidDeps.sources - .map(place => place.identifier.name?.value) + for (const call of derivedSetStateCall) { + const placeNames = Array.from(call.derivedDep.sources) + .map(place => { + return place.identifier.name?.value; + }) + .filter(Boolean) .join(', '); - let sourceNames = ''; - let invalidDepInfo = ''; - console.log(call.invalidDeps); - if (call.invalidDeps.typeOfValue === 'fromProps') { - sourceNames += `[${placeNames}], `; - sourceNames = sourceNames.slice(0, -2); - invalidDepInfo = sourceNames - ? `Invalid deps from props ${sourceNames}` - : ''; - } else if (call.invalidDeps.typeOfValue === 'fromState') { - sourceNames += `[${placeNames}], `; - sourceNames = sourceNames.slice(0, -2); - invalidDepInfo = sourceNames - ? `Invalid deps from local state: ${sourceNames}` - : ''; + let errorDescription = ''; + + if (call.derivedDep.typeOfValue === 'fromProps') { + errorDescription = `props [${placeNames}].`; + } else if (call.derivedDep.typeOfValue === 'fromState') { + errorDescription = `local state [${placeNames}].`; } else { - sourceNames += `[${placeNames}], `; - sourceNames = sourceNames.slice(0, -2); - invalidDepInfo = sourceNames - ? `Invalid deps from both props and local state: ${sourceNames}` - : ''; + errorDescription = `both props and local state [${placeNames}].`; } errors.push({ - errorType: call.invalidDeps.typeOfValue, - invalidDepInfo: invalidDepInfo, + type: call.derivedDep.typeOfValue, + description: `This setState() appears to derive a value from ${errorDescription}`, loc: call.loc, setStateName: call.loc !== GeneratedSource ? call.loc.identifierName : undefined, diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md deleted file mode 100644 index b7a1c85d52..0000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.expect.md +++ /dev/null @@ -1,79 +0,0 @@ - -## Input - -```javascript -// @validateNoDerivedComputationsInEffects -import {useEffect, useState} from 'react'; - -function Component({value, enabled}) { - const [localValue, setLocalValue] = useState(''); - - useEffect(() => { - if (enabled) { - setLocalValue(value); - } else { - setLocalValue('disabled'); - } - }, [value, enabled]); - - return
{localValue}
; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{value: 'test', enabled: true}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects -import { useEffect, useState } from "react"; - -function Component(t0) { - const $ = _c(6); - const { value, enabled } = t0; - const [localValue, setLocalValue] = useState(""); - let t1; - let t2; - if ($[0] !== enabled || $[1] !== value) { - t1 = () => { - if (enabled) { - setLocalValue(value); - } else { - setLocalValue("disabled"); - } - }; - - t2 = [value, enabled]; - $[0] = enabled; - $[1] = value; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); - let t3; - if ($[4] !== localValue) { - t3 =
{localValue}
; - $[4] = localValue; - $[5] = t3; - } else { - t3 = $[5]; - } - return t3; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ value: "test", enabled: true }], -}; - -``` - -### Eval output -(kind: ok)
test
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md deleted file mode 100644 index e0708dd1f7..0000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.expect.md +++ /dev/null @@ -1,74 +0,0 @@ - -## Input - -```javascript -// @validateNoDerivedComputationsInEffects -import {useEffect, useState} from 'react'; - -function Component({value}) { - const [localValue, setLocalValue] = useState(''); - - useEffect(() => { - console.log('Value changed:', value); - setLocalValue(value); - document.title = `Value: ${value}`; - }, [value]); - - return
{localValue}
; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{value: 'test'}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects -import { useEffect, useState } from "react"; - -function Component(t0) { - const $ = _c(5); - const { value } = t0; - const [localValue, setLocalValue] = useState(""); - let t1; - let t2; - if ($[0] !== value) { - t1 = () => { - console.log("Value changed:", value); - setLocalValue(value); - document.title = `Value: ${value}`; - }; - t2 = [value]; - $[0] = value; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useEffect(t1, t2); - let t3; - if ($[3] !== localValue) { - t3 =
{localValue}
; - $[3] = localValue; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ value: "test" }], -}; - -``` - -### Eval output -(kind: ok)
test
-logs: ['Value changed:','test'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md index 8124f4b3f3..2588a014af 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -34,15 +34,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: Derive values in render, not effects. -This effect updates state based on other state values. Consider calculating this value directly during render. +This setState() appears to derive a value from both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.bug-derived-state-from-mixed-deps.ts:9:4 7 | 8 | useEffect(() => { > 9 | setDisplayName(prefix + name); - | ^^^^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^^^^ This should be computed during render, not in an effect 10 | }, [prefix, name]); 11 | 12 | return ( diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md new file mode 100644 index 0000000000..66079d40bb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md @@ -0,0 +1,58 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useState, useEffect} from 'react'; + +function Component({props, number}) { + const nothing = 0; + const missDirection = number; + const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + + useEffect(() => { + setDisplayValue(props.prefix + missDirection + nothing); + }, [props.prefix, missDirection, nothing]); + + return ( +
{ + setDisplayValue('clicked'); + }}> + {displayValue} +
+ ); +} + +``` + + +## Error + +``` +Found 1 error: + +Error: Local state shadows parent state. + +This setState() appears to derive a value from props [props, number]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. + +error.derived-state-from-shadowed-props.ts:10:4 + 8 | + 9 | useEffect(() => { +> 10 | setDisplayValue(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^ this setState synchronizes the state + 11 | }, [props.prefix, missDirection, nothing]); + 12 | + 13 | return ( + +error.derived-state-from-shadowed-props.ts:16:8 + 14 |
{ +> 16 | setDisplayValue('clicked'); + | ^^^^^^^^^^^^^^^ this setState updates the shadowed state, but should call an onChange event from the parent + 17 | }}> + 18 | {displayValue} + 19 |
+``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js new file mode 100644 index 0000000000..6b4cefedf5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js @@ -0,0 +1,21 @@ +// @validateNoDerivedComputationsInEffects +import {useState, useEffect} from 'react'; + +function Component({props, number}) { + const nothing = 0; + const missDirection = number; + const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + + useEffect(() => { + setDisplayValue(props.prefix + missDirection + nothing); + }, [props.prefix, missDirection, nothing]); + + return ( +
{ + setDisplayValue('clicked'); + }}> + {displayValue} +
+ ); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md new file mode 100644 index 0000000000..0643af7722 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md @@ -0,0 +1,49 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value, enabled}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + if (enabled) { + setLocalValue(value); + } else { + setLocalValue('disabled'); + } + }, [value, enabled]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test', enabled: true}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Derive values in render, not effects. + +This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. + +error.derived-state-with-conditional.ts:9:6 + 7 | useEffect(() => { + 8 | if (enabled) { +> 9 | setLocalValue(value); + | ^^^^^^^^^^^^^ This should be computed during render, not in an effect + 10 | } else { + 11 | setLocalValue('disabled'); + 12 | } +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-conditional-no-error.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md new file mode 100644 index 0000000000..0f25b76660 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md @@ -0,0 +1,47 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component({value}) { + const [localValue, setLocalValue] = useState(''); + + useEffect(() => { + console.log('Value changed:', value); + setLocalValue(value); + document.title = `Value: ${value}`; + }, [value]); + + return
{localValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'test'}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Derive values in render, not effects. + +This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. + +error.derived-state-with-side-effects.ts:9:4 + 7 | useEffect(() => { + 8 | console.log('Value changed:', value); +> 9 | setLocalValue(value); + | ^^^^^^^^^^^^^ This should be computed during render, not in an effect + 10 | document.title = `Value: ${value}`; + 11 | }, [value]); + 12 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/derived-state-with-side-effects-no-error.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md similarity index 58% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md index 1d7e24b3ef..bdf7a9b209 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md @@ -24,15 +24,15 @@ function BadExample() { ``` Found 1 error: -Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: Derive values in render, not effects. -This effect updates state based on other state values. Consider calculating this value directly during render. +This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-computation-in-effect.ts:9:4 7 | const [fullName, setFullName] = useState(''); 8 | useEffect(() => { > 9 | setFullName(capitalize(firstName + ' ' + lastName)); - | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ This should be computed during render, not in an effect 10 | }, [firstName, lastName]); 11 | 12 | return
{fullName}
; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md new file mode 100644 index 0000000000..7773a2cc8d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md @@ -0,0 +1,46 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects +import {useEffect, useState} from 'react'; + +function Component(props) { + const [displayValue, setDisplayValue] = useState(''); + + useEffect(() => { + const computed = props.prefix + props.value + props.suffix; + setDisplayValue(computed); + }, [props.prefix, props.value, props.suffix]); + + return
{displayValue}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prefix: '[', value: 'test', suffix: ']'}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Derive values in render, not effects. + +This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. + +error.invalid-derived-state-from-props-computed.ts:9:4 + 7 | useEffect(() => { + 8 | const computed = props.prefix + props.value + props.suffix; +> 9 | setDisplayValue(computed); + | ^^^^^^^^^^^^^^^ This should be computed during render, not in an effect + 10 | }, [props.prefix, props.value, props.suffix]); + 11 | + 12 | return
{displayValue}
; +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index 26b8b7930b..99b596c4ce 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -5,19 +5,19 @@ // @validateNoDerivedComputationsInEffects import {useEffect, useState} from 'react'; -function Component({user: {firstName, lastName}}) { - const [fullName, setFullName] = useState(''); +function Component({props}) { + const [fullName, setFullName] = useState(props.firstName + ' ' + props.lastName); useEffect(() => { - setFullName(firstName + ' ' + lastName); - }, [firstName, lastName]); + setFullName(props.firstName + ' ' + props.lastName); + }, [props.firstName, props.lastName]); return
{fullName}
; } export const FIXTURE_ENTRYPOINT = { fn: Component, - params: [{user: {firstName: 'John', lastName: 'Doe'}}], + params: [{firstName: 'John', lastName: 'Doe'}], }; ``` @@ -28,16 +28,16 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: Derive values in render, not effects. -This effect updates state based on other state values. Consider calculating this value directly during render. +This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-destructured.ts:8:4 6 | 7 | useEffect(() => { -> 8 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) - 9 | }, [firstName, lastName]); +> 8 | setFullName(props.firstName + ' ' + props.lastName); + | ^^^^^^^^^^^ This should be computed during render, not in an effect + 9 | }, [props.firstName, props.lastName]); 10 | 11 | return
{fullName}
; ``` diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js index 966f09ea89..78f7c910ff 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js @@ -1,12 +1,12 @@ // @validateNoDerivedComputationsInEffects import {useEffect, useState} from 'react'; -function Component({firstName, lastName}) { - const [fullName, setFullName] = useState(''); +function Component({props}) { + const [fullName, setFullName] = useState(props.firstName + ' ' + props.lastName); useEffect(() => { - setFullName(firstName + ' ' + lastName); - }, [firstName, lastName]); + setFullName(props.firstName + ' ' + props.lastName); + }, [props.firstName, props.lastName]); return
{fullName}
; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md index 1f7ff8dc5d..88c722b8f6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -28,15 +28,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: Derive values in render, not effects. -This effect updates state based on other state values. Consider calculating this value directly during render. +This setState() appears to derive a value from props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-in-effect.ts:8:4 6 | 7 | useEffect(() => { > 8 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ This should be computed during render, not in an effect 9 | }, [firstName, lastName]); 10 | 11 | return
{fullName}
; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md new file mode 100644 index 0000000000..3af0c00ecc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md @@ -0,0 +1,43 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects + +export default function InProductLobbyGeminiCard( + input = 'empty', +) { + const [currInput, setCurrInput] = useState(input); + + useEffect(() => { + setCurrInput(input) + }, [input]); + + return ( +
{currInput}
+ ) +} + +``` + + +## Error + +``` +Found 1 error: + +Error: Derive values in render, not effects. + +This setState() appears to derive a value from props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. + +error.invalid-derived-state-from-props-with-default-value.ts:9:4 + 7 | + 8 | useEffect(() => { +> 9 | setCurrInput(input) + | ^^^^^^^^^^^^ This should be computed during render, not in an effect + 10 | }, [input]); + 11 | + 12 | return ( +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js new file mode 100644 index 0000000000..a2ad3de584 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js @@ -0,0 +1,15 @@ +// @validateNoDerivedComputationsInEffects + +export default function InProductLobbyGeminiCard( + input = 'empty', +) { + const [currInput, setCurrInput] = useState(input); + + useEffect(() => { + setCurrInput(input) + }, [input]); + + return ( +
{currInput}
+ ) +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md index c5548c970b..5a029cb0cc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -36,15 +36,15 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) +Error: Derive values in render, not effects. -This effect updates state based on other state values. Consider calculating this value directly during render. +This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-state-in-effect.ts:10:4 8 | 9 | useEffect(() => { > 10 | setFullName(firstName + ' ' + lastName); - | ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) + | ^^^^^^^^^^^ This should be computed during render, not in an effect 11 | }, [firstName, lastName]); 12 | 13 | return ( diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md deleted file mode 100644 index 3d0c4fe9c8..0000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/invalid-derived-state-from-props-computed.expect.md +++ /dev/null @@ -1,72 +0,0 @@ - -## Input - -```javascript -// @validateNoDerivedComputationsInEffects -import {useEffect, useState} from 'react'; - -function Component(props) { - const [displayValue, setDisplayValue] = useState(''); - - useEffect(() => { - const computed = props.prefix + props.value + props.suffix; - setDisplayValue(computed); - }, [props.prefix, props.value, props.suffix]); - - return
{displayValue}
; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{prefix: '[', value: 'test', suffix: ']'}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects -import { useEffect, useState } from "react"; - -function Component(props) { - const $ = _c(7); - const [displayValue, setDisplayValue] = useState(""); - let t0; - let t1; - if ($[0] !== props.prefix || $[1] !== props.suffix || $[2] !== props.value) { - t0 = () => { - const computed = props.prefix + props.value + props.suffix; - setDisplayValue(computed); - }; - t1 = [props.prefix, props.value, props.suffix]; - $[0] = props.prefix; - $[1] = props.suffix; - $[2] = props.value; - $[3] = t0; - $[4] = t1; - } else { - t0 = $[3]; - t1 = $[4]; - } - useEffect(t0, t1); - let t2; - if ($[5] !== displayValue) { - t2 =
{displayValue}
; - $[5] = displayValue; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ prefix: "[", value: "test", suffix: "]" }], -}; - -``` - -### Eval output -(kind: ok)
[test]
\ No newline at end of file From aebbab483968308df467fb1abe55aeaedb1e1ae6 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Thu, 4 Sep 2025 15:35:04 -0700 Subject: [PATCH 20/25] [compiler] Add catching useStates that shadow a reactive value --- .../ValidateNoDerivedComputationsInEffects.ts | 158 +++++++++++------- ...ug-derived-state-from-mixed-deps.expect.md | 4 +- ...erived-state-from-shadowed-props.expect.md | 24 ++- ...r.derived-state-with-conditional.expect.md | 4 +- ....derived-state-with-side-effects.expect.md | 4 +- ...id-derived-computation-in-effect.expect.md | 4 +- ...erived-state-from-props-computed.expect.md | 4 +- ...ed-state-from-props-destructured.expect.md | 4 +- ...rived-state-from-props-in-effect.expect.md | 4 +- ...te-from-props-with-default-value.expect.md | 4 +- ...rived-state-from-state-in-effect.expect.md | 4 +- ...ror.shadowed-props-with-onchange.expect.md | 61 +++++++ .../error.shadowed-props-with-onchange.js | 15 ++ compiler/yarn.lock | 31 +--- 14 files changed, 216 insertions(+), 109 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index bcae209aa2..1b185cef91 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -42,7 +42,7 @@ type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type DerivationMetadata = { typeOfValue: TypeOfValue; place: Place; - sources: Set; + sources: Array; }; type ErrorMetadata = { @@ -50,6 +50,7 @@ type ErrorMetadata = { description: string | undefined; loc: SourceLocation; setStateName: string | undefined | null; + derivedDepsNames: Array; }; /** @@ -80,6 +81,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const functions: Map = new Map(); const locals: Map = new Map(); const derivationCache: Map = new Map(); + const shadowingUseState: Map> = new Map(); const effectSetStates: Map< string | undefined | null, @@ -94,7 +96,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (param.kind === 'Identifier') { derivationCache.set(param.identifier.id, { place: param, - sources: new Set([param]), + sources: [param], typeOfValue: 'fromProps', }); } @@ -104,7 +106,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (props != null && props.kind === 'Identifier') { derivationCache.set(props.identifier.id, { place: props, - sources: new Set([props]), + sources: [props], typeOfValue: 'fromProps', }); } @@ -116,7 +118,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { for (const instr of block.instructions) { const {lvalue, value} = instr; - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); @@ -168,6 +170,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const compilerError = generateCompilerError( setStateCalls, effectSetStates, + shadowingUseState, errors, ); @@ -179,21 +182,12 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function generateCompilerError( setStateCalls: Map>, effectSetStates: Map>, + shadowingUseState: Map>, errors: Array, ): CompilerError { const throwableErrors = new CompilerError(); for (const error of errors) { let compilerDiagnostic: CompilerDiagnostic | undefined = undefined; - let detailMessage = ''; - switch (error.type) { - case 'fromProps': - detailMessage = 'This state value shadows a value passed as a prop.'; - break; - case 'fromPropsOrState': - detailMessage = - 'This state value shadows a value passed as a prop or a value from state.'; - break; - } /* * If we use a setState from an invalid useEffect elsewhere then we probably have to @@ -205,15 +199,27 @@ function generateCompilerError( error.type !== 'fromState' ) { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.`, - category: `Local state shadows parent state.`, + description: `The setState within a useEffect is deriving from ${error.description}. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render`, + category: `You might not need an effect. Local state shadows parent state.`, severity: ErrorSeverity.InvalidReact, }).withDetail({ kind: 'error', loc: error.loc, - message: 'this setState synchronizes the state', + message: `this derives values from props ${error.type === 'fromPropsOrState' ? 'and local state ' : ''}to synchronize state`, }); + for (const derivedDep of error.derivedDepsNames) { + if (shadowingUseState.has(derivedDep)) { + for (const loc of shadowingUseState.get(derivedDep)!) { + compilerDiagnostic.withDetail({ + kind: 'error', + loc: loc, + message: `this useState shadows ${derivedDep}`, + }); + } + } + } + for (const [key, setStateCallArray] of effectSetStates) { if (setStateCallArray.length === 0) { continue; @@ -235,8 +241,8 @@ function generateCompilerError( } } else { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, - category: `Derive values in render, not effects.`, + description: `${error.description ? error.description.charAt(0).toUpperCase() + error.description.slice(1) : ''}. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, + category: `You might not need an effect. Derive values in render, not effects.`, severity: ErrorSeverity.InvalidReact, }).withDetail({ kind: 'error', @@ -271,7 +277,7 @@ function updateDerivationMetadata( ): void { let newValue: DerivationMetadata = { place: target, - sources: new Set(), + sources: [], typeOfValue: typeOfValue ?? 'ignored', }; @@ -286,9 +292,9 @@ function updateDerivationMetadata( place.identifier.name === null || place.identifier.name?.kind === 'promoted' ) { - newValue.sources.add(target); + newValue.sources.push(target); } else { - newValue.sources.add(place); + newValue.sources.push(place); } } } @@ -301,38 +307,19 @@ function parseInstr( instr: Instruction, derivationCache: Map, setStateCalls: Map>, + shadowingUseState: Map>, ): void { // Recursively parse function expressions + let typeOfValue: TypeOfValue = 'ignored'; + + let sources: Array = []; if (instr.value.kind === 'FunctionExpression') { for (const [, block] of instr.value.loweredFunc.func.body.blocks) { for (const instr of block.instructions) { - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); } } - } - - let typeOfValue: TypeOfValue = 'ignored'; - - // Catch any useState hook calls - let sources: Array = []; - if ( - instr.value.kind === 'Destructure' && - instr.value.lvalue.pattern.kind === 'ArrayPattern' && - isUseStateType(instr.value.value.identifier) - ) { - typeOfValue = 'fromState'; - - const stateValueSource = instr.value.lvalue.pattern.items[0]; - if (stateValueSource.kind === 'Identifier') { - sources.push({ - place: stateValueSource, - typeOfValue: typeOfValue, - sources: new Set([stateValueSource]), - }); - } - } - - if ( + } else if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && instr.value.args.length === 1 && @@ -348,6 +335,21 @@ function parseInstr( instr.value.callee, ]); } + } else if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + isUseStateType(instr.lvalue.identifier) + ) { + const stateValueSource = instr.value.args[0]; + if (stateValueSource.kind === 'Identifier') { + sources.push({ + place: stateValueSource, + typeOfValue: typeOfValue, + sources: [stateValueSource], + }); + } + + typeOfValue = joinValue(typeOfValue, 'fromState'); } for (const operand of eachInstructionOperand(instr)) { @@ -358,6 +360,27 @@ function parseInstr( typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); sources.push(opSource); + + if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + opSource.typeOfValue === 'fromProps' && + isUseStateType(instr.lvalue.identifier) + ) { + opSource.sources.forEach(source => { + if (source.identifier.name !== null) { + if (shadowingUseState.has(source.identifier.name.value)) { + shadowingUseState + .get(source.identifier.name.value) + ?.push(instr.lvalue.loc); + } else { + shadowingUseState.set(source.identifier.name.value, [ + instr.lvalue.loc, + ]); + } + } + }); + } } if (typeOfValue !== 'ignored') { @@ -411,16 +434,26 @@ function parseBlockPhi( derivationCache: Map, ): void { for (const phi of block.phis) { + let typeOfValue: TypeOfValue = 'ignored'; + let sources: Array = []; for (const operand of phi.operands.values()) { - const phiSource = derivationCache.get(operand.identifier.id); - if (phiSource !== undefined) { - updateDerivationMetadata( - phi.place, - [phiSource], - phiSource?.typeOfValue, - derivationCache, - ); + const opSource = derivationCache.get(operand.identifier.id); + + if (opSource === undefined) { + continue; } + + typeOfValue = joinValue(typeOfValue, opSource?.typeOfValue ?? 'ignored'); + sources.push(opSource); + } + + if (typeOfValue !== 'ignored') { + updateDerivationMetadata( + phi.place, + sources, + typeOfValue, + derivationCache, + ); } } } @@ -524,7 +557,7 @@ function validateEffect( } for (const call of derivedSetStateCall) { - const placeNames = Array.from(call.derivedDep.sources) + const derivedDepsStr = Array.from(call.derivedDep.sources) .map(place => { return place.identifier.name?.value; }) @@ -534,19 +567,24 @@ function validateEffect( let errorDescription = ''; if (call.derivedDep.typeOfValue === 'fromProps') { - errorDescription = `props [${placeNames}].`; + errorDescription = `props [${derivedDepsStr}]`; } else if (call.derivedDep.typeOfValue === 'fromState') { - errorDescription = `local state [${placeNames}].`; + errorDescription = `local state [${derivedDepsStr}]`; } else { - errorDescription = `both props and local state [${placeNames}].`; + errorDescription = `both props and local state [${derivedDepsStr}]`; } errors.push({ type: call.derivedDep.typeOfValue, - description: `This setState() appears to derive a value from ${errorDescription}`, + description: `${errorDescription}`, loc: call.loc, setStateName: call.loc !== GeneratedSource ? call.loc.identifierName : undefined, + derivedDepsNames: Array.from(call.derivedDep.sources) + .map(place => { + return place.identifier.name?.value ?? ''; + }) + .filter(Boolean), }); } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md index 2588a014af..5255636da7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -34,9 +34,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.bug-derived-state-from-mixed-deps.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md index 66079d40bb..cd7c024fe9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md @@ -32,19 +32,37 @@ function Component({props, number}) { ``` Found 1 error: -Error: Local state shadows parent state. +Error: You might not need an effect. Local state shadows parent state. -This setState() appears to derive a value from props [props, number]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. +The setState within a useEffect is deriving from props [props, number]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render error.derived-state-from-shadowed-props.ts:10:4 8 | 9 | useEffect(() => { > 10 | setDisplayValue(props.prefix + missDirection + nothing); - | ^^^^^^^^^^^^^^^ this setState synchronizes the state + | ^^^^^^^^^^^^^^^ this derives values from props to synchronize state 11 | }, [props.prefix, missDirection, nothing]); 12 | 13 | return ( +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows props + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows number + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + error.derived-state-from-shadowed-props.ts:16:8 14 |
{ diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md index 0643af7722..5cf7a99730 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md @@ -32,9 +32,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-conditional.ts:9:6 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md index 0f25b76660..ba8d835199 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md @@ -30,9 +30,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-side-effects.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md index bdf7a9b209..61ae320eec 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md @@ -24,9 +24,9 @@ function BadExample() { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-computation-in-effect.ts:9:4 7 | const [fullName, setFullName] = useState(''); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md index 7773a2cc8d..daf74031d9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md @@ -29,9 +29,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-computed.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index 99b596c4ce..c5509ceae3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-destructured.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md index 88c722b8f6..9cb8a7427f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-in-effect.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md index 3af0c00ecc..8136511e6f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md @@ -26,9 +26,9 @@ export default function InProductLobbyGeminiCard( ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-with-default-value.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md index 5a029cb0cc..e7f8cd4584 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -36,9 +36,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-state-in-effect.ts:10:4 8 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md new file mode 100644 index 0000000000..f08a65dad2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md @@ -0,0 +1,61 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} + +``` + + +## Error + +``` +Found 1 error: + +Error: You might not need an effect. Local state shadows parent state. + +The setState within a useEffect is deriving from props [startDate]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render + +error.shadowed-props-with-onchange.ts:7:8 + 5 | + 6 | useEffect(() => { +> 7 | setLocalStartDate(startDate); + | ^^^^^^^^^^^^^^^^^ this derives values from props to synchronize state + 8 | }, [startDate]); + 9 | + 10 | const onChange = (date) => { + +error.shadowed-props-with-onchange.ts:4:47 + 2 | + 3 | function EndDate({startDate, endDate, onStartDateChange}) { +> 4 | const [localStartDate, setLocalStartDate] = useState(startDate); + | ^^^^^^^^^^^^^^^^^^^ this useState shadows startDate + 5 | + 6 | useEffect(() => { + 7 | setLocalStartDate(startDate); + +error.shadowed-props-with-onchange.ts:11:8 + 9 | + 10 | const onChange = (date) => { +> 11 | setLocalStartDate(date); + | ^^^^^^^^^^^^^^^^^ this setState updates the shadowed state, but should call an onChange event from the parent + 12 | onStartDateChange(date); + 13 | } + 14 | return +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js new file mode 100644 index 0000000000..52e74312e6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js @@ -0,0 +1,15 @@ +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} diff --git a/compiler/yarn.lock b/compiler/yarn.lock index 696261cbf5..a2ae8a1acf 100644 --- a/compiler/yarn.lock +++ b/compiler/yarn.lock @@ -10494,16 +10494,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10576,14 +10567,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11360,7 +11344,7 @@ workerpool@^6.5.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -11378,15 +11362,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" From c94d76945916e8517283de233c8f4029234fbe87 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Thu, 4 Sep 2025 15:35:04 -0700 Subject: [PATCH 21/25] [compiler] Add catching useStates that shadow a reactive value --- .../src/CompilerError.ts | 17 +- .../ValidateNoDerivedComputationsInEffects.ts | 162 +++++++++++------- ...ug-derived-state-from-mixed-deps.expect.md | 4 +- ...erived-state-from-shadowed-props.expect.md | 24 ++- ...r.derived-state-with-conditional.expect.md | 4 +- ....derived-state-with-side-effects.expect.md | 4 +- ...id-derived-computation-in-effect.expect.md | 4 +- ...erived-state-from-props-computed.expect.md | 4 +- ...ed-state-from-props-destructured.expect.md | 4 +- ...rived-state-from-props-in-effect.expect.md | 4 +- ...te-from-props-with-default-value.expect.md | 4 +- ...rived-state-from-state-in-effect.expect.md | 4 +- ...ror.shadowed-props-with-onchange.expect.md | 61 +++++++ .../error.shadowed-props-with-onchange.js | 15 ++ compiler/yarn.lock | 31 +--- 15 files changed, 234 insertions(+), 112 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts b/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts index e12530a8db..a0e0e22b97 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts @@ -575,7 +575,9 @@ export enum ErrorCategory { // Checks for no setState in effect bodies EffectSetState = 'EffectSetState', - EffectDerivationsOfState = 'EffectDerivationsOfState', + EffectDerivationDeriveInRender = 'EffectDerivationDeriveInRender', + + EffectDerivationShadowingParentState = 'EffectDerivationShadowingParentState', // Validates against try/catch in place of error boundaries ErrorBoundaries = 'ErrorBoundaries', @@ -692,12 +694,21 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule { recommended: false, }; } - case ErrorCategory.EffectDerivationsOfState: { + case ErrorCategory.EffectDerivationDeriveInRender: { return { category, name: 'no-deriving-state-in-effects', description: - 'Validates against deriving values from state in an effect', + 'Validates if a useEffect is deriving state from props and/or local state that could be calculated in render.', + recommended: false, + }; + } + case ErrorCategory.EffectDerivationShadowingParentState: { + return { + category, + name: 'no-deriving-state-in-effects', + description: + 'Validates if a useEffect is deriving state from parent state and if the component is updating the shadowed state locally.', recommended: false, }; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index bcae209aa2..3b030a58c0 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -42,7 +42,7 @@ type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type DerivationMetadata = { typeOfValue: TypeOfValue; place: Place; - sources: Set; + sources: Array; }; type ErrorMetadata = { @@ -50,6 +50,7 @@ type ErrorMetadata = { description: string | undefined; loc: SourceLocation; setStateName: string | undefined | null; + derivedDepsNames: Array; }; /** @@ -80,6 +81,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const functions: Map = new Map(); const locals: Map = new Map(); const derivationCache: Map = new Map(); + const shadowingUseState: Map> = new Map(); const effectSetStates: Map< string | undefined | null, @@ -94,7 +96,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (param.kind === 'Identifier') { derivationCache.set(param.identifier.id, { place: param, - sources: new Set([param]), + sources: [param], typeOfValue: 'fromProps', }); } @@ -104,7 +106,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (props != null && props.kind === 'Identifier') { derivationCache.set(props.identifier.id, { place: props, - sources: new Set([props]), + sources: [props], typeOfValue: 'fromProps', }); } @@ -116,7 +118,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { for (const instr of block.instructions) { const {lvalue, value} = instr; - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); @@ -168,6 +170,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const compilerError = generateCompilerError( setStateCalls, effectSetStates, + shadowingUseState, errors, ); @@ -179,21 +182,12 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function generateCompilerError( setStateCalls: Map>, effectSetStates: Map>, + shadowingUseState: Map>, errors: Array, ): CompilerError { const throwableErrors = new CompilerError(); for (const error of errors) { let compilerDiagnostic: CompilerDiagnostic | undefined = undefined; - let detailMessage = ''; - switch (error.type) { - case 'fromProps': - detailMessage = 'This state value shadows a value passed as a prop.'; - break; - case 'fromPropsOrState': - detailMessage = - 'This state value shadows a value passed as a prop or a value from state.'; - break; - } /* * If we use a setState from an invalid useEffect elsewhere then we probably have to @@ -205,15 +199,29 @@ function generateCompilerError( error.type !== 'fromState' ) { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.`, - category: `Local state shadows parent state.`, + description: `The setState within a useEffect is deriving from ${error.description}. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render`, + category: ErrorCategory.EffectDerivationShadowingParentState, severity: ErrorSeverity.InvalidReact, + reason: + 'You might not need an effect. Local state shadows parent state.', }).withDetail({ kind: 'error', loc: error.loc, - message: 'this setState synchronizes the state', + message: `this derives values from props ${error.type === 'fromPropsOrState' ? 'and local state ' : ''}to synchronize state`, }); + for (const derivedDep of error.derivedDepsNames) { + if (shadowingUseState.has(derivedDep)) { + for (const loc of shadowingUseState.get(derivedDep)!) { + compilerDiagnostic.withDetail({ + kind: 'error', + loc: loc, + message: `this useState shadows ${derivedDep}`, + }); + } + } + } + for (const [key, setStateCallArray] of effectSetStates) { if (setStateCallArray.length === 0) { continue; @@ -235,9 +243,11 @@ function generateCompilerError( } } else { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, - category: `Derive values in render, not effects.`, + description: `${error.description ? error.description.charAt(0).toUpperCase() + error.description.slice(1) : ''}. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, + category: ErrorCategory.EffectDerivationDeriveInRender, severity: ErrorSeverity.InvalidReact, + reason: + 'You might not need an effect. Derive values in render, not effects.', }).withDetail({ kind: 'error', loc: error.loc, @@ -271,7 +281,7 @@ function updateDerivationMetadata( ): void { let newValue: DerivationMetadata = { place: target, - sources: new Set(), + sources: [], typeOfValue: typeOfValue ?? 'ignored', }; @@ -286,9 +296,9 @@ function updateDerivationMetadata( place.identifier.name === null || place.identifier.name?.kind === 'promoted' ) { - newValue.sources.add(target); + newValue.sources.push(target); } else { - newValue.sources.add(place); + newValue.sources.push(place); } } } @@ -301,38 +311,19 @@ function parseInstr( instr: Instruction, derivationCache: Map, setStateCalls: Map>, + shadowingUseState: Map>, ): void { // Recursively parse function expressions + let typeOfValue: TypeOfValue = 'ignored'; + + let sources: Array = []; if (instr.value.kind === 'FunctionExpression') { for (const [, block] of instr.value.loweredFunc.func.body.blocks) { for (const instr of block.instructions) { - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); } } - } - - let typeOfValue: TypeOfValue = 'ignored'; - - // Catch any useState hook calls - let sources: Array = []; - if ( - instr.value.kind === 'Destructure' && - instr.value.lvalue.pattern.kind === 'ArrayPattern' && - isUseStateType(instr.value.value.identifier) - ) { - typeOfValue = 'fromState'; - - const stateValueSource = instr.value.lvalue.pattern.items[0]; - if (stateValueSource.kind === 'Identifier') { - sources.push({ - place: stateValueSource, - typeOfValue: typeOfValue, - sources: new Set([stateValueSource]), - }); - } - } - - if ( + } else if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && instr.value.args.length === 1 && @@ -348,6 +339,21 @@ function parseInstr( instr.value.callee, ]); } + } else if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + isUseStateType(instr.lvalue.identifier) + ) { + const stateValueSource = instr.value.args[0]; + if (stateValueSource.kind === 'Identifier') { + sources.push({ + place: stateValueSource, + typeOfValue: typeOfValue, + sources: [stateValueSource], + }); + } + + typeOfValue = joinValue(typeOfValue, 'fromState'); } for (const operand of eachInstructionOperand(instr)) { @@ -358,6 +364,27 @@ function parseInstr( typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); sources.push(opSource); + + if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + opSource.typeOfValue === 'fromProps' && + isUseStateType(instr.lvalue.identifier) + ) { + opSource.sources.forEach(source => { + if (source.identifier.name !== null) { + if (shadowingUseState.has(source.identifier.name.value)) { + shadowingUseState + .get(source.identifier.name.value) + ?.push(instr.lvalue.loc); + } else { + shadowingUseState.set(source.identifier.name.value, [ + instr.lvalue.loc, + ]); + } + } + }); + } } if (typeOfValue !== 'ignored') { @@ -411,16 +438,26 @@ function parseBlockPhi( derivationCache: Map, ): void { for (const phi of block.phis) { + let typeOfValue: TypeOfValue = 'ignored'; + let sources: Array = []; for (const operand of phi.operands.values()) { - const phiSource = derivationCache.get(operand.identifier.id); - if (phiSource !== undefined) { - updateDerivationMetadata( - phi.place, - [phiSource], - phiSource?.typeOfValue, - derivationCache, - ); + const opSource = derivationCache.get(operand.identifier.id); + + if (opSource === undefined) { + continue; } + + typeOfValue = joinValue(typeOfValue, opSource?.typeOfValue ?? 'ignored'); + sources.push(opSource); + } + + if (typeOfValue !== 'ignored') { + updateDerivationMetadata( + phi.place, + sources, + typeOfValue, + derivationCache, + ); } } } @@ -524,7 +561,7 @@ function validateEffect( } for (const call of derivedSetStateCall) { - const placeNames = Array.from(call.derivedDep.sources) + const derivedDepsStr = Array.from(call.derivedDep.sources) .map(place => { return place.identifier.name?.value; }) @@ -534,19 +571,24 @@ function validateEffect( let errorDescription = ''; if (call.derivedDep.typeOfValue === 'fromProps') { - errorDescription = `props [${placeNames}].`; + errorDescription = `props [${derivedDepsStr}]`; } else if (call.derivedDep.typeOfValue === 'fromState') { - errorDescription = `local state [${placeNames}].`; + errorDescription = `local state [${derivedDepsStr}]`; } else { - errorDescription = `both props and local state [${placeNames}].`; + errorDescription = `both props and local state [${derivedDepsStr}]`; } errors.push({ type: call.derivedDep.typeOfValue, - description: `This setState() appears to derive a value from ${errorDescription}`, + description: `${errorDescription}`, loc: call.loc, setStateName: call.loc !== GeneratedSource ? call.loc.identifierName : undefined, + derivedDepsNames: Array.from(call.derivedDep.sources) + .map(place => { + return place.identifier.name?.value ?? ''; + }) + .filter(Boolean), }); } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md index 2588a014af..5255636da7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -34,9 +34,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.bug-derived-state-from-mixed-deps.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md index 66079d40bb..cd7c024fe9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md @@ -32,19 +32,37 @@ function Component({props, number}) { ``` Found 1 error: -Error: Local state shadows parent state. +Error: You might not need an effect. Local state shadows parent state. -This setState() appears to derive a value from props [props, number]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. +The setState within a useEffect is deriving from props [props, number]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render error.derived-state-from-shadowed-props.ts:10:4 8 | 9 | useEffect(() => { > 10 | setDisplayValue(props.prefix + missDirection + nothing); - | ^^^^^^^^^^^^^^^ this setState synchronizes the state + | ^^^^^^^^^^^^^^^ this derives values from props to synchronize state 11 | }, [props.prefix, missDirection, nothing]); 12 | 13 | return ( +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows props + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows number + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + error.derived-state-from-shadowed-props.ts:16:8 14 |
{ diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md index 0643af7722..5cf7a99730 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md @@ -32,9 +32,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-conditional.ts:9:6 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md index 0f25b76660..ba8d835199 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md @@ -30,9 +30,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-side-effects.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md index bdf7a9b209..61ae320eec 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md @@ -24,9 +24,9 @@ function BadExample() { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-computation-in-effect.ts:9:4 7 | const [fullName, setFullName] = useState(''); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md index 7773a2cc8d..daf74031d9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md @@ -29,9 +29,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-computed.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index 99b596c4ce..c5509ceae3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-destructured.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md index 88c722b8f6..9cb8a7427f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-in-effect.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md index 3af0c00ecc..8136511e6f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md @@ -26,9 +26,9 @@ export default function InProductLobbyGeminiCard( ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-with-default-value.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md index 5a029cb0cc..e7f8cd4584 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -36,9 +36,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-state-in-effect.ts:10:4 8 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md new file mode 100644 index 0000000000..f08a65dad2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md @@ -0,0 +1,61 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} + +``` + + +## Error + +``` +Found 1 error: + +Error: You might not need an effect. Local state shadows parent state. + +The setState within a useEffect is deriving from props [startDate]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render + +error.shadowed-props-with-onchange.ts:7:8 + 5 | + 6 | useEffect(() => { +> 7 | setLocalStartDate(startDate); + | ^^^^^^^^^^^^^^^^^ this derives values from props to synchronize state + 8 | }, [startDate]); + 9 | + 10 | const onChange = (date) => { + +error.shadowed-props-with-onchange.ts:4:47 + 2 | + 3 | function EndDate({startDate, endDate, onStartDateChange}) { +> 4 | const [localStartDate, setLocalStartDate] = useState(startDate); + | ^^^^^^^^^^^^^^^^^^^ this useState shadows startDate + 5 | + 6 | useEffect(() => { + 7 | setLocalStartDate(startDate); + +error.shadowed-props-with-onchange.ts:11:8 + 9 | + 10 | const onChange = (date) => { +> 11 | setLocalStartDate(date); + | ^^^^^^^^^^^^^^^^^ this setState updates the shadowed state, but should call an onChange event from the parent + 12 | onStartDateChange(date); + 13 | } + 14 | return +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js new file mode 100644 index 0000000000..52e74312e6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js @@ -0,0 +1,15 @@ +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} diff --git a/compiler/yarn.lock b/compiler/yarn.lock index 696261cbf5..a2ae8a1acf 100644 --- a/compiler/yarn.lock +++ b/compiler/yarn.lock @@ -10494,16 +10494,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10576,14 +10567,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11360,7 +11344,7 @@ workerpool@^6.5.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -11378,15 +11362,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" From 2dfacc000ba6fadcf6dffe309b6983735d455afe Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Thu, 4 Sep 2025 15:35:04 -0700 Subject: [PATCH 22/25] [compiler] Add catching useStates that shadow a reactive value --- .../src/CompilerError.ts | 17 +- .../ValidateNoDerivedComputationsInEffects.ts | 162 +++++++++++------- ...ug-derived-state-from-mixed-deps.expect.md | 4 +- ...erived-state-from-shadowed-props.expect.md | 24 ++- ...error.derived-state-from-shadowed-props.js | 4 +- ...r.derived-state-with-conditional.expect.md | 4 +- ....derived-state-with-side-effects.expect.md | 4 +- ...id-derived-computation-in-effect.expect.md | 4 +- ...erived-state-from-props-computed.expect.md | 4 +- ...ed-state-from-props-destructured.expect.md | 4 +- ...d-derived-state-from-props-destructured.js | 4 +- ...rived-state-from-props-in-effect.expect.md | 4 +- ...te-from-props-with-default-value.expect.md | 4 +- ...ved-state-from-props-with-default-value.js | 10 +- ...rived-state-from-state-in-effect.expect.md | 4 +- ...ror.shadowed-props-with-onchange.expect.md | 61 +++++++ .../error.shadowed-props-with-onchange.js | 17 ++ compiler/yarn.lock | 31 +--- 18 files changed, 245 insertions(+), 121 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts b/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts index e12530a8db..a0e0e22b97 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts @@ -575,7 +575,9 @@ export enum ErrorCategory { // Checks for no setState in effect bodies EffectSetState = 'EffectSetState', - EffectDerivationsOfState = 'EffectDerivationsOfState', + EffectDerivationDeriveInRender = 'EffectDerivationDeriveInRender', + + EffectDerivationShadowingParentState = 'EffectDerivationShadowingParentState', // Validates against try/catch in place of error boundaries ErrorBoundaries = 'ErrorBoundaries', @@ -692,12 +694,21 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule { recommended: false, }; } - case ErrorCategory.EffectDerivationsOfState: { + case ErrorCategory.EffectDerivationDeriveInRender: { return { category, name: 'no-deriving-state-in-effects', description: - 'Validates against deriving values from state in an effect', + 'Validates if a useEffect is deriving state from props and/or local state that could be calculated in render.', + recommended: false, + }; + } + case ErrorCategory.EffectDerivationShadowingParentState: { + return { + category, + name: 'no-deriving-state-in-effects', + description: + 'Validates if a useEffect is deriving state from parent state and if the component is updating the shadowed state locally.', recommended: false, }; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index bcae209aa2..3b030a58c0 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -42,7 +42,7 @@ type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type DerivationMetadata = { typeOfValue: TypeOfValue; place: Place; - sources: Set; + sources: Array; }; type ErrorMetadata = { @@ -50,6 +50,7 @@ type ErrorMetadata = { description: string | undefined; loc: SourceLocation; setStateName: string | undefined | null; + derivedDepsNames: Array; }; /** @@ -80,6 +81,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const functions: Map = new Map(); const locals: Map = new Map(); const derivationCache: Map = new Map(); + const shadowingUseState: Map> = new Map(); const effectSetStates: Map< string | undefined | null, @@ -94,7 +96,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (param.kind === 'Identifier') { derivationCache.set(param.identifier.id, { place: param, - sources: new Set([param]), + sources: [param], typeOfValue: 'fromProps', }); } @@ -104,7 +106,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (props != null && props.kind === 'Identifier') { derivationCache.set(props.identifier.id, { place: props, - sources: new Set([props]), + sources: [props], typeOfValue: 'fromProps', }); } @@ -116,7 +118,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { for (const instr of block.instructions) { const {lvalue, value} = instr; - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); @@ -168,6 +170,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const compilerError = generateCompilerError( setStateCalls, effectSetStates, + shadowingUseState, errors, ); @@ -179,21 +182,12 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function generateCompilerError( setStateCalls: Map>, effectSetStates: Map>, + shadowingUseState: Map>, errors: Array, ): CompilerError { const throwableErrors = new CompilerError(); for (const error of errors) { let compilerDiagnostic: CompilerDiagnostic | undefined = undefined; - let detailMessage = ''; - switch (error.type) { - case 'fromProps': - detailMessage = 'This state value shadows a value passed as a prop.'; - break; - case 'fromPropsOrState': - detailMessage = - 'This state value shadows a value passed as a prop or a value from state.'; - break; - } /* * If we use a setState from an invalid useEffect elsewhere then we probably have to @@ -205,15 +199,29 @@ function generateCompilerError( error.type !== 'fromState' ) { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.`, - category: `Local state shadows parent state.`, + description: `The setState within a useEffect is deriving from ${error.description}. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render`, + category: ErrorCategory.EffectDerivationShadowingParentState, severity: ErrorSeverity.InvalidReact, + reason: + 'You might not need an effect. Local state shadows parent state.', }).withDetail({ kind: 'error', loc: error.loc, - message: 'this setState synchronizes the state', + message: `this derives values from props ${error.type === 'fromPropsOrState' ? 'and local state ' : ''}to synchronize state`, }); + for (const derivedDep of error.derivedDepsNames) { + if (shadowingUseState.has(derivedDep)) { + for (const loc of shadowingUseState.get(derivedDep)!) { + compilerDiagnostic.withDetail({ + kind: 'error', + loc: loc, + message: `this useState shadows ${derivedDep}`, + }); + } + } + } + for (const [key, setStateCallArray] of effectSetStates) { if (setStateCallArray.length === 0) { continue; @@ -235,9 +243,11 @@ function generateCompilerError( } } else { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, - category: `Derive values in render, not effects.`, + description: `${error.description ? error.description.charAt(0).toUpperCase() + error.description.slice(1) : ''}. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, + category: ErrorCategory.EffectDerivationDeriveInRender, severity: ErrorSeverity.InvalidReact, + reason: + 'You might not need an effect. Derive values in render, not effects.', }).withDetail({ kind: 'error', loc: error.loc, @@ -271,7 +281,7 @@ function updateDerivationMetadata( ): void { let newValue: DerivationMetadata = { place: target, - sources: new Set(), + sources: [], typeOfValue: typeOfValue ?? 'ignored', }; @@ -286,9 +296,9 @@ function updateDerivationMetadata( place.identifier.name === null || place.identifier.name?.kind === 'promoted' ) { - newValue.sources.add(target); + newValue.sources.push(target); } else { - newValue.sources.add(place); + newValue.sources.push(place); } } } @@ -301,38 +311,19 @@ function parseInstr( instr: Instruction, derivationCache: Map, setStateCalls: Map>, + shadowingUseState: Map>, ): void { // Recursively parse function expressions + let typeOfValue: TypeOfValue = 'ignored'; + + let sources: Array = []; if (instr.value.kind === 'FunctionExpression') { for (const [, block] of instr.value.loweredFunc.func.body.blocks) { for (const instr of block.instructions) { - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); } } - } - - let typeOfValue: TypeOfValue = 'ignored'; - - // Catch any useState hook calls - let sources: Array = []; - if ( - instr.value.kind === 'Destructure' && - instr.value.lvalue.pattern.kind === 'ArrayPattern' && - isUseStateType(instr.value.value.identifier) - ) { - typeOfValue = 'fromState'; - - const stateValueSource = instr.value.lvalue.pattern.items[0]; - if (stateValueSource.kind === 'Identifier') { - sources.push({ - place: stateValueSource, - typeOfValue: typeOfValue, - sources: new Set([stateValueSource]), - }); - } - } - - if ( + } else if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && instr.value.args.length === 1 && @@ -348,6 +339,21 @@ function parseInstr( instr.value.callee, ]); } + } else if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + isUseStateType(instr.lvalue.identifier) + ) { + const stateValueSource = instr.value.args[0]; + if (stateValueSource.kind === 'Identifier') { + sources.push({ + place: stateValueSource, + typeOfValue: typeOfValue, + sources: [stateValueSource], + }); + } + + typeOfValue = joinValue(typeOfValue, 'fromState'); } for (const operand of eachInstructionOperand(instr)) { @@ -358,6 +364,27 @@ function parseInstr( typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); sources.push(opSource); + + if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + opSource.typeOfValue === 'fromProps' && + isUseStateType(instr.lvalue.identifier) + ) { + opSource.sources.forEach(source => { + if (source.identifier.name !== null) { + if (shadowingUseState.has(source.identifier.name.value)) { + shadowingUseState + .get(source.identifier.name.value) + ?.push(instr.lvalue.loc); + } else { + shadowingUseState.set(source.identifier.name.value, [ + instr.lvalue.loc, + ]); + } + } + }); + } } if (typeOfValue !== 'ignored') { @@ -411,16 +438,26 @@ function parseBlockPhi( derivationCache: Map, ): void { for (const phi of block.phis) { + let typeOfValue: TypeOfValue = 'ignored'; + let sources: Array = []; for (const operand of phi.operands.values()) { - const phiSource = derivationCache.get(operand.identifier.id); - if (phiSource !== undefined) { - updateDerivationMetadata( - phi.place, - [phiSource], - phiSource?.typeOfValue, - derivationCache, - ); + const opSource = derivationCache.get(operand.identifier.id); + + if (opSource === undefined) { + continue; } + + typeOfValue = joinValue(typeOfValue, opSource?.typeOfValue ?? 'ignored'); + sources.push(opSource); + } + + if (typeOfValue !== 'ignored') { + updateDerivationMetadata( + phi.place, + sources, + typeOfValue, + derivationCache, + ); } } } @@ -524,7 +561,7 @@ function validateEffect( } for (const call of derivedSetStateCall) { - const placeNames = Array.from(call.derivedDep.sources) + const derivedDepsStr = Array.from(call.derivedDep.sources) .map(place => { return place.identifier.name?.value; }) @@ -534,19 +571,24 @@ function validateEffect( let errorDescription = ''; if (call.derivedDep.typeOfValue === 'fromProps') { - errorDescription = `props [${placeNames}].`; + errorDescription = `props [${derivedDepsStr}]`; } else if (call.derivedDep.typeOfValue === 'fromState') { - errorDescription = `local state [${placeNames}].`; + errorDescription = `local state [${derivedDepsStr}]`; } else { - errorDescription = `both props and local state [${placeNames}].`; + errorDescription = `both props and local state [${derivedDepsStr}]`; } errors.push({ type: call.derivedDep.typeOfValue, - description: `This setState() appears to derive a value from ${errorDescription}`, + description: `${errorDescription}`, loc: call.loc, setStateName: call.loc !== GeneratedSource ? call.loc.identifierName : undefined, + derivedDepsNames: Array.from(call.derivedDep.sources) + .map(place => { + return place.identifier.name?.value ?? ''; + }) + .filter(Boolean), }); } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md index 2588a014af..5255636da7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -34,9 +34,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.bug-derived-state-from-mixed-deps.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md index 66079d40bb..cd7c024fe9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md @@ -32,19 +32,37 @@ function Component({props, number}) { ``` Found 1 error: -Error: Local state shadows parent state. +Error: You might not need an effect. Local state shadows parent state. -This setState() appears to derive a value from props [props, number]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. +The setState within a useEffect is deriving from props [props, number]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render error.derived-state-from-shadowed-props.ts:10:4 8 | 9 | useEffect(() => { > 10 | setDisplayValue(props.prefix + missDirection + nothing); - | ^^^^^^^^^^^^^^^ this setState synchronizes the state + | ^^^^^^^^^^^^^^^ this derives values from props to synchronize state 11 | }, [props.prefix, missDirection, nothing]); 12 | 13 | return ( +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows props + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows number + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + error.derived-state-from-shadowed-props.ts:16:8 14 |
{ diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js index 6b4cefedf5..b7cbcabf07 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js @@ -4,7 +4,9 @@ import {useState, useEffect} from 'react'; function Component({props, number}) { const nothing = 0; const missDirection = number; - const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + const [displayValue, setDisplayValue] = useState( + props.prefix + missDirection + nothing + ); useEffect(() => { setDisplayValue(props.prefix + missDirection + nothing); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md index 0643af7722..5cf7a99730 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md @@ -32,9 +32,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-conditional.ts:9:6 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md index 0f25b76660..ba8d835199 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md @@ -30,9 +30,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-side-effects.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md index bdf7a9b209..61ae320eec 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md @@ -24,9 +24,9 @@ function BadExample() { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-computation-in-effect.ts:9:4 7 | const [fullName, setFullName] = useState(''); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md index 7773a2cc8d..daf74031d9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md @@ -29,9 +29,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-computed.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index 99b596c4ce..c5509ceae3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-destructured.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js index 78f7c910ff..fc9d6bd257 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js @@ -2,7 +2,9 @@ import {useEffect, useState} from 'react'; function Component({props}) { - const [fullName, setFullName] = useState(props.firstName + ' ' + props.lastName); + const [fullName, setFullName] = useState( + props.firstName + ' ' + props.lastName + ); useEffect(() => { setFullName(props.firstName + ' ' + props.lastName); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md index 88c722b8f6..9cb8a7427f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-in-effect.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md index 3af0c00ecc..8136511e6f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md @@ -26,9 +26,9 @@ export default function InProductLobbyGeminiCard( ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-with-default-value.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js index a2ad3de584..70480f7d3d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js @@ -1,15 +1,11 @@ // @validateNoDerivedComputationsInEffects -export default function InProductLobbyGeminiCard( - input = 'empty', -) { +export default function InProductLobbyGeminiCard(input = 'empty') { const [currInput, setCurrInput] = useState(input); useEffect(() => { - setCurrInput(input) + setCurrInput(input); }, [input]); - return ( -
{currInput}
- ) + return
{currInput}
; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md index 5a029cb0cc..e7f8cd4584 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -36,9 +36,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-state-in-effect.ts:10:4 8 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md new file mode 100644 index 0000000000..f08a65dad2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md @@ -0,0 +1,61 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} + +``` + + +## Error + +``` +Found 1 error: + +Error: You might not need an effect. Local state shadows parent state. + +The setState within a useEffect is deriving from props [startDate]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render + +error.shadowed-props-with-onchange.ts:7:8 + 5 | + 6 | useEffect(() => { +> 7 | setLocalStartDate(startDate); + | ^^^^^^^^^^^^^^^^^ this derives values from props to synchronize state + 8 | }, [startDate]); + 9 | + 10 | const onChange = (date) => { + +error.shadowed-props-with-onchange.ts:4:47 + 2 | + 3 | function EndDate({startDate, endDate, onStartDateChange}) { +> 4 | const [localStartDate, setLocalStartDate] = useState(startDate); + | ^^^^^^^^^^^^^^^^^^^ this useState shadows startDate + 5 | + 6 | useEffect(() => { + 7 | setLocalStartDate(startDate); + +error.shadowed-props-with-onchange.ts:11:8 + 9 | + 10 | const onChange = (date) => { +> 11 | setLocalStartDate(date); + | ^^^^^^^^^^^^^^^^^ this setState updates the shadowed state, but should call an onChange event from the parent + 12 | onStartDateChange(date); + 13 | } + 14 | return +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js new file mode 100644 index 0000000000..af63377c4b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js @@ -0,0 +1,17 @@ +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = date => { + setLocalStartDate(date); + onStartDateChange(date); + }; + return ( + + ); +} diff --git a/compiler/yarn.lock b/compiler/yarn.lock index 696261cbf5..a2ae8a1acf 100644 --- a/compiler/yarn.lock +++ b/compiler/yarn.lock @@ -10494,16 +10494,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10576,14 +10567,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11360,7 +11344,7 @@ workerpool@^6.5.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -11378,15 +11362,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" From a46e4b8729949f833554e78eac5382ec77b65f37 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Thu, 4 Sep 2025 15:35:04 -0700 Subject: [PATCH 23/25] [compiler] Add catching useStates that shadow a reactive value --- .../src/CompilerError.ts | 17 +- .../ValidateNoDerivedComputationsInEffects.ts | 163 +++++++++++------- ...ug-derived-state-from-mixed-deps.expect.md | 4 +- ...erived-state-from-shadowed-props.expect.md | 24 ++- ...error.derived-state-from-shadowed-props.js | 4 +- ...r.derived-state-with-conditional.expect.md | 4 +- ....derived-state-with-side-effects.expect.md | 4 +- ...id-derived-computation-in-effect.expect.md | 4 +- ...erived-state-from-props-computed.expect.md | 4 +- ...ed-state-from-props-destructured.expect.md | 4 +- ...d-derived-state-from-props-destructured.js | 4 +- ...rived-state-from-props-in-effect.expect.md | 4 +- ...te-from-props-with-default-value.expect.md | 4 +- ...ved-state-from-props-with-default-value.js | 10 +- ...rived-state-from-state-in-effect.expect.md | 4 +- ...ror.shadowed-props-with-onchange.expect.md | 61 +++++++ .../error.shadowed-props-with-onchange.js | 17 ++ compiler/yarn.lock | 31 +--- 18 files changed, 246 insertions(+), 121 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts b/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts index e12530a8db..a0e0e22b97 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts @@ -575,7 +575,9 @@ export enum ErrorCategory { // Checks for no setState in effect bodies EffectSetState = 'EffectSetState', - EffectDerivationsOfState = 'EffectDerivationsOfState', + EffectDerivationDeriveInRender = 'EffectDerivationDeriveInRender', + + EffectDerivationShadowingParentState = 'EffectDerivationShadowingParentState', // Validates against try/catch in place of error boundaries ErrorBoundaries = 'ErrorBoundaries', @@ -692,12 +694,21 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule { recommended: false, }; } - case ErrorCategory.EffectDerivationsOfState: { + case ErrorCategory.EffectDerivationDeriveInRender: { return { category, name: 'no-deriving-state-in-effects', description: - 'Validates against deriving values from state in an effect', + 'Validates if a useEffect is deriving state from props and/or local state that could be calculated in render.', + recommended: false, + }; + } + case ErrorCategory.EffectDerivationShadowingParentState: { + return { + category, + name: 'no-deriving-state-in-effects', + description: + 'Validates if a useEffect is deriving state from parent state and if the component is updating the shadowed state locally.', recommended: false, }; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index bcae209aa2..cdbda3c2ea 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -42,7 +42,7 @@ type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type DerivationMetadata = { typeOfValue: TypeOfValue; place: Place; - sources: Set; + sources: Array; }; type ErrorMetadata = { @@ -50,6 +50,7 @@ type ErrorMetadata = { description: string | undefined; loc: SourceLocation; setStateName: string | undefined | null; + derivedDepsNames: Array; }; /** @@ -80,6 +81,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const functions: Map = new Map(); const locals: Map = new Map(); const derivationCache: Map = new Map(); + const shadowingUseState: Map> = new Map(); const effectSetStates: Map< string | undefined | null, @@ -94,7 +96,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (param.kind === 'Identifier') { derivationCache.set(param.identifier.id, { place: param, - sources: new Set([param]), + sources: [param], typeOfValue: 'fromProps', }); } @@ -104,7 +106,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (props != null && props.kind === 'Identifier') { derivationCache.set(props.identifier.id, { place: props, - sources: new Set([props]), + sources: [props], typeOfValue: 'fromProps', }); } @@ -116,7 +118,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { for (const instr of block.instructions) { const {lvalue, value} = instr; - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); @@ -168,6 +170,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const compilerError = generateCompilerError( setStateCalls, effectSetStates, + shadowingUseState, errors, ); @@ -179,21 +182,12 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function generateCompilerError( setStateCalls: Map>, effectSetStates: Map>, + shadowingUseState: Map>, errors: Array, ): CompilerError { const throwableErrors = new CompilerError(); for (const error of errors) { let compilerDiagnostic: CompilerDiagnostic | undefined = undefined; - let detailMessage = ''; - switch (error.type) { - case 'fromProps': - detailMessage = 'This state value shadows a value passed as a prop.'; - break; - case 'fromPropsOrState': - detailMessage = - 'This state value shadows a value passed as a prop or a value from state.'; - break; - } /* * If we use a setState from an invalid useEffect elsewhere then we probably have to @@ -205,15 +199,29 @@ function generateCompilerError( error.type !== 'fromState' ) { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.`, - category: `Local state shadows parent state.`, + description: `The setState within a useEffect is deriving from ${error.description}. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render`, + category: ErrorCategory.EffectDerivationShadowingParentState, severity: ErrorSeverity.InvalidReact, + reason: + 'You might not need an effect. Local state shadows parent state.', }).withDetail({ kind: 'error', loc: error.loc, - message: 'this setState synchronizes the state', + message: `this derives values from props ${error.type === 'fromPropsOrState' ? 'and local state ' : ''}to synchronize state`, }); + for (const derivedDep of error.derivedDepsNames) { + if (shadowingUseState.has(derivedDep)) { + for (const loc of shadowingUseState.get(derivedDep)!) { + compilerDiagnostic.withDetail({ + kind: 'error', + loc: loc, + message: `this useState shadows ${derivedDep}`, + }); + } + } + } + for (const [key, setStateCallArray] of effectSetStates) { if (setStateCallArray.length === 0) { continue; @@ -235,9 +243,11 @@ function generateCompilerError( } } else { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, - category: `Derive values in render, not effects.`, + description: `${error.description ? error.description.charAt(0).toUpperCase() + error.description.slice(1) : ''}. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, + category: ErrorCategory.EffectDerivationDeriveInRender, severity: ErrorSeverity.InvalidReact, + reason: + 'You might not need an effect. Derive values in render, not effects.', }).withDetail({ kind: 'error', loc: error.loc, @@ -271,7 +281,7 @@ function updateDerivationMetadata( ): void { let newValue: DerivationMetadata = { place: target, - sources: new Set(), + sources: [], typeOfValue: typeOfValue ?? 'ignored', }; @@ -286,9 +296,9 @@ function updateDerivationMetadata( place.identifier.name === null || place.identifier.name?.kind === 'promoted' ) { - newValue.sources.add(target); + newValue.sources.push(target); } else { - newValue.sources.add(place); + newValue.sources.push(place); } } } @@ -301,38 +311,19 @@ function parseInstr( instr: Instruction, derivationCache: Map, setStateCalls: Map>, + shadowingUseState: Map>, ): void { // Recursively parse function expressions + let typeOfValue: TypeOfValue = 'ignored'; + + let sources: Array = []; if (instr.value.kind === 'FunctionExpression') { for (const [, block] of instr.value.loweredFunc.func.body.blocks) { for (const instr of block.instructions) { - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); } } - } - - let typeOfValue: TypeOfValue = 'ignored'; - - // Catch any useState hook calls - let sources: Array = []; - if ( - instr.value.kind === 'Destructure' && - instr.value.lvalue.pattern.kind === 'ArrayPattern' && - isUseStateType(instr.value.value.identifier) - ) { - typeOfValue = 'fromState'; - - const stateValueSource = instr.value.lvalue.pattern.items[0]; - if (stateValueSource.kind === 'Identifier') { - sources.push({ - place: stateValueSource, - typeOfValue: typeOfValue, - sources: new Set([stateValueSource]), - }); - } - } - - if ( + } else if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && instr.value.args.length === 1 && @@ -348,6 +339,22 @@ function parseInstr( instr.value.callee, ]); } + } else if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + isUseStateType(instr.lvalue.identifier) && + instr.value.args.length > 0 + ) { + const stateValueSource = instr.value.args[0]; + if (stateValueSource.kind === 'Identifier') { + sources.push({ + place: stateValueSource, + typeOfValue: typeOfValue, + sources: [stateValueSource], + }); + } + + typeOfValue = joinValue(typeOfValue, 'fromState'); } for (const operand of eachInstructionOperand(instr)) { @@ -358,6 +365,27 @@ function parseInstr( typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); sources.push(opSource); + + if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + opSource.typeOfValue === 'fromProps' && + isUseStateType(instr.lvalue.identifier) + ) { + opSource.sources.forEach(source => { + if (source.identifier.name !== null) { + if (shadowingUseState.has(source.identifier.name.value)) { + shadowingUseState + .get(source.identifier.name.value) + ?.push(instr.lvalue.loc); + } else { + shadowingUseState.set(source.identifier.name.value, [ + instr.lvalue.loc, + ]); + } + } + }); + } } if (typeOfValue !== 'ignored') { @@ -411,16 +439,26 @@ function parseBlockPhi( derivationCache: Map, ): void { for (const phi of block.phis) { + let typeOfValue: TypeOfValue = 'ignored'; + let sources: Array = []; for (const operand of phi.operands.values()) { - const phiSource = derivationCache.get(operand.identifier.id); - if (phiSource !== undefined) { - updateDerivationMetadata( - phi.place, - [phiSource], - phiSource?.typeOfValue, - derivationCache, - ); + const opSource = derivationCache.get(operand.identifier.id); + + if (opSource === undefined) { + continue; } + + typeOfValue = joinValue(typeOfValue, opSource?.typeOfValue ?? 'ignored'); + sources.push(opSource); + } + + if (typeOfValue !== 'ignored') { + updateDerivationMetadata( + phi.place, + sources, + typeOfValue, + derivationCache, + ); } } } @@ -524,7 +562,7 @@ function validateEffect( } for (const call of derivedSetStateCall) { - const placeNames = Array.from(call.derivedDep.sources) + const derivedDepsStr = Array.from(call.derivedDep.sources) .map(place => { return place.identifier.name?.value; }) @@ -534,19 +572,24 @@ function validateEffect( let errorDescription = ''; if (call.derivedDep.typeOfValue === 'fromProps') { - errorDescription = `props [${placeNames}].`; + errorDescription = `props [${derivedDepsStr}]`; } else if (call.derivedDep.typeOfValue === 'fromState') { - errorDescription = `local state [${placeNames}].`; + errorDescription = `local state [${derivedDepsStr}]`; } else { - errorDescription = `both props and local state [${placeNames}].`; + errorDescription = `both props and local state [${derivedDepsStr}]`; } errors.push({ type: call.derivedDep.typeOfValue, - description: `This setState() appears to derive a value from ${errorDescription}`, + description: `${errorDescription}`, loc: call.loc, setStateName: call.loc !== GeneratedSource ? call.loc.identifierName : undefined, + derivedDepsNames: Array.from(call.derivedDep.sources) + .map(place => { + return place.identifier.name?.value ?? ''; + }) + .filter(Boolean), }); } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md index 2588a014af..5255636da7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -34,9 +34,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.bug-derived-state-from-mixed-deps.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md index 66079d40bb..cd7c024fe9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md @@ -32,19 +32,37 @@ function Component({props, number}) { ``` Found 1 error: -Error: Local state shadows parent state. +Error: You might not need an effect. Local state shadows parent state. -This setState() appears to derive a value from props [props, number]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. +The setState within a useEffect is deriving from props [props, number]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render error.derived-state-from-shadowed-props.ts:10:4 8 | 9 | useEffect(() => { > 10 | setDisplayValue(props.prefix + missDirection + nothing); - | ^^^^^^^^^^^^^^^ this setState synchronizes the state + | ^^^^^^^^^^^^^^^ this derives values from props to synchronize state 11 | }, [props.prefix, missDirection, nothing]); 12 | 13 | return ( +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows props + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this useState shadows number + 8 | + 9 | useEffect(() => { + 10 | setDisplayValue(props.prefix + missDirection + nothing); + error.derived-state-from-shadowed-props.ts:16:8 14 |
{ diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js index 6b4cefedf5..b7cbcabf07 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js @@ -4,7 +4,9 @@ import {useState, useEffect} from 'react'; function Component({props, number}) { const nothing = 0; const missDirection = number; - const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + const [displayValue, setDisplayValue] = useState( + props.prefix + missDirection + nothing + ); useEffect(() => { setDisplayValue(props.prefix + missDirection + nothing); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md index 0643af7722..5cf7a99730 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md @@ -32,9 +32,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-conditional.ts:9:6 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md index 0f25b76660..ba8d835199 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md @@ -30,9 +30,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-side-effects.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md index bdf7a9b209..61ae320eec 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md @@ -24,9 +24,9 @@ function BadExample() { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-computation-in-effect.ts:9:4 7 | const [fullName, setFullName] = useState(''); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md index 7773a2cc8d..daf74031d9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md @@ -29,9 +29,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-computed.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index 99b596c4ce..c5509ceae3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-destructured.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js index 78f7c910ff..fc9d6bd257 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js @@ -2,7 +2,9 @@ import {useEffect, useState} from 'react'; function Component({props}) { - const [fullName, setFullName] = useState(props.firstName + ' ' + props.lastName); + const [fullName, setFullName] = useState( + props.firstName + ' ' + props.lastName + ); useEffect(() => { setFullName(props.firstName + ' ' + props.lastName); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md index 88c722b8f6..9cb8a7427f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-in-effect.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md index 3af0c00ecc..8136511e6f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md @@ -26,9 +26,9 @@ export default function InProductLobbyGeminiCard( ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-with-default-value.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js index a2ad3de584..70480f7d3d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js @@ -1,15 +1,11 @@ // @validateNoDerivedComputationsInEffects -export default function InProductLobbyGeminiCard( - input = 'empty', -) { +export default function InProductLobbyGeminiCard(input = 'empty') { const [currInput, setCurrInput] = useState(input); useEffect(() => { - setCurrInput(input) + setCurrInput(input); }, [input]); - return ( -
{currInput}
- ) + return
{currInput}
; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md index 5a029cb0cc..e7f8cd4584 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -36,9 +36,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-state-in-effect.ts:10:4 8 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md new file mode 100644 index 0000000000..f08a65dad2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md @@ -0,0 +1,61 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = (date) => { + setLocalStartDate(date); + onStartDateChange(date); + } + return +} + +``` + + +## Error + +``` +Found 1 error: + +Error: You might not need an effect. Local state shadows parent state. + +The setState within a useEffect is deriving from props [startDate]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render + +error.shadowed-props-with-onchange.ts:7:8 + 5 | + 6 | useEffect(() => { +> 7 | setLocalStartDate(startDate); + | ^^^^^^^^^^^^^^^^^ this derives values from props to synchronize state + 8 | }, [startDate]); + 9 | + 10 | const onChange = (date) => { + +error.shadowed-props-with-onchange.ts:4:47 + 2 | + 3 | function EndDate({startDate, endDate, onStartDateChange}) { +> 4 | const [localStartDate, setLocalStartDate] = useState(startDate); + | ^^^^^^^^^^^^^^^^^^^ this useState shadows startDate + 5 | + 6 | useEffect(() => { + 7 | setLocalStartDate(startDate); + +error.shadowed-props-with-onchange.ts:11:8 + 9 | + 10 | const onChange = (date) => { +> 11 | setLocalStartDate(date); + | ^^^^^^^^^^^^^^^^^ this setState updates the shadowed state, but should call an onChange event from the parent + 12 | onStartDateChange(date); + 13 | } + 14 | return +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js new file mode 100644 index 0000000000..af63377c4b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js @@ -0,0 +1,17 @@ +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = date => { + setLocalStartDate(date); + onStartDateChange(date); + }; + return ( + + ); +} diff --git a/compiler/yarn.lock b/compiler/yarn.lock index 696261cbf5..a2ae8a1acf 100644 --- a/compiler/yarn.lock +++ b/compiler/yarn.lock @@ -10494,16 +10494,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10576,14 +10567,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11360,7 +11344,7 @@ workerpool@^6.5.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -11378,15 +11362,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" From e380d2e1592d334dc77ca72f48dcdff33738a958 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Fri, 5 Sep 2025 09:33:53 -0700 Subject: [PATCH 24/25] [compiler] Add catching useStates that shadow a reactive value --- .../src/CompilerError.ts | 17 +- .../ValidateNoDerivedComputationsInEffects.ts | 163 +++++++++++------- ...ug-derived-state-from-mixed-deps.expect.md | 4 +- ...erived-state-from-shadowed-props.expect.md | 64 +++++-- ...error.derived-state-from-shadowed-props.js | 4 +- ...r.derived-state-with-conditional.expect.md | 4 +- ....derived-state-with-side-effects.expect.md | 4 +- ...id-derived-computation-in-effect.expect.md | 4 +- ...erived-state-from-props-computed.expect.md | 4 +- ...ed-state-from-props-destructured.expect.md | 22 +-- ...d-derived-state-from-props-destructured.js | 4 +- ...rived-state-from-props-in-effect.expect.md | 4 +- ...te-from-props-with-default-value.expect.md | 28 ++- ...ved-state-from-props-with-default-value.js | 10 +- ...rived-state-from-state-in-effect.expect.md | 4 +- ...ror.shadowed-props-with-onchange.expect.md | 63 +++++++ .../error.shadowed-props-with-onchange.js | 17 ++ compiler/yarn.lock | 31 +--- 18 files changed, 293 insertions(+), 158 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts b/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts index e12530a8db..a0e0e22b97 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts @@ -575,7 +575,9 @@ export enum ErrorCategory { // Checks for no setState in effect bodies EffectSetState = 'EffectSetState', - EffectDerivationsOfState = 'EffectDerivationsOfState', + EffectDerivationDeriveInRender = 'EffectDerivationDeriveInRender', + + EffectDerivationShadowingParentState = 'EffectDerivationShadowingParentState', // Validates against try/catch in place of error boundaries ErrorBoundaries = 'ErrorBoundaries', @@ -692,12 +694,21 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule { recommended: false, }; } - case ErrorCategory.EffectDerivationsOfState: { + case ErrorCategory.EffectDerivationDeriveInRender: { return { category, name: 'no-deriving-state-in-effects', description: - 'Validates against deriving values from state in an effect', + 'Validates if a useEffect is deriving state from props and/or local state that could be calculated in render.', + recommended: false, + }; + } + case ErrorCategory.EffectDerivationShadowingParentState: { + return { + category, + name: 'no-deriving-state-in-effects', + description: + 'Validates if a useEffect is deriving state from parent state and if the component is updating the shadowed state locally.', recommended: false, }; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts index bcae209aa2..cdbda3c2ea 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts @@ -42,7 +42,7 @@ type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsOrState'; type DerivationMetadata = { typeOfValue: TypeOfValue; place: Place; - sources: Set; + sources: Array; }; type ErrorMetadata = { @@ -50,6 +50,7 @@ type ErrorMetadata = { description: string | undefined; loc: SourceLocation; setStateName: string | undefined | null; + derivedDepsNames: Array; }; /** @@ -80,6 +81,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const functions: Map = new Map(); const locals: Map = new Map(); const derivationCache: Map = new Map(); + const shadowingUseState: Map> = new Map(); const effectSetStates: Map< string | undefined | null, @@ -94,7 +96,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (param.kind === 'Identifier') { derivationCache.set(param.identifier.id, { place: param, - sources: new Set([param]), + sources: [param], typeOfValue: 'fromProps', }); } @@ -104,7 +106,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { if (props != null && props.kind === 'Identifier') { derivationCache.set(props.identifier.id, { place: props, - sources: new Set([props]), + sources: [props], typeOfValue: 'fromProps', }); } @@ -116,7 +118,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { for (const instr of block.instructions) { const {lvalue, value} = instr; - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); if (value.kind === 'LoadLocal') { locals.set(lvalue.identifier.id, value.place.identifier.id); @@ -168,6 +170,7 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { const compilerError = generateCompilerError( setStateCalls, effectSetStates, + shadowingUseState, errors, ); @@ -179,21 +182,12 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void { function generateCompilerError( setStateCalls: Map>, effectSetStates: Map>, + shadowingUseState: Map>, errors: Array, ): CompilerError { const throwableErrors = new CompilerError(); for (const error of errors) { let compilerDiagnostic: CompilerDiagnostic | undefined = undefined; - let detailMessage = ''; - switch (error.type) { - case 'fromProps': - detailMessage = 'This state value shadows a value passed as a prop.'; - break; - case 'fromPropsOrState': - detailMessage = - 'This state value shadows a value passed as a prop or a value from state.'; - break; - } /* * If we use a setState from an invalid useEffect elsewhere then we probably have to @@ -205,15 +199,29 @@ function generateCompilerError( error.type !== 'fromState' ) { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there.`, - category: `Local state shadows parent state.`, + description: `The setState within a useEffect is deriving from ${error.description}. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render`, + category: ErrorCategory.EffectDerivationShadowingParentState, severity: ErrorSeverity.InvalidReact, + reason: + 'You might not need an effect. Local state shadows parent state.', }).withDetail({ kind: 'error', loc: error.loc, - message: 'this setState synchronizes the state', + message: `this derives values from props ${error.type === 'fromPropsOrState' ? 'and local state ' : ''}to synchronize state`, }); + for (const derivedDep of error.derivedDepsNames) { + if (shadowingUseState.has(derivedDep)) { + for (const loc of shadowingUseState.get(derivedDep)!) { + compilerDiagnostic.withDetail({ + kind: 'error', + loc: loc, + message: `this useState shadows ${derivedDep}`, + }); + } + } + } + for (const [key, setStateCallArray] of effectSetStates) { if (setStateCallArray.length === 0) { continue; @@ -235,9 +243,11 @@ function generateCompilerError( } } else { compilerDiagnostic = CompilerDiagnostic.create({ - description: `${error.description} Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, - category: `Derive values in render, not effects.`, + description: `${error.description ? error.description.charAt(0).toUpperCase() + error.description.slice(1) : ''}. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.`, + category: ErrorCategory.EffectDerivationDeriveInRender, severity: ErrorSeverity.InvalidReact, + reason: + 'You might not need an effect. Derive values in render, not effects.', }).withDetail({ kind: 'error', loc: error.loc, @@ -271,7 +281,7 @@ function updateDerivationMetadata( ): void { let newValue: DerivationMetadata = { place: target, - sources: new Set(), + sources: [], typeOfValue: typeOfValue ?? 'ignored', }; @@ -286,9 +296,9 @@ function updateDerivationMetadata( place.identifier.name === null || place.identifier.name?.kind === 'promoted' ) { - newValue.sources.add(target); + newValue.sources.push(target); } else { - newValue.sources.add(place); + newValue.sources.push(place); } } } @@ -301,38 +311,19 @@ function parseInstr( instr: Instruction, derivationCache: Map, setStateCalls: Map>, + shadowingUseState: Map>, ): void { // Recursively parse function expressions + let typeOfValue: TypeOfValue = 'ignored'; + + let sources: Array = []; if (instr.value.kind === 'FunctionExpression') { for (const [, block] of instr.value.loweredFunc.func.body.blocks) { for (const instr of block.instructions) { - parseInstr(instr, derivationCache, setStateCalls); + parseInstr(instr, derivationCache, setStateCalls, shadowingUseState); } } - } - - let typeOfValue: TypeOfValue = 'ignored'; - - // Catch any useState hook calls - let sources: Array = []; - if ( - instr.value.kind === 'Destructure' && - instr.value.lvalue.pattern.kind === 'ArrayPattern' && - isUseStateType(instr.value.value.identifier) - ) { - typeOfValue = 'fromState'; - - const stateValueSource = instr.value.lvalue.pattern.items[0]; - if (stateValueSource.kind === 'Identifier') { - sources.push({ - place: stateValueSource, - typeOfValue: typeOfValue, - sources: new Set([stateValueSource]), - }); - } - } - - if ( + } else if ( instr.value.kind === 'CallExpression' && isSetStateType(instr.value.callee.identifier) && instr.value.args.length === 1 && @@ -348,6 +339,22 @@ function parseInstr( instr.value.callee, ]); } + } else if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + isUseStateType(instr.lvalue.identifier) && + instr.value.args.length > 0 + ) { + const stateValueSource = instr.value.args[0]; + if (stateValueSource.kind === 'Identifier') { + sources.push({ + place: stateValueSource, + typeOfValue: typeOfValue, + sources: [stateValueSource], + }); + } + + typeOfValue = joinValue(typeOfValue, 'fromState'); } for (const operand of eachInstructionOperand(instr)) { @@ -358,6 +365,27 @@ function parseInstr( typeOfValue = joinValue(typeOfValue, opSource.typeOfValue); sources.push(opSource); + + if ( + (instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall') && + opSource.typeOfValue === 'fromProps' && + isUseStateType(instr.lvalue.identifier) + ) { + opSource.sources.forEach(source => { + if (source.identifier.name !== null) { + if (shadowingUseState.has(source.identifier.name.value)) { + shadowingUseState + .get(source.identifier.name.value) + ?.push(instr.lvalue.loc); + } else { + shadowingUseState.set(source.identifier.name.value, [ + instr.lvalue.loc, + ]); + } + } + }); + } } if (typeOfValue !== 'ignored') { @@ -411,16 +439,26 @@ function parseBlockPhi( derivationCache: Map, ): void { for (const phi of block.phis) { + let typeOfValue: TypeOfValue = 'ignored'; + let sources: Array = []; for (const operand of phi.operands.values()) { - const phiSource = derivationCache.get(operand.identifier.id); - if (phiSource !== undefined) { - updateDerivationMetadata( - phi.place, - [phiSource], - phiSource?.typeOfValue, - derivationCache, - ); + const opSource = derivationCache.get(operand.identifier.id); + + if (opSource === undefined) { + continue; } + + typeOfValue = joinValue(typeOfValue, opSource?.typeOfValue ?? 'ignored'); + sources.push(opSource); + } + + if (typeOfValue !== 'ignored') { + updateDerivationMetadata( + phi.place, + sources, + typeOfValue, + derivationCache, + ); } } } @@ -524,7 +562,7 @@ function validateEffect( } for (const call of derivedSetStateCall) { - const placeNames = Array.from(call.derivedDep.sources) + const derivedDepsStr = Array.from(call.derivedDep.sources) .map(place => { return place.identifier.name?.value; }) @@ -534,19 +572,24 @@ function validateEffect( let errorDescription = ''; if (call.derivedDep.typeOfValue === 'fromProps') { - errorDescription = `props [${placeNames}].`; + errorDescription = `props [${derivedDepsStr}]`; } else if (call.derivedDep.typeOfValue === 'fromState') { - errorDescription = `local state [${placeNames}].`; + errorDescription = `local state [${derivedDepsStr}]`; } else { - errorDescription = `both props and local state [${placeNames}].`; + errorDescription = `both props and local state [${derivedDepsStr}]`; } errors.push({ type: call.derivedDep.typeOfValue, - description: `This setState() appears to derive a value from ${errorDescription}`, + description: `${errorDescription}`, loc: call.loc, setStateName: call.loc !== GeneratedSource ? call.loc.identifierName : undefined, + derivedDepsNames: Array.from(call.derivedDep.sources) + .map(place => { + return place.identifier.name?.value ?? ''; + }) + .filter(Boolean), }); } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md index 2588a014af..5255636da7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.bug-derived-state-from-mixed-deps.expect.md @@ -34,9 +34,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Both props and local state [prefix, name]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.bug-derived-state-from-mixed-deps.ts:9:4 7 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md index 66079d40bb..1f0ed8c4e6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.expect.md @@ -8,7 +8,9 @@ import {useState, useEffect} from 'react'; function Component({props, number}) { const nothing = 0; const missDirection = number; - const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + const [displayValue, setDisplayValue] = useState( + props.prefix + missDirection + nothing + ); useEffect(() => { setDisplayValue(props.prefix + missDirection + nothing); @@ -32,27 +34,53 @@ function Component({props, number}) { ``` Found 1 error: -Error: Local state shadows parent state. +Error: You might not need an effect. Local state shadows parent state. -This setState() appears to derive a value from props [props, number]. This state value shadows a value passed as a prop. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. +The setState within a useEffect is deriving from props [props, number]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render -error.derived-state-from-shadowed-props.ts:10:4 - 8 | - 9 | useEffect(() => { -> 10 | setDisplayValue(props.prefix + missDirection + nothing); - | ^^^^^^^^^^^^^^^ this setState synchronizes the state - 11 | }, [props.prefix, missDirection, nothing]); - 12 | - 13 | return ( +error.derived-state-from-shadowed-props.ts:12:4 + 10 | + 11 | useEffect(() => { +> 12 | setDisplayValue(props.prefix + missDirection + nothing); + | ^^^^^^^^^^^^^^^ this derives values from props to synchronize state + 13 | }, [props.prefix, missDirection, nothing]); + 14 | + 15 | return ( -error.derived-state-from-shadowed-props.ts:16:8 - 14 |
{ -> 16 | setDisplayValue('clicked'); +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState( + | ^^^^^^^^^ +> 8 | props.prefix + missDirection + nothing + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 9 | ); + | ^^^^ this useState shadows props + 10 | + 11 | useEffect(() => { + 12 | setDisplayValue(props.prefix + missDirection + nothing); + +error.derived-state-from-shadowed-props.ts:7:42 + 5 | const nothing = 0; + 6 | const missDirection = number; +> 7 | const [displayValue, setDisplayValue] = useState( + | ^^^^^^^^^ +> 8 | props.prefix + missDirection + nothing + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 9 | ); + | ^^^^ this useState shadows number + 10 | + 11 | useEffect(() => { + 12 | setDisplayValue(props.prefix + missDirection + nothing); + +error.derived-state-from-shadowed-props.ts:18:8 + 16 |
{ +> 18 | setDisplayValue('clicked'); | ^^^^^^^^^^^^^^^ this setState updates the shadowed state, but should call an onChange event from the parent - 17 | }}> - 18 | {displayValue} - 19 |
+ 19 | }}> + 20 | {displayValue} + 21 |
``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js index 6b4cefedf5..b7cbcabf07 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-from-shadowed-props.js @@ -4,7 +4,9 @@ import {useState, useEffect} from 'react'; function Component({props, number}) { const nothing = 0; const missDirection = number; - const [displayValue, setDisplayValue] = useState(props.prefix + missDirection + nothing); + const [displayValue, setDisplayValue] = useState( + props.prefix + missDirection + nothing + ); useEffect(() => { setDisplayValue(props.prefix + missDirection + nothing); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md index 0643af7722..5cf7a99730 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-conditional.expect.md @@ -32,9 +32,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-conditional.ts:9:6 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md index 0f25b76660..ba8d835199 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.derived-state-with-side-effects.expect.md @@ -30,9 +30,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [value]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.derived-state-with-side-effects.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md index bdf7a9b209..61ae320eec 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-computation-in-effect.expect.md @@ -24,9 +24,9 @@ function BadExample() { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-computation-in-effect.ts:9:4 7 | const [fullName, setFullName] = useState(''); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md index 7773a2cc8d..daf74031d9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-computed.expect.md @@ -29,9 +29,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-computed.ts:9:4 7 | useEffect(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md index 99b596c4ce..e2d87c4afb 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.expect.md @@ -6,7 +6,9 @@ import {useEffect, useState} from 'react'; function Component({props}) { - const [fullName, setFullName] = useState(props.firstName + ' ' + props.lastName); + const [fullName, setFullName] = useState( + props.firstName + ' ' + props.lastName + ); useEffect(() => { setFullName(props.firstName + ' ' + props.lastName); @@ -28,18 +30,18 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [props, props]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. -error.invalid-derived-state-from-props-destructured.ts:8:4 - 6 | - 7 | useEffect(() => { -> 8 | setFullName(props.firstName + ' ' + props.lastName); +error.invalid-derived-state-from-props-destructured.ts:10:4 + 8 | + 9 | useEffect(() => { +> 10 | setFullName(props.firstName + ' ' + props.lastName); | ^^^^^^^^^^^ This should be computed during render, not in an effect - 9 | }, [props.firstName, props.lastName]); - 10 | - 11 | return
{fullName}
; + 11 | }, [props.firstName, props.lastName]); + 12 | + 13 | return
{fullName}
; ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js index 78f7c910ff..fc9d6bd257 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-destructured.js @@ -2,7 +2,9 @@ import {useEffect, useState} from 'react'; function Component({props}) { - const [fullName, setFullName] = useState(props.firstName + ' ' + props.lastName); + const [fullName, setFullName] = useState( + props.firstName + ' ' + props.lastName + ); useEffect(() => { setFullName(props.firstName + ' ' + props.lastName); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md index 88c722b8f6..9cb8a7427f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-in-effect.expect.md @@ -28,9 +28,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-props-in-effect.ts:8:4 6 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md index 3af0c00ecc..bfc2d7b624 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.expect.md @@ -4,18 +4,14 @@ ```javascript // @validateNoDerivedComputationsInEffects -export default function InProductLobbyGeminiCard( - input = 'empty', -) { +export default function InProductLobbyGeminiCard(input = 'empty') { const [currInput, setCurrInput] = useState(input); useEffect(() => { - setCurrInput(input) + setCurrInput(input); }, [input]); - return ( -
{currInput}
- ) + return
{currInput}
; } ``` @@ -26,18 +22,18 @@ export default function InProductLobbyGeminiCard( ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Props [input]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. -error.invalid-derived-state-from-props-with-default-value.ts:9:4 - 7 | - 8 | useEffect(() => { -> 9 | setCurrInput(input) +error.invalid-derived-state-from-props-with-default-value.ts:7:4 + 5 | + 6 | useEffect(() => { +> 7 | setCurrInput(input); | ^^^^^^^^^^^^ This should be computed during render, not in an effect - 10 | }, [input]); - 11 | - 12 | return ( + 8 | }, [input]); + 9 | + 10 | return
{currInput}
; ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js index a2ad3de584..70480f7d3d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-props-with-default-value.js @@ -1,15 +1,11 @@ // @validateNoDerivedComputationsInEffects -export default function InProductLobbyGeminiCard( - input = 'empty', -) { +export default function InProductLobbyGeminiCard(input = 'empty') { const [currInput, setCurrInput] = useState(input); useEffect(() => { - setCurrInput(input) + setCurrInput(input); }, [input]); - return ( -
{currInput}
- ) + return
{currInput}
; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md index 5a029cb0cc..e7f8cd4584 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.invalid-derived-state-from-state-in-effect.expect.md @@ -36,9 +36,9 @@ export const FIXTURE_ENTRYPOINT = { ``` Found 1 error: -Error: Derive values in render, not effects. +Error: You might not need an effect. Derive values in render, not effects. -This setState() appears to derive a value from local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. +Local state [firstName, lastName]. Derived values should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user. error.invalid-derived-state-from-state-in-effect.ts:10:4 8 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md new file mode 100644 index 0000000000..19aa902529 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.expect.md @@ -0,0 +1,63 @@ + +## Input + +```javascript +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = date => { + setLocalStartDate(date); + onStartDateChange(date); + }; + return ( + + ); +} + +``` + + +## Error + +``` +Found 1 error: + +Error: You might not need an effect. Local state shadows parent state. + +The setState within a useEffect is deriving from props [startDate]. Instead of shadowing the prop with local state, hoist the state to the parent component and update it there. If you are purposefully initializing state with a prop, and want to update it when a prop changes, do so conditionally in render + +error.shadowed-props-with-onchange.ts:7:4 + 5 | + 6 | useEffect(() => { +> 7 | setLocalStartDate(startDate); + | ^^^^^^^^^^^^^^^^^ this derives values from props to synchronize state + 8 | }, [startDate]); + 9 | + 10 | const onChange = date => { + +error.shadowed-props-with-onchange.ts:4:46 + 2 | + 3 | function EndDate({startDate, endDate, onStartDateChange}) { +> 4 | const [localStartDate, setLocalStartDate] = useState(startDate); + | ^^^^^^^^^^^^^^^^^^^ this useState shadows startDate + 5 | + 6 | useEffect(() => { + 7 | setLocalStartDate(startDate); + +error.shadowed-props-with-onchange.ts:11:4 + 9 | + 10 | const onChange = date => { +> 11 | setLocalStartDate(date); + | ^^^^^^^^^^^^^^^^^ this setState updates the shadowed state, but should call an onChange event from the parent + 12 | onStartDateChange(date); + 13 | }; + 14 | return ( +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js new file mode 100644 index 0000000000..af63377c4b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useEffect/error.shadowed-props-with-onchange.js @@ -0,0 +1,17 @@ +// @validateNoDerivedComputationsInEffects + +function EndDate({startDate, endDate, onStartDateChange}) { + const [localStartDate, setLocalStartDate] = useState(startDate); + + useEffect(() => { + setLocalStartDate(startDate); + }, [startDate]); + + const onChange = date => { + setLocalStartDate(date); + onStartDateChange(date); + }; + return ( + + ); +} diff --git a/compiler/yarn.lock b/compiler/yarn.lock index 696261cbf5..a2ae8a1acf 100644 --- a/compiler/yarn.lock +++ b/compiler/yarn.lock @@ -10494,16 +10494,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10576,14 +10567,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11360,7 +11344,7 @@ workerpool@^6.5.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -11378,15 +11362,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" From e507b44214049e4063335f297e131940ad94c1f6 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Mon, 8 Sep 2025 13:01:08 -0700 Subject: [PATCH 25/25] [compiler] Have react-compiler eslint plugin return a RuleModule --- compiler/packages/eslint-plugin-react-compiler/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compiler/packages/eslint-plugin-react-compiler/src/index.ts b/compiler/packages/eslint-plugin-react-compiler/src/index.ts index 9d49b16c57..dbe0c4a68a 100644 --- a/compiler/packages/eslint-plugin-react-compiler/src/index.ts +++ b/compiler/packages/eslint-plugin-react-compiler/src/index.ts @@ -20,7 +20,9 @@ const configs = { recommended: { plugins: { 'react-compiler': { - rules: allRules, + rules: Object.fromEntries( + Object.entries(allRules).map(([name, config]) => [name, config.rule]), + ), }, }, rules: Object.fromEntries(