diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts index 1127e91029328..1fc1e11fbc6fa 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -103,6 +103,7 @@ import {validateNoSetStateInPassiveEffects} from '../Validation/ValidateNoSetSta import {validateNoJSXInTryStatement} from '../Validation/ValidateNoJSXInTryStatement'; import {propagateScopeDependenciesHIR} from '../HIR/PropagateScopeDependenciesHIR'; import {outlineJSX} from '../Optimization/OutlineJsx'; +import {validateUseState} from '../Validation/ValidateUseState'; export type CompilerPipelineValue = | {kind: 'ast'; name: string; value: CodegenFunction} @@ -251,6 +252,10 @@ function* runWithEnvironment( validateNoJSXInTryStatement(hir); } + if (env.config.validateUseState) { + validateUseState(hir); + } + inferReactivePlaces(hir); yield log({kind: 'hir', name: 'InferReactivePlaces', value: hir}); 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 855bc039abf37..59b5abad45885 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -293,6 +293,15 @@ const EnvironmentConfigSchema = z.object({ validateNoCapitalizedCalls: z.nullable(z.array(z.string())).default(null), validateBlocklistedImports: z.nullable(z.array(z.string())).default(null), + /** + * Validates that useState() calls use both the state and setter. If either are unused, the program + * may fail to update properly. An unused state value is generally used to force an update, but + * with the compiler applied a force update should not have any impact. An unused setter will cause + * the state value to never update, which implies turning a reactive state input into a non-reactive + * output, which will trigger stale UIs. + */ + validateUseState: z.boolean().default(false), + /* * When enabled, the compiler assumes that hooks follow the Rules of React: * - Hooks may memoize computation based on any of their parameters, thus diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateUseState.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateUseState.ts new file mode 100644 index 0000000000000..e0af33b4fe3cf --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateUseState.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {CompilerError, ErrorSeverity} from '..'; +import {HIRFunction, isUseStateType} from '../HIR'; + +/** + * Validates that `useState()` is not used for common anti-patterns: + * + * - Discarding the setter (`const [value] = useState(...)`) is a way + * to force a reactive value not to update, which is not recommended. + * - Discarding the value (`const [,setter] = useState(...)`) is a way + * to force-update the component, which is generally only required if + * there is some shared mutable value that isn't properly subscribed. + * + * Note: this pass relies on DCE having run first to prune unused patterns + * from destructuring of useState results. + */ +export function validateUseState(fn: HIRFunction): void { + const error = new CompilerError(); + for (const [, block] of fn.body.blocks) { + for (const instr of block.instructions) { + const value = instr.value; + if ( + value.kind !== 'Destructure' || + !isUseStateType(value.value.identifier) || + value.lvalue.pattern.kind !== 'ArrayPattern' || + value.lvalue.pattern.items.some(item => item.kind === 'Spread') + ) { + continue; + } + const items = value.lvalue.pattern.items; + if (items.length === 0 || items[0].kind === 'Hole') { + // unused state value + error.push({ + reason: + 'Using only a state setter, but not its value, will cause a component to re-render without updating', + description: null, + severity: ErrorSeverity.InvalidReact, + loc: value.value.loc, + suggestions: null, + }); + } + if (items.length < 2 || items[1].kind === 'Hole') { + // unused setter + error.push({ + reason: + 'Using only a state value, but not its setter, will cause the component not to update when the state input changes', + description: null, + severity: ErrorSeverity.InvalidReact, + loc: value.value.loc, + suggestions: null, + }); + } + } + } + if (error.hasErrors()) { + throw error; + } +}