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.