Repository: facebook/react
Pull request: [compiler] rebase #32285


Redo of #32285 which was created with ghstack and is tedious to rebase with sapling.


Changed files:
filename: compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
patch: @@ -106,6 +106,7 @@ import {validateStaticComponents} from '../Validation/ValidateStaticComponents';
 import {validateNoFreezingKnownMutableFunctions} from '../Validation/ValidateNoFreezingKnownMutableFunctions';
 import {inferMutationAliasingEffects} from '../Inference/InferMutationAliasingEffects';
 import {inferMutationAliasingRanges} from '../Inference/InferMutationAliasingRanges';
+import {validateNoDerivedComputationsInEffects} from '../Validation/ValidateNoDerivedComputationsInEffects';
 
 export type CompilerPipelineValue =
   | {kind: 'ast'; name: string; value: CodegenFunction}
@@ -292,6 +293,10 @@ function runWithEnvironment(
       validateNoSetStateInRender(hir).unwrap();
     }
 
+    if (env.config.validateNoDerivedComputationsInEffects) {
+      validateNoDerivedComputationsInEffects(hir);
+    }
+
     if (env.config.validateNoSetStateInEffects) {
       env.logErrors(validateNoSetStateInEffects(hir));
     }

filename: compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
patch: @@ -323,6 +323,12 @@ export const EnvironmentConfigSchema = z.object({
    */
   validateNoSetStateInEffects: z.boolean().default(false),
 
+  /**
+   * Validates that effects are not used to calculate derived data which could instead be computed
+   * during render.
+   */
+  validateNoDerivedComputationsInEffects: z.boolean().default(false),
+
   /**
    * Validates against creating JSX within a try block and recommends using an error boundary
    * instead.

filename: compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoDerivedComputationsInEffects.ts
patch: @@ -0,0 +1,230 @@
+/**
+ * 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, SourceLocation} from '..';
+import {
+  ArrayExpression,
+  BlockId,
+  FunctionExpression,
+  HIRFunction,
+  IdentifierId,
+  isSetStateType,
+  isUseEffectHookType,
+} from '../HIR';
+import {
+  eachInstructionValueOperand,
+  eachTerminalOperand,
+} from '../HIR/visitors';
+
+/**
+ * Validates that useEffect is not used for derived computations which could/should
+ * be performed in render.
+ *
+ * See https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state
+ *
+ * Example:
+ *
+ * ```
+ * // 🔴 Avoid: redundant state and unnecessary Effect
+ * const [fullName, setFullName] = useState('');
+ * useEffect(() => {
+ *   setFullName(firstName + ' ' + lastName);
+ * }, [firstName, lastName]);
+ * ```
+ *
+ * Instead use:
+ *
+ * ```
+ * // ✅ Good: calculated during rendering
+ * const fullName = firstName + ' ' + lastName;
+ * ```
+ */
+export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
+  const candidateDependencies: Map<IdentifierId, ArrayExpression> = new Map();
+  const functions: Map<IdentifierId, FunctionExpression> = new Map();
+  const locals: Map<IdentifierId, IdentifierId> = new Map();
+
+  const errors = new CompilerError();
+
+  for (const block of fn.body.blocks.values()) {
+    for (const instr of block.instructions) {
+      const {lvalue, value} = instr;
+      if (value.kind === 'LoadLocal') {
+        locals.set(lvalue.identifier.id, value.place.identifier.id);
+      } else if (value.kind === 'ArrayExpression') {
+        candidateDependencies.set(lvalue.identifier.id, value);
+      } else if (value.kind === 'FunctionExpression') {
+        functions.set(lvalue.identifier.id, value);
+      } else if (
+        value.kind === 'CallExpression' ||
+        value.kind === 'MethodCall'
+      ) {
+        const callee =
+          value.kind === 'CallExpression' ? value.callee : value.property;
+        if (
+          isUseEffectHookType(callee.identifier) &&
+          value.args.length === 2 &&
+          value.args[0].kind === 'Identifier' &&
+          value.args[1].kind === 'Identifier'
+        ) {
+          const effectFunction = functions.get(value.args[0].identifier.id);
+          const deps = candidateDependencies.get(value.args[1].identifier.id);
+          if (
+            effectFunction != null &&
+            deps != null &&
+            deps.elements.length !== 0 &&
+            deps.elements.every(element => element.kind === 'Identifier')
+          ) {
+            const dependencies: Array<IdentifierId> = deps.elements.map(dep => {
+              CompilerError.invariant(dep.kind === 'Identifier', {
+                reason: `Dependency is checked as a place above`,
+                loc: value.loc,
+              });
+              return locals.get(dep.identifier.id) ?? dep.identifier.id;
+            });
+            validateEffect(
+              effectFunction.loweredFunc.func,
+              dependencies,
+              errors,
+            );
+          }
+        }
+      }
+    }
+  }
+  if (errors.hasErrors()) {
+    throw errors;
+  }
+}
+
+function validateEffect(
+  effectFunction: HIRFunction,
+  effectDeps: Array<IdentifierId>,
+  errors: CompilerError,
+): void {
+  for (const operand of effectFunction.context) {
+    if (isSetStateType(operand.identifier)) {
+      continue;
+    } else if (effectDeps.find(dep => dep === operand.identifier.id) != null) {
+      continue;
+    } else {
+      // Captured something other than the effect dep or setState
+      return;
+    }
+  }
+  for (const dep of effectDeps) {
+    if (
+      effectFunction.context.find(operand => operand.identifier.id === dep) ==
+      null
+    ) {
+      // effect dep wasn't actually used in the function
+      return;
+    }
+  }
+
+  const seenBlocks: Set<BlockId> = new Set();
+  const values: Map<IdentifierId, Array<IdentifierId>> = new Map();
+  for (const dep of effectDeps) {
+    values.set(dep, [dep]);
+  }
+
+  const setStateLocations: Array<SourceLocation> = [];
+  for (const block of effectFunction.body.blocks.values()) {
+    for (const pred of block.preds) {
+      if (!seenBlocks.has(pred)) {
+        // skip if block has a back edge
+        return;
+      }
+    }
+    for (const phi of block.phis) {
+      const aggregateDeps: Set<IdentifierId> = new Set();
+      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);
+          }
+        }
+      }
+      if (aggregateDeps.size !== 0) {
+        values.set(phi.place.identifier.id, Array.from(aggregateDeps));
+      }
+    }
+    for (const instr of block.instructions) {
+      switch (instr.value.kind) {
+        case 'Primitive':
+        case 'JSXText':
+        case 'LoadGlobal': {
+          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':
+        case 'PropertyLoad':
+        case 'BinaryExpression':
+        case 'TemplateLiteral':
+        case 'CallExpression':
+        case 'MethodCall': {
+          const aggregateDeps: Set<IdentifierId> = new Set();
+          for (const operand of eachInstructionValueOperand(instr.value)) {
+            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 deps = values.get(instr.value.args[0].identifier.id);
+            if (deps != null && new Set(deps).size === effectDeps.length) {
+              setStateLocations.push(instr.value.callee.loc);
+            } else {
+              // doesn't depend on any deps
+              return;
+            }
+          }
+          break;
+        }
+        default: {
+          return;
+        }
+      }
+    }
+    for (const operand of eachTerminalOperand(block.terminal)) {
+      if (values.has(operand.identifier.id)) {
+        //
+        return;
+      }
+    }
+    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,
+    });
+  }
+}

