@samitouri / QOS-React-2 / commits / bcc3fd8b05

[compiler] Implement exhaustive dependency checking for manual memoization (#34394)

The compiler currently drops manual memoization and rewrites it using its own inference. If the existing manual memo dependencies has missing or extra dependencies, compilation can change behavior by running the computation more often (if deps were missing) or less often (if there were extra deps). We currently address this by relying on the developer to use the ESLint plugin and have `eslint-disable-next-line react-hooks/exhaustive-deps` suppressions in their code. If a suppression exists, we skip compilation. But not everyone is using the linter! Relying on the linter is also imprecise since it forces us to bail out on exhaustive-deps checks that only effect (ahem) effects — and while it isn't good to have incorrect deps on effects, it isn't a problem for compilation. So this PR is a rough sketch of validating manual memoization dependencies in the compiler. Long-term we could use this to also check effect deps and replace the ExhaustiveDeps lint rule, but for now I'm focused specifically on manual memoization use-cases. If this works, we can stop bailing out on ESLint suppressions, since the compiler will implement all the appropriate checks (we already check rules of hooks). --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34394). * #34472 * #34471 * __->__ #34394

Joseph Savona committed Nov 20, 2025 at 19:26 UTC bcc3fd8b05acc6cb4947b15938dc55b4b72fe31f
9 files changed +1144
compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts
+24
@@ -304,6 +304,30 @@ export class CompilerError extends Error {
304 disabledDetails: Array<CompilerErrorDetail | CompilerDiagnostic> = [];
305 printedMessage: string | null = null;
306
307 + static simpleInvariant(
308 + condition: unknown,
309 + options: {
310 + reason: CompilerDiagnosticOptions['reason'];
311 + description?: CompilerDiagnosticOptions['description'];
312 + loc: SourceLocation;
313 + },
314 + ): asserts condition {
315 + if (!condition) {
316 + const errors = new CompilerError();
317 + errors.pushDiagnostic(
318 + CompilerDiagnostic.create({
319 + reason: options.reason,
320 + description: options.description ?? null,
321 + category: ErrorCategory.Invariant,
322 + }).withDetails({
323 + kind: 'error',
324 + loc: options.loc,
325 + message: options.reason,
326 + }),
327 + );
328 + throw errors;
329 + }
330 + }
331 static invariant(
332 condition: unknown,
333 options: Omit<CompilerDiagnosticOptions, 'category'>,
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
+6
@@ -105,6 +105,7 @@ import {validateNoDerivedComputationsInEffects} from '../Validation/ValidateNoDe
105 import {validateNoDerivedComputationsInEffects_exp} from '../Validation/ValidateNoDerivedComputationsInEffects_exp';
106 import {nameAnonymousFunctions} from '../Transform/NameAnonymousFunctions';
107 import {optimizeForSSR} from '../Optimization/OptimizeForSSR';
108 +import {validateExhaustiveDependencies} from '../Validation/ValidateExhaustiveDependencies';
109 import {validateSourceLocations} from '../Validation/ValidateSourceLocations';
110
111 export type CompilerPipelineValue =
@@ -302,6 +303,11 @@ function runWithEnvironment(
303 inferReactivePlaces(hir);
304 log({kind: 'hir', name: 'InferReactivePlaces', value: hir});
305
306 + if (env.config.validateExhaustiveMemoizationDependencies) {
307 + // NOTE: this relies on reactivity inference running first
308 + validateExhaustiveDependencies(hir).unwrap();
309 + }
310 +
311 rewriteInstructionKindsBasedOnReassignment(hir);
312 log({
313 kind: 'hir',
compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+5
@@ -218,6 +218,11 @@ export const EnvironmentConfigSchema = z.object({
218 */
219 validatePreserveExistingMemoizationGuarantees: z.boolean().default(true),
220
221 + /**
222 + * Validate that dependencies supplied to manual memoization calls are exhaustive.
223 + */
224 + validateExhaustiveMemoizationDependencies: z.boolean().default(false),
225 +
226 /**
227 * When this is true, rather than pruning existing manual memoization but ensuring or validating
228 * that the memoized values remain memoized, the compiler will simply not prune existing calls to
compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts
+22
@@ -1680,6 +1680,28 @@ export function areEqualPaths(a: DependencyPath, b: DependencyPath): boolean {
1680 )
1681 );
1682 }
1683 +export function isSubPath(
1684 + subpath: DependencyPath,
1685 + path: DependencyPath,
1686 +): boolean {
1687 + return (
1688 + subpath.length <= path.length &&
1689 + subpath.every(
1690 + (item, ix) =>
1691 + item.property === path[ix].property &&
1692 + item.optional === path[ix].optional,
1693 + )
1694 + );
1695 +}
1696 +export function isSubPathIgnoringOptionals(
1697 + subpath: DependencyPath,
1698 + path: DependencyPath,
1699 +): boolean {
1700 + return (
1701 + subpath.length <= path.length &&
1702 + subpath.every((item, ix) => item.property === path[ix].property)
1703 + );
1704 +}
1705
1706 export function getPlaceScope(
1707 id: InstructionId,
compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateExhaustiveDependencies.ts new
+749
@@ -0,0 +1,749 @@
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 {CompilerDiagnostic, CompilerError, SourceLocation} from '..';
10 +import {ErrorCategory} from '../CompilerError';
11 +import {
12 + areEqualPaths,
13 + BlockId,
14 + DependencyPath,
15 + FinishMemoize,
16 + HIRFunction,
17 + Identifier,
18 + IdentifierId,
19 + InstructionKind,
20 + isSubPath,
21 + LoadGlobal,
22 + ManualMemoDependency,
23 + Place,
24 + StartMemoize,
25 +} from '../HIR';
26 +import {
27 + eachInstructionLValue,
28 + eachInstructionValueLValue,
29 + eachInstructionValueOperand,
30 + eachTerminalOperand,
31 +} from '../HIR/visitors';
32 +import {Result} from '../Utils/Result';
33 +import {retainWhere} from '../Utils/utils';
34 +
35 +const DEBUG = false;
36 +
37 +/**
38 + * Validates that existing manual memoization had exhaustive dependencies.
39 + * Memoization with missing or extra reactive dependencies is invalid React
40 + * and compilation can change behavior, causing a value to be computed more
41 + * or less times.
42 + *
43 + * TODOs:
44 + * - Better handling of cases where we infer multiple dependencies related to a single
45 + * variable. Eg if the user has dep `x` and we inferred `x.y, x.z`, the user's dep
46 + * is sufficient.
47 + * - Handle cases where the user deps were not simple identifiers + property chains.
48 + * We try to detect this in ValidateUseMemo but we miss some cases. The problem
49 + * is that invalid forms can be value blocks or function calls that don't get
50 + * removed by DCE, leaving a structure like:
51 + *
52 + * StartMemoize
53 + * t0 = <value to memoize>
54 + * ...non-DCE'd code for manual deps...
55 + * FinishMemoize decl=t0
56 + *
57 + * When we go to compute the dependencies, we then think that the user's manual dep
58 + * logic is part of what the memo computation logic.
59 + */
60 +export function validateExhaustiveDependencies(
61 + fn: HIRFunction,
62 +): Result<void, CompilerError> {
63 + const reactive = collectReactiveIdentifiersHIR(fn);
64 +
65 + const temporaries: Map<IdentifierId, Temporary> = new Map();
66 + for (const param of fn.params) {
67 + const place = param.kind === 'Identifier' ? param : param.place;
68 + temporaries.set(place.identifier.id, {
69 + kind: 'Local',
70 + identifier: place.identifier,
71 + path: [],
72 + context: false,
73 + loc: place.loc,
74 + });
75 + }
76 + const error = new CompilerError();
77 + let startMemo: StartMemoize | null = null;
78 +
79 + function onStartMemoize(
80 + value: StartMemoize,
81 + dependencies: Set<InferredDependency>,
82 + locals: Set<IdentifierId>,
83 + ): void {
84 + CompilerError.simpleInvariant(startMemo == null, {
85 + reason: 'Unexpected nested memo calls',
86 + loc: value.loc,
87 + });
88 + startMemo = value;
89 + dependencies.clear();
90 + locals.clear();
91 + }
92 + function onFinishMemoize(
93 + value: FinishMemoize,
94 + dependencies: Set<InferredDependency>,
95 + locals: Set<IdentifierId>,
96 + ): void {
97 + CompilerError.simpleInvariant(
98 + startMemo != null && startMemo.manualMemoId === value.manualMemoId,
99 + {
100 + reason: 'Found FinishMemoize without corresponding StartMemoize',
101 + loc: value.loc,
102 + },
103 + );
104 + visitCandidateDependency(value.decl, temporaries, dependencies, locals);
105 + const inferred: Array<InferredDependency> = Array.from(dependencies);
106 + // Sort dependencies by name, and path, with shorter/non-optional paths first
107 + inferred.sort((a, b) => {
108 + if (a.kind === 'Global' && b.kind == 'Global') {
109 + return a.binding.name.localeCompare(b.binding.name);
110 + } else if (a.kind == 'Local' && b.kind == 'Local') {
111 + CompilerError.simpleInvariant(
112 + a.identifier.name != null &&
113 + a.identifier.name.kind === 'named' &&
114 + b.identifier.name != null &&
115 + b.identifier.name.kind === 'named',
116 + {
117 + reason: 'Expected dependencies to be named variables',
118 + loc: a.loc,
119 + },
120 + );
121 + if (a.identifier.id !== b.identifier.id) {
122 + return a.identifier.name.value.localeCompare(b.identifier.name.value);
123 + }
124 + if (a.path.length !== b.path.length) {
125 + // if a's path is shorter this returns a negative, sorting a first
126 + return a.path.length - b.path.length;
127 + }
128 + for (let i = 0; i < a.path.length; i++) {
129 + const aProperty = a.path[i];
130 + const bProperty = b.path[i];
131 + const aOptional = aProperty.optional ? 0 : 1;
132 + const bOptional = bProperty.optional ? 0 : 1;
133 + if (aOptional !== bOptional) {
134 + // sort non-optionals first
135 + return aOptional - bOptional;
136 + } else if (aProperty.property !== bProperty.property) {
137 + return String(aProperty.property).localeCompare(
138 + String(bProperty.property),
139 + );
140 + }
141 + }
142 + return 0;
143 + } else {
144 + const aName =
145 + a.kind === 'Global' ? a.binding.name : a.identifier.name?.value;
146 + const bName =
147 + b.kind === 'Global' ? b.binding.name : b.identifier.name?.value;
148 + if (aName != null && bName != null) {
149 + return aName.localeCompare(bName);
150 + }
151 + return 0;
152 + }
153 + });
154 + // remove redundant inferred dependencies
155 + retainWhere(inferred, (dep, ix) => {
156 + const match = inferred.findIndex(prevDep => {
157 + return (
158 + isEqualTemporary(prevDep, dep) ||
159 + (prevDep.kind === 'Local' &&
160 + dep.kind === 'Local' &&
161 + prevDep.identifier.id === dep.identifier.id &&
162 + isSubPath(prevDep.path, dep.path))
163 + );
164 + });
165 + // only retain entries that don't have a prior match
166 + return match === -1 || match >= ix;
167 + });
168 + // Validate that all manual dependencies belong there
169 + if (DEBUG) {
170 + console.log('manual');
171 + console.log(
172 + (startMemo.deps ?? [])
173 + .map(x => ' ' + printManualMemoDependency(x))
174 + .join('\n'),
175 + );
176 + console.log('inferred');
177 + console.log(
178 + inferred.map(x => ' ' + printInferredDependency(x)).join('\n'),
179 + );
180 + }
181 + const manualDependencies = startMemo.deps ?? [];
182 + const matched: Set<ManualMemoDependency> = new Set();
183 + const missing: Array<Extract<InferredDependency, {kind: 'Local'}>> = [];
184 + const extra: Array<ManualMemoDependency> = [];
185 + for (const inferredDependency of inferred) {
186 + if (inferredDependency.kind === 'Global') {
187 + for (const manualDependency of manualDependencies) {
188 + if (
189 + manualDependency.root.kind === 'Global' &&
190 + manualDependency.root.identifierName ===
191 + inferredDependency.binding.name
192 + ) {
193 + matched.add(manualDependency);
194 + extra.push(manualDependency);
195 + }
196 + }
197 + continue;
198 + }
199 + CompilerError.simpleInvariant(inferredDependency.kind === 'Local', {
200 + reason: 'Unexpected function dependency',
201 + loc: value.loc,
202 + });
203 + let hasMatchingManualDependency = false;
204 + for (const manualDependency of manualDependencies) {
205 + if (
206 + manualDependency.root.kind === 'NamedLocal' &&
207 + manualDependency.root.value.identifier.id ===
208 + inferredDependency.identifier.id &&
209 + (areEqualPaths(manualDependency.path, inferredDependency.path) ||
210 + isSubPath(manualDependency.path, inferredDependency.path))
211 + ) {
212 + hasMatchingManualDependency = true;
213 + matched.add(manualDependency);
214 + }
215 + }
216 + if (!hasMatchingManualDependency) {
217 + missing.push(inferredDependency);
218 + }
219 + }
220 +
221 + for (const dep of startMemo.deps ?? []) {
222 + if (
223 + matched.has(dep) ||
224 + (dep.root.kind === 'NamedLocal' &&
225 + !reactive.has(dep.root.value.identifier.id))
226 + ) {
227 + continue;
228 + }
229 + extra.push(dep);
230 + }
231 +
232 + if (missing.length !== 0) {
233 + // Error
234 + const diagnostic = CompilerDiagnostic.create({
235 + category: ErrorCategory.PreserveManualMemo,
236 + reason: 'Found non-exhaustive dependencies',
237 + description:
238 + 'Missing dependencies can cause a value not to update when those inputs change, ' +
239 + 'resulting in stale UI. This memoization cannot be safely rewritten by the compiler.',
240 + });
241 + for (const dep of missing) {
242 + diagnostic.withDetails({
243 + kind: 'error',
244 + message: `Missing dependency \`${printInferredDependency(dep)}\``,
245 + loc: dep.loc,
246 + });
247 + }
248 + error.pushDiagnostic(diagnostic);
249 + } else if (extra.length !== 0) {
250 + const diagnostic = CompilerDiagnostic.create({
251 + category: ErrorCategory.PreserveManualMemo,
252 + reason: 'Found unnecessary memoization dependencies',
253 + description:
254 + 'Unnecessary dependencies can cause a value to update more often than necessary, ' +
255 + 'which can cause effects to run more than expected. This memoization cannot be safely ' +
256 + 'rewritten by the compiler',
257 + });
258 + diagnostic.withDetails({
259 + kind: 'error',
260 + message: `Unnecessary dependencies ${extra.map(dep => `\`${printManualMemoDependency(dep)}\``).join(', ')}`,
261 + loc: value.loc,
262 + });
263 + error.pushDiagnostic(diagnostic);
264 + }
265 +
266 + dependencies.clear();
267 + locals.clear();
268 + startMemo = null;
269 + }
270 +
271 + collectDependencies(fn, temporaries, {
272 + onStartMemoize,
273 + onFinishMemoize,
274 + });
275 + return error.asResult();
276 +}
277 +
278 +function addDependency(
279 + dep: Temporary,
280 + dependencies: Set<InferredDependency>,
281 + locals: Set<IdentifierId>,
282 +): void {
283 + if (dep.kind === 'Function') {
284 + for (const x of dep.dependencies) {
285 + addDependency(x, dependencies, locals);
286 + }
287 + } else if (dep.kind === 'Global') {
288 + dependencies.add(dep);
289 + } else if (!locals.has(dep.identifier.id)) {
290 + dependencies.add(dep);
291 + }
292 +}
293 +
294 +function visitCandidateDependency(
295 + place: Place,
296 + temporaries: Map<IdentifierId, Temporary>,
297 + dependencies: Set<InferredDependency>,
298 + locals: Set<IdentifierId>,
299 +): void {
300 + const dep = temporaries.get(place.identifier.id);
301 + if (dep != null) {
302 + addDependency(dep, dependencies, locals);
303 + }
304 +}
305 +
306 +/**
307 + * This function determines the dependencies of the given function relative to
308 + * its external context. Dependencies are collected eagerly, the first time an
309 + * external variable is referenced, as opposed to trying to delay or aggregate
310 + * calculation of dependencies until they are later "used".
311 + *
312 + * For example, in
313 + *
314 + * ```
315 + * function f() {
316 + * let x = y; // we record a dependency on `y` here
317 + * ...
318 + * use(x); // as opposed to trying to delay that dependency until here
319 + * }
320 + * ```
321 + *
322 + * That said, LoadLocal/LoadContext does not immediately take a dependency,
323 + * we store the dependency in a temporary and set it as used when that temporary
324 + * is referenced as an operand.
325 + *
326 + * As we proceed through the function we track local variables that it creates
327 + * and don't consider later references to these variables as dependencies.
328 + *
329 + * For function expressions we first collect the function's dependencies by
330 + * calling this function recursively, _without_ taking into account whether
331 + * the "external" variables it accesses are actually external or just locals
332 + * in the parent. We then prune any locals and immediately consider any
333 + * remaining externals that it accesses as a dependency:
334 + *
335 + * ```
336 + * function Component() {
337 + * const local = ...;
338 + * const f = () => { return [external, local] };
339 + * }
340 + * ```
341 + *
342 + * Here we calculate `f` as having dependencies `external, `local` and save
343 + * this into `temporaries`. We then also immediately take these as dependencies
344 + * at the Component scope, at which point we filter out `local` as a local variable,
345 + * leaving just a dependency on `external`.
346 + *
347 + * When calling this function on a top-level component or hook, the collected dependencies
348 + * will only contain the globals that it accesses which isn't useful. Instead, passing
349 + * onStartMemoize/onFinishMemoize callbacks allows looking at the dependencies within
350 + * blocks of manual memoization.
351 + */
352 +function collectDependencies(
353 + fn: HIRFunction,
354 + temporaries: Map<IdentifierId, Temporary>,
355 + callbacks: {
356 + onStartMemoize: (
357 + startMemo: StartMemoize,
358 + dependencies: Set<InferredDependency>,
359 + locals: Set<IdentifierId>,
360 + ) => void;
361 + onFinishMemoize: (
362 + finishMemo: FinishMemoize,
363 + dependencies: Set<InferredDependency>,
364 + locals: Set<IdentifierId>,
365 + ) => void;
366 + } | null,
367 +): Extract<Temporary, {kind: 'Function'}> {
368 + const optionals = findOptionalPlaces(fn);
369 + if (DEBUG) {
370 + console.log(prettyFormat(optionals));
371 + }
372 + const locals: Set<IdentifierId> = new Set();
373 + const dependencies: Set<InferredDependency> = new Set();
374 + function visit(place: Place): void {
375 + visitCandidateDependency(place, temporaries, dependencies, locals);
376 + }
377 + for (const block of fn.body.blocks.values()) {
378 + for (const phi of block.phis) {
379 + let deps: Array<Temporary> | null = null;
380 + for (const operand of phi.operands.values()) {
381 + const dep = temporaries.get(operand.identifier.id);
382 + if (dep == null) {
383 + continue;
384 + }
385 + if (deps == null) {
386 + deps = [dep];
387 + } else {
388 + deps.push(dep);
389 + }
390 + }
391 + if (deps == null) {
392 + continue;
393 + } else if (deps.length === 1) {
394 + temporaries.set(phi.place.identifier.id, deps[0]!);
395 + } else {
396 + temporaries.set(phi.place.identifier.id, {
397 + kind: 'Function',
398 + dependencies: new Set(deps),
399 + });
400 + }
401 + }
402 +
403 + for (const instr of block.instructions) {
404 + const {lvalue, value} = instr;
405 + switch (value.kind) {
406 + case 'LoadGlobal': {
407 + temporaries.set(lvalue.identifier.id, {
408 + kind: 'Global',
409 + binding: value.binding,
410 + });
411 + break;
412 + }
413 + case 'LoadContext':
414 + case 'LoadLocal': {
415 + if (locals.has(value.place.identifier.id)) {
416 + break;
417 + }
418 + const temp = temporaries.get(value.place.identifier.id);
419 + if (temp != null) {
420 + if (temp.kind === 'Local') {
421 + const local: Temporary = {...temp, loc: value.place.loc};
422 + temporaries.set(lvalue.identifier.id, local);
423 + } else {
424 + temporaries.set(lvalue.identifier.id, temp);
425 + }
426 + }
427 + break;
428 + }
429 + case 'DeclareLocal': {
430 + const local: Temporary = {
431 + kind: 'Local',
432 + identifier: value.lvalue.place.identifier,
433 + path: [],
434 + context: false,
435 + loc: value.lvalue.place.loc,
436 + };
437 + temporaries.set(value.lvalue.place.identifier.id, local);
438 + locals.add(value.lvalue.place.identifier.id);
439 + break;
440 + }
441 + case 'StoreLocal': {
442 + if (value.lvalue.place.identifier.name == null) {
443 + const temp = temporaries.get(value.value.identifier.id);
444 + if (temp != null) {
445 + temporaries.set(value.lvalue.place.identifier.id, temp);
446 + }
447 + break;
448 + }
449 + visit(value.value);
450 + if (value.lvalue.kind !== InstructionKind.Reassign) {
451 + const local: Temporary = {
452 + kind: 'Local',
453 + identifier: value.lvalue.place.identifier,
454 + path: [],
455 + context: false,
456 + loc: value.lvalue.place.loc,
457 + };
458 + temporaries.set(value.lvalue.place.identifier.id, local);
459 + locals.add(value.lvalue.place.identifier.id);
460 + }
461 + break;
462 + }
463 + case 'DeclareContext': {
464 + const local: Temporary = {
465 + kind: 'Local',
466 + identifier: value.lvalue.place.identifier,
467 + path: [],
468 + context: true,
469 + loc: value.lvalue.place.loc,
470 + };
471 + temporaries.set(value.lvalue.place.identifier.id, local);
472 + break;
473 + }
474 + case 'StoreContext': {
475 + visit(value.value);
476 + if (value.lvalue.kind !== InstructionKind.Reassign) {
477 + const local: Temporary = {
478 + kind: 'Local',
479 + identifier: value.lvalue.place.identifier,
480 + path: [],
481 + context: true,
482 + loc: value.lvalue.place.loc,
483 + };
484 + temporaries.set(value.lvalue.place.identifier.id, local);
485 + locals.add(value.lvalue.place.identifier.id);
486 + }
487 + break;
488 + }
489 + case 'Destructure': {
490 + visit(value.value);
491 + if (value.lvalue.kind !== InstructionKind.Reassign) {
492 + for (const lvalue of eachInstructionValueLValue(value)) {
493 + const local: Temporary = {
494 + kind: 'Local',
495 + identifier: lvalue.identifier,
496 + path: [],
497 + context: false,
498 + loc: lvalue.loc,
499 + };
500 + temporaries.set(lvalue.identifier.id, local);
501 + locals.add(lvalue.identifier.id);
502 + }
503 + }
504 + break;
505 + }
506 + case 'PropertyLoad': {
507 + if (typeof value.property === 'number') {
508 + visit(value.object);
509 + break;
510 + }
511 + const object = temporaries.get(value.object.identifier.id);
512 + if (object != null && object.kind === 'Local') {
513 + const optional = optionals.get(value.object.identifier.id) ?? false;
514 + const local: Temporary = {
515 + kind: 'Local',
516 + identifier: object.identifier,
517 + context: object.context,
518 + path: [
519 + ...object.path,
520 + {
521 + optional,
522 + property: value.property,
523 + },
524 + ],
525 + loc: value.loc,
526 + };
527 + temporaries.set(lvalue.identifier.id, local);
528 + }
529 + break;
530 + }
531 + case 'FunctionExpression':
532 + case 'ObjectMethod': {
533 + const functionDeps = collectDependencies(
534 + value.loweredFunc.func,
535 + temporaries,
536 + null,
537 + );
538 + temporaries.set(lvalue.identifier.id, functionDeps);
539 + addDependency(functionDeps, dependencies, locals);
540 + break;
541 + }
542 + case 'StartMemoize': {
543 + const onStartMemoize = callbacks?.onStartMemoize;
544 + if (onStartMemoize != null) {
545 + onStartMemoize(value, dependencies, locals);
546 + }
547 + break;
548 + }
549 + case 'FinishMemoize': {
550 + const onFinishMemoize = callbacks?.onFinishMemoize;
551 + if (onFinishMemoize != null) {
552 + onFinishMemoize(value, dependencies, locals);
553 + }
554 + break;
555 + }
556 + case 'MethodCall': {
557 + // Ignore the method itself
558 + for (const operand of eachInstructionValueOperand(value)) {
559 + if (operand.identifier.id === value.property.identifier.id) {
560 + continue;
561 + }
562 + visit(operand);
563 + }
564 + break;
565 + }
566 + default: {
567 + for (const operand of eachInstructionValueOperand(value)) {
568 + visit(operand);
569 + }
570 + for (const lvalue of eachInstructionLValue(instr)) {
571 + locals.add(lvalue.identifier.id);
572 + }
573 + }
574 + }
575 + }
576 + for (const operand of eachTerminalOperand(block.terminal)) {
577 + if (optionals.has(operand.identifier.id)) {
578 + continue;
579 + }
580 + visit(operand);
581 + }
582 + }
583 + return {kind: 'Function', dependencies};
584 +}
585 +
586 +function printInferredDependency(dep: InferredDependency): string {
587 + switch (dep.kind) {
588 + case 'Global': {
589 + return dep.binding.name;
590 + }
591 + case 'Local': {
592 + CompilerError.simpleInvariant(
593 + dep.identifier.name != null && dep.identifier.name.kind === 'named',
594 + {
595 + reason: 'Expected dependencies to be named variables',
596 + loc: dep.loc,
597 + },
598 + );
599 + return `${dep.identifier.name.value}${dep.path.map(p => (p.optional ? '?' : '') + '.' + p.property).join('')}`;
600 + }
601 + }
602 +}
603 +
604 +function printManualMemoDependency(dep: ManualMemoDependency): string {
605 + let identifierName: string;
606 + if (dep.root.kind === 'Global') {
607 + identifierName = dep.root.identifierName;
608 + } else {
609 + const name = dep.root.value.identifier.name;
610 + CompilerError.simpleInvariant(name != null && name.kind === 'named', {
611 + reason: 'Expected manual dependencies to be named variables',
612 + loc: dep.root.value.loc,
613 + });
614 + identifierName = name.value;
615 + }
616 + return `${identifierName}${dep.path.map(p => (p.optional ? '?' : '') + '.' + p.property).join('')}`;
617 +}
618 +
619 +function isEqualTemporary(a: Temporary, b: Temporary): boolean {
620 + switch (a.kind) {
621 + case 'Function': {
622 + return false;
623 + }
624 + case 'Global': {
625 + return b.kind === 'Global' && a.binding.name === b.binding.name;
626 + }
627 + case 'Local': {
628 + return (
629 + b.kind === 'Local' &&
630 + a.identifier.id === b.identifier.id &&
631 + areEqualPaths(a.path, b.path)
632 + );
633 + }
634 + }
635 +}
636 +
637 +type Temporary =
638 + | {kind: 'Global'; binding: LoadGlobal['binding']}
639 + | {
640 + kind: 'Local';
641 + identifier: Identifier;
642 + path: DependencyPath;
643 + context: boolean;
644 + loc: SourceLocation;
645 + }
646 + | {kind: 'Function'; dependencies: Set<Temporary>};
647 +type InferredDependency = Extract<Temporary, {kind: 'Local' | 'Global'}>;
648 +
649 +function collectReactiveIdentifiersHIR(fn: HIRFunction): Set<IdentifierId> {
650 + const reactive = new Set<IdentifierId>();
651 + for (const block of fn.body.blocks.values()) {
652 + for (const instr of block.instructions) {
653 + for (const lvalue of eachInstructionLValue(instr)) {
654 + if (lvalue.reactive) {
655 + reactive.add(lvalue.identifier.id);
656 + }
657 + }
658 + for (const operand of eachInstructionValueOperand(instr.value)) {
659 + if (operand.reactive) {
660 + reactive.add(operand.identifier.id);
661 + }
662 + }
663 + }
664 + for (const operand of eachTerminalOperand(block.terminal)) {
665 + if (operand.reactive) {
666 + reactive.add(operand.identifier.id);
667 + }
668 + }
669 + }
670 + return reactive;
671 +}
672 +
673 +export function findOptionalPlaces(
674 + fn: HIRFunction,
675 +): Map<IdentifierId, boolean> {
676 + const optionals = new Map<IdentifierId, boolean>();
677 + const visited: Set<BlockId> = new Set();
678 + for (const [, block] of fn.body.blocks) {
679 + if (visited.has(block.id)) {
680 + continue;
681 + }
682 + if (block.terminal.kind === 'optional') {
683 + visited.add(block.id);
684 + const optionalTerminal = block.terminal;
685 + let testBlock = fn.body.blocks.get(block.terminal.test)!;
686 + const queue: Array<boolean | null> = [block.terminal.optional];
687 + loop: while (true) {
688 + visited.add(testBlock.id);
689 + const terminal = testBlock.terminal;
690 + switch (terminal.kind) {
691 + case 'branch': {
692 + const isOptional = queue.pop();
693 + CompilerError.simpleInvariant(isOptional !== undefined, {
694 + reason:
695 + 'Expected an optional value for each optional test condition',
696 + loc: terminal.test.loc,
697 + });
698 + if (isOptional != null) {
699 + optionals.set(terminal.test.identifier.id, isOptional);
700 + }
701 + if (terminal.fallthrough === optionalTerminal.fallthrough) {
702 + // found it
703 + const consequent = fn.body.blocks.get(terminal.consequent)!;
704 + const last = consequent.instructions.at(-1);
705 + if (last !== undefined && last.value.kind === 'StoreLocal') {
706 + if (isOptional != null) {
707 + optionals.set(last.value.value.identifier.id, isOptional);
708 + }
709 + }
710 + break loop;
711 + } else {
712 + testBlock = fn.body.blocks.get(terminal.fallthrough)!;
713 + }
714 + break;
715 + }
716 + case 'optional': {
717 + queue.push(terminal.optional);
718 + testBlock = fn.body.blocks.get(terminal.test)!;
719 + break;
720 + }
721 + case 'logical':
722 + case 'ternary': {
723 + queue.push(null);
724 + testBlock = fn.body.blocks.get(terminal.test)!;
725 + break;
726 + }
727 +
728 + case 'sequence': {
729 + // Do we need sequence?? In any case, don't push to queue bc there is no corresponding branch terminal
730 + testBlock = fn.body.blocks.get(terminal.block)!;
731 + break;
732 + }
733 + default: {
734 + CompilerError.simpleInvariant(false, {
735 + reason: `Unexpected terminal in optional`,
736 + loc: terminal.loc,
737 + });
738 + }
739 + }
740 + }
741 + CompilerError.simpleInvariant(queue.length === 0, {
742 + reason:
743 + 'Expected a matching number of conditional blocks and branch points',
744 + loc: block.terminal.loc,
745 + });
746 + }
747 + }
748 + return optionals;
749 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-exhaustive-deps.expect.md new
+98
@@ -0,0 +1,98 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateExhaustiveMemoizationDependencies
6 +import {useMemo} from 'react';
7 +import {Stringify} from 'shared-runtime';
8 +
9 +function Component({x, y, z}) {
10 + const a = useMemo(() => {
11 + return x?.y.z?.a;
12 + }, [x?.y.z?.a.b]);
13 + const b = useMemo(() => {
14 + return x.y.z?.a;
15 + }, [x.y.z.a]);
16 + const c = useMemo(() => {
17 + return x?.y.z.a?.b;
18 + }, [x?.y.z.a?.b.z]);
19 + const d = useMemo(() => {
20 + return x?.y?.[(console.log(y), z?.b)];
21 + }, [x?.y, y, z?.b]);
22 + const e = useMemo(() => {
23 + const e = [];
24 + e.push(x);
25 + return e;
26 + }, [x]);
27 + const f = useMemo(() => {
28 + return [];
29 + }, [x, y.z, z?.y?.a]);
30 + return <Stringify results={[a, b, c, d, e, f]} />;
31 +}
32 +
33 +```
34 +
35 +
36 +## Error
37 +
38 +```
39 +Found 4 errors:
40 +
41 +Compilation Skipped: Found non-exhaustive dependencies
42 +
43 +Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI. This memoization cannot be safely rewritten by the compiler..
44 +
45 +error.invalid-exhaustive-deps.ts:7:11
46 + 5 | function Component({x, y, z}) {
47 + 6 | const a = useMemo(() => {
48 +> 7 | return x?.y.z?.a;
49 + | ^^^^^^^^^ Missing dependency `x?.y.z?.a`
50 + 8 | }, [x?.y.z?.a.b]);
51 + 9 | const b = useMemo(() => {
52 + 10 | return x.y.z?.a;
53 +
54 +Compilation Skipped: Found non-exhaustive dependencies
55 +
56 +Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI. This memoization cannot be safely rewritten by the compiler..
57 +
58 +error.invalid-exhaustive-deps.ts:10:11
59 + 8 | }, [x?.y.z?.a.b]);
60 + 9 | const b = useMemo(() => {
61 +> 10 | return x.y.z?.a;
62 + | ^^^^^^^^ Missing dependency `x.y.z?.a`
63 + 11 | }, [x.y.z.a]);
64 + 12 | const c = useMemo(() => {
65 + 13 | return x?.y.z.a?.b;
66 +
67 +Compilation Skipped: Found non-exhaustive dependencies
68 +
69 +Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI. This memoization cannot be safely rewritten by the compiler..
70 +
71 +error.invalid-exhaustive-deps.ts:13:11
72 + 11 | }, [x.y.z.a]);
73 + 12 | const c = useMemo(() => {
74 +> 13 | return x?.y.z.a?.b;
75 + | ^^^^^^^^^^^ Missing dependency `x?.y.z.a?.b`
76 + 14 | }, [x?.y.z.a?.b.z]);
77 + 15 | const d = useMemo(() => {
78 + 16 | return x?.y?.[(console.log(y), z?.b)];
79 +
80 +Compilation Skipped: Found unnecessary memoization dependencies
81 +
82 +Unnecessary dependencies can cause a value to update more often than necessary, which can cause effects to run more than expected. This memoization cannot be safely rewritten by the compiler.
83 +
84 +error.invalid-exhaustive-deps.ts:23:20
85 + 21 | return e;
86 + 22 | }, [x]);
87 +> 23 | const f = useMemo(() => {
88 + | ^^^^^^^
89 +> 24 | return [];
90 + | ^^^^^^^^^^^^^^
91 +> 25 | }, [x, y.z, z?.y?.a]);
92 + | ^^^^ Unnecessary dependencies `x`, `y.z`, `z?.y?.a`
93 + 26 | return <Stringify results={[a, b, c, d, e, f]} />;
94 + 27 | }
95 + 28 |
96 +```
97 +
98 +
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-exhaustive-deps.js new
+27
@@ -0,0 +1,27 @@
1 +// @validateExhaustiveMemoizationDependencies
2 +import {useMemo} from 'react';
3 +import {Stringify} from 'shared-runtime';
4 +
5 +function Component({x, y, z}) {
6 + const a = useMemo(() => {
7 + return x?.y.z?.a;
8 + }, [x?.y.z?.a.b]);
9 + const b = useMemo(() => {
10 + return x.y.z?.a;
11 + }, [x.y.z.a]);
12 + const c = useMemo(() => {
13 + return x?.y.z.a?.b;
14 + }, [x?.y.z.a?.b.z]);
15 + const d = useMemo(() => {
16 + return x?.y?.[(console.log(y), z?.b)];
17 + }, [x?.y, y, z?.b]);
18 + const e = useMemo(() => {
19 + const e = [];
20 + e.push(x);
21 + return e;
22 + }, [x]);
23 + const f = useMemo(() => {
24 + return [];
25 + }, [x, y.z, z?.y?.a]);
26 + return <Stringify results={[a, b, c, d, e, f]} />;
27 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/exhaustive-deps.expect.md new
+159
@@ -0,0 +1,159 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateExhaustiveMemoizationDependencies
6 +import {useMemo} from 'react';
7 +import {makeObject_Primitives, Stringify} from 'shared-runtime';
8 +
9 +function useHook1(x) {
10 + return useMemo(() => {
11 + return x?.y.z?.a;
12 + }, [x?.y.z?.a]);
13 +}
14 +function useHook2(x) {
15 + useMemo(() => {
16 + return x.y.z?.a;
17 + }, [x.y.z?.a]);
18 +}
19 +function useHook3(x) {
20 + return useMemo(() => {
21 + return x?.y.z.a?.b;
22 + }, [x?.y.z.a?.b]);
23 +}
24 +function useHook4(x, y, z) {
25 + return useMemo(() => {
26 + return x?.y?.[(console.log(y), z?.b)];
27 + }, [x?.y, y, z?.b]);
28 +}
29 +function useHook5(x) {
30 + return useMemo(() => {
31 + const e = [];
32 + const local = makeObject_Primitives(x);
33 + const fn = () => {
34 + e.push(local);
35 + };
36 + fn();
37 + return e;
38 + }, [x]);
39 +}
40 +function useHook6(x) {
41 + return useMemo(() => {
42 + const f = [];
43 + f.push(x.y.z);
44 + f.push(x.y);
45 + f.push(x);
46 + return f;
47 + }, [x]);
48 +}
49 +
50 +function Component({x, y, z}) {
51 + const a = useHook1(x);
52 + const b = useHook2(x);
53 + const c = useHook3(x);
54 + const d = useHook4(x, y, z);
55 + const e = useHook5(x);
56 + const f = useHook6(x);
57 + return <Stringify results={[a, b, c, d, e, f]} />;
58 +}
59 +
60 +```
61 +
62 +## Code
63 +
64 +```javascript
65 +import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveMemoizationDependencies
66 +import { useMemo } from "react";
67 +import { makeObject_Primitives, Stringify } from "shared-runtime";
68 +
69 +function useHook1(x) {
70 + x?.y.z?.a;
71 + return x?.y.z?.a;
72 +}
73 +
74 +function useHook2(x) {
75 + x.y.z?.a;
76 +}
77 +
78 +function useHook3(x) {
79 + x?.y.z.a?.b;
80 + return x?.y.z.a?.b;
81 +}
82 +
83 +function useHook4(x, y, z) {
84 + x?.y;
85 + z?.b;
86 + return x?.y?.[(console.log(y), z?.b)];
87 +}
88 +
89 +function useHook5(x) {
90 + const $ = _c(2);
91 + let e;
92 + if ($[0] !== x) {
93 + e = [];
94 + const local = makeObject_Primitives(x);
95 + const fn = () => {
96 + e.push(local);
97 + };
98 +
99 + fn();
100 + $[0] = x;
101 + $[1] = e;
102 + } else {
103 + e = $[1];
104 + }
105 + return e;
106 +}
107 +
108 +function useHook6(x) {
109 + const $ = _c(2);
110 + let f;
111 + if ($[0] !== x) {
112 + f = [];
113 + f.push(x.y.z);
114 + f.push(x.y);
115 + f.push(x);
116 + $[0] = x;
117 + $[1] = f;
118 + } else {
119 + f = $[1];
120 + }
121 + return f;
122 +}
123 +
124 +function Component(t0) {
125 + const $ = _c(7);
126 + const { x, y, z } = t0;
127 + const a = useHook1(x);
128 + const b = useHook2(x);
129 + const c = useHook3(x);
130 + const d = useHook4(x, y, z);
131 + const e = useHook5(x);
132 + const f = useHook6(x);
133 + let t1;
134 + if (
135 + $[0] !== a ||
136 + $[1] !== b ||
137 + $[2] !== c ||
138 + $[3] !== d ||
139 + $[4] !== e ||
140 + $[5] !== f
141 + ) {
142 + t1 = <Stringify results={[a, b, c, d, e, f]} />;
143 + $[0] = a;
144 + $[1] = b;
145 + $[2] = c;
146 + $[3] = d;
147 + $[4] = e;
148 + $[5] = f;
149 + $[6] = t1;
150 + } else {
151 + t1 = $[6];
152 + }
153 + return t1;
154 +}
155 +
156 +```
157 +
158 +### Eval output
159 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/exhaustive-deps.js new
+54
@@ -0,0 +1,54 @@
1 +// @validateExhaustiveMemoizationDependencies
2 +import {useMemo} from 'react';
3 +import {makeObject_Primitives, Stringify} from 'shared-runtime';
4 +
5 +function useHook1(x) {
6 + return useMemo(() => {
7 + return x?.y.z?.a;
8 + }, [x?.y.z?.a]);
9 +}
10 +function useHook2(x) {
11 + useMemo(() => {
12 + return x.y.z?.a;
13 + }, [x.y.z?.a]);
14 +}
15 +function useHook3(x) {
16 + return useMemo(() => {
17 + return x?.y.z.a?.b;
18 + }, [x?.y.z.a?.b]);
19 +}
20 +function useHook4(x, y, z) {
21 + return useMemo(() => {
22 + return x?.y?.[(console.log(y), z?.b)];
23 + }, [x?.y, y, z?.b]);
24 +}
25 +function useHook5(x) {
26 + return useMemo(() => {
27 + const e = [];
28 + const local = makeObject_Primitives(x);
29 + const fn = () => {
30 + e.push(local);
31 + };
32 + fn();
33 + return e;
34 + }, [x]);
35 +}
36 +function useHook6(x) {
37 + return useMemo(() => {
38 + const f = [];
39 + f.push(x.y.z);
40 + f.push(x.y);
41 + f.push(x);
42 + return f;
43 + }, [x]);
44 +}
45 +
46 +function Component({x, y, z}) {
47 + const a = useHook1(x);
48 + const b = useHook2(x);
49 + const c = useHook3(x);
50 + const d = useHook4(x, y, z);
51 + const e = useHook5(x);
52 + const f = useHook6(x);
53 + return <Stringify results={[a, b, c, d, e, f]} />;
54 +}