main
ts 618 lines 20 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 import {
9 CompilerDiagnostic,
10 CompilerError,
11 ErrorCategory,
12 } from '../CompilerError';
13 import {
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';
30 import {Environment} from '../HIR/Environment';
31 import {printIdentifier, printManualMemoDependency} from '../HIR/PrintHIR';
32 import {
33 eachInstructionValueLValue,
34 eachInstructionValueOperand,
35 } from '../HIR/visitors';
36 import {collectMaybeMemoDependencies} from '../Inference/DropManualMemoization';
37 import {
38 ReactiveFunctionVisitor,
39 visitReactiveFunction,
40 } from '../ReactiveScopes/visitors';
41 import {getOrInsertDefault} from '../Utils/utils';
42
43 /**
44 * Validates that all explicit manual memoization (useMemo/useCallback) was accurately
45 * 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 and
48 * was pruned.
49 */
50 export function validatePreservedManualMemoization(fn: ReactiveFunction): void {
51 const state = {
52 env: fn.env,
53 manualMemoState: null,
54 };
55 visitReactiveFunction(fn, new Visitor(), state);
56 }
57
58 const DEBUG = false;
59
60 type ManualMemoBlockState = {
61 /**
62 * Tracks reassigned temporaries.
63 * This is necessary because useMemo calls are usually inlined.
64 * Inlining produces a `let` declaration, followed by reassignments
65 * to the newly declared variable (one per return statement).
66 * Since InferReactiveScopes does not merge scopes across reassigned
67 * variables (except in the case of a mutate-after-phi), we need to
68 * 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 errors
72 loc: SourceLocation;
73
74 /**
75 * Values produced within manual memoization blocks.
76 * We track these to ensure our inferred dependencies are
77 * produced before the manual memo block starts
78 *
79 * As an example:
80 * ```js
81 * // source
82 * const result = useMemo(() => {
83 * return [makeObject(input1), input2],
84 * }, [input1, input2]);
85 * ```
86 * Here, we record inferred dependencies as [input1, input2]
87 * but not t0
88 * ```js
89 * // StartMemoize
90 * 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>;
103
104 /*
105 * normalized depslist from useMemo/useCallback
106 * callsite in source
107 */
108 depsFromSource: Array<ManualMemoDependency> | null;
109 manualMemoId: number;
110 };
111
112 type VisitorState = {
113 env: Environment;
114 manualMemoState: ManualMemoBlockState | null;
115 };
116
117 function 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 }
126
127 enum CompareDependencyResult {
128 Ok = 0,
129 RootDifference = 1,
130 PathDifference = 2,
131 Subpath = 3,
132 RefAccessDifference = 4,
133 }
134
135 function merge(
136 a: CompareDependencyResult,
137 b: CompareDependencyResult,
138 ): CompareDependencyResult {
139 return Math.max(a, b);
140 }
141
142 function 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 }
157
158 function 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 }
172
173 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 have
182 * been optional too.
183 */
184 return CompareDependencyResult.PathDifference;
185 }
186 }
187
188 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 }
210
211 /**
212 * Validate that an inferred dependency either matches a source dependency
213 * or is produced by earlier instructions in the same manual memoization
214 * 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 this
218 * identifier must be also named in source, as DropManualMemoization, which
219 * runs before any renaming passes, only records loads from named variables.
220 * - and one of the following holds:
221 * - pathA and pathB are identifical
222 * - 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 immutable
225 * values, e.g.
226 * ref_prev === ref_new does not imply ref_prev.current === ref_new.current
227 */
228 function 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.declarationId
270 ) {
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 mode
292 (dep.identifier.name != null && dep.identifier.name.kind === 'named')
293 ? `The inferred dependency was \`${prettyPrintScopeDependency(
294 dep,
295 )}\`, but the source dependencies were [${validDepsInMemoBlock
296 .map(dep => printManualMemoDependency(dep, true))
297 .join(', ')}]. ${
298 errorDiagnostic
299 ? 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 }
314
315 class Visitor extends ReactiveFunctionVisitor<VisitorState> {
316 /**
317 * Records all completed scopes (regardless of transitive memoization
318 * of scope dependencies)
319 *
320 * Both @scopes and @prunedScopes are live sets. We rely on iterating
321 * the reactive-ir in evaluation order, as they are used to determine
322 * 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();
327
328 /**
329 * Recursively visit values and instructions to collect declarations
330 * and property loads.
331 * @returns a @{ManualMemoDependency} representing the variable +
332 * property reads represented by @value
333 */
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 }
386
387 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 }
398
399 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 }
412
413 override visitScope(
414 scopeBlock: ReactiveScopeBlock,
415 state: VisitorState,
416 ): void {
417 this.traverseScope(scopeBlock, state);
418
419 if (
420 state.manualMemoState != null &&
421 state.manualMemoState.depsFromSource != null
422 ) {
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 }
434
435 this.scopes.add(scopeBlock.scope.id);
436 for (const id of scopeBlock.scope.merged) {
437 this.scopes.add(id);
438 }
439 }
440
441 override visitPrunedScope(
442 scopeBlock: PrunedReactiveScopeBlock,
443 state: VisitorState,
444 ): void {
445 this.traversePrunedScope(scopeBlock, state);
446 this.prunedScopes.add(scopeBlock.scope.id);
447 }
448
449 override visitInstruction(
450 instruction: ReactiveInstruction,
451 state: VisitorState,
452 ): void {
453 /**
454 * We don't invoke traverseInstructions because `recordDepsInValue`
455 * recursively visits ReactiveValues and instructions
456 */
457 this.recordTemporaries(instruction, state);
458 const value = instruction.value;
459 // Track reassignments from inlining of manual memo
460 if (
461 value.kind === 'StoreLocal' &&
462 value.lvalue.kind === 'Reassign' &&
463 state.manualMemoState != null
464 ) {
465 // Complex cases of inlining end up with a temporary that is reassigned
466 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 != null
479 ) {
480 // Simpler cases of inlining assign to the original IIFE lvalue
481 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 });
494
495 if (value.hasInvalidDeps === true) {
496 /*
497 * ValidateExhaustiveDependencies already reported an error for this
498 * memo block, skip validation to avoid duplicate errors
499 */
500 return;
501 }
502
503 let depsFromSource: Array<ManualMemoDependency> | null = null;
504 if (value.deps != null) {
505 depsFromSource = value.deps;
506 }
507
508 state.manualMemoState = {
509 loc: instruction.loc,
510 decls: new Set(),
511 depsFromSource,
512 manualMemoId: value.manualMemoId,
513 reassignments: new Map(),
514 };
515
516 /**
517 * We check that each scope dependency is either:
518 * (1) Not scoped
519 * Checking `identifier.scope == null` is a proxy for whether the dep
520 * 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-typed
523 * 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 before
526 * the manual useMemo as a proxy for mutable-range checking. This
527 * validates that there are no potential rule-of-react violations
528 * in source.
529 * Note that scope range is an overly conservative proxy as we merge
530 * 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 validation
561 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 through
581 * 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 }
587
588 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 DEBUG
597 ? `${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 }
615
616 function isUnmemoized(operand: Identifier, scopes: Set<ScopeId>): boolean {
617 return operand.scope != null && !scopes.has(operand.scope.id);
618 }