Skip to content

Commit e697386

Browse files
jbrown215mofeiZ
andauthored
[compiler] First cut at dep inference (#31386)
This is for researching/prototyping, not a feature we are releasing imminently. Putting up an early version of inferring effect dependencies to get feedback on the approach. We do not plan to ship this as-is, and may not start by going after direct `useEffect` calls. Until we make that decision, the heuristic I use to detect when to insert effect deps will suffice for testing. The approach is simple: when we see a useEffect call with no dep array we insert the deps inferred for the lambda passed in. If the first argument is not a lambda then we do not do anything. This diff is the easy part. I think the harder part will be ensuring that we can infer the deps even when we have to bail out of memoization. We have no other features that *must* run regardless of rules of react violations. Does anyone foresee any issues using the compiler passes to infer reactive deps when there may be violations? I have a few questions: 1. Will there ever be more than one instruction in a block containing a useEffect? if no, I can get rid of the`addedInstrs` variable that I use to make sure I insert the effect deps array temp creation at the right spot. 2. Are there any cases for resolving the first argument beyond just looking at the lvalue's identifier id that I'll need to take into account? e.g., do I need to recursively resolve certain bindings? --------- Co-authored-by: Mofei Zhang <feifei0@meta.com>
1 parent 9106107 commit e697386

25 files changed

+1134
-0
lines changed

compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
inferReactivePlaces,
3737
inferReferenceEffects,
3838
inlineImmediatelyInvokedFunctionExpressions,
39+
inferEffectDependencies,
3940
} from '../Inference';
4041
import {
4142
constantPropagation,
@@ -354,6 +355,10 @@ function* runWithEnvironment(
354355
value: hir,
355356
});
356357

