main
ts 1,119 lines 35.9 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 prettyFormat from 'pretty-format';
9 import {
10 CompilerDiagnostic,
11 CompilerError,
12 CompilerSuggestionOperation,
13 Effect,
14 SourceLocation,
15 } from '..';
16 import {CompilerSuggestion, ErrorCategory} from '../CompilerError';
17 import {
18 areEqualPaths,
19 BlockId,
20 DependencyPath,
21 FinishMemoize,
22 GeneratedSource,
23 HIRFunction,
24 Identifier,
25 IdentifierId,
26 InstructionKind,
27 isEffectEventFunctionType,
28 isPrimitiveType,
29 isStableType,
30 isSubPath,
31 isSubPathIgnoringOptionals,
32 isUseEffectHookType,
33 isUseInsertionEffectHookType,
34 isUseLayoutEffectHookType,
35 isUseRefType,
36 LoadGlobal,
37 ManualMemoDependency,
38 Place,
39 StartMemoize,
40 } from '../HIR';
41 import {
42 eachInstructionLValue,
43 eachInstructionValueLValue,
44 eachInstructionValueOperand,
45 eachTerminalOperand,
46 } from '../HIR/visitors';
47 import {retainWhere} from '../Utils/utils';
48
49 const DEBUG = false;
50
51 /**
52 * Validates that existing manual memoization is exhaustive and does not
53 * have extraneous dependencies. The goal of the validation is to ensure
54 * that auto-memoization will not substantially change the behavior of
55 * the program:
56 * - If the manual dependencies were non-exhaustive (missing important deps)
57 * then auto-memoization will include those dependencies, and cause the
58 * value to update *more* frequently.
59 * - If the manual dependencies had extraneous deps, then auto memoization
60 * will remove them and cause the value to update *less* frequently.
61 *
62 * The implementation compares the manual dependencies against the values
63 * actually used within the memoization function
64 * - For each value V referenced in the memo function, either:
65 * - If the value is non-reactive *and* a known stable type, then the
66 * value may optionally be specified as an exact dependency.
67 * - Otherwise, report an error unless there is a manual dependency that will
68 * invalidate whenever V invalidates. If `x.y.z` is referenced, there must
69 * be a manual dependency for `x.y.z`, `x.y`, or `x`. Note that we assume
70 * no interior mutability, ie we assume that any changes to inner paths must
71 * always cause the other path to change as well.
72 * - Any dependencies that do not correspond to a value referenced in the memo
73 * function are considered extraneous and throw an error
74 *
75 * ## TODO: Invalid, Complex Deps
76 *
77 * Handle cases where the user deps were not simple identifiers + property chains.
78 * We try to detect this in ValidateUseMemo but we miss some cases. The problem
79 * is that invalid forms can be value blocks or function calls that don't get
80 * removed by DCE, leaving a structure like:
81 *
82 * StartMemoize
83 * t0 = <value to memoize>
84 * ...non-DCE'd code for manual deps...
85 * FinishMemoize decl=t0
86 *
87 * When we go to compute the dependencies, we then think that the user's manual dep
88 * logic is part of what the memo computation logic.
89 */
90 export function validateExhaustiveDependencies(fn: HIRFunction): void {
91 const env = fn.env;
92 const reactive = collectReactiveIdentifiersHIR(fn);
93
94 const temporaries: Map<IdentifierId, Temporary> = new Map();
95 for (const param of fn.params) {
96 const place = param.kind === 'Identifier' ? param : param.place;
97 temporaries.set(place.identifier.id, {
98 kind: 'Local',
99 identifier: place.identifier,
100 path: [],
101 context: false,
102 loc: place.loc,
103 });
104 }
105 let startMemo: StartMemoize | null = null;
106
107 function onStartMemoize(
108 value: StartMemoize,
109 dependencies: Set<InferredDependency>,
110 locals: Set<IdentifierId>,
111 ): void {
112 CompilerError.invariant(startMemo == null, {
113 reason: 'Unexpected nested memo calls',
114 loc: value.loc,
115 });
116 startMemo = value;
117 dependencies.clear();
118 locals.clear();
119 }
120 function onFinishMemoize(
121 value: FinishMemoize,
122 dependencies: Set<InferredDependency>,
123 locals: Set<IdentifierId>,
124 ): void {
125 CompilerError.invariant(
126 startMemo != null && startMemo.manualMemoId === value.manualMemoId,
127 {
128 reason: 'Found FinishMemoize without corresponding StartMemoize',
129 loc: value.loc,
130 },
131 );
132 if (env.config.validateExhaustiveMemoizationDependencies) {
133 visitCandidateDependency(value.decl, temporaries, dependencies, locals);
134 const inferred: Array<InferredDependency> = Array.from(dependencies);
135
136 const diagnostic = validateDependencies(
137 inferred,
138 startMemo.deps ?? [],
139 reactive,
140 startMemo.depsLoc,
141 ErrorCategory.MemoDependencies,
142 'all',
143 );
144 if (diagnostic != null) {
145 fn.env.recordError(diagnostic);
146 startMemo.hasInvalidDeps = true;
147 }
148 }
149
150 dependencies.clear();
151 locals.clear();
152 startMemo = null;
153 }
154
155 collectDependencies(
156 fn,
157 temporaries,
158 {
159 onStartMemoize,
160 onFinishMemoize,
161 onEffect: (inferred, manual, manualMemoLoc) => {
162 if (env.config.validateExhaustiveEffectDependencies === 'off') {
163 return;
164 }
165 if (DEBUG) {
166 console.log(Array.from(inferred, printInferredDependency));
167 console.log(Array.from(manual, printInferredDependency));
168 }
169 const manualDeps: Array<ManualMemoDependency> = [];
170 for (const dep of manual) {
171 if (dep.kind === 'Local') {
172 manualDeps.push({
173 root: {
174 kind: 'NamedLocal',
175 constant: false,
176 value: {
177 effect: Effect.Read,
178 identifier: dep.identifier,
179 kind: 'Identifier',
180 loc: dep.loc,
181 reactive: reactive.has(dep.identifier.id),
182 },
183 },
184 path: dep.path,
185 loc: dep.loc,
186 });
187 } else {
188 manualDeps.push({
189 root: {
190 kind: 'Global',
191 identifierName: dep.binding.name,
192 },
193 path: [],
194 loc: GeneratedSource,
195 });
196 }
197 }
198 const effectReportMode =
199 typeof env.config.validateExhaustiveEffectDependencies === 'string'
200 ? env.config.validateExhaustiveEffectDependencies
201 : 'all';
202 const diagnostic = validateDependencies(
203 Array.from(inferred),
204 manualDeps,
205 reactive,
206 manualMemoLoc,
207 ErrorCategory.EffectExhaustiveDependencies,
208 effectReportMode,
209 );
210 if (diagnostic != null) {
211 fn.env.recordError(diagnostic);
212 }
213 },
214 },
215 false, // isFunctionExpression
216 );
217 }
218
219 function validateDependencies(
220 inferred: Array<InferredDependency>,
221 manualDependencies: Array<ManualMemoDependency>,
222 reactive: Set<IdentifierId>,
223 manualMemoLoc: SourceLocation | null,
224 category:
225 | ErrorCategory.MemoDependencies
226 | ErrorCategory.EffectExhaustiveDependencies,
227 exhaustiveDepsReportMode: 'all' | 'missing-only' | 'extra-only',
228 ): CompilerDiagnostic | null {
229 // Sort dependencies by name and path, with shorter/non-optional paths first
230 inferred.sort((a, b) => {
231 if (a.kind === 'Global' && b.kind == 'Global') {
232 return a.binding.name.localeCompare(b.binding.name);
233 } else if (a.kind == 'Local' && b.kind == 'Local') {
234 CompilerError.invariant(
235 a.identifier.name != null &&
236 a.identifier.name.kind === 'named' &&
237 b.identifier.name != null &&
238 b.identifier.name.kind === 'named',
239 {
240 reason: 'Expected dependencies to be named variables',
241 loc: a.loc,
242 },
243 );
244 if (a.identifier.id !== b.identifier.id) {
245 return a.identifier.name.value.localeCompare(b.identifier.name.value);
246 }
247 if (a.path.length !== b.path.length) {
248 // if a's path is shorter this returns a negative, sorting a first
249 return a.path.length - b.path.length;
250 }
251 for (let i = 0; i < a.path.length; i++) {
252 const aProperty = a.path[i];
253 const bProperty = b.path[i];
254 const aOptional = aProperty.optional ? 0 : 1;
255 const bOptional = bProperty.optional ? 0 : 1;
256 if (aOptional !== bOptional) {
257 // sort non-optionals first
258 return aOptional - bOptional;
259 } else if (aProperty.property !== bProperty.property) {
260 return String(aProperty.property).localeCompare(
261 String(bProperty.property),
262 );
263 }
264 }
265 return 0;
266 } else {
267 const aName =
268 a.kind === 'Global' ? a.binding.name : a.identifier.name?.value;
269 const bName =
270 b.kind === 'Global' ? b.binding.name : b.identifier.name?.value;
271 if (aName != null && bName != null) {
272 return aName.localeCompare(bName);
273 }
274 return 0;
275 }
276 });
277 // remove redundant inferred dependencies
278 retainWhere(inferred, (dep, ix) => {
279 const match = inferred.findIndex(prevDep => {
280 return (
281 isEqualTemporary(prevDep, dep) ||
282 (prevDep.kind === 'Local' &&
283 dep.kind === 'Local' &&
284 prevDep.identifier.id === dep.identifier.id &&
285 isSubPath(prevDep.path, dep.path))
286 );
287 });
288 // only retain entries that don't have a prior match
289 return match === -1 || match >= ix;
290 });
291 // Validate that all manual dependencies belong there
292 if (DEBUG) {
293 console.log('manual');
294 console.log(
295 manualDependencies
296 .map(x => ' ' + printManualMemoDependency(x))
297 .join('\n'),
298 );
299 console.log('inferred');
300 console.log(
301 inferred.map(x => ' ' + printInferredDependency(x)).join('\n'),
302 );
303 }
304 const matched: Set<ManualMemoDependency> = new Set();
305 const missing: Array<Extract<InferredDependency, {kind: 'Local'}>> = [];
306 const extra: Array<ManualMemoDependency> = [];
307 for (const inferredDependency of inferred) {
308 if (inferredDependency.kind === 'Global') {
309 for (const manualDependency of manualDependencies) {
310 if (
311 manualDependency.root.kind === 'Global' &&
312 manualDependency.root.identifierName ===
313 inferredDependency.binding.name
314 ) {
315 matched.add(manualDependency);
316 extra.push(manualDependency);
317 }
318 }
319 continue;
320 }
321 CompilerError.invariant(inferredDependency.kind === 'Local', {
322 reason: 'Unexpected function dependency',
323 loc: inferredDependency.loc,
324 });
325 /**
326 * Skip effect event functions as they are not valid dependencies
327 */
328 if (isEffectEventFunctionType(inferredDependency.identifier)) {
329 continue;
330 }
331 let hasMatchingManualDependency = false;
332 for (const manualDependency of manualDependencies) {
333 if (
334 manualDependency.root.kind === 'NamedLocal' &&
335 manualDependency.root.value.identifier.id ===
336 inferredDependency.identifier.id &&
337 (areEqualPaths(manualDependency.path, inferredDependency.path) ||
338 isSubPathIgnoringOptionals(
339 manualDependency.path,
340 inferredDependency.path,
341 ))
342 ) {
343 hasMatchingManualDependency = true;
344 matched.add(manualDependency);
345 }
346 }
347 if (
348 hasMatchingManualDependency ||
349 isOptionalDependency(inferredDependency, reactive)
350 ) {
351 continue;
352 }
353
354 missing.push(inferredDependency);
355 }
356
357 for (const dep of manualDependencies) {
358 if (matched.has(dep)) {
359 continue;
360 }
361 if (dep.root.kind === 'NamedLocal' && dep.root.constant) {
362 CompilerError.invariant(
363 !dep.root.value.reactive && isPrimitiveType(dep.root.value.identifier),
364 {
365 reason: 'Expected constant-folded dependency to be non-reactive',
366 loc: dep.root.value.loc,
367 },
368 );
369 /*
370 * Constant primitives can get constant-folded, which means we won't
371 * see a LoadLocal for the value within the memo function.
372 */
373 continue;
374 }
375 extra.push(dep);
376 }
377
378 // Filter based on report mode
379 const filteredMissing =
380 exhaustiveDepsReportMode === 'extra-only' ? [] : missing;
381 const filteredExtra =
382 exhaustiveDepsReportMode === 'missing-only' ? [] : extra;
383
384 if (filteredMissing.length !== 0 || filteredExtra.length !== 0) {
385 let suggestion: CompilerSuggestion | null = null;
386 if (
387 manualMemoLoc != null &&
388 typeof manualMemoLoc !== 'symbol' &&
389 manualMemoLoc.start.index != null &&
390 manualMemoLoc.end.index != null
391 ) {
392 suggestion = {
393 description: 'Update dependencies',
394 range: [manualMemoLoc.start.index, manualMemoLoc.end.index],
395 op: CompilerSuggestionOperation.Replace,
396 text: `[${inferred
397 .filter(
398 dep =>
399 dep.kind === 'Local' &&
400 !isOptionalDependency(dep, reactive) &&
401 !isEffectEventFunctionType(dep.identifier),
402 )
403 .map(printInferredDependency)
404 .join(', ')}]`,
405 };
406 }
407 const diagnostic = createDiagnostic(
408 category,
409 filteredMissing,
410 filteredExtra,
411 suggestion,
412 );
413 for (const dep of filteredMissing) {
414 let reactiveStableValueHint = '';
415 if (isStableType(dep.identifier)) {
416 reactiveStableValueHint =
417 '. Refs, setState functions, and other "stable" values generally do not need to be added ' +
418 'as dependencies, but this variable may change over time to point to different values';
419 }
420 diagnostic.withDetails({
421 kind: 'error',
422 message: `Missing dependency \`${printInferredDependency(dep)}\`${reactiveStableValueHint}`,
423 loc: dep.loc,
424 });
425 }
426 for (const dep of filteredExtra) {
427 if (dep.root.kind === 'Global') {
428 diagnostic.withDetails({
429 kind: 'error',
430 message:
431 `Unnecessary dependency \`${printManualMemoDependency(dep)}\`. ` +
432 'Values declared outside of a component/hook should not be listed as ' +
433 'dependencies as the component will not re-render if they change',
434 loc: dep.loc ?? manualMemoLoc,
435 });
436 } else {
437 const root = dep.root.value;
438 const matchingInferred = inferred.find(
439 (
440 inferredDep,
441 ): inferredDep is Extract<InferredDependency, {kind: 'Local'}> => {
442 return (
443 inferredDep.kind === 'Local' &&
444 inferredDep.identifier.id === root.identifier.id &&
445 isSubPathIgnoringOptionals(inferredDep.path, dep.path)
446 );
447 },
448 );
449 if (
450 matchingInferred != null &&
451 isEffectEventFunctionType(matchingInferred.identifier)
452 ) {
453 diagnostic.withDetails({
454 kind: 'error',
455 message:
456 `Functions returned from \`useEffectEvent\` must not be included in the dependency array. ` +
457 `Remove \`${printManualMemoDependency(dep)}\` from the dependencies.`,
458 loc: dep.loc ?? manualMemoLoc,
459 });
460 } else if (
461 matchingInferred != null &&
462 !isOptionalDependency(matchingInferred, reactive)
463 ) {
464 diagnostic.withDetails({
465 kind: 'error',
466 message:
467 `Overly precise dependency \`${printManualMemoDependency(dep)}\`, ` +
468 `use \`${printInferredDependency(matchingInferred)}\` instead`,
469 loc: dep.loc ?? manualMemoLoc,
470 });
471 } else {
472 /**
473 * Else this dependency doesn't correspond to anything referenced in the memo function,
474 * or is an optional dependency so we don't want to suggest adding it
475 */
476 diagnostic.withDetails({
477 kind: 'error',
478 message: `Unnecessary dependency \`${printManualMemoDependency(dep)}\``,
479 loc: dep.loc ?? manualMemoLoc,
480 });
481 }
482 }
483 }
484 if (suggestion != null) {
485 diagnostic.withDetails({
486 kind: 'hint',
487 message: `Inferred dependencies: \`${suggestion.text}\``,
488 });
489 }
490 return diagnostic;
491 }
492 return null;
493 }
494
495 function addDependency(
496 dep: Temporary,
497 dependencies: Set<InferredDependency>,
498 locals: Set<IdentifierId>,
499 ): void {
500 if (dep.kind === 'Aggregate') {
501 for (const x of dep.dependencies) {
502 addDependency(x, dependencies, locals);
503 }
504 } else if (dep.kind === 'Global') {
505 dependencies.add(dep);
506 } else if (!locals.has(dep.identifier.id)) {
507 dependencies.add(dep);
508 }
509 }
510
511 function visitCandidateDependency(
512 place: Place,
513 temporaries: Map<IdentifierId, Temporary>,
514 dependencies: Set<InferredDependency>,
515 locals: Set<IdentifierId>,
516 ): void {
517 const dep = temporaries.get(place.identifier.id);
518 if (dep != null) {
519 addDependency(dep, dependencies, locals);
520 }
521 }
522
523 /**
524 * This function determines the dependencies of the given function relative to
525 * its external context. Dependencies are collected eagerly, the first time an
526 * external variable is referenced, as opposed to trying to delay or aggregate
527 * calculation of dependencies until they are later "used".
528 *
529 * For example, in
530 *
531 * ```
532 * function f() {
533 * let x = y; // we record a dependency on `y` here
534 * ...
535 * use(x); // as opposed to trying to delay that dependency until here
536 * }
537 * ```
538 *
539 * That said, LoadLocal/LoadContext does not immediately take a dependency,
540 * we store the dependency in a temporary and set it as used when that temporary
541 * is referenced as an operand.
542 *
543 * As we proceed through the function we track local variables that it creates
544 * and don't consider later references to these variables as dependencies.
545 *
546 * For function expressions we first collect the function's dependencies by
547 * calling this function recursively, _without_ taking into account whether
548 * the "external" variables it accesses are actually external or just locals
549 * in the parent. We then prune any locals and immediately consider any
550 * remaining externals that it accesses as a dependency:
551 *
552 * ```
553 * function Component() {
554 * const local = ...;
555 * const f = () => { return [external, local] };
556 * }
557 * ```
558 *
559 * Here we calculate `f` as having dependencies `external, `local` and save
560 * this into `temporaries`. We then also immediately take these as dependencies
561 * at the Component scope, at which point we filter out `local` as a local variable,
562 * leaving just a dependency on `external`.
563 *
564 * When calling this function on a top-level component or hook, the collected dependencies
565 * will only contain the globals that it accesses which isn't useful. Instead, passing
566 * onStartMemoize/onFinishMemoize callbacks allows looking at the dependencies within
567 * blocks of manual memoization.
568 */
569 function collectDependencies(
570 fn: HIRFunction,
571 temporaries: Map<IdentifierId, Temporary>,
572 callbacks: {
573 onStartMemoize: (
574 startMemo: StartMemoize,
575 dependencies: Set<InferredDependency>,
576 locals: Set<IdentifierId>,
577 ) => void;
578 onFinishMemoize: (
579 finishMemo: FinishMemoize,
580 dependencies: Set<InferredDependency>,
581 locals: Set<IdentifierId>,
582 ) => void;
583 onEffect: (
584 inferred: Set<InferredDependency>,
585 manual: Set<InferredDependency>,
586 manualMemoLoc: SourceLocation | null,
587 ) => void;
588 } | null,
589 isFunctionExpression: boolean,
590 ): Extract<Temporary, {kind: 'Aggregate'}> {
591 const optionals = findOptionalPlaces(fn);
592 if (DEBUG) {
593 console.log(prettyFormat(optionals));
594 }
595 const locals: Set<IdentifierId> = new Set();
596 if (isFunctionExpression) {
597 for (const param of fn.params) {
598 const place = param.kind === 'Identifier' ? param : param.place;
599 locals.add(place.identifier.id);
600 }
601 }
602
603 const dependencies: Set<InferredDependency> = new Set();
604 function visit(place: Place): void {
605 visitCandidateDependency(place, temporaries, dependencies, locals);
606 }
607 for (const block of fn.body.blocks.values()) {
608 for (const phi of block.phis) {
609 const deps: Array<InferredDependency> = [];
610 for (const operand of phi.operands.values()) {
611 const dep = temporaries.get(operand.identifier.id);
612 if (dep == null) {
613 continue;
614 }
615 if (dep.kind === 'Aggregate') {
616 deps.push(...dep.dependencies);
617 } else {
618 deps.push(dep);
619 }
620 }
621 if (deps.length === 0) {
622 continue;
623 } else if (deps.length === 1) {
624 temporaries.set(phi.place.identifier.id, deps[0]!);
625 } else {
626 temporaries.set(phi.place.identifier.id, {
627 kind: 'Aggregate',
628 dependencies: new Set(deps),
629 });
630 }
631 }
632
633 for (const instr of block.instructions) {
634 const {lvalue, value} = instr;
635 switch (value.kind) {
636 case 'LoadGlobal': {
637 temporaries.set(lvalue.identifier.id, {
638 kind: 'Global',
639 binding: value.binding,
640 });
641 break;
642 }
643 case 'LoadContext':
644 case 'LoadLocal': {
645 const temp = temporaries.get(value.place.identifier.id);
646 if (temp != null) {
647 if (temp.kind === 'Local') {
648 const local: Temporary = {...temp, loc: value.place.loc};
649 temporaries.set(lvalue.identifier.id, local);
650 } else {
651 temporaries.set(lvalue.identifier.id, temp);
652 }
653 if (locals.has(value.place.identifier.id)) {
654 locals.add(lvalue.identifier.id);
655 }
656 }
657 break;
658 }
659 case 'DeclareLocal': {
660 const local: Temporary = {
661 kind: 'Local',
662 identifier: value.lvalue.place.identifier,
663 path: [],
664 context: false,
665 loc: value.lvalue.place.loc,
666 };
667 temporaries.set(value.lvalue.place.identifier.id, local);
668 locals.add(value.lvalue.place.identifier.id);
669 break;
670 }
671 case 'StoreLocal': {
672 if (value.lvalue.place.identifier.name == null) {
673 const temp = temporaries.get(value.value.identifier.id);
674 if (temp != null) {
675 temporaries.set(value.lvalue.place.identifier.id, temp);
676 }
677 break;
678 }
679 visit(value.value);
680 if (value.lvalue.kind !== InstructionKind.Reassign) {
681 const local: Temporary = {
682 kind: 'Local',
683 identifier: value.lvalue.place.identifier,
684 path: [],
685 context: false,
686 loc: value.lvalue.place.loc,
687 };
688 temporaries.set(value.lvalue.place.identifier.id, local);
689 locals.add(value.lvalue.place.identifier.id);
690 }
691 break;
692 }
693 case 'DeclareContext': {
694 const local: Temporary = {
695 kind: 'Local',
696 identifier: value.lvalue.place.identifier,
697 path: [],
698 context: true,
699 loc: value.lvalue.place.loc,
700 };
701 temporaries.set(value.lvalue.place.identifier.id, local);
702 break;
703 }
704 case 'StoreContext': {
705 visit(value.value);
706 if (value.lvalue.kind !== InstructionKind.Reassign) {
707 const local: Temporary = {
708 kind: 'Local',
709 identifier: value.lvalue.place.identifier,
710 path: [],
711 context: true,
712 loc: value.lvalue.place.loc,
713 };
714 temporaries.set(value.lvalue.place.identifier.id, local);
715 locals.add(value.lvalue.place.identifier.id);
716 }
717 break;
718 }
719 case 'Destructure': {
720 visit(value.value);
721 if (value.lvalue.kind !== InstructionKind.Reassign) {
722 for (const lvalue of eachInstructionValueLValue(value)) {
723 const local: Temporary = {
724 kind: 'Local',
725 identifier: lvalue.identifier,
726 path: [],
727 context: false,
728 loc: lvalue.loc,
729 };
730 temporaries.set(lvalue.identifier.id, local);
731 locals.add(lvalue.identifier.id);
732 }
733 }
734 break;
735 }
736 case 'PropertyLoad': {
737 if (
738 typeof value.property === 'number' ||
739 (isUseRefType(value.object.identifier) &&
740 value.property === 'current')
741 ) {
742 visit(value.object);
743 break;
744 }
745 const object = temporaries.get(value.object.identifier.id);
746 if (object != null && object.kind === 'Local') {
747 const optional = optionals.get(value.object.identifier.id) ?? false;
748 const local: Temporary = {
749 kind: 'Local',
750 identifier: object.identifier,
751 context: object.context,
752 path: [
753 ...object.path,
754 {
755 optional,
756 property: value.property,
757 loc: value.loc,
758 },
759 ],
760 loc: value.loc,
761 };
762 temporaries.set(lvalue.identifier.id, local);
763 }
764 break;
765 }
766 case 'FunctionExpression':
767 case 'ObjectMethod': {
768 const functionDeps = collectDependencies(
769 value.loweredFunc.func,
770 temporaries,
771 null,
772 true, // isFunctionExpression
773 );
774 temporaries.set(lvalue.identifier.id, functionDeps);
775 addDependency(functionDeps, dependencies, locals);
776 break;
777 }
778 case 'StartMemoize': {
779 const onStartMemoize = callbacks?.onStartMemoize;
780 if (onStartMemoize != null) {
781 onStartMemoize(value, dependencies, locals);
782 }
783 break;
784 }
785 case 'FinishMemoize': {
786 const onFinishMemoize = callbacks?.onFinishMemoize;
787 if (onFinishMemoize != null) {
788 onFinishMemoize(value, dependencies, locals);
789 }
790 break;
791 }
792 case 'ArrayExpression': {
793 const arrayDeps: Set<InferredDependency> = new Set();
794 for (const item of value.elements) {
795 if (item.kind === 'Hole') {
796 continue;
797 }
798 const place = item.kind === 'Identifier' ? item : item.place;
799 // Visit with alternative deps/locals to record manual dependencies
800 visitCandidateDependency(place, temporaries, arrayDeps, new Set());
801 // Visit normally to propagate inferred dependencies upward
802 visit(place);
803 }
804 temporaries.set(lvalue.identifier.id, {
805 kind: 'Aggregate',
806 dependencies: arrayDeps,
807 loc: value.loc,
808 });
809 break;
810 }
811 case 'CallExpression':
812 case 'MethodCall': {
813 const receiver =
814 value.kind === 'CallExpression' ? value.callee : value.property;
815
816 const onEffect = callbacks?.onEffect;
817 if (onEffect != null && isEffectHook(receiver.identifier)) {
818 const [fn, deps] = value.args;
819 if (fn?.kind === 'Identifier' && deps?.kind === 'Identifier') {
820 const fnDeps = temporaries.get(fn.identifier.id);
821 const manualDeps = temporaries.get(deps.identifier.id);
822 if (
823 fnDeps?.kind === 'Aggregate' &&
824 manualDeps?.kind === 'Aggregate'
825 ) {
826 onEffect(
827 fnDeps.dependencies,
828 manualDeps.dependencies,
829 manualDeps.loc ?? null,
830 );
831 }
832 }
833 }
834
835 // Ignore the method itself
836 for (const operand of eachInstructionValueOperand(value)) {
837 if (
838 value.kind === 'MethodCall' &&
839 operand.identifier.id === value.property.identifier.id
840 ) {
841 continue;
842 }
843 visit(operand);
844 }
845 break;
846 }
847 default: {
848 for (const operand of eachInstructionValueOperand(value)) {
849 visit(operand);
850 }
851 for (const lvalue of eachInstructionLValue(instr)) {
852 locals.add(lvalue.identifier.id);
853 }
854 }
855 }
856 }
857 for (const operand of eachTerminalOperand(block.terminal)) {
858 if (optionals.has(operand.identifier.id)) {
859 continue;
860 }
861 visit(operand);
862 }
863 }
864 return {kind: 'Aggregate', dependencies};
865 }
866
867 function printInferredDependency(dep: InferredDependency): string {
868 switch (dep.kind) {
869 case 'Global': {
870 return dep.binding.name;
871 }
872 case 'Local': {
873 CompilerError.invariant(
874 dep.identifier.name != null && dep.identifier.name.kind === 'named',
875 {
876 reason: 'Expected dependencies to be named variables',
877 loc: dep.loc,
878 },
879 );
880 return `${dep.identifier.name.value}${dep.path.map(p => (p.optional ? '?' : '') + '.' + p.property).join('')}`;
881 }
882 }
883 }
884
885 function printManualMemoDependency(dep: ManualMemoDependency): string {
886 let identifierName: string;
887 if (dep.root.kind === 'Global') {
888 identifierName = dep.root.identifierName;
889 } else {
890 const name = dep.root.value.identifier.name;
891 CompilerError.invariant(name != null && name.kind === 'named', {
892 reason: 'Expected manual dependencies to be named variables',
893 loc: dep.root.value.loc,
894 });
895 identifierName = name.value;
896 }
897 return `${identifierName}${dep.path.map(p => (p.optional ? '?' : '') + '.' + p.property).join('')}`;
898 }
899
900 function isEqualTemporary(a: Temporary, b: Temporary): boolean {
901 switch (a.kind) {
902 case 'Aggregate': {
903 return false;
904 }
905 case 'Global': {
906 return b.kind === 'Global' && a.binding.name === b.binding.name;
907 }
908 case 'Local': {
909 return (
910 b.kind === 'Local' &&
911 a.identifier.id === b.identifier.id &&
912 areEqualPaths(a.path, b.path)
913 );
914 }
915 }
916 }
917
918 type Temporary =
919 | {kind: 'Global'; binding: LoadGlobal['binding']}
920 | {
921 kind: 'Local';
922 identifier: Identifier;
923 path: DependencyPath;
924 context: boolean;
925 loc: SourceLocation;
926 }
927 | {
928 kind: 'Aggregate';
929 dependencies: Set<InferredDependency>;
930 loc?: SourceLocation;
931 };
932 type InferredDependency = Extract<Temporary, {kind: 'Local' | 'Global'}>;
933
934 function collectReactiveIdentifiersHIR(fn: HIRFunction): Set<IdentifierId> {
935 const reactive = new Set<IdentifierId>();
936 for (const block of fn.body.blocks.values()) {
937 for (const instr of block.instructions) {
938 for (const lvalue of eachInstructionLValue(instr)) {
939 if (lvalue.reactive) {
940 reactive.add(lvalue.identifier.id);
941 }
942 }
943 for (const operand of eachInstructionValueOperand(instr.value)) {
944 if (operand.reactive) {
945 reactive.add(operand.identifier.id);
946 }
947 }
948 }
949 for (const operand of eachTerminalOperand(block.terminal)) {
950 if (operand.reactive) {
951 reactive.add(operand.identifier.id);
952 }
953 }
954 }
955 return reactive;
956 }
957
958 export function findOptionalPlaces(
959 fn: HIRFunction,
960 ): Map<IdentifierId, boolean> {
961 const optionals = new Map<IdentifierId, boolean>();
962 const visited: Set<BlockId> = new Set();
963 for (const [, block] of fn.body.blocks) {
964 if (visited.has(block.id)) {
965 continue;
966 }
967 if (block.terminal.kind === 'optional') {
968 visited.add(block.id);
969 const optionalTerminal = block.terminal;
970 let testBlock = fn.body.blocks.get(block.terminal.test)!;
971 const queue: Array<boolean | null> = [block.terminal.optional];
972 loop: while (true) {
973 visited.add(testBlock.id);
974 const terminal = testBlock.terminal;
975 switch (terminal.kind) {
976 case 'branch': {
977 const isOptional = queue.pop();
978 CompilerError.invariant(isOptional !== undefined, {
979 reason:
980 'Expected an optional value for each optional test condition',
981 loc: terminal.test.loc,
982 });
983 if (isOptional != null) {
984 optionals.set(terminal.test.identifier.id, isOptional);
985 }
986 if (terminal.fallthrough === optionalTerminal.fallthrough) {
987 // found it
988 const consequent = fn.body.blocks.get(terminal.consequent)!;
989 const last = consequent.instructions.at(-1);
990 if (last !== undefined && last.value.kind === 'StoreLocal') {
991 if (isOptional != null) {
992 optionals.set(last.value.value.identifier.id, isOptional);
993 }
994 }
995 break loop;
996 } else {
997 testBlock = fn.body.blocks.get(terminal.fallthrough)!;
998 }
999 break;
1000 }
1001 case 'optional': {
1002 queue.push(terminal.optional);
1003 testBlock = fn.body.blocks.get(terminal.test)!;
1004 break;
1005 }
1006 case 'logical':
1007 case 'ternary': {
1008 queue.push(null);
1009 testBlock = fn.body.blocks.get(terminal.test)!;
1010 break;
1011 }
1012
1013 case 'sequence': {
1014 // Do we need sequence?? In any case, don't push to queue bc there is no corresponding branch terminal
1015 testBlock = fn.body.blocks.get(terminal.block)!;
1016 break;
1017 }
1018 case 'maybe-throw': {
1019 testBlock = fn.body.blocks.get(terminal.continuation)!;
1020 break;
1021 }
1022 default: {
1023 CompilerError.invariant(false, {
1024 reason: `Unexpected terminal in optional`,
1025 message: `Unexpected ${terminal.kind} in optional`,
1026 loc: terminal.loc,
1027 });
1028 }
1029 }
1030 }
1031 CompilerError.invariant(queue.length === 0, {
1032 reason:
1033 'Expected a matching number of conditional blocks and branch points',
1034 loc: block.terminal.loc,
1035 });
1036 }
1037 }
1038 return optionals;
1039 }
1040
1041 function isOptionalDependency(
1042 inferredDependency: Extract<InferredDependency, {kind: 'Local'}>,
1043 reactive: Set<IdentifierId>,
1044 ): boolean {
1045 return (
1046 !reactive.has(inferredDependency.identifier.id) &&
1047 (isStableType(inferredDependency.identifier) ||
1048 isPrimitiveType(inferredDependency.identifier))
1049 );
1050 }
1051
1052 function createDiagnostic(
1053 category:
1054 | ErrorCategory.MemoDependencies
1055 | ErrorCategory.EffectExhaustiveDependencies,
1056 missing: Array<InferredDependency>,
1057 extra: Array<ManualMemoDependency>,
1058 suggestion: CompilerSuggestion | null,
1059 ): CompilerDiagnostic {
1060 let reason: string;
1061 let description: string;
1062
1063 function joinMissingExtraDetail(
1064 missingString: string,
1065 extraString: string,
1066 joinStr: string,
1067 ): string {
1068 return [
1069 missing.length !== 0 ? missingString : null,
1070 extra.length !== 0 ? extraString : null,
1071 ]
1072 .filter(Boolean)
1073 .join(joinStr);
1074 }
1075
1076 switch (category) {
1077 case ErrorCategory.MemoDependencies: {
1078 reason = `Found ${joinMissingExtraDetail('missing', 'extra', '/')} memoization dependencies`;
1079 description = joinMissingExtraDetail(
1080 'Missing dependencies can cause a value to update less often than it should, resulting in stale UI',
1081 'Extra dependencies can cause a value to update more often than it should, resulting in performance' +
1082 ' problems such as excessive renders or effects firing too often',
1083 '. ',
1084 );
1085 break;
1086 }
1087 case ErrorCategory.EffectExhaustiveDependencies: {
1088 reason = `Found ${joinMissingExtraDetail('missing', 'extra', '/')} effect dependencies`;
1089 description = joinMissingExtraDetail(
1090 'Missing dependencies can cause an effect to fire less often than it should',
1091 'Extra dependencies can cause an effect to fire more often than it should, resulting' +
1092 ' in performance problems such as excessive renders and side effects',
1093 '. ',
1094 );
1095 break;
1096 }
1097 default: {
1098 CompilerError.invariant(false, {
1099 reason: `Unexpected error category: ${category}`,
1100 loc: GeneratedSource,
1101 });
1102 }
1103 }
1104
1105 return CompilerDiagnostic.create({
1106 category,
1107 reason,
1108 description,
1109 suggestions: suggestion != null ? [suggestion] : null,
1110 });
1111 }
1112
1113 export function isEffectHook(identifier: Identifier): boolean {
1114 return (
1115 isUseEffectHookType(identifier) ||
1116 isUseLayoutEffectHookType(identifier) ||
1117 isUseInsertionEffectHookType(identifier)
1118 );
1119 }