filename: compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.expect.md
patch: @@ -0,0 +1,39 @@
+
+## Input
+
+```javascript
+// @validateNoDerivedComputationsInEffects
+function BadExample() {
+  const [firstName, setFirstName] = useState('Taylor');
+  const [lastName, setLastName] = useState('Swift');
+
+  // 🔴 Avoid: redundant state and unnecessary Effect
+  const [fullName, setFullName] = useState('');
+  useEffect(() => {
+    setFullName(capitalize(firstName + ' ' + lastName));
+  }, [firstName, lastName]);
+
+  return <div>{fullName}</div>;
+}
+
+```
+
+
+## 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-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)
+  10 |   }, [firstName, lastName]);
+  11 |
+  12 |   return <div>{fullName}</div>;
+```
+          
+      
\ No newline at end of file

filename: compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-derived-computation-in-effect.js
patch: @@ -0,0 +1,13 @@
+// @validateNoDerivedComputationsInEffects
+function BadExample() {
+  const [firstName, setFirstName] = useState('Taylor');
+  const [lastName, setLastName] = useState('Swift');
+
+  // 🔴 Avoid: redundant state and unnecessary Effect
+  const [fullName, setFullName] = useState('');
+  useEffect(() => {
+    setFullName(capitalize(firstName + ' ' + lastName));
+  }, [firstName, lastName]);
+
+  return <div>{fullName}</div>;
+}