358+
if (env.config.inferEffectDependencies) {
359+
inferEffectDependencies(env, hir);
360+
}
361+
357362
if (env.config.inlineJsxTransform) {
358363
inlineJsxTransform(hir, env.config.inlineJsxTransform);
359364
yield log({

compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,19 @@ const EnvironmentConfigSchema = z.object({
233233

234234
enableFunctionDependencyRewrite: z.boolean().default(true),
235235

236+
/**
237+
* Enables inference of optional dependency chains. Without this flag
238+
* a property chain such as `props?.items?.foo` will infer as a dep on
239+
* just `props`. With this flag enabled, we'll infer that full path as
240+
* the dependency.
241+
*/
242+
enableOptionalDependencies: z.boolean().default(true),
243+
244+
/**
245+
* Enables inference and auto-insertion of effect dependencies. Still experimental.
246+
*/
247+
inferEffectDependencies: z.boolean().default(false),
248+
236249
/**
237250
* Enables inlining ReactElement object literals in place of JSX
238251
* An alternative to the standard JSX transform which replaces JSX with React's jsxProd() runtime
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import {CompilerError, SourceLocation} from '..';
2+
import {
3+
ArrayExpression,
4+
Effect,
5+
Environment,
6+
FunctionExpression,
7+
GeneratedSource,
8+
HIRFunction,
9+
IdentifierId,
10+
Instruction,
11+
isUseEffectHookType,
12+
makeInstructionId,
13+
TInstruction,
14+
InstructionId,
15+
ScopeId,
16+
ReactiveScopeDependency,
17+
Place,
18+
ReactiveScopeDependencies,
19+
} from '../HIR';
20+
import {
21+
createTemporaryPlace,
22+
fixScopeAndIdentifierRanges,
23+
markInstructionIds,
24+
} from '../HIR/HIRBuilder';
25+
import {eachInstructionOperand, eachTerminalOperand} from '../HIR/visitors';
26+
27+
/**
28+
* Infers reactive dependencies captured by useEffect lambdas and adds them as
29+
* a second argument to the useEffect call if no dependency array is provided.
30+
*/
31+
export function inferEffectDependencies(
32+
env: Environment,
33+
fn: HIRFunction,
34+
): void {
35+
let hasRewrite = false;
36+
const fnExpressions = new Map<
37+
IdentifierId,
38+
TInstruction<FunctionExpression>
39+
>();
40+
const scopeInfos = new Map<
41+
ScopeId,
42+
{pruned: boolean; deps: ReactiveScopeDependencies; hasSingleInstr: boolean}
43+
>();
44+
45+
/**
46+
* When inserting LoadLocals, we need to retain the reactivity of the base
47+
* identifier, as later passes e.g. PruneNonReactiveDeps take the reactivity of
48+
* a base identifier as the "maximal" reactivity of all its references.
49+
* Concretely,
50+
* reactive(Identifier i) = Union_{reference of i}(reactive(reference))
51+
*/
52+
const reactiveIds = inferReactiveIdentifiers(fn);
53+
54+
for (const [, block] of fn.body.blocks) {
55+
if (
56+
block.terminal.kind === 'scope' ||
57+
block.terminal.kind === 'pruned-scope'
58+
) {
59+
const scopeBlock = fn.body.blocks.get(block.terminal.block)!;
60+
scopeInfos.set(block.terminal.scope.id, {
61+
pruned: block.terminal.kind === 'pruned-scope',
62+
deps: block.terminal.scope.dependencies,
63+
hasSingleInstr:
64+
scopeBlock.instructions.length === 1 &&
65+
scopeBlock.terminal.kind === 'goto' &&
66+
scopeBlock.terminal.block === block.terminal.fallthrough,
67+
});
68+
}
69+
const rewriteInstrs = new Map<InstructionId, Array<Instruction>>();
70+
for (const instr of block.instructions) {
71+
const {value, lvalue} = instr;
72+
if (value.kind === 'FunctionExpression') {
73+
fnExpressions.set(
74+
lvalue.identifier.id,
75+
instr as TInstruction<FunctionExpression>,
76+
);
77+
} else if (
78+
/*
79+
* This check is not final. Right now we only look for useEffects without a dependency array.
80+
* This is likely not how we will ship this feature, but it is good enough for us to make progress
81+
* on the implementation and test it.
82+
*/
83+
value.kind === 'CallExpression' &&
84+
isUseEffectHookType(value.callee.identifier) &&
85+
value.args.length === 1 &&
86+
value.args[0].kind === 'Identifier'
87+
) {
88+
const fnExpr = fnExpressions.get(value.args[0].identifier.id);
89+
if (fnExpr != null) {
90+
const scopeInfo =
91+
fnExpr.lvalue.identifier.scope != null
92+
? scopeInfos.get(fnExpr.lvalue.identifier.scope.id)
93+
: null;
94+
CompilerError.invariant(scopeInfo != null, {
95+
reason: 'Expected function expression scope to exist',
96+
loc: value.loc,
97+
});
98+
if (scopeInfo.pruned || !scopeInfo.hasSingleInstr) {
99+
/**
100+
* TODO: retry pipeline that ensures effect function expressions
101+
* are placed into their own scope
102+
*/
103+
CompilerError.throwTodo({
104+
reason:
105+
'[InferEffectDependencies] Expected effect function to have non-pruned scope and its scope to have exactly one instruction',
106+
loc: fnExpr.loc,
107+
});
108+
}
109+
110+
/**
111+
* Step 1: write new instructions to insert a dependency array
112+
*
113+
* Note that it's invalid to prune non-reactive deps in this pass, see
114+
* the `infer-effect-deps/pruned-nonreactive-obj` fixture for an
115+
* explanation.
116+
*/
117+
const effectDeps: Array<Place> = [];
118+
const newInstructions: Array<Instruction> = [];
119+
for (const dep of scopeInfo.deps) {
120+
const {place, instructions} = writeDependencyToInstructions(
121+
dep,
122+
reactiveIds.has(dep.identifier.id),
123+
fn.env,
124+
fnExpr.loc,
125+
);
126+
newInstructions.push(...instructions);
127+
effectDeps.push(place);
128+
}
129+
const deps: ArrayExpression = {
130+
kind: 'ArrayExpression',
131+
elements: effectDeps,
132+
loc: GeneratedSource,
133+
};
134+
135+
const depsPlace = createTemporaryPlace(env, GeneratedSource);
136+
depsPlace.effect = Effect.Read;
137+
138+
newInstructions.push({
139+
id: makeInstructionId(0),
140+
loc: GeneratedSource,
141+
lvalue: {...depsPlace, effect: Effect.Mutate},
142+
value: deps,
143+
});
144+
145+
// Step 2: insert the deps array as an argument of the useEffect
146+
value.args[1] = {...depsPlace, effect: Effect.Freeze};
147+
rewriteInstrs.set(instr.id, newInstructions);
148+
}
149+
}
150+
}
151+
if (rewriteInstrs.size > 0) {
152+
hasRewrite = true;
153+
const newInstrs = [];
154+
for (const instr of block.instructions) {
155+
const newInstr = rewriteInstrs.get(instr.id);
156+
if (newInstr != null) {
157+
newInstrs.push(...newInstr, instr);
158+
} else {
159+
newInstrs.push(instr);
160+
}
161+
}
162+
block.instructions = newInstrs;
163+
}
164+
}
165+
if (hasRewrite) {
166+
// Renumber instructions and fix scope ranges
167+
markInstructionIds(fn.body);
168+
fixScopeAndIdentifierRanges(fn.body);
169+
}
170+
}
171+
172+
function writeDependencyToInstructions(
173+
dep: ReactiveScopeDependency,
174+
reactive: boolean,
175+
env: Environment,
176+
loc: SourceLocation,
177+
): {place: Place; instructions: Array<Instruction>} {
178+
const instructions: Array<Instruction> = [];
179+
let currValue = createTemporaryPlace(env, GeneratedSource);
180+
currValue.reactive = reactive;
181+
instructions.push({
182+
id: makeInstructionId(0),
183+
loc: GeneratedSource,
184+
lvalue: {...currValue, effect: Effect.Mutate},
185+
value: {
186+
kind: 'LoadLocal',
187+
place: {
188+
kind: 'Identifier',
189+
identifier: dep.identifier,
190+
effect: Effect.Capture,
191+
reactive,
192+
loc: loc,
193+
},
194+
loc: loc,
195+
},
196+
});
197+
for (const path of dep.path) {
198+
if (path.optional) {
199+
/**
200+
* TODO: instead of truncating optional paths, reuse
201+
* instructions from hoisted dependencies block(s)
202+
*/
203+
break;
204+
}
205+
const nextValue = createTemporaryPlace(env, GeneratedSource);
206+
nextValue.reactive = reactive;
207+
instructions.push({
208+
id: makeInstructionId(0),
209+
loc: GeneratedSource,
210+
lvalue: {...nextValue, effect: Effect.Mutate},
211+
value: {
212+
kind: 'PropertyLoad',
213+
object: {...currValue, effect: Effect.Capture},
214+
property: path.property,
215+
loc: loc,
216+
},
217+
});
218+
currValue = nextValue;
219+
}
220+
currValue.effect = Effect.Freeze;
221+
return {place: currValue, instructions};
222+
}
223+
224+
function inferReactiveIdentifiers(fn: HIRFunction): Set<IdentifierId> {
225+
const reactiveIds: Set<IdentifierId> = new Set();
226+
for (const [, block] of fn.body.blocks) {
227+
for (const instr of block.instructions) {
228+
/**
229+
* No need to traverse into nested functions as
230+
* 1. their effects are recorded in `LoweredFunction.dependencies`
231+
* 2. we don't mark `reactive` in these anyways
232+
*/
233+
for (const place of eachInstructionOperand(instr)) {
234+
if (place.reactive) {
235+
reactiveIds.add(place.identifier.id);
236+
}
237+
}
238+
}
239+
240+
for (const place of eachTerminalOperand(block.terminal)) {
241+
if (place.reactive) {
242+
reactiveIds.add(place.identifier.id);
243+
}
244+
}
245+
}
246+
return reactiveIds;
247+
}

compiler/packages/babel-plugin-react-compiler/src/Inference/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export {inferMutableRanges} from './InferMutableRanges';
1111
export {inferReactivePlaces} from './InferReactivePlaces';
1212
export {default as inferReferenceEffects} from './InferReferenceEffects';
1313
export {inlineImmediatelyInvokedFunctionExpressions} from './InlineImmediatelyInvokedFunctionExpressions';
14+
export {inferEffectDependencies} from './InferEffectDependencies';

0 commit comments

Comments
 (0)