@samitouri / QOS-React-1 / commits / e697386c10

[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>

Jordan Brown committed Nov 22, 2024 at 12:15 UTC e697386c10d837017de9516b9252b717fbb60924
25 files changed +1134
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
+5
@@ -36,6 +36,7 @@ import {
36 inferReactivePlaces,
37 inferReferenceEffects,
38 inlineImmediatelyInvokedFunctionExpressions,
39 + inferEffectDependencies,
40 } from '../Inference';
41 import {
42 constantPropagation,
@@ -354,6 +355,10 @@ function* runWithEnvironment(
355 value: hir,
356 });
357
358 + if (env.config.inferEffectDependencies) {
359 + inferEffectDependencies(env, hir);
360 + }
361 +
362 if (env.config.inlineJsxTransform) {
363 inlineJsxTransform(hir, env.config.inlineJsxTransform);
364 yield log({
compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+13
@@ -233,6 +233,19 @@ const EnvironmentConfigSchema = z.object({
233
234 enableFunctionDependencyRewrite: z.boolean().default(true),
235
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 +
249 /**
250 * Enables inlining ReactElement object literals in place of JSX
251 * An alternative to the standard JSX transform which replaces JSX with React's jsxProd() runtime
compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts new
+247
@@ -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
+1
@@ -11,3 +11,4 @@ export {inferMutableRanges} from './InferMutableRanges';
11 export {inferReactivePlaces} from './InferReactivePlaces';
12 export {default as inferReferenceEffects} from './InferReferenceEffects';
13 export {inlineImmediatelyInvokedFunctionExpressions} from './InlineImmediatelyInvokedFunctionExpressions';
14 +export {inferEffectDependencies} from './InferEffectDependencies';
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies.expect.md new
+129
@@ -0,0 +1,129 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies
6 +const moduleNonReactive = 0;
7 +
8 +function Component({foo, bar}) {
9 + const localNonreactive = 0;
10 + const ref = useRef(0);
11 + const localNonPrimitiveReactive = {
12 + foo,
13 + };
14 + const localNonPrimitiveNonreactive = {};
15 + useEffect(() => {
16 + console.log(foo);
17 + console.log(bar);
18 + console.log(moduleNonReactive);
19 + console.log(localNonreactive);
20 + console.log(globalValue);
21 + console.log(ref.current);
22 + console.log(localNonPrimitiveReactive);
23 + console.log(localNonPrimitiveNonreactive);
24 + });
25 +
26 + // Optional chains and property accesses
27 + // TODO: we may be able to save bytes by omitting property accesses if the
28 + // object of the member expression is already included in the inferred deps
29 + useEffect(() => {
30 + console.log(bar?.baz);
31 + console.log(bar.qux);
32 + });
33 +
34 + function f() {
35 + console.log(foo);
36 + }
37 +
38 + // No inferred dep array, the argument is not a lambda
39 + useEffect(f);
40 +}
41 +
42 +```
43 +
44 +## Code
45 +
46 +```javascript
47 +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies
48 +const moduleNonReactive = 0;
49 +
50 +function Component(t0) {
51 + const $ = _c(12);
52 + const { foo, bar } = t0;
53 +
54 + const ref = useRef(0);
55 + let t1;
56 + if ($[0] !== foo) {
57 + t1 = { foo };
58 + $[0] = foo;
59 + $[1] = t1;
60 + } else {
61 + t1 = $[1];
62 + }
63 + const localNonPrimitiveReactive = t1;
64 + let t2;
65 + if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
66 + t2 = {};
67 + $[2] = t2;
68 + } else {
69 + t2 = $[2];
70 + }
71 + const localNonPrimitiveNonreactive = t2;
72 + let t3;
73 + if ($[3] !== bar || $[4] !== foo || $[5] !== localNonPrimitiveReactive) {
74 + t3 = () => {
75 + console.log(foo);
76 + console.log(bar);
77 + console.log(moduleNonReactive);
78 + console.log(0);
79 + console.log(globalValue);
80 + console.log(ref.current);
81 + console.log(localNonPrimitiveReactive);
82 + console.log(localNonPrimitiveNonreactive);
83 + };
84 + $[3] = bar;
85 + $[4] = foo;
86 + $[5] = localNonPrimitiveReactive;
87 + $[6] = t3;
88 + } else {
89 + t3 = $[6];
90 + }
91 + useEffect(t3, [
92 + foo,
93 + bar,
94 + ref,
95 + localNonPrimitiveReactive,
96 + localNonPrimitiveNonreactive,
97 + ]);
98 + let t4;
99 + if ($[7] !== bar.baz || $[8] !== bar.qux) {
100 + t4 = () => {
101 + console.log(bar?.baz);
102 + console.log(bar.qux);
103 + };
104 + $[7] = bar.baz;
105 + $[8] = bar.qux;
106 + $[9] = t4;
107 + } else {
108 + t4 = $[9];
109 + }
110 + useEffect(t4, [bar.baz, bar.qux]);
111 + let t5;
112 + if ($[10] !== foo) {
113 + t5 = function f() {
114 + console.log(foo);
115 + };
116 + $[10] = foo;
117 + $[11] = t5;
118 + } else {
119 + t5 = $[11];
120 + }
121 + const f = t5;
122 +
123 + useEffect(f);
124 +}
125 +
126 +```
127 +
128 +### Eval output
129 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies.js new
+36
@@ -0,0 +1,36 @@
1 +// @inferEffectDependencies
2 +const moduleNonReactive = 0;
3 +
4 +function Component({foo, bar}) {
5 + const localNonreactive = 0;
6 + const ref = useRef(0);
7 + const localNonPrimitiveReactive = {
8 + foo,
9 + };
10 + const localNonPrimitiveNonreactive = {};
11 + useEffect(() => {
12 + console.log(foo);
13 + console.log(bar);
14 + console.log(moduleNonReactive);
15 + console.log(localNonreactive);
16 + console.log(globalValue);
17 + console.log(ref.current);
18 + console.log(localNonPrimitiveReactive);
19 + console.log(localNonPrimitiveNonreactive);
20 + });
21 +
22 + // Optional chains and property accesses
23 + // TODO: we may be able to save bytes by omitting property accesses if the
24 + // object of the member expression is already included in the inferred deps
25 + useEffect(() => {
26 + console.log(bar?.baz);
27 + console.log(bar.qux);
28 + });
29 +
30 + function f() {
31 + console.log(foo);
32 + }
33 +
34 + // No inferred dep array, the argument is not a lambda
35 + useEffect(f);
36 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonreactive-dep.expect.md new
+80
@@ -0,0 +1,80 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies
6 +import {useEffect} from 'react';
7 +import {makeObject_Primitives, print} from 'shared-runtime';
8 +
9 +/**
10 + * Note that `obj` is currently added to the effect dependency array, even
11 + * though it's non-reactive due to memoization.
12 + *
13 + * This is a TODO in effect dependency inference. Note that we cannot simply
14 + * filter out non-reactive effect dependencies, as some non-reactive (by data
15 + * flow) values become reactive due to scope pruning. See the
16 + * `infer-effect-deps/pruned-nonreactive-obj` fixture for why this matters.
17 + *
18 + * Realizing that this `useEffect` should have an empty dependency array
19 + * requires effect dependency inference to be structured similarly to memo
20 + * dependency inference.
21 + * Pass 1: add all potential dependencies regardless of dataflow reactivity
22 + * Pass 2: (todo) prune non-reactive dependencies
23 + *
24 + * Note that instruction reordering should significantly reduce scope pruning
25 + */
26 +function NonReactiveDepInEffect() {
27 + const obj = makeObject_Primitives();
28 + useEffect(() => print(obj));
29 +}
30 +
31 +```
32 +
33 +## Code
34 +
35 +```javascript
36 +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies
37 +import { useEffect } from "react";
38 +import { makeObject_Primitives, print } from "shared-runtime";
39 +
40 +/**
41 + * Note that `obj` is currently added to the effect dependency array, even
42 + * though it's non-reactive due to memoization.
43 + *
44 + * This is a TODO in effect dependency inference. Note that we cannot simply
45 + * filter out non-reactive effect dependencies, as some non-reactive (by data
46 + * flow) values become reactive due to scope pruning. See the
47 + * `infer-effect-deps/pruned-nonreactive-obj` fixture for why this matters.
48 + *
49 + * Realizing that this `useEffect` should have an empty dependency array
50 + * requires effect dependency inference to be structured similarly to memo
51 + * dependency inference.
52 + * Pass 1: add all potential dependencies regardless of dataflow reactivity
53 + * Pass 2: (todo) prune non-reactive dependencies
54 + *
55 + * Note that instruction reordering should significantly reduce scope pruning
56 + */
57 +function NonReactiveDepInEffect() {
58 + const $ = _c(2);
59 + let t0;
60 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
61 + t0 = makeObject_Primitives();
62 + $[0] = t0;
63 + } else {
64 + t0 = $[0];
65 + }
66 + const obj = t0;
67 + let t1;
68 + if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
69 + t1 = () => print(obj);
70 + $[1] = t1;
71 + } else {
72 + t1 = $[1];
73 + }
74 + useEffect(t1, [obj]);
75 +}
76 +
77 +```
78 +
79 +### Eval output
80 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonreactive-dep.js new
+25
@@ -0,0 +1,25 @@
1 +// @inferEffectDependencies
2 +import {useEffect} from 'react';
3 +import {makeObject_Primitives, print} from 'shared-runtime';
4 +
5 +/**
6 + * Note that `obj` is currently added to the effect dependency array, even
7 + * though it's non-reactive due to memoization.
8 + *
9 + * This is a TODO in effect dependency inference. Note that we cannot simply
10 + * filter out non-reactive effect dependencies, as some non-reactive (by data
11 + * flow) values become reactive due to scope pruning. See the
12 + * `infer-effect-deps/pruned-nonreactive-obj` fixture for why this matters.
13 + *
14 + * Realizing that this `useEffect` should have an empty dependency array
15 + * requires effect dependency inference to be structured similarly to memo
16 + * dependency inference.
17 + * Pass 1: add all potential dependencies regardless of dataflow reactivity
18 + * Pass 2: (todo) prune non-reactive dependencies
19 + *
20 + * Note that instruction reordering should significantly reduce scope pruning
21 + */
22 +function NonReactiveDepInEffect() {
23 + const obj = makeObject_Primitives();
24 + useEffect(() => print(obj));
25 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonreactive-ref.expect.md new
+51
@@ -0,0 +1,51 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies
6 +import {useEffect, useRef} from 'react';
7 +import {print} from 'shared-runtime';
8 +
9 +/**
10 + * Special case of `infer-effect-deps/nonreactive-dep`.
11 + *
12 + * We know that local `useRef` return values are stable, regardless of
13 + * inferred memoization.
14 + */
15 +function NonReactiveRefInEffect() {
16 + const ref = useRef('initial value');
17 + useEffect(() => print(ref.current));
18 +}
19 +
20 +```
21 +
22 +## Code
23 +
24 +```javascript
25 +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies
26 +import { useEffect, useRef } from "react";
27 +import { print } from "shared-runtime";
28 +
29 +/**
30 + * Special case of `infer-effect-deps/nonreactive-dep`.
31 + *
32 + * We know that local `useRef` return values are stable, regardless of
33 + * inferred memoization.
34 + */
35 +function NonReactiveRefInEffect() {
36 + const $ = _c(1);
37 + const ref = useRef("initial value");
38 + let t0;
39 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
40 + t0 = () => print(ref.current);
41 + $[0] = t0;
42 + } else {
43 + t0 = $[0];
44 + }
45 + useEffect(t0, [ref]);
46 +}
47 +
48 +```
49 +
50 +### Eval output
51 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonreactive-ref.js new
+14
@@ -0,0 +1,14 @@
1 +// @inferEffectDependencies
2 +import {useEffect, useRef} from 'react';
3 +import {print} from 'shared-runtime';
4 +
5 +/**
6 + * Special case of `infer-effect-deps/nonreactive-dep`.
7 + *
8 + * We know that local `useRef` return values are stable, regardless of
9 + * inferred memoization.
10 + */
11 +function NonReactiveRefInEffect() {
12 + const ref = useRef('initial value');
13 + useEffect(() => print(ref.current));
14 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/outlined-function.expect.md new
+46
@@ -0,0 +1,46 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies
6 +import {useEffect} from 'react';
7 +import {print} from 'shared-runtime';
8 +/**
9 + * This compiled output is technically incorrect but this is currently the same
10 + * case as a bailout (an effect that overfires).
11 + *
12 + * To ensure an empty deps array is passed, we need special case
13 + * `InferEffectDependencies` for outlined functions (likely easier) or run it
14 + * before OutlineFunctions
15 + */
16 +function OutlinedFunctionInEffect() {
17 + useEffect(() => print('hello world!'));
18 +}
19 +
20 +```
21 +
22 +## Code
23 +
24 +```javascript
25 +// @inferEffectDependencies
26 +import { useEffect } from "react";
27 +import { print } from "shared-runtime";
28 +/**
29 + * This compiled output is technically incorrect but this is currently the same
30 + * case as a bailout (an effect that overfires).
31 + *
32 + * To ensure an empty deps array is passed, we need special case
33 + * `InferEffectDependencies` for outlined functions (likely easier) or run it
34 + * before OutlineFunctions
35 + */
36 +function OutlinedFunctionInEffect() {
37 + useEffect(_temp);
38 +}
39 +function _temp() {
40 + return print("hello world!");
41 +}
42 +
43 +```
44 +
45 +### Eval output
46 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/outlined-function.js new
+14
@@ -0,0 +1,14 @@
1 +// @inferEffectDependencies
2 +import {useEffect} from 'react';
3 +import {print} from 'shared-runtime';
4 +/**
5 + * This compiled output is technically incorrect but this is currently the same
6 + * case as a bailout (an effect that overfires).
7 + *
8 + * To ensure an empty deps array is passed, we need special case
9 + * `InferEffectDependencies` for outlined functions (likely easier) or run it
10 + * before OutlineFunctions
11 + */
12 +function OutlinedFunctionInEffect() {
13 + useEffect(() => print('hello world!'));
14 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/pruned-nonreactive-obj.expect.md new
+119
@@ -0,0 +1,119 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies
6 +import {useIdentity, mutate, makeObject} from 'shared-runtime';
7 +import {useEffect} from 'react';
8 +
9 +/**
10 + * When a semantically non-reactive value has a pruned scope (i.e. the object
11 + * identity becomes reactive, but the underlying value it represents should be
12 + * constant), the compiler can choose to either
13 + * - add it as a dependency (and rerun the effect)
14 + * - not add it as a dependency
15 + *
16 + * We keep semantically non-reactive values in both memo block and effect
17 + * dependency arrays to avoid versioning invariants e.g. `x !== y.aliasedX`.
18 + * ```js
19 + * function Component() {
20 + * // obj is semantically non-reactive, but its memo scope is pruned due to
21 + * // the interleaving hook call
22 + * const obj = {};
23 + * useHook();
24 + * write(obj);
25 + *
26 + * const ref = useRef();
27 + *
28 + * // this effect needs to be rerun when obj's referential identity changes,
29 + * // because it might alias obj to a useRef / mutable store.
30 + * useEffect(() => ref.current = obj, ???);
31 + *
32 + * // in a custom hook (or child component), the user might expect versioning
33 + * // invariants to hold
34 + * useHook(ref, obj);
35 + * }
36 + *
37 + * // defined elsewhere
38 + * function useHook(someRef, obj) {
39 + * useEffect(
40 + * () => assert(someRef.current === obj),
41 + * [someRef, obj]
42 + * );
43 + * }
44 + * ```
45 + */
46 +function PrunedNonReactive() {
47 + const obj = makeObject();
48 + useIdentity(null);
49 + mutate(obj);
50 +
51 + useEffect(() => print(obj.value));
52 +}
53 +
54 +```
55 +
56 +## Code
57 +
58 +```javascript
59 +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies
60 +import { useIdentity, mutate, makeObject } from "shared-runtime";
61 +import { useEffect } from "react";
62 +
63 +/**
64 + * When a semantically non-reactive value has a pruned scope (i.e. the object
65 + * identity becomes reactive, but the underlying value it represents should be
66 + * constant), the compiler can choose to either
67 + * - add it as a dependency (and rerun the effect)
68 + * - not add it as a dependency
69 + *
70 + * We keep semantically non-reactive values in both memo block and effect
71 + * dependency arrays to avoid versioning invariants e.g. `x !== y.aliasedX`.
72 + * ```js
73 + * function Component() {
74 + * // obj is semantically non-reactive, but its memo scope is pruned due to
75 + * // the interleaving hook call
76 + * const obj = {};
77 + * useHook();
78 + * write(obj);
79 + *
80 + * const ref = useRef();
81 + *
82 + * // this effect needs to be rerun when obj's referential identity changes,
83 + * // because it might alias obj to a useRef / mutable store.
84 + * useEffect(() => ref.current = obj, ???);
85 + *
86 + * // in a custom hook (or child component), the user might expect versioning
87 + * // invariants to hold
88 + * useHook(ref, obj);
89 + * }
90 + *
91 + * // defined elsewhere
92 + * function useHook(someRef, obj) {
93 + * useEffect(
94 + * () => assert(someRef.current === obj),
95 + * [someRef, obj]
96 + * );
97 + * }
98 + * ```
99 + */
100 +function PrunedNonReactive() {
101 + const $ = _c(2);
102 + const obj = makeObject();
103 + useIdentity(null);
104 + mutate(obj);
105 + let t0;
106 + if ($[0] !== obj.value) {
107 + t0 = () => print(obj.value);
108 + $[0] = obj.value;
109 + $[1] = t0;
110 + } else {
111 + t0 = $[1];
112 + }
113 + useEffect(t0, [obj.value]);
114 +}
115 +
116 +```
117 +
118 +### Eval output
119 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/pruned-nonreactive-obj.js new
+48
@@ -0,0 +1,48 @@
1 +// @inferEffectDependencies
2 +import {useIdentity, mutate, makeObject} from 'shared-runtime';
3 +import {useEffect} from 'react';
4 +
5 +/**
6 + * When a semantically non-reactive value has a pruned scope (i.e. the object
7 + * identity becomes reactive, but the underlying value it represents should be
8 + * constant), the compiler can choose to either
9 + * - add it as a dependency (and rerun the effect)
10 + * - not add it as a dependency
11 + *
12 + * We keep semantically non-reactive values in both memo block and effect
13 + * dependency arrays to avoid versioning invariants e.g. `x !== y.aliasedX`.
14 + * ```js
15 + * function Component() {
16 + * // obj is semantically non-reactive, but its memo scope is pruned due to
17 + * // the interleaving hook call
18 + * const obj = {};
19 + * useHook();
20 + * write(obj);
21 + *
22 + * const ref = useRef();
23 + *
24 + * // this effect needs to be rerun when obj's referential identity changes,
25 + * // because it might alias obj to a useRef / mutable store.
26 + * useEffect(() => ref.current = obj, ???);
27 + *
28 + * // in a custom hook (or child component), the user might expect versioning
29 + * // invariants to hold
30 + * useHook(ref, obj);
31 + * }
32 + *
33 + * // defined elsewhere
34 + * function useHook(someRef, obj) {
35 + * useEffect(
36 + * () => assert(someRef.current === obj),
37 + * [someRef, obj]
38 + * );
39 + * }
40 + * ```
41 + */
42 +function PrunedNonReactive() {
43 + const obj = makeObject();
44 + useIdentity(null);
45 + mutate(obj);
46 +
47 + useEffect(() => print(obj.value));
48 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reactive-memberexpr-merge.expect.md new
+49
@@ -0,0 +1,49 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies
6 +import {useEffect} from 'react';
7 +import {print} from 'shared-runtime';
8 +
9 +function ReactiveMemberExprMerge({propVal}) {
10 + const obj = {a: {b: propVal}};
11 + useEffect(() => print(obj.a, obj.a.b));
12 +}
13 +
14 +```
15 +
16 +## Code
17 +
18 +```javascript
19 +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies
20 +import { useEffect } from "react";
21 +import { print } from "shared-runtime";
22 +
23 +function ReactiveMemberExprMerge(t0) {
24 + const $ = _c(4);
25 + const { propVal } = t0;
26 + let t1;
27 + if ($[0] !== propVal) {
28 + t1 = { a: { b: propVal } };
29 + $[0] = propVal;
30 + $[1] = t1;
31 + } else {
32 + t1 = $[1];
33 + }
34 + const obj = t1;
35 + let t2;
36 + if ($[2] !== obj.a) {
37 + t2 = () => print(obj.a, obj.a.b);
38 + $[2] = obj.a;
39 + $[3] = t2;
40 + } else {
41 + t2 = $[3];
42 + }
43 + useEffect(t2, [obj.a]);
44 +}
45 +
46 +```
47 +
48 +### Eval output
49 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reactive-memberexpr-merge.js new
+8
@@ -0,0 +1,8 @@
1 +// @inferEffectDependencies
2 +import {useEffect} from 'react';
3 +import {print} from 'shared-runtime';
4 +
5 +function ReactiveMemberExprMerge({propVal}) {
6 + const obj = {a: {b: propVal}};
7 + useEffect(() => print(obj.a, obj.a.b));
8 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reactive-memberexpr.expect.md new
+49
@@ -0,0 +1,49 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies
6 +import {useEffect} from 'react';
7 +import {print} from 'shared-runtime';
8 +
9 +function ReactiveMemberExpr({propVal}) {
10 + const obj = {a: {b: propVal}};
11 + useEffect(() => print(obj.a.b));
12 +}
13 +
14 +```
15 +
16 +## Code
17 +
18 +```javascript
19 +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies
20 +import { useEffect } from "react";
21 +import { print } from "shared-runtime";
22 +
23 +function ReactiveMemberExpr(t0) {
24 + const $ = _c(4);
25 + const { propVal } = t0;
26 + let t1;
27 + if ($[0] !== propVal) {
28 + t1 = { a: { b: propVal } };
29 + $[0] = propVal;
30 + $[1] = t1;
31 + } else {
32 + t1 = $[1];
33 + }
34 + const obj = t1;
35 + let t2;
36 + if ($[2] !== obj.a.b) {
37 + t2 = () => print(obj.a.b);
38 + $[2] = obj.a.b;
39 + $[3] = t2;
40 + } else {
41 + t2 = $[3];
42 + }
43 + useEffect(t2, [obj.a.b]);
44 +}
45 +
46 +```
47 +
48 +### Eval output
49 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reactive-memberexpr.js new
+8
@@ -0,0 +1,8 @@
1 +// @inferEffectDependencies
2 +import {useEffect} from 'react';
3 +import {print} from 'shared-runtime';
4 +
5 +function ReactiveMemberExpr({propVal}) {
6 + const obj = {a: {b: propVal}};
7 + useEffect(() => print(obj.a.b));
8 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reactive-optional-chain.expect.md new
+60
@@ -0,0 +1,60 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies
6 +import {useEffect} from 'react';
7 +import {print} from 'shared-runtime';
8 +
9 +// TODO: take optional chains as dependencies
10 +function ReactiveMemberExpr({cond, propVal}) {
11 + const obj = {a: cond ? {b: propVal} : null};
12 + useEffect(() => print(obj.a?.b));
13 +}
14 +
15 +```
16 +
17 +## Code
18 +
19 +```javascript
20 +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies
21 +import { useEffect } from "react";
22 +import { print } from "shared-runtime";
23 +
24 +// TODO: take optional chains as dependencies
25 +function ReactiveMemberExpr(t0) {
26 + const $ = _c(7);
27 + const { cond, propVal } = t0;
28 + let t1;
29 + if ($[0] !== cond || $[1] !== propVal) {
30 + t1 = cond ? { b: propVal } : null;
31 + $[0] = cond;
32 + $[1] = propVal;
33 + $[2] = t1;
34 + } else {
35 + t1 = $[2];
36 + }
37 + let t2;
38 + if ($[3] !== t1) {
39 + t2 = { a: t1 };
40 + $[3] = t1;
41 + $[4] = t2;
42 + } else {
43 + t2 = $[4];
44 + }
45 + const obj = t2;
46 + let t3;
47 + if ($[5] !== obj.a?.b) {
48 + t3 = () => print(obj.a?.b);
49 + $[5] = obj.a?.b;
50 + $[6] = t3;
51 + } else {
52 + t3 = $[6];
53 + }
54 + useEffect(t3, [obj.a]);
55 +}
56 +
57 +```
58 +
59 +### Eval output
60 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reactive-optional-chain.js new
+9
@@ -0,0 +1,9 @@
1 +// @inferEffectDependencies
2 +import {useEffect} from 'react';
3 +import {print} from 'shared-runtime';
4 +
5 +// TODO: take optional chains as dependencies
6 +function ReactiveMemberExpr({cond, propVal}) {
7 + const obj = {a: cond ? {b: propVal} : null};
8 + useEffect(() => print(obj.a?.b));
9 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reactive-variable.expect.md new
+49
@@ -0,0 +1,49 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies
6 +import {useEffect} from 'react';
7 +import {print} from 'shared-runtime';
8 +
9 +function ReactiveVariable({propVal}) {
10 + const arr = [propVal];
11 + useEffect(() => print(arr));
12 +}
13 +
14 +```
15 +
16 +## Code
17 +
18 +```javascript
19 +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies
20 +import { useEffect } from "react";
21 +import { print } from "shared-runtime";
22 +
23 +function ReactiveVariable(t0) {
24 + const $ = _c(4);
25 + const { propVal } = t0;
26 + let t1;
27 + if ($[0] !== propVal) {
28 + t1 = [propVal];
29 + $[0] = propVal;
30 + $[1] = t1;
31 + } else {
32 + t1 = $[1];
33 + }
34 + const arr = t1;
35 + let t2;
36 + if ($[2] !== arr) {
37 + t2 = () => print(arr);
38 + $[2] = arr;
39 + $[3] = t2;
40 + } else {
41 + t2 = $[3];
42 + }
43 + useEffect(t2, [arr]);
44 +}
45 +
46 +```
47 +
48 +### Eval output
49 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reactive-variable.js new
+8
@@ -0,0 +1,8 @@
1 +// @inferEffectDependencies
2 +import {useEffect} from 'react';
3 +import {print} from 'shared-runtime';
4 +
5 +function ReactiveVariable({propVal}) {
6 + const arr = [propVal];
7 + useEffect(() => print(arr));
8 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-import-namespace-useEffect.expect.md new
+50
@@ -0,0 +1,50 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies
6 +import * as React from 'react';
7 +
8 +/**
9 + * TODO: recognize import namespace
10 + */
11 +function NonReactiveDepInEffect() {
12 + const obj = makeObject_Primitives();
13 + React.useEffect(() => print(obj));
14 +}
15 +
16 +```
17 +
18 +## Code
19 +
20 +```javascript
21 +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies
22 +import * as React from "react";
23 +
24 +/**
25 + * TODO: recognize import namespace
26 + */
27 +function NonReactiveDepInEffect() {
28 + const $ = _c(2);
29 + let t0;
30 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
31 + t0 = makeObject_Primitives();
32 + $[0] = t0;
33 + } else {
34 + t0 = $[0];
35 + }
36 + const obj = t0;
37 + let t1;
38 + if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
39 + t1 = () => print(obj);
40 + $[1] = t1;
41 + } else {
42 + t1 = $[1];
43 + }
44 + React.useEffect(t1);
45 +}
46 +
47 +```
48 +
49 +### Eval output
50 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-import-namespace-useEffect.js new
+10
@@ -0,0 +1,10 @@
1 +// @inferEffectDependencies
2 +import * as React from 'react';
3 +
4 +/**
5 + * TODO: recognize import namespace
6 + */
7 +function NonReactiveDepInEffect() {
8 + const obj = makeObject_Primitives();
9 + React.useEffect(() => print(obj));
10 +}
compiler/packages/snap/src/compiler.ts
+6
@@ -174,6 +174,11 @@ function makePluginOptions(
174 .filter(s => s.length > 0);
175 }
176
177 + let inferEffectDependencies = false;
178 + if (firstLine.includes('@inferEffectDependencies')) {
179 + inferEffectDependencies = true;
180 + }
181 +
182 let logs: Array<{filename: string | null; event: LoggerEvent}> = [];
183 let logger: Logger | null = null;
184 if (firstLine.includes('@logger')) {
@@ -197,6 +202,7 @@ function makePluginOptions(
202 hookPattern,
203 validatePreserveExistingMemoizationGuarantees,
204 validateBlocklistedImports,
205 + inferEffectDependencies,
206 },
207 compilationMode,
208 logger,