@samitouri / QOS-React-1 / commits / 89e8875ec4

[compiler] Fallback for inferred effect dependencies (#32984)

When effect dependencies cannot be inferred due to memoization-related bailouts or unexpected mutable ranges (which currently often have to do with writes to refs), fall back to traversing the effect lambda itself. This fallback uses the same logic as PropagateScopeDependencies: 1. Collect a sidemap of loads and property loads 2. Find hoistable accesses from the control flow graph. Note that here, we currently take into account the mutable ranges of instructions (see `mutate-after-useeffect-granular-access` fixture) 3. Collect the set of property paths accessed by the effect 4. Merge to get the set of minimal dependencies

mofeiZ committed Apr 25, 2025 at 15:44 UTC 89e8875ec48c86b366bf62398112923cdf76016a
12 files changed +393 -78
compiler/packages/babel-plugin-react-compiler/src/HIR/CollectHoistablePropertyLoads.ts
+29
@@ -19,6 +19,7 @@ import {
19 BasicBlock,
20 BlockId,
21 DependencyPathEntry,
22 + FunctionExpression,
23 GeneratedSource,
24 getHookKind,
25 HIRFunction,
@@ -30,6 +31,7 @@ import {
31 PropertyLiteral,
32 ReactiveScopeDependency,
33 ScopeId,
34 + TInstruction,
35 } from './HIR';
36
37 const DEBUG_PRINT = false;
@@ -127,6 +129,33 @@ export function collectHoistablePropertyLoads(
129 });
130 }
131
132 +export function collectHoistablePropertyLoadsInInnerFn(
133 + fnInstr: TInstruction<FunctionExpression>,
134 + temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>,
135 + hoistableFromOptionals: ReadonlyMap<BlockId, ReactiveScopeDependency>,
136 +): ReadonlyMap<BlockId, BlockInfo> {
137 + const fn = fnInstr.value.loweredFunc.func;
138 + const initialContext: CollectHoistablePropertyLoadsContext = {
139 + temporaries,
140 + knownImmutableIdentifiers: new Set(),
141 + hoistableFromOptionals,
142 + registry: new PropertyPathRegistry(),
143 + nestedFnImmutableContext: null,
144 + assumedInvokedFns: fn.env.config.enableTreatFunctionDepsAsConditional
145 + ? new Set()
146 + : getAssumedInvokedFunctions(fn),
147 + };
148 + const nestedFnImmutableContext = new Set(
149 + fn.context
150 + .filter(place =>
151 + isImmutableAtInstr(place.identifier, fnInstr.id, initialContext),
152 + )
153 + .map(place => place.identifier.id),
154 + );
155 + initialContext.nestedFnImmutableContext = nestedFnImmutableContext;
156 + return collectHoistablePropertyLoadsImpl(fn, initialContext);
157 +}
158 +
159 type CollectHoistablePropertyLoadsContext = {
160 temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>;
161 knownImmutableIdentifiers: ReadonlySet<IdentifierId>;
compiler/packages/babel-plugin-react-compiler/src/HIR/PropagateScopeDependenciesHIR.ts
+7 -4
@@ -116,7 +116,7 @@ export function propagateScopeDependenciesHIR(fn: HIRFunction): void {
116 }
117 }
118
119 -function findTemporariesUsedOutsideDeclaringScope(
119 +export function findTemporariesUsedOutsideDeclaringScope(
120 fn: HIRFunction,
121 ): ReadonlySet<DeclarationId> {
122 /*
@@ -378,7 +378,7 @@ type Decl = {
378 scope: Stack<ReactiveScope>;
379 };
380
381 -class Context {
381 +export class DependencyCollectionContext {
382 #declarations: Map<DeclarationId, Decl> = new Map();
383 #reassignments: Map<Identifier, Decl> = new Map();
384
@@ -645,7 +645,10 @@ enum HIRValue {
645 Terminal,
646 }
647
648 -function handleInstruction(instr: Instruction, context: Context): void {
648 +export function handleInstruction(
649 + instr: Instruction,
650 + context: DependencyCollectionContext,
651 +): void {
652 const {id, value, lvalue} = instr;
653 context.declare(lvalue.identifier, {
654 id,
@@ -708,7 +711,7 @@ function collectDependencies(
711 temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>,
712 processedInstrsInOptional: ReadonlySet<Instruction | Terminal>,
713 ): Map<ReactiveScope, Array<ReactiveScopeDependency>> {
711 - const context = new Context(
714 + const context = new DependencyCollectionContext(
715 usedOutsideDeclaringScope,
716 temporaries,
717 processedInstrsInOptional,
compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts
+160 -32
@@ -22,18 +22,30 @@ import {
22 ScopeId,
23 ReactiveScopeDependency,
24 Place,
25 + ReactiveScope,
26 ReactiveScopeDependencies,
27 + Terminal,
28 isUseRefType,
29 isSetStateType,
30 isFireFunctionType,
31 + makeScopeId,
32 } from '../HIR';
33 +import {collectHoistablePropertyLoadsInInnerFn} from '../HIR/CollectHoistablePropertyLoads';
34 +import {collectOptionalChainSidemap} from '../HIR/CollectOptionalChainDependencies';
35 +import {ReactiveScopeDependencyTreeHIR} from '../HIR/DeriveMinimalDependenciesHIR';
36 import {DEFAULT_EXPORT} from '../HIR/Environment';
37 import {
38 createTemporaryPlace,
39 fixScopeAndIdentifierRanges,
40 markInstructionIds,
41 } from '../HIR/HIRBuilder';
42 +import {
43 + collectTemporariesSidemap,
44 + DependencyCollectionContext,
45 + handleInstruction,
46 +} from '../HIR/PropagateScopeDependenciesHIR';
47 import {eachInstructionOperand, eachTerminalOperand} from '../HIR/visitors';
48 +import {empty} from '../Utils/Stack';
49 import {getOrInsertWith} from '../Utils/utils';
50
51 /**
@@ -62,10 +74,7 @@ export function inferEffectDependencies(fn: HIRFunction): void {
74 const autodepFnLoads = new Map<IdentifierId, number>();
75 const autodepModuleLoads = new Map<IdentifierId, Map<string, number>>();
76
65 - const scopeInfos = new Map<
66 - ScopeId,
67 - {pruned: boolean; deps: ReactiveScopeDependencies; hasSingleInstr: boolean}
68 - >();
77 + const scopeInfos = new Map<ScopeId, ReactiveScopeDependencies>();
78
79 const loadGlobals = new Set<IdentifierId>();
80
@@ -79,19 +88,18 @@ export function inferEffectDependencies(fn: HIRFunction): void {
88 const reactiveIds = inferReactiveIdentifiers(fn);
89
90 for (const [, block] of fn.body.blocks) {
82 - if (
83 - block.terminal.kind === 'scope' ||
84 - block.terminal.kind === 'pruned-scope'
85 - ) {
91 + if (block.terminal.kind === 'scope') {
92 const scopeBlock = fn.body.blocks.get(block.terminal.block)!;
87 - scopeInfos.set(block.terminal.scope.id, {
88 - pruned: block.terminal.kind === 'pruned-scope',
89 - deps: block.terminal.scope.dependencies,
90 - hasSingleInstr:
91 - scopeBlock.instructions.length === 1 &&
92 - scopeBlock.terminal.kind === 'goto' &&
93 - scopeBlock.terminal.block === block.terminal.fallthrough,
94 - });
93 + if (
94 + scopeBlock.instructions.length === 1 &&
95 + scopeBlock.terminal.kind === 'goto' &&
96 + scopeBlock.terminal.block === block.terminal.fallthrough
97 + ) {
98 + scopeInfos.set(
99 + block.terminal.scope.id,
100 + block.terminal.scope.dependencies,
101 + );
102 + }
103 }
104 const rewriteInstrs = new Map<InstructionId, Array<Instruction>>();
105 for (const instr of block.instructions) {
@@ -173,22 +181,12 @@ export function inferEffectDependencies(fn: HIRFunction): void {
181 fnExpr.lvalue.identifier.scope != null
182 ? scopeInfos.get(fnExpr.lvalue.identifier.scope.id)
183 : null;
176 - CompilerError.invariant(scopeInfo != null, {
177 - reason: 'Expected function expression scope to exist',
178 - loc: value.loc,
179 - });
180 - if (scopeInfo.pruned || !scopeInfo.hasSingleInstr) {
181 - /**
182 - * TODO: retry pipeline that ensures effect function expressions
183 - * are placed into their own scope
184 - */
185 - CompilerError.throwTodo({
186 - reason:
187 - '[InferEffectDependencies] Expected effect function to have non-pruned scope and its scope to have exactly one instruction',
188 - loc: fnExpr.loc,
189 - });
184 + let minimalDeps: Set<ReactiveScopeDependency>;
185 + if (scopeInfo != null) {
186 + minimalDeps = new Set(scopeInfo);
187 + } else {
188 + minimalDeps = inferMinimalDependencies(fnExpr);
189 }
191 -
190 /**
191 * Step 1: push dependencies to the effect deps array
192 *
@@ -196,8 +194,9 @@ export function inferEffectDependencies(fn: HIRFunction): void {
194 * the `infer-effect-deps/pruned-nonreactive-obj` fixture for an
195 * explanation.
196 */
197 +
198 const usedDeps = [];
200 - for (const dep of scopeInfo.deps) {
199 + for (const dep of minimalDeps) {
200 if (
201 ((isUseRefType(dep.identifier) ||
202 isSetStateType(dep.identifier)) &&
@@ -422,3 +421,132 @@ function collectDepUsages(
421
422 return sourceLocations;
423 }
424 +
425 +function inferMinimalDependencies(
426 + fnInstr: TInstruction<FunctionExpression>,
427 +): Set<ReactiveScopeDependency> {
428 + const fn = fnInstr.value.loweredFunc.func;
429 +
430 + const temporaries = collectTemporariesSidemap(fn, new Set());
431 + const {
432 + hoistableObjects,
433 + processedInstrsInOptional,
434 + temporariesReadInOptional,
435 + } = collectOptionalChainSidemap(fn);
436 +
437 + const hoistablePropertyLoads = collectHoistablePropertyLoadsInInnerFn(
438 + fnInstr,
439 + temporaries,
440 + hoistableObjects,
441 + );
442 + const hoistableToFnEntry = hoistablePropertyLoads.get(fn.body.entry);
443 + CompilerError.invariant(hoistableToFnEntry != null, {
444 + reason:
445 + '[InferEffectDependencies] Internal invariant broken: missing entry block',
446 + loc: fnInstr.loc,
447 + });
448 +
449 + const dependencies = inferDependencies(
450 + fnInstr,
451 + new Map([...temporaries, ...temporariesReadInOptional]),
452 + processedInstrsInOptional,
453 + );
454 +
455 + const tree = new ReactiveScopeDependencyTreeHIR(
456 + [...hoistableToFnEntry.assumedNonNullObjects].map(o => o.fullPath),
457 + );
458 + for (const dep of dependencies) {
459 + tree.addDependency({...dep});
460 + }
461 +
462 + return tree.deriveMinimalDependencies();
463 +}
464 +
465 +function inferDependencies(
466 + fnInstr: TInstruction<FunctionExpression>,
467 + temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>,
468 + processedInstrsInOptional: ReadonlySet<Instruction | Terminal>,
469 +): Set<ReactiveScopeDependency> {
470 + const fn = fnInstr.value.loweredFunc.func;
471 + const context = new DependencyCollectionContext(
472 + new Set(),
473 + temporaries,
474 + processedInstrsInOptional,
475 + );
476 + for (const dep of fn.context) {
477 + context.declare(dep.identifier, {
478 + id: makeInstructionId(0),
479 + scope: empty(),
480 + });
481 + }
482 + const placeholderScope: ReactiveScope = {
483 + id: makeScopeId(0),
484 + range: {
485 + start: fnInstr.id,
486 + end: makeInstructionId(fnInstr.id + 1),
487 + },
488 + dependencies: new Set(),
489 + reassignments: new Set(),
490 + declarations: new Map(),
491 + earlyReturnValue: null,
492 + merged: new Set(),
493 + loc: GeneratedSource,
494 + };
495 + context.enterScope(placeholderScope);
496 + inferDependenciesInFn(fn, context, temporaries);
497 + context.exitScope(placeholderScope, false);
498 + const resultUnfiltered = context.deps.get(placeholderScope);
499 + CompilerError.invariant(resultUnfiltered != null, {
500 + reason:
501 + '[InferEffectDependencies] Internal invariant broken: missing scope dependencies',
502 + loc: fn.loc,
503 + });
504 +
505 + const fnContext = new Set(fn.context.map(dep => dep.identifier.id));
506 + const result = new Set<ReactiveScopeDependency>();
507 + for (const dep of resultUnfiltered) {
508 + if (fnContext.has(dep.identifier.id)) {
509 + result.add(dep);
510 + }
511 + }
512 +
513 + return result;
514 +}
515 +
516 +function inferDependenciesInFn(
517 + fn: HIRFunction,
518 + context: DependencyCollectionContext,
519 + temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>,
520 +): void {
521 + for (const [, block] of fn.body.blocks) {
522 + // Record referenced optional chains in phis
523 + for (const phi of block.phis) {
524 + for (const operand of phi.operands) {
525 + const maybeOptionalChain = temporaries.get(operand[1].identifier.id);
526 + if (maybeOptionalChain) {
527 + context.visitDependency(maybeOptionalChain);
528 + }
529 + }
530 + }
531 + for (const instr of block.instructions) {
532 + if (
533 + instr.value.kind === 'FunctionExpression' ||
534 + instr.value.kind === 'ObjectMethod'
535 + ) {
536 + context.declare(instr.lvalue.identifier, {
537 + id: instr.id,
538 + scope: context.currentScope,
539 + });
540 + /**
541 + * Recursively visit the inner function to extract dependencies
542 + */
543 + const innerFn = instr.value.loweredFunc.func;
544 + context.enterInnerFn(instr as TInstruction<FunctionExpression>, () => {
545 + inferDependenciesInFn(innerFn, context, temporaries);
546 + });
547 + } else {
548 + handleInstruction(instr, context);
549 + }
550 + }
551 + }
552 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-granular-access.expect.md new
+39
@@ -0,0 +1,39 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies @panicThreshold(none)
6 +import {useEffect} from 'react';
7 +import {print} from 'shared-runtime';
8 +
9 +function Component({foo}) {
10 + const arr = [];
11 + // Taking either arr[0].value or arr as a dependency is reasonable
12 + // as long as developers know what to expect.
13 + useEffect(() => print(arr[0].value));
14 + arr.push({value: foo});
15 + return arr;
16 +}
17 +
18 +```
19 +
20 +## Code
21 +
22 +```javascript
23 +// @inferEffectDependencies @panicThreshold(none)
24 +import { useEffect } from "react";
25 +import { print } from "shared-runtime";
26 +
27 +function Component(t0) {
28 + const { foo } = t0;
29 + const arr = [];
30 +
31 + useEffect(() => print(arr[0].value), [arr[0].value]);
32 + arr.push({ value: foo });
33 + return arr;
34 +}
35 +
36 +```
37 +
38 +### Eval output
39 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-granular-access.js new
+12
@@ -0,0 +1,12 @@
1 +// @inferEffectDependencies @panicThreshold(none)
2 +import {useEffect} from 'react';
3 +import {print} from 'shared-runtime';
4 +
5 +function Component({foo}) {
6 + const arr = [];
7 + // Taking either arr[0].value or arr as a dependency is reasonable
8 + // as long as developers know what to expect.
9 + useEffect(() => print(arr[0].value));
10 + arr.push({value: foo});
11 + return arr;
12 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.expect.md new
+38
@@ -0,0 +1,38 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies @panicThreshold(none)
6 +
7 +import {useEffect, useRef} from 'react';
8 +import {print} from 'shared-runtime';
9 +
10 +function Component({arrRef}) {
11 + // Avoid taking arr.current as a dependency
12 + useEffect(() => print(arrRef.current));
13 + arrRef.current.val = 2;
14 + return arrRef;
15 +}
16 +
17 +```
18 +
19 +## Code
20 +
21 +```javascript
22 +// @inferEffectDependencies @panicThreshold(none)
23 +
24 +import { useEffect, useRef } from "react";
25 +import { print } from "shared-runtime";
26 +
27 +function Component(t0) {
28 + const { arrRef } = t0;
29 +
30 + useEffect(() => print(arrRef.current), [arrRef]);
31 + arrRef.current.val = 2;
32 + return arrRef;
33 +}
34 +
35 +```
36 +
37 +### Eval output
38 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.js new
+11
@@ -0,0 +1,11 @@
1 +// @inferEffectDependencies @panicThreshold(none)
2 +
3 +import {useEffect, useRef} from 'react';
4 +import {print} from 'shared-runtime';
5 +
6 +function Component({arrRef}) {
7 + // Avoid taking arr.current as a dependency
8 + useEffect(() => print(arrRef.current));
9 + arrRef.current.val = 2;
10 + return arrRef;
11 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect.expect.md new
+34
@@ -0,0 +1,34 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies @panicThreshold(none)
6 +import {useEffect} from 'react';
7 +
8 +function Component({foo}) {
9 + const arr = [];
10 + useEffect(() => arr.push(foo));
11 + arr.push(2);
12 + return arr;
13 +}
14 +
15 +```
16 +
17 +## Code
18 +
19 +```javascript
20 +// @inferEffectDependencies @panicThreshold(none)
21 +import { useEffect } from "react";
22 +
23 +function Component(t0) {
24 + const { foo } = t0;
25 + const arr = [];
26 + useEffect(() => arr.push(foo), [arr, foo]);
27 + arr.push(2);
28 + return arr;
29 +}
30 +
31 +```
32 +
33 +### Eval output
34 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect.js new
+9
@@ -0,0 +1,9 @@
1 +// @inferEffectDependencies @panicThreshold(none)
2 +import {useEffect} from 'react';
3 +
4 +function Component({foo}) {
5 + const arr = [];
6 + useEffect(() => arr.push(foo));
7 + arr.push(2);
8 + return arr;
9 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/error.todo-infer-deps-on-retry.expect.md deleted
-42
@@ -1,42 +0,0 @@
1 -
2 -## Input
3 -
4 -```javascript
5 -// @inferEffectDependencies @panicThreshold(none)
6 -import {useRef} from 'react';
7 -import {useSpecialEffect} from 'shared-runtime';
8 -
9 -/**
10 - * The retry pipeline disables memoization features, which means we need to
11 - * provide an alternate implementation of effect dependencies which does not
12 - * rely on memoization.
13 - */
14 -function useFoo({cond}) {
15 - const ref = useRef();
16 - const derived = cond ? ref.current : makeObject();
17 - useSpecialEffect(() => {
18 - log(derived);
19 - }, [derived]);
20 - return ref;
21 -}
22 -
23 -```
24 -
25 -
26 -## Error
27 -
28 -```
29 - 11 | const ref = useRef();
30 - 12 | const derived = cond ? ref.current : makeObject();
31 -> 13 | useSpecialEffect(() => {
32 - | ^^^^^^^^^^^^^^^^^^^^^^^^
33 -> 14 | log(derived);
34 - | ^^^^^^^^^^^^^^^^^
35 -> 15 | }, [derived]);
36 - | ^^^^^^^^^^^^^^^^ InvalidReact: [InferEffectDependencies] React Compiler is unable to infer dependencies of this effect. This will break your build! To resolve, either pass your own dependency array or fix reported compiler bailout diagnostics.. (Bailout reason: Invariant: Expected function expression scope to exist (13:15)) (13:15)
37 - 16 | return ref;
38 - 17 | }
39 - 18 |
40 -```
41 -
42 -
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/infer-deps-on-retry.expect.md new
+54
@@ -0,0 +1,54 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies @panicThreshold(none)
6 +import {useRef} from 'react';
7 +import {useSpecialEffect} from 'shared-runtime';
8 +
9 +/**
10 + * The retry pipeline disables memoization features, which means we need to
11 + * provide an alternate implementation of effect dependencies which does not
12 + * rely on memoization.
13 + */
14 +function useFoo({cond}) {
15 + const ref = useRef();
16 + const derived = cond ? ref.current : makeObject();
17 + useSpecialEffect(() => {
18 + log(derived);
19 + }, [derived]);
20 + return ref;
21 +}
22 +
23 +```
24 +
25 +## Code
26 +
27 +```javascript
28 +// @inferEffectDependencies @panicThreshold(none)
29 +import { useRef } from "react";
30 +import { useSpecialEffect } from "shared-runtime";
31 +
32 +/**
33 + * The retry pipeline disables memoization features, which means we need to
34 + * provide an alternate implementation of effect dependencies which does not
35 + * rely on memoization.
36 + */
37 +function useFoo(t0) {
38 + const { cond } = t0;
39 + const ref = useRef();
40 + const derived = cond ? ref.current : makeObject();
41 + useSpecialEffect(
42 + () => {
43 + log(derived);
44 + },
45 + [derived],
46 + [derived],
47 + );
48 + return ref;
49 +}
50 +
51 +```
52 +
53 +### Eval output
54 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/infer-deps-on-retry.js renamed