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