Repository: facebook/react
File: compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInRender.ts (TypeScript)

Code before:
          if (
            isSetStateType(callee.identifier) ||
            unconditionalSetStateFunctions.has(callee.identifier.id)
          ) {
            if (activeManualMemoId !== null) {
              errors.pushDiagnostic(
                CompilerDiagnostic.create({
                  category: ErrorCategory.RenderSetState,
                  reason:
                    'Calling setState from useMemo may trigger an infinite loop',
                  description:
                    'Each time the memo callback is evaluated it will change state. This can cause a memoization dependency to change, running the memo function again and causing an infinite loop. Instead of setting state in useMemo(), prefer deriving the value during render. (https://react.dev/reference/react/useState)',
                  suggestions: null,
                }).withDetails({
                  kind: 'error',
                  loc: callee.loc,
                  message: 'Found setState() within useMemo()',
                }),
              );
            } else if (unconditionalBlocks.has(block.id)) {
              let isArgPrimitive = false;

              if (instr.value.args.length > 0) {
                const arg = instr.value.args[0];
                if (arg.kind === 'Identifier') {
                  isArgPrimitive = isPrimitiveSetArg(arg, fn);
                }
              }

              if (isArgPrimitive) {
                if (enableUseKeyedState) {
                  errors.pushDiagnostic(
                    CompilerDiagnostic.create({
                      category: ErrorCategory.RenderSetState,
                      reason:
                        'Calling setState during render may trigger an infinite loop',
                      description:
                        'Use useKeyedState instead of calling setState directly in render. Example: const [value, setValue] = useKeyedState(initialValue, key)',
                      suggestions: null,
                    }).withDetails({
                      kind: 'error',
                      loc: callee.loc,
                      message: 'Found setState() in render',
                    }),
                  );
                }
              } else {
                errors.pushDiagnostic(
                  CompilerDiagnostic.create({
                    category: ErrorCategory.RenderSetState,
                    reason:

Review comment (bug):
I expected that this would be something like: `if (!arg.reactive && HIR.isPrimitiveType(arg.identifier)) { /* allow */ }` - ie reusing type inference to determine if a value is a primitive. And likely combining with a `!arg.reactive` check because if the value being set is reactive, then it could still infinite loop even if its a primitive.

But see the larger comment about the overall direction and whether we want to allow passing primitives

Code after:
/**
 * 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 {
  CompilerDiagnostic,
  CompilerError,
  ErrorCategory,
} from '../CompilerError';
import {HIRFunction, IdentifierId, isSetStateType} from '../HIR';
import {computeUnconditionalBlocks} from '../HIR/ComputeUnconditionalBlocks';
import {eachInstructionValueOperand} from '../HIR/visitors';
import {Result} from '../Utils/Result';

/**
 * Validates that the given function does not have an infinite update loop
 * caused by unconditionally calling setState during render. This validation
 * is conservative and cannot catch all cases of unconditional setState in
 * render, but avoids false positives. Examples of cases that are caught:
 *
 * ```javascript
 * // Direct call of setState:
 * const [state, setState] = useState(false);
 * setState(true);
 *
 * // Indirect via a function:
 * const [state, setState] = useState(false);
 * const setTrue = () => setState(true);
 * setTrue();
 * ```
 *
 * However, storing setState inside another value and accessing it is not yet
 * validated:
 *
 * ```
 * // false negative, not detected but will cause an infinite render loop
 * const [state, setState] = useState(false);
 * const x = [setState];
 * const y = x.pop();
 * y();
 * ```
 */
export function validateNoSetStateInRender(
  fn: HIRFunction,
): Result<void, CompilerError> {
  const unconditionalSetStateFunctions: Set<IdentifierId> = new Set();
  return validateNoSetStateInRenderImpl(fn, unconditionalSetStateFunctions);
}

function validateNoSetStateInRenderImpl(
  fn: HIRFunction,
  unconditionalSetStateFunctions: Set<IdentifierId>,
): Result<void, CompilerError> {
  const unconditionalBlocks = computeUnconditionalBlocks(fn);
  let activeManualMemoId: number | null = null;
  const errors = new CompilerError();
  for (const [, block] of fn.body.blocks) {
    for (const instr of block.instructions) {
      switch (instr.value.kind) {
        case 'LoadLocal': {
          if (
            unconditionalSetStateFunctions.has(instr.value.place.identifier.id)
          ) {
            unconditionalSetStateFunctions.add(instr.lvalue.identifier.id);
          }
          break;
        }
        case 'StoreLocal': {
          if (
            unconditionalSetStateFunctions.has(instr.value.value.identifier.id)
          ) {
            unconditionalSetStateFunctions.add(
              instr.value.lvalue.place.identifier.id,
            );
            unconditionalSetStateFunctions.add(instr.lvalue.identifier.id);
          }
          break;
        }
        case 'ObjectMethod':
        case 'FunctionExpression': {
          if (
            // faster-path to check if the function expression references a setState
            [...eachInstructionValueOperand(instr.value)].some(
              operand =>
                isSetStateType(operand.identifier) ||
                unconditionalSetStateFunctions.has(operand.identifier.id),
            ) &&
            // if yes, does it unconditonally call it?
            validateNoSetStateInRenderImpl(
              instr.value.loweredFunc.func,
              unconditionalSetStateFunctions,
            ).isErr()
          ) {
            // This function expression unconditionally calls a setState
            unconditionalSetStateFunctions.add(instr.lvalue.identifier.id);
          }
          break;
        }
        case 'StartMemoize': {
          CompilerError.invariant(activeManualMemoId === null, {
            reason: 'Unexpected nested StartMemoize instructions',
            description: null,
            details: [
              {
                kind: 'error',
                loc: instr.value.loc,
                message: null,
              },
            ],
          });
          activeManualMemoId = instr.value.manualMemoId;
          break;
        }
        case 'FinishMemoize': {
          CompilerError.invariant(
            activeManualMemoId === instr.value.manualMemoId,
            {
              reason:
                'Expected FinishMemoize to align with previous StartMemoize instruction',
              description: null,
              details: [
                {
                  kind: 'error',
                  loc: instr.value.loc,
                  message: null,
                },
              ],
            },
          );
          activeManualMemoId = null;
          break;
        }
        case 'CallExpression': {
          const callee = instr.value.callee;
          if (
            isSetStateType(callee.identifier) ||
            unconditionalSetStateFunctions.has(callee.identifier.id)
          ) {
            if (activeManualMemoId !== null) {
              errors.pushDiagnostic(
                CompilerDiagnostic.create({
                  category: ErrorCategory.RenderSetState,
                  reason:
                    'Calling setState from useMemo may trigger an infinite loop',
                  description:
                    'Each time the memo callback is evaluated it will change state. This can cause a memoization dependency to change, running the memo function again and causing an infinite loop. Instead of setting state in useMemo(), prefer deriving the value during render. (https://react.dev/reference/react/useState)',
                  suggestions: null,
                }).withDetails({
                  kind: 'error',
                  loc: callee.loc,
                  message: 'Found setState() within useMemo()',
                }),
              );
            } else if (unconditionalBlocks.has(block.id)) {
              const enableUseKeyedState = fn.env.config.enableUseKeyedState;
              if (enableUseKeyedState) {
                errors.pushDiagnostic(
                  CompilerDiagnostic.create({
                    category: ErrorCategory.RenderSetState,
                    reason: 'Cannot call setState during render',
                    description:
                      'Calling setState during render may trigger an infinite loop.\n' +
                      '* To reset state when other state/props change, use `const [state, setState] = useKeyedState(initialState, key)` to reset `state` when `key` changes.\n' +
                      '* To derive data from other state/props, compute the derived data during render without using state',
                    suggestions: null,
                  }).withDetails({
                    kind: 'error',
                    loc: callee.loc,
                    message: 'Found setState() in render',
                  }),
                );
              } else {
                errors.pushDiagnostic(
                  CompilerDiagnostic.create({
                    category: ErrorCategory.RenderSetState,
                    reason: 'Cannot call setState during render',
                    description:
                      'Calling setState during render may trigger an infinite loop.\n' +
                      '* To reset state when other state/props change, store the previous value in state and update conditionally: https://react.dev/reference/react/useState#storing-information-from-previous-renders\n' +
                      '* To derive data from other state/props, compute the derived data during render without using state',
                    suggestions: null,
                  }).withDetails({
                    kind: 'error',
                    loc: callee.loc,
                    message: 'Found setState() in render',
                  }),
                );
              }
            }
          }
          break;
        }
      }
    }
  }

  return errors.asResult();
}