compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts TYPESCRIPT 619 lines View on github.com → Search inside
1/**2 * Copyright (c) Meta Platforms, Inc. and affiliates.3 *4 * This source code is licensed under the MIT license found in the5 * LICENSE file in the root directory of this source tree.6 */78import {9  CompilerDiagnostic,10  CompilerError,11  ErrorCategory,12} from '../CompilerError';13import {14  DeclarationId,15  Effect,16  GeneratedSource,17  Identifier,18  IdentifierId,19  InstructionValue,20  ManualMemoDependency,21  PrunedReactiveScopeBlock,22  ReactiveFunction,23  ReactiveInstruction,24  ReactiveScopeBlock,25  ReactiveScopeDependency,26  ReactiveValue,27  ScopeId,28  SourceLocation,29} from '../HIR';30import {Environment} from '../HIR/Environment';31import {printIdentifier, printManualMemoDependency} from '../HIR/PrintHIR';32import {33  eachInstructionValueLValue,34  eachInstructionValueOperand,35} from '../HIR/visitors';36import {collectMaybeMemoDependencies} from '../Inference/DropManualMemoization';37import {38  ReactiveFunctionVisitor,39  visitReactiveFunction,40} from '../ReactiveScopes/visitors';41import {getOrInsertDefault} from '../Utils/utils';4243/**44 * Validates that all explicit manual memoization (useMemo/useCallback) was accurately45 * preserved, and that no originally memoized values became unmemoized in the output.46 *47 * This can occur if a value's mutable range somehow extended to include a hook and48 * was pruned.49 */50export function validatePreservedManualMemoization(fn: ReactiveFunction): void {51  const state = {52    env: fn.env,53    manualMemoState: null,54  };55  visitReactiveFunction(fn, new Visitor(), state);56}5758const DEBUG = false;5960type ManualMemoBlockState = {61  /**62   * Tracks reassigned temporaries.63   * This is necessary because useMemo calls are usually inlined.64   * Inlining produces a `let` declaration, followed by reassignments65   * to the newly declared variable (one per return statement).66   * Since InferReactiveScopes does not merge scopes across reassigned67   * variables (except in the case of a mutate-after-phi), we need to68   * track reassignments to validate we're retaining manual memo.69   */70  reassignments: Map<DeclarationId, Set<Identifier>>;71  // The source of the original memoization, used when reporting errors72  loc: SourceLocation;7374  /**75   * Values produced within manual memoization blocks.76   * We track these to ensure our inferred dependencies are77   * produced before the manual memo block starts78   *79   * As an example:80   * ```js81   * // source82   * const result = useMemo(() => {83   *   return [makeObject(input1), input2],84   * }, [input1, input2]);85   * ```86   * Here, we record inferred dependencies as [input1, input2]87   * but not t088   * ```js89   * // StartMemoize90   * let t0;91   * if ($[0] != input1) {92   *   t0 = makeObject(input1);93   *   // ...94   * } else { ... }95   *96   * let result;97   * if ($[1] != t0 || $[2] != input2) {98   *   result = [t0, input2];99   * } else { ... }100   * ```101   */102  decls: Set<DeclarationId>;103104  /*105   * normalized depslist from useMemo/useCallback106   * callsite in source107   */108  depsFromSource: Array<ManualMemoDependency> | null;109  manualMemoId: number;110};111112type VisitorState = {113  env: Environment;114  manualMemoState: ManualMemoBlockState | null;115};116117function prettyPrintScopeDependency(val: ReactiveScopeDependency): string {118  let rootStr;119  if (val.identifier.name?.kind === 'named') {120    rootStr = val.identifier.name.value;121  } else {122    rootStr = '[unnamed]';123  }124  return `${rootStr}${val.path.map(v => `${v.optional ? '?.' : '.'}${v.property}`).join('')}`;125}126127enum CompareDependencyResult {128  Ok = 0,129  RootDifference = 1,130  PathDifference = 2,131  Subpath = 3,132  RefAccessDifference = 4,133}134135function merge(136  a: CompareDependencyResult,137  b: CompareDependencyResult,138): CompareDependencyResult {139  return Math.max(a, b);140}141142function getCompareDependencyResultDescription(143  result: CompareDependencyResult,144): string {145  switch (result) {146    case CompareDependencyResult.Ok:147      return 'Dependencies equal';148    case CompareDependencyResult.RootDifference:149    case CompareDependencyResult.PathDifference:150      return 'Inferred different dependency than source';151    case CompareDependencyResult.RefAccessDifference:152      return 'Differences in ref.current access';153    case CompareDependencyResult.Subpath:154      return 'Inferred less specific property than source';155  }156}157158function compareDeps(159  inferred: ManualMemoDependency,160  source: ManualMemoDependency,161): CompareDependencyResult {162  const rootsEqual =163    (inferred.root.kind === 'Global' &&164      source.root.kind === 'Global' &&165      inferred.root.identifierName === source.root.identifierName) ||166    (inferred.root.kind === 'NamedLocal' &&167      source.root.kind === 'NamedLocal' &&168      inferred.root.value.identifier.id === source.root.value.identifier.id);169  if (!rootsEqual) {170    return CompareDependencyResult.RootDifference;171  }172173  let isSubpath = true;174  for (let i = 0; i < Math.min(inferred.path.length, source.path.length); i++) {175    if (inferred.path[i].property !== source.path[i].property) {176      isSubpath = false;177      break;178    } else if (inferred.path[i].optional !== source.path[i].optional) {179      /**180       * The inferred path must be at least as precise as the manual path:181       * if the inferred path is optional, then the source path must have182       * been optional too.183       */184      return CompareDependencyResult.PathDifference;185    }186  }187188  if (189    isSubpath &&190    (source.path.length === inferred.path.length ||191      (inferred.path.length >= source.path.length &&192        !inferred.path.some(token => token.property === 'current')))193  ) {194    return CompareDependencyResult.Ok;195  } else {196    if (isSubpath) {197      if (198        source.path.some(token => token.property === 'current') ||199        inferred.path.some(token => token.property === 'current')200      ) {201        return CompareDependencyResult.RefAccessDifference;202      } else {203        return CompareDependencyResult.Subpath;204      }205    } else {206      return CompareDependencyResult.PathDifference;207    }208  }209}210211/**212 * Validate that an inferred dependency either matches a source dependency213 * or is produced by earlier instructions in the same manual memoization214 * call.215 * Inferred dependency `rootA.[pathA]` matches a source dependency `rootB.[pathB]`216 * when:217 *   - rootA and rootB are loads from the same named identifier. Note that this218 *     identifier must be also named in source, as DropManualMemoization, which219 *     runs before any renaming passes, only records loads from named variables.220 *   - and one of the following holds:221 *       - pathA and pathB are identifical222 *       - pathB is a subpath of pathA and neither read into a `ref` type*223 *224 * We do not allow for partial matches on ref types because they are not immutable225 * values, e.g.226 * ref_prev === ref_new does not imply ref_prev.current === ref_new.current227 */228function validateInferredDep(229  dep: ReactiveScopeDependency,230  temporaries: Map<IdentifierId, ManualMemoDependency>,231  declsWithinMemoBlock: Set<DeclarationId>,232  validDepsInMemoBlock: Array<ManualMemoDependency>,233  env: Environment,234  memoLocation: SourceLocation,235): void {236  let normalizedDep: ManualMemoDependency;237  const maybeNormalizedRoot = temporaries.get(dep.identifier.id);238  if (maybeNormalizedRoot != null) {239    normalizedDep = {240      root: maybeNormalizedRoot.root,241      path: [...maybeNormalizedRoot.path, ...dep.path],242      loc: maybeNormalizedRoot.loc,243    };244  } else {245    CompilerError.invariant(dep.identifier.name?.kind === 'named', {246      reason:247        'ValidatePreservedManualMemoization: expected scope dependency to be named',248      loc: GeneratedSource,249    });250    normalizedDep = {251      root: {252        kind: 'NamedLocal',253        value: {254          kind: 'Identifier',255          identifier: dep.identifier,256          loc: GeneratedSource,257          effect: Effect.Read,258          reactive: false,259        },260        constant: false,261      },262      path: [...dep.path],263      loc: GeneratedSource,264    };265  }266  for (const decl of declsWithinMemoBlock) {267    if (268      normalizedDep.root.kind === 'NamedLocal' &&269      decl === normalizedDep.root.value.identifier.declarationId270    ) {271      return;272    }273  }274  let errorDiagnostic: CompareDependencyResult | null = null;275  for (const originalDep of validDepsInMemoBlock) {276    const compareResult = compareDeps(normalizedDep, originalDep);277    if (compareResult === CompareDependencyResult.Ok) {278      return;279    } else {280      errorDiagnostic = merge(errorDiagnostic ?? compareResult, compareResult);281    }282  }283  env.recordError(284    CompilerDiagnostic.create({285      category: ErrorCategory.PreserveManualMemo,286      reason: 'Existing memoization could not be preserved',287      description: [288        'React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. ',289        'The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. ',290        DEBUG ||291        // If the dependency is a named variable then we can report it. Otherwise only print in debug mode292        (dep.identifier.name != null && dep.identifier.name.kind === 'named')293          ? `The inferred dependency was \`${prettyPrintScopeDependency(294              dep,295            )}\`, but the source dependencies were [${validDepsInMemoBlock296              .map(dep => printManualMemoDependency(dep, true))297              .join(', ')}]. ${298              errorDiagnostic299                ? getCompareDependencyResultDescription(errorDiagnostic)300                : 'Inferred dependency not present in source'301            }`302          : '',303      ]304        .join('')305        .trim(),306      suggestions: null,307    }).withDetails({308      kind: 'error',309      loc: memoLocation,310      message: 'Could not preserve existing manual memoization',311    }),312  );313}314315class Visitor extends ReactiveFunctionVisitor<VisitorState> {316  /**317   * Records all completed scopes (regardless of transitive memoization318   * of scope dependencies)319   *320   * Both @scopes and @prunedScopes are live sets. We rely on iterating321   * the reactive-ir in evaluation order, as they are used to determine322   * whether scope dependencies / declarations have completed mutation.323   */324  scopes: Set<ScopeId> = new Set();325  prunedScopes: Set<ScopeId> = new Set();326  temporaries: Map<IdentifierId, ManualMemoDependency> = new Map();327328  /**329   * Recursively visit values and instructions to collect declarations330   * and property loads.331   * @returns a @{ManualMemoDependency} representing the variable +332   * property reads represented by @value333   */334  recordDepsInValue(value: ReactiveValue, state: VisitorState): void {335    switch (value.kind) {336      case 'SequenceExpression': {337        for (const instr of value.instructions) {338          this.visitInstruction(instr, state);339        }340        this.recordDepsInValue(value.value, state);341        break;342      }343      case 'OptionalExpression': {344        this.recordDepsInValue(value.value, state);345        break;346      }347      case 'ConditionalExpression': {348        this.recordDepsInValue(value.test, state);349        this.recordDepsInValue(value.consequent, state);350        this.recordDepsInValue(value.alternate, state);351        break;352      }353      case 'LogicalExpression': {354        this.recordDepsInValue(value.left, state);355        this.recordDepsInValue(value.right, state);356        break;357      }358      default: {359        collectMaybeMemoDependencies(value, this.temporaries, false);360        if (361          value.kind === 'StoreLocal' ||362          value.kind === 'StoreContext' ||363          value.kind === 'Destructure'364        ) {365          for (const storeTarget of eachInstructionValueLValue(value)) {366            state.manualMemoState?.decls.add(367              storeTarget.identifier.declarationId,368            );369            if (storeTarget.identifier.name?.kind === 'named') {370              this.temporaries.set(storeTarget.identifier.id, {371                root: {372                  kind: 'NamedLocal',373                  value: storeTarget,374                  constant: false,375                },376                path: [],377                loc: storeTarget.loc,378              });379            }380          }381        }382        break;383      }384    }385  }386387  recordTemporaries(instr: ReactiveInstruction, state: VisitorState): void {388    const temporaries = this.temporaries;389    const {lvalue, value} = instr;390    const lvalId = lvalue?.identifier.id;391    if (lvalId != null && temporaries.has(lvalId)) {392      return;393    }394    const isNamedLocal = lvalue?.identifier.name?.kind === 'named';395    if (lvalue !== null && isNamedLocal && state.manualMemoState != null) {396      state.manualMemoState.decls.add(lvalue.identifier.declarationId);397    }398399    this.recordDepsInValue(value, state);400    if (lvalue != null) {401      temporaries.set(lvalue.identifier.id, {402        root: {403          kind: 'NamedLocal',404          value: {...lvalue},405          constant: false,406        },407        path: [],408        loc: lvalue.loc,409      });410    }411  }412413  override visitScope(414    scopeBlock: ReactiveScopeBlock,415    state: VisitorState,416  ): void {417    this.traverseScope(scopeBlock, state);418419    if (420      state.manualMemoState != null &&421      state.manualMemoState.depsFromSource != null422    ) {423      for (const dep of scopeBlock.scope.dependencies) {424        validateInferredDep(425          dep,426          this.temporaries,427          state.manualMemoState.decls,428          state.manualMemoState.depsFromSource,429          state.env,430          state.manualMemoState.loc,431        );432      }433    }434435    this.scopes.add(scopeBlock.scope.id);436    for (const id of scopeBlock.scope.merged) {437      this.scopes.add(id);438    }439  }440441  override visitPrunedScope(442    scopeBlock: PrunedReactiveScopeBlock,443    state: VisitorState,444  ): void {445    this.traversePrunedScope(scopeBlock, state);446    this.prunedScopes.add(scopeBlock.scope.id);447  }448449  override visitInstruction(450    instruction: ReactiveInstruction,451    state: VisitorState,452  ): void {453    /**454     * We don't invoke traverseInstructions because `recordDepsInValue`455     * recursively visits ReactiveValues and instructions456     */457    this.recordTemporaries(instruction, state);458    const value = instruction.value;459    // Track reassignments from inlining of manual memo460    if (461      value.kind === 'StoreLocal' &&462      value.lvalue.kind === 'Reassign' &&463      state.manualMemoState != null464    ) {465      // Complex cases of inlining end up with a temporary that is reassigned466      const ids = getOrInsertDefault(467        state.manualMemoState.reassignments,468        value.lvalue.place.identifier.declarationId,469        new Set(),470      );471      ids.add(value.value.identifier);472    }473    if (474      value.kind === 'LoadLocal' &&475      value.place.identifier.scope != null &&476      instruction.lvalue != null &&477      instruction.lvalue.identifier.scope == null &&478      state.manualMemoState != null479    ) {480      // Simpler cases of inlining assign to the original IIFE lvalue481      const ids = getOrInsertDefault(482        state.manualMemoState.reassignments,483        instruction.lvalue.identifier.declarationId,484        new Set(),485      );486      ids.add(value.place.identifier);487    }488    if (value.kind === 'StartMemoize') {489      CompilerError.invariant(state.manualMemoState == null, {490        reason: 'Unexpected nested StartMemoize instructions',491        description: `Bad manual memoization ids: ${state.manualMemoState?.manualMemoId}, ${value.manualMemoId}`,492        loc: value.loc,493      });494495      if (value.hasInvalidDeps === true) {496        /*497         * ValidateExhaustiveDependencies already reported an error for this498         * memo block, skip validation to avoid duplicate errors499         */500        return;501      }502503      let depsFromSource: Array<ManualMemoDependency> | null = null;504      if (value.deps != null) {505        depsFromSource = value.deps;506      }507508      state.manualMemoState = {509        loc: instruction.loc,510        decls: new Set(),511        depsFromSource,512        manualMemoId: value.manualMemoId,513        reassignments: new Map(),514      };515516      /**517       * We check that each scope dependency is either:518       * (1) Not scoped519       *     Checking `identifier.scope == null` is a proxy for whether the dep520       *     is a primitive, global, or other guaranteed non-allocating value.521       *     Non-allocating values do not need memoization.522       *     Note that this is a conservative estimate as some primitive-typed523       *     variables do receive scopes.524       * (2) Scoped (a maybe newly-allocated value with a mutable range)525       *     Here, we check that the dependency's scope has completed before526       *     the manual useMemo as a proxy for mutable-range checking. This527       *     validates that there are no potential rule-of-react violations528       *     in source.529       *     Note that scope range is an overly conservative proxy as we merge530       *     overlapping ranges.531       *     See fixture `error.false-positive-useMemo-overlap-scopes`532       */533      for (const {identifier, loc} of eachInstructionValueOperand(534        value as InstructionValue,535      )) {536        if (537          identifier.scope != null &&538          !this.scopes.has(identifier.scope.id) &&539          !this.prunedScopes.has(identifier.scope.id)540        ) {541          state.env.recordError(542            CompilerDiagnostic.create({543              category: ErrorCategory.PreserveManualMemo,544              reason: 'Existing memoization could not be preserved',545              description: [546                'React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. ',547                'This dependency may be mutated later, which could cause the value to change unexpectedly',548              ].join(''),549            }).withDetails({550              kind: 'error',551              loc,552              message: 'This dependency may be modified later',553            }),554          );555        }556      }557    }558    if (value.kind === 'FinishMemoize') {559      if (state.manualMemoState == null) {560        // StartMemoize had invalid deps, skip validation561        return;562      }563      CompilerError.invariant(564        state.manualMemoState.manualMemoId === value.manualMemoId,565        {566          reason: 'Unexpected mismatch between StartMemoize and FinishMemoize',567          description: `Encountered StartMemoize id=${state.manualMemoState.manualMemoId} followed by FinishMemoize id=${value.manualMemoId}`,568          loc: value.loc,569        },570      );571      const reassignments = state.manualMemoState.reassignments;572      state.manualMemoState = null;573      if (!value.pruned) {574        for (const {identifier, loc} of eachInstructionValueOperand(575          value as InstructionValue,576        )) {577          let decls;578          if (identifier.scope == null) {579            /**580             * If the manual memo was a useMemo that got inlined, iterate through581             * all reassignments to the iife temporary to ensure they're memoized.582             */583            decls = reassignments.get(identifier.declarationId) ?? [identifier];584          } else {585            decls = [identifier];586          }587588          for (const identifier of decls) {589            if (isUnmemoized(identifier, this.scopes)) {590              state.env.recordError(591                CompilerDiagnostic.create({592                  category: ErrorCategory.PreserveManualMemo,593                  reason: 'Existing memoization could not be preserved',594                  description: [595                    'React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output',596                    DEBUG597                      ? `${printIdentifier(identifier)} was not memoized.`598                      : '',599                  ]600                    .join('')601                    .trim(),602                }).withDetails({603                  kind: 'error',604                  loc,605                  message: 'Could not preserve existing memoization',606                }),607              );608            }609          }610        }611      }612    }613  }614}615616function isUnmemoized(operand: Identifier, scopes: Set<ScopeId>): boolean {617  return operand.scope != null && !scopes.has(operand.scope.id);618}

Findings

✓ No findings reported for this file.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.