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

[compiler] Infer deps configuration (#31616)

Adds a way to configure how we insert deps for experimental purposes. ``` [ { module: 'react', imported: 'useEffect', numRequiredArgs: 1, }, { module: 'MyExperimentalEffectHooks', imported: 'useExperimentalEffect', numRequiredArgs: 2, }, ] ``` would insert dependencies for calls of `useEffect` imported from `react` if they have 1 argument and calls of useExperimentalEffect` from `MyExperimentalEffectHooks` if they have 2 arguments. The pushed dep array is appended to the arg list.

Jordan Brown committed Nov 22, 2024 at 17:19 UTC 2a9f4c04e54294b668e0a2ae11c1930c2e57b248
9 files changed +168 -22
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
+1 -1
@@ -356,7 +356,7 @@ function* runWithEnvironment(
356 });
357
358 if (env.config.inferEffectDependencies) {
359 - inferEffectDependencies(env, hir);
359 + inferEffectDependencies(hir);
360 }
361
362 if (env.config.inlineJsxTransform) {
compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+49 -2
@@ -242,9 +242,40 @@ const EnvironmentConfigSchema = z.object({
242 enableOptionalDependencies: z.boolean().default(true),
243
244 /**
245 - * Enables inference and auto-insertion of effect dependencies. Still experimental.
245 + * Enables inference and auto-insertion of effect dependencies. Takes in an array of
246 + * configurable module and import pairs to allow for user-land experimentation. For example,
247 + * [
248 + * {
249 + * module: 'react',
250 + * imported: 'useEffect',
251 + * numRequiredArgs: 1,
252 + * },{
253 + * module: 'MyExperimentalEffectHooks',
254 + * imported: 'useExperimentalEffect',
255 + * numRequiredArgs: 2,
256 + * },
257 + * ]
258 + * would insert dependencies for calls of `useEffect` imported from `react` and calls of
259 + * useExperimentalEffect` from `MyExperimentalEffectHooks`.
260 + *
261 + * `numRequiredArgs` tells the compiler the amount of arguments required to append a dependency
262 + * array to the end of the call. With the configuration above, we'd insert dependencies for
263 + * `useEffect` if it is only given a single argument and it would be appended to the argument list.
264 + *
265 + * numRequiredArgs must always be greater than 0, otherwise there is no function to analyze for dependencies
266 + *
267 + * Still experimental.
268 */
247 - inferEffectDependencies: z.boolean().default(false),
269 + inferEffectDependencies: z
270 + .nullable(
271 + z.array(
272 + z.object({
273 + function: ExternalFunctionSchema,
274 + numRequiredArgs: z.number(),
275 + }),
276 + ),
277 + )
278 + .default(null),
279
280 /**
281 * Enables inlining ReactElement object literals in place of JSX
@@ -614,6 +645,22 @@ const testComplexConfigDefaults: PartialEnvironmentConfig = {
645 source: 'react-compiler-runtime',
646 importSpecifierName: 'useContext_withSelector',
647 },
648 + inferEffectDependencies: [
649 + {
650 + function: {
651 + source: 'react',
652 + importSpecifierName: 'useEffect',
653 + },
654 + numRequiredArgs: 1,
655 + },
656 + {
657 + function: {
658 + source: 'shared-runtime',
659 + importSpecifierName: 'useSpecialEffect',
660 + },
661 + numRequiredArgs: 2,
662 + },
663 + ],
664 };
665
666 /**
compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts
+33 -13
@@ -8,7 +8,6 @@ import {
8 HIRFunction,
9 IdentifierId,
10 Instruction,
11 - isUseEffectHookType,
11 makeInstructionId,
12 TInstruction,
13 InstructionId,
@@ -23,20 +22,33 @@ import {
22 markInstructionIds,
23 } from '../HIR/HIRBuilder';
24 import {eachInstructionOperand, eachTerminalOperand} from '../HIR/visitors';
25 +import {getOrInsertWith} from '../Utils/utils';
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 {
31 +export function inferEffectDependencies(fn: HIRFunction): void {
32 let hasRewrite = false;
33 const fnExpressions = new Map<
34 IdentifierId,
35 TInstruction<FunctionExpression>
36 >();
37 +
38 + const autodepFnConfigs = new Map<string, Map<string, number>>();
39 + for (const effectTarget of fn.env.config.inferEffectDependencies!) {
40 + const moduleTargets = getOrInsertWith(
41 + autodepFnConfigs,
42 + effectTarget.function.source,
43 + () => new Map<string, number>(),
44 + );
45 + moduleTargets.set(
46 + effectTarget.function.importSpecifierName,
47 + effectTarget.numRequiredArgs,
48 + );
49 + }
50 + const autodepFnLoads = new Map<IdentifierId, number>();
51 +
52 const scopeInfos = new Map<
53 ScopeId,
54 {pruned: boolean; deps: ReactiveScopeDependencies; hasSingleInstr: boolean}
@@ -74,15 +86,23 @@ export function inferEffectDependencies(
86 lvalue.identifier.id,
87 instr as TInstruction<FunctionExpression>,
88 );
89 + } else if (
90 + value.kind === 'LoadGlobal' &&
91 + value.binding.kind === 'ImportSpecifier'
92 + ) {
93 + const moduleTargets = autodepFnConfigs.get(value.binding.module);
94 + if (moduleTargets != null) {
95 + const numRequiredArgs = moduleTargets.get(value.binding.imported);
96 + if (numRequiredArgs != null) {
97 + autodepFnLoads.set(lvalue.identifier.id, numRequiredArgs);
98 + }
99 + }
100 } else if (
101 /*
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.
102 + * TODO: Handle method calls
103 */
104 value.kind === 'CallExpression' &&
84 - isUseEffectHookType(value.callee.identifier) &&
85 - value.args.length === 1 &&
105 + autodepFnLoads.get(value.callee.identifier.id) === value.args.length &&
106 value.args[0].kind === 'Identifier'
107 ) {
108 const fnExpr = fnExpressions.get(value.args[0].identifier.id);
@@ -132,7 +152,7 @@ export function inferEffectDependencies(
152 loc: GeneratedSource,
153 };
154
135 - const depsPlace = createTemporaryPlace(env, GeneratedSource);
155 + const depsPlace = createTemporaryPlace(fn.env, GeneratedSource);
156 depsPlace.effect = Effect.Read;
157
158 newInstructions.push({
@@ -142,8 +162,8 @@ export function inferEffectDependencies(
162 value: deps,
163 });
164
145 - // Step 2: insert the deps array as an argument of the useEffect
146 - value.args[1] = {...depsPlace, effect: Effect.Freeze};
165 + // Step 2: push the inferred deps array as an argument of the useEffect
166 + value.args.push({...depsPlace, effect: Effect.Freeze});
167 rewriteInstrs.set(instr.id, newInstructions);
168 }
169 }
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-deps-custom-config.expect.md new
+61
@@ -0,0 +1,61 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies
6 +import {print, useSpecialEffect} from 'shared-runtime';
7 +
8 +function CustomConfig({propVal}) {
9 + // Insertion
10 + useSpecialEffect(() => print(propVal), [propVal]);
11 + // No insertion
12 + useSpecialEffect(() => print(propVal), [propVal], [propVal]);
13 +}
14 +
15 +```
16 +
17 +## Code
18 +
19 +```javascript
20 +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies
21 +import { print, useSpecialEffect } from "shared-runtime";
22 +
23 +function CustomConfig(t0) {
24 + const $ = _c(7);
25 + const { propVal } = t0;
26 + let t1;
27 + let t2;
28 + if ($[0] !== propVal) {
29 + t1 = () => print(propVal);
30 + t2 = [propVal];
31 + $[0] = propVal;
32 + $[1] = t1;
33 + $[2] = t2;
34 + } else {
35 + t1 = $[1];
36 + t2 = $[2];
37 + }
38 + useSpecialEffect(t1, t2, [propVal]);
39 + let t3;
40 + let t4;
41 + let t5;
42 + if ($[3] !== propVal) {
43 + t3 = () => print(propVal);
44 + t4 = [propVal];
45 + t5 = [propVal];
46 + $[3] = propVal;
47 + $[4] = t3;
48 + $[5] = t4;
49 + $[6] = t5;
50 + } else {
51 + t3 = $[4];
52 + t4 = $[5];
53 + t5 = $[6];
54 + }
55 + useSpecialEffect(t3, t4, t5);
56 +}
57 +
58 +```
59 +
60 +### Eval output
61 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-deps-custom-config.js new
+9
@@ -0,0 +1,9 @@
1 +// @inferEffectDependencies
2 +import {print, useSpecialEffect} from 'shared-runtime';
3 +
4 +function CustomConfig({propVal}) {
5 + // Insertion
6 + useSpecialEffect(() => print(propVal), [propVal]);
7 + // No insertion
8 + useSpecialEffect(() => print(propVal), [propVal], [propVal]);
9 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies.expect.md
+4
@@ -3,6 +3,8 @@
3
4 ```javascript
5 // @inferEffectDependencies
6 +import {useEffect, useRef} from 'react';
7 +
8 const moduleNonReactive = 0;
9
10 function Component({foo, bar}) {
@@ -45,6 +47,8 @@ function Component({foo, bar}) {
47
48 ```javascript
49 import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies
50 +import { useEffect, useRef } from "react";
51 +
52 const moduleNonReactive = 0;
53
54 function Component(t0) {
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies.js
+2
@@ -1,4 +1,6 @@
1 // @inferEffectDependencies
2 +import {useEffect, useRef} from 'react';
3 +
4 const moduleNonReactive = 0;
5
6 function Component({foo, bar}) {
compiler/packages/snap/src/compiler.ts
-6
@@ -174,11 +174,6 @@ function makePluginOptions(
174 .filter(s => s.length > 0);
175 }
176
177 - let inferEffectDependencies = false;
178 - if (firstLine.includes('@inferEffectDependencies')) {
179 - inferEffectDependencies = true;
180 - }
181 -
177 let logs: Array<{filename: string | null; event: LoggerEvent}> = [];
178 let logger: Logger | null = null;
179 if (firstLine.includes('@logger')) {
@@ -202,7 +197,6 @@ function makePluginOptions(
197 hookPattern,
198 validatePreserveExistingMemoizationGuarantees,
199 validateBlocklistedImports,
205 - inferEffectDependencies,
200 },
201 compilationMode,
202 logger,
compiler/packages/snap/src/sprout/shared-runtime.ts
+9
@@ -363,6 +363,14 @@ export function useFragment(..._args: Array<any>): object {
363 };
364 }
365
366 +export function useSpecialEffect(
367 + fn: () => any,
368 + _secondArg: any,
369 + deps: Array<any>,
370 +) {
371 + React.useEffect(fn, deps);
372 +}
373 +
374 export function typedArrayPush<T>(array: Array<T>, item: T): void {
375 array.push(item);
376 }
@@ -370,4 +378,5 @@ export function typedArrayPush<T>(array: Array<T>, item: T): void {
378 export function typedLog(...values: Array<any>): void {
379 console.log(...values);
380 }
381 +
382 export default typedLog;