@samitouri / QOS-React-2 / commits / 8039f1b2a0

[compiler] Fix inferEffectDependencies lint false positives (#32769)

Currently, inferred effect dependencies are considered a "compiler-required" feature. This means that untransformed callsites should escalate to a build error. `ValidateNoUntransformedReferences` iterates 'special effect' callsites and checks that the compiler was able to successfully transform them. Prior to this PR, this relied on checking the number of arguments passed to this special effect. This obviously doesn't work with `noEmit: true`, which is used for our eslint plugin (this avoids mutating the babel program as other linters run with the same ast). This PR adds a set of `babel.SourceLocation`s to do best effort matching in this mode.

mofeiZ committed Mar 27, 2025 at 12:18 UTC 8039f1b2a05d00437cd29707761aeae098c80adc
9 files changed +78 -9
compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts
+1 -1
@@ -73,7 +73,7 @@ export default function BabelPluginReactCompiler(
73 pass.filename ?? null,
74 opts.logger,
75 opts.environment,
76 - result?.retryErrors ?? [],
76 + result,
77 );
78 if (ENABLE_REACT_COMPILER_TIMINGS === true) {
79 performance.mark(`${filename}:end`, {
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts
+13 -2
@@ -30,6 +30,7 @@ import {
30 findProgramSuppressions,
31 suppressionsToCompilerError,
32 } from './Suppression';
33 +import {GeneratedSource} from '../HIR';
34
35 export type CompilerPass = {
36 opts: PluginOptions;
@@ -267,8 +268,9 @@ function isFilePartOfSources(
268 return false;
269 }
270
270 -type CompileProgramResult = {
271 +export type CompileProgramResult = {
272 retryErrors: Array<{fn: BabelFn; error: CompilerError}>;
273 + inferredEffectLocations: Set<t.SourceLocation>;
274 };
275 /**
276 * `compileProgram` is directly invoked by the react-compiler babel plugin, so
@@ -369,6 +371,7 @@ export function compileProgram(
371 },
372 );
373 const retryErrors: Array<{fn: BabelFn; error: CompilerError}> = [];
374 + const inferredEffectLocations = new Set<t.SourceLocation>();
375 const processFn = (
376 fn: BabelFn,
377 fnType: ReactFunctionType,
@@ -509,6 +512,14 @@ export function compileProgram(
512 if (!pass.opts.noEmit) {
513 return compileResult.compiledFn;
514 }
515 + /**
516 + * inferEffectDependencies + noEmit is currently only used for linting. In
517 + * this mode, add source locations for where the compiler *can* infer effect
518 + * dependencies.
519 + */
520 + for (const loc of compileResult.compiledFn.inferredEffectLocations) {
521 + if (loc !== GeneratedSource) inferredEffectLocations.add(loc);
522 + }
523 return null;
524 };
525
@@ -587,7 +598,7 @@ export function compileProgram(
598 if (compiledFns.length > 0) {
599 addImportsToProgram(program, programContext);
600 }
590 - return {retryErrors};
601 + return {retryErrors, inferredEffectLocations};
602 }
603
604 function shouldSkipCompilation(
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/ValidateNoUntransformedReferences.ts
+13 -5
@@ -11,6 +11,7 @@ import {
11 import {getOrInsertWith} from '../Utils/utils';
12 import {Environment} from '../HIR';
13 import {DEFAULT_EXPORT} from '../HIR/Environment';
14 +import {CompileProgramResult} from './Program';
15
16 function throwInvalidReact(
17 options: Omit<CompilerErrorDetailOptions, 'severity'>,
@@ -36,12 +37,16 @@ function assertValidEffectImportReference(
37 const parent = path.parentPath;
38 if (parent != null && parent.isCallExpression()) {
39 const args = parent.get('arguments');
40 + const maybeCalleeLoc = path.node.loc;
41 + const hasInferredEffect =
42 + maybeCalleeLoc != null &&
43 + context.inferredEffectLocations.has(maybeCalleeLoc);
44 /**
45 * Only error on untransformed references of the form `useMyEffect(...)`
46 * or `moduleNamespace.useMyEffect(...)`, with matching argument counts.
47 * TODO: do we also want a mode to also hard error on non-call references?
48 */
44 - if (args.length === numArgs) {
49 + if (args.length === numArgs && !hasInferredEffect) {
50 const maybeErrorDiagnostic = matchCompilerDiagnostic(
51 path,
52 context.transformErrors,
@@ -97,7 +102,7 @@ export default function validateNoUntransformedReferences(
102 filename: string | null,
103 logger: Logger | null,
104 env: EnvironmentConfig,
100 - transformErrors: Array<{fn: NodePath<t.Node>; error: CompilerError}>,
105 + compileResult: CompileProgramResult | null,
106 ): void {
107 const moduleLoadChecks = new Map<
108 string,
@@ -126,7 +131,7 @@ export default function validateNoUntransformedReferences(
131 }
132 }
133 if (moduleLoadChecks.size > 0) {
129 - transformProgram(path, moduleLoadChecks, filename, logger, transformErrors);
134 + transformProgram(path, moduleLoadChecks, filename, logger, compileResult);
135 }
136 }
137
@@ -136,6 +141,7 @@ type TraversalState = {
141 logger: Logger | null;
142 filename: string | null;
143 transformErrors: Array<{fn: NodePath<t.Node>; error: CompilerError}>;
144 + inferredEffectLocations: Set<t.SourceLocation>;
145 };
146 type CheckInvalidReferenceFn = (
147 paths: Array<NodePath<t.Node>>,
@@ -223,14 +229,16 @@ function transformProgram(
229 moduleLoadChecks: Map<string, Map<string, CheckInvalidReferenceFn>>,
230 filename: string | null,
231 logger: Logger | null,
226 - transformErrors: Array<{fn: NodePath<t.Node>; error: CompilerError}>,
232 + compileResult: CompileProgramResult | null,
233 ): void {
234 const traversalState: TraversalState = {
235 shouldInvalidateScopes: true,
236 program: path,
237 filename,
238 logger,
233 - transformErrors,
239 + transformErrors: compileResult?.retryErrors ?? [],
240 + inferredEffectLocations:
241 + compileResult?.inferredEffectLocations ?? new Set(),
242 };
243 path.traverse({
244 ImportDeclaration(path: NodePath<t.ImportDeclaration>) {
compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+8
@@ -11,6 +11,7 @@ import {fromZodError} from 'zod-validation-error';
11 import {CompilerError} from '../CompilerError';
12 import {
13 CompilationMode,
14 + defaultOptions,
15 Logger,
16 PanicThresholdOptions,
17 parsePluginOptions,
@@ -779,6 +780,7 @@ export function parseConfigPragmaForTests(
780 const environment = parseConfigPragmaEnvironmentForTest(pragma);
781 let compilationMode: CompilationMode = defaults.compilationMode;
782 let panicThreshold: PanicThresholdOptions = 'all_errors';
783 + let noEmit: boolean = defaultOptions.noEmit;
784 for (const token of pragma.split(' ')) {
785 if (!token.startsWith('@')) {
786 continue;
@@ -804,12 +806,17 @@ export function parseConfigPragmaForTests(
806 panicThreshold = 'none';
807 break;
808 }
809 + case '@noEmit': {
810 + noEmit = true;
811 + break;
812 + }
813 }
814 }
815 return parsePluginOptions({
816 environment,
817 compilationMode,
818 panicThreshold,
819 + noEmit,
820 });
821 }
822
@@ -852,6 +859,7 @@ export class Environment {
859 programContext: ProgramContext;
860 hasFireRewrite: boolean;
861 hasInferredEffect: boolean;
862 + inferredEffectLocations: Set<SourceLocation> = new Set();
863
864 #contextIdentifiers: Set<t.Identifier>;
865 #hoistedIdentifiers: Set<t.Identifier>;
compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts
+2
@@ -217,6 +217,7 @@ export function inferEffectDependencies(fn: HIRFunction): void {
217 // Step 2: push the inferred deps array as an argument of the useEffect
218 value.args.push({...depsPlace, effect: Effect.Freeze});
219 rewriteInstrs.set(instr.id, newInstructions);
220 + fn.env.inferredEffectLocations.add(callee.loc);
221 } else if (loadGlobals.has(value.args[0].identifier.id)) {
222 // Global functions have no reactive dependencies, so we can insert an empty array
223 newInstructions.push({
@@ -227,6 +228,7 @@ export function inferEffectDependencies(fn: HIRFunction): void {
228 });
229 value.args.push({...depsPlace, effect: Effect.Freeze});
230 rewriteInstrs.set(instr.id, newInstructions);
231 + fn.env.inferredEffectLocations.add(callee.loc);
232 }
233 }
234 }
compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts
+2
@@ -104,6 +104,7 @@ export type CodegenFunction = {
104 * This is true if the compiler has compiled inferred effect dependencies
105 */
106 hasInferredEffect: boolean;
107 + inferredEffectLocations: Set<SourceLocation>;
108
109 /**
110 * This is true if the compiler has compiled a fire to a useFire call
@@ -389,6 +390,7 @@ function codegenReactiveFunction(
390 outlined: [],
391 hasFireRewrite: fn.env.hasFireRewrite,
392 hasInferredEffect: fn.env.hasInferredEffect,
393 + inferredEffectLocations: fn.env.inferredEffectLocations,
394 });
395 }
396
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/no-emit-lint-repro.expect.md new
+31
@@ -0,0 +1,31 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @inferEffectDependencies @noEmit
6 +import {print} from 'shared-runtime';
7 +import useEffectWrapper from 'useEffectWrapper';
8 +
9 +function ReactiveVariable({propVal}) {
10 + const arr = [propVal];
11 + useEffectWrapper(() => print(arr));
12 +}
13 +
14 +```
15 +
16 +## Code
17 +
18 +```javascript
19 +// @inferEffectDependencies @noEmit
20 +import { print } from "shared-runtime";
21 +import useEffectWrapper from "useEffectWrapper";
22 +
23 +function ReactiveVariable({ propVal }) {
24 + const arr = [propVal];
25 + useEffectWrapper(() => print(arr));
26 +}
27 +
28 +```
29 +
30 +### Eval output
31 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/no-emit-lint-repro.js new
+8
@@ -0,0 +1,8 @@
1 +// @inferEffectDependencies @noEmit
2 +import {print} from 'shared-runtime';
3 +import useEffectWrapper from 'useEffectWrapper';
4 +
5 +function ReactiveVariable({propVal}) {
6 + const arr = [propVal];
7 + useEffectWrapper(() => print(arr));
8 +}
compiler/packages/snap/src/compiler.ts
-1
@@ -187,7 +187,6 @@ function makePluginOptions(
187 },
188 logger,
189 gating,
190 - noEmit: false,
190 eslintSuppressionRules,
191 flowSuppressions,
192 ignoreUseNoForget,