From 54429645c7210702a9be5352c8eb850acd6fe152 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Fri, 5 Dec 2025 15:21:13 -0800 Subject: [PATCH] [compiler] Add enableVerboseNoSetStateInEffect to suggest options to user/agent The current `validateNoSetStateInEffects` error has potential false positives because we cannot fully statically detect patterns where calling setState in an effect is actually valid. This flag `enableVerboseNoSetStateInEffect` adds a verbose error mode that presents multiple possible use-cases, allowing an agent to reason about which fix is appropriate before acting: 1. Non-local derived data - suggests restructuring state ownership 2. Derived event pattern - suggests requesting an event callback from parent 3. Force update / external sync - suggests using `useSyncExternalStore` This gives agents the context needed to make informed decisions rather than blindly applying a fix that may not be correct for the specific situation. --- .../src/CompilerError.ts | 4 +- .../src/HIR/Environment.ts | 10 ++ .../Validation/ValidateNoSetStateInEffects.ts | 72 ++++++++++---- ...-in-effect-verbose-derived-event.expect.md | 74 ++++++++++++++ ...t-state-in-effect-verbose-derived-event.js | 18 ++++ ...e-in-effect-verbose-force-update.expect.md | 97 +++++++++++++++++++ ...et-state-in-effect-verbose-force-update.js | 28 ++++++ ...effect-verbose-non-local-derived.expect.md | 68 +++++++++++++ ...ate-in-effect-verbose-non-local-derived.js | 15 +++ 9 files changed, 365 insertions(+), 21 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-derived-event.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-derived-event.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-force-update.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-force-update.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-non-local-derived.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-non-local-derived.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts b/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts index c7c8d6a161e..7bc0d6a551a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts @@ -854,7 +854,9 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule { severity: ErrorSeverity.Error, name: 'set-state-in-effect', description: - 'Validates against calling setState synchronously in an effect, which can lead to re-renders that degrade performance', + 'Validates against calling setState synchronously in an effect. ' + + 'This can indicate non-local derived data, a derived event pattern, or ' + + 'improper external data synchronization.', preset: LintRulePreset.Recommended, }; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts index 17dd53adf56..750e650dec1 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -695,6 +695,16 @@ export const EnvironmentConfigSchema = z.object({ */ enableAllowSetStateFromRefsInEffects: z.boolean().default(true), + /** + * When enabled, provides verbose error messages for setState calls within effects, + * presenting multiple possible fixes to the user/agent since we cannot statically + * determine which specific use-case applies: + * 1. Non-local derived data - requires restructuring state ownership + * 2. Derived event pattern - detecting when a prop changes + * 3. Force update / external sync - should use useSyncExternalStore + */ + enableVerboseNoSetStateInEffect: z.boolean().default(false), + /** * Enables inference of event handler types for JSX props on built-in DOM elements. * When enabled, functions passed to event handler props (props starting with "on") diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInEffects.ts index 86070e2973e..91de8f20671 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInEffects.ts @@ -121,26 +121,58 @@ export function validateNoSetStateInEffects( if (arg !== undefined && arg.kind === 'Identifier') { const setState = setStateFunctions.get(arg.identifier.id); if (setState !== undefined) { - errors.pushDiagnostic( - CompilerDiagnostic.create({ - category: ErrorCategory.EffectSetState, - reason: - 'Calling setState synchronously within an effect can trigger cascading renders', - description: - 'Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. ' + - 'In general, the body of an effect should do one or both of the following:\n' + - '* Update external systems with the latest state from React.\n' + - '* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\n' + - 'Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. ' + - '(https://react.dev/learn/you-might-not-need-an-effect)', - suggestions: null, - }).withDetails({ - kind: 'error', - loc: setState.loc, - message: - 'Avoid calling setState() directly within an effect', - }), - ); + const enableVerbose = + env.config.enableVerboseNoSetStateInEffect; + if (enableVerbose) { + errors.pushDiagnostic( + CompilerDiagnostic.create({ + category: ErrorCategory.EffectSetState, + reason: + 'Calling setState synchronously within an effect can trigger cascading renders', + description: + 'Effects are intended to synchronize state between React and external systems. ' + + 'Calling setState synchronously causes cascading renders that hurt performance.\n\n' + + 'This pattern may indicate one of several issues:\n\n' + + '**1. Non-local derived data**: If the value being set could be computed from props/state ' + + 'but requires data from a parent component, consider restructuring state ownership so the ' + + 'derivation can happen during render in the component that owns the relevant state.\n\n' + + "**2. Derived event pattern**: If you're detecting when a prop changes (e.g., `isPlaying` " + + 'transitioning from false to true), this often indicates the parent should provide an event ' + + 'callback (like `onPlay`) instead of just the current state. Request access to the original event.\n\n' + + "**3. Force update / external sync**: If you're forcing a re-render to sync with an external " + + 'data source (mutable values outside React), use `useSyncExternalStore` to properly subscribe ' + + 'to external state changes.\n\n' + + 'See: https://react.dev/learn/you-might-not-need-an-effect', + suggestions: null, + }).withDetails({ + kind: 'error', + loc: setState.loc, + message: + 'Avoid calling setState() directly within an effect', + }), + ); + } else { + errors.pushDiagnostic( + CompilerDiagnostic.create({ + category: ErrorCategory.EffectSetState, + reason: + 'Calling setState synchronously within an effect can trigger cascading renders', + description: + 'Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. ' + + 'In general, the body of an effect should do one or both of the following:\n' + + '* Update external systems with the latest state from React.\n' + + '* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\n' + + 'Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. ' + + '(https://react.dev/learn/you-might-not-need-an-effect)', + suggestions: null, + }).withDetails({ + kind: 'error', + loc: setState.loc, + message: + 'Avoid calling setState() directly within an effect', + }), + ); + } } } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-derived-event.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-derived-event.expect.md new file mode 100644 index 00000000000..60479b8c230 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-derived-event.expect.md @@ -0,0 +1,74 @@ + +## Input + +```javascript +// @validateNoSetStateInEffects @enableVerboseNoSetStateInEffect +import {useState, useEffect} from 'react'; + +function VideoPlayer({isPlaying}) { + const [wasPlaying, setWasPlaying] = useState(isPlaying); + useEffect(() => { + if (isPlaying !== wasPlaying) { + setWasPlaying(isPlaying); + console.log('Play state changed!'); + } + }, [isPlaying, wasPlaying]); + return