[compile] Error on fire outside of effects and ensure correct compilation, correct import (#31798)
Traverse the compiled functions to ensure there are no lingering fires and that all fire calls are inside an effect lambda. Also corrects the import to import from the compiler runtime instead --
Jordan Brown committed
Dec 20, 2024 at 16:55 UTC
45a720f7c7ff98e22fb299b50fef90fe319081a7
12 files changed
+221
-16
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts
+4
-1
@@ -567,7 +567,10 @@ export function compileProgram(
567
568
const hasFireRewrite = compiledFns.some(c => c.compiledFn.hasFireRewrite);
569
if (environment.enableFire && hasFireRewrite) {
570
- externalFunctions.push({source: 'react', importSpecifierName: 'useFire'});
570
+ externalFunctions.push({
571
+ source: getReactCompilerRuntimeModule(pass.opts),
572
+ importSpecifierName: 'useFire',
573
+ });
574
}
575
} catch (err) {
576
handleError(err, pass, null);
compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts
+8
@@ -897,6 +897,14 @@ export function printSourceLocation(loc: SourceLocation): string {
897
}
898
}
899
900
+export function printSourceLocationLine(loc: SourceLocation): string {
901
+ if (typeof loc === 'symbol') {
902
+ return 'generated';
903
+ } else {
904
+ return `${loc.start.line}:${loc.end.line}`;
905
+ }
906
+}
907
+
908
export function printAliases(aliases: DisjointSet<Identifier>): string {
909
const aliasSets = aliases.buildSets();
910
compiler/packages/babel-plugin-react-compiler/src/Transform/TransformFire.ts
+94
-10
@@ -5,7 +5,12 @@
5
* LICENSE file in the root directory of this source tree.
6
*/
7
8
-import {CompilerError, CompilerErrorDetailOptions, ErrorSeverity} from '..';
8
+import {
9
+ CompilerError,
10
+ CompilerErrorDetailOptions,
11
+ ErrorSeverity,
12
+ SourceLocation,
13
+} from '..';
14
import {
15
CallExpression,
16
Effect,
@@ -28,14 +33,11 @@ import {
33
import {createTemporaryPlace, markInstructionIds} from '../HIR/HIRBuilder';
34
import {getOrInsertWith} from '../Utils/utils';
35
import {BuiltInFireId, DefaultNonmutatingHook} from '../HIR/ObjectShape';
36
+import {eachInstructionOperand} from '../HIR/visitors';
37
+import {printSourceLocationLine} from '../HIR/PrintHIR';
38
39
/*
40
* TODO(jmbrown):
34
- * In this stack:
35
- * - Assert no lingering fire calls
36
- * - Ensure a fired function is not called regularly elsewhere in the same effect
37
- *
38
- * Future:
41
* - rewrite dep arrays
42
* - traverse object methods
43
* - method calls
@@ -47,6 +49,9 @@ const CANNOT_COMPILE_FIRE = 'Cannot compile `fire`';
49
export function transformFire(fn: HIRFunction): void {
50
const context = new Context(fn.env);
51
replaceFireFunctions(fn, context);
52
+ if (!context.hasErrors()) {
53
+ ensureNoMoreFireUses(fn, context);
54
+ }
55
context.throwIfErrorsFound();
56
}
57
@@ -120,6 +125,11 @@ function replaceFireFunctions(fn: HIRFunction, context: Context): void {
125
}
126
rewriteInstrs.set(loadUseEffectInstrId, newInstrs);
127
}
128
+ ensureNoRemainingCalleeCaptures(
129
+ lambda.loweredFunc.func,
130
+ context,
131
+ capturedCallees,
132
+ );
133
}
134
}
135
} else if (
@@ -159,7 +169,10 @@ function replaceFireFunctions(fn: HIRFunction, context: Context): void {
169
}
170
171
const fireFunctionBinding =
162
- context.getOrGenerateFireFunctionBinding(loadLocal.place);
172
+ context.getOrGenerateFireFunctionBinding(
173
+ loadLocal.place,
174
+ value.loc,
175
+ );
176
177
loadLocal.place = {...fireFunctionBinding};
178
@@ -320,6 +333,69 @@ function visitFunctionExpressionAndPropagateFireDependencies(
333
return calleesCapturedByFnExpression;
334
}
335
336
+/*
337
+ * eachInstructionOperand is not sufficient for our cases because:
338
+ * 1. fire is a global, which will not appear
339
+ * 2. The HIR may be malformed, so can't rely on function deps and must
340
+ * traverse the whole function.
341
+ */
342
+function* eachReachablePlace(fn: HIRFunction): Iterable<Place> {
343
+ for (const [, block] of fn.body.blocks) {
344
+ for (const instr of block.instructions) {
345
+ if (
346
+ instr.value.kind === 'FunctionExpression' ||
347
+ instr.value.kind === 'ObjectMethod'
348
+ ) {
349
+ yield* eachReachablePlace(instr.value.loweredFunc.func);
350
+ } else {
351
+ yield* eachInstructionOperand(instr);
352
+ }
353
+ }
354
+ }
355
+}
356
+
357
+function ensureNoRemainingCalleeCaptures(
358
+ fn: HIRFunction,
359
+ context: Context,
360
+ capturedCallees: FireCalleesToFireFunctionBinding,
361
+): void {
362
+ for (const place of eachReachablePlace(fn)) {
363
+ const calleeInfo = capturedCallees.get(place.identifier.id);
364
+ if (calleeInfo != null) {
365
+ const calleeName =
366
+ calleeInfo.capturedCalleeIdentifier.name?.kind === 'named'
367
+ ? calleeInfo.capturedCalleeIdentifier.name.value
368
+ : '<unknown>';
369
+ context.pushError({
370
+ loc: place.loc,
371
+ description: `All uses of ${calleeName} must be either used with a fire() call in \
372
+this effect or not used with a fire() call at all. ${calleeName} was used with fire() on line \
373
+${printSourceLocationLine(calleeInfo.fireLoc)} in this effect`,
374
+ severity: ErrorSeverity.InvalidReact,
375
+ reason: CANNOT_COMPILE_FIRE,
376
+ suggestions: null,
377
+ });
378
+ }
379
+ }
380
+}
381
+
382
+function ensureNoMoreFireUses(fn: HIRFunction, context: Context): void {
383
+ for (const place of eachReachablePlace(fn)) {
384
+ if (
385
+ place.identifier.type.kind === 'Function' &&
386
+ place.identifier.type.shapeId === BuiltInFireId
387
+ ) {
388
+ context.pushError({
389
+ loc: place.identifier.loc,
390
+ description: 'Cannot use `fire` outside of a useEffect function',
391
+ severity: ErrorSeverity.Invariant,
392
+ reason: CANNOT_COMPILE_FIRE,
393
+ suggestions: null,
394
+ });
395
+ }
396
+ }
397
+}
398
+
399
function makeLoadUseFireInstruction(env: Environment): Instruction {
400
const useFirePlace = createTemporaryPlace(env, GeneratedSource);
401
useFirePlace.effect = Effect.Read;
@@ -422,6 +498,7 @@ type FireCalleesToFireFunctionBinding = Map<
498
{
499
fireFunctionBinding: Place;
500
capturedCalleeIdentifier: Identifier;
501
+ fireLoc: SourceLocation;
502
}
503
>;
504
@@ -523,8 +600,10 @@ class Context {
600
getLoadLocalInstr(id: IdentifierId): LoadLocal | undefined {
601
return this.#loadLocals.get(id);
602
}
526
-
527
- getOrGenerateFireFunctionBinding(callee: Place): Place {
603
+ getOrGenerateFireFunctionBinding(
604
+ callee: Place,
605
+ fireLoc: SourceLocation,
606
+ ): Place {
607
const fireFunctionBinding = getOrInsertWith(
608
this.#fireCalleesToFireFunctions,
609
callee.identifier.id,
@@ -534,6 +613,7 @@ class Context {
613
this.#capturedCalleeIdentifierIds.set(callee.identifier.id, {
614
fireFunctionBinding,
615
capturedCalleeIdentifier: callee.identifier,
616
+ fireLoc,
617
});
618
619
return fireFunctionBinding;
@@ -575,8 +655,12 @@ class Context {
655
return this.#loadGlobalInstructionIds.get(id);
656
}
657
658
+ hasErrors(): boolean {
659
+ return this.#errors.hasErrors();
660
+ }
661
+
662
throwIfErrorsFound(): void {
579
- if (this.#errors.hasErrors()) throw this.#errors;
663
+ if (this.hasErrors()) throw this.#errors;
664
}
665
}
666
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/basic.expect.md
+1
-1
@@ -21,7 +21,7 @@ function Component(props) {
21
## Code
22
23
```javascript
24
-import { useFire } from "react";
24
+import { useFire } from "react/compiler-runtime";
25
import { c as _c } from "react/compiler-runtime"; // @enableFire
26
import { fire } from "react";
27
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/deep-scope.expect.md
+1
-1
@@ -30,7 +30,7 @@ function Component(props) {
30
## Code
31
32
```javascript
33
-import { useFire } from "react";
33
+import { useFire } from "react/compiler-runtime";
34
import { c as _c } from "react/compiler-runtime"; // @enableFire
35
import { fire } from "react";
36
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/error.invalid-mix-fire-and-no-fire.expect.md
new
+39
@@ -0,0 +1,39 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+// @enableFire
6
+import {fire} from 'react';
7
+
8
+function Component(props) {
9
+ const foo = props => {
10
+ console.log(props);
11
+ };
12
+ useEffect(() => {
13
+ function nested() {
14
+ fire(foo(props));
15
+ foo(props);
16
+ }
17
+
18
+ nested();
19
+ });
20
+
21
+ return null;
22
+}
23
+
24
+```
25
+
26
+
27
+## Error
28
+
29
+```
30
+ 9 | function nested() {
31
+ 10 | fire(foo(props));
32
+> 11 | foo(props);
33
+ | ^^^ InvalidReact: Cannot compile `fire`. All uses of foo must be either used with a fire() call in this effect or not used with a fire() call at all. foo was used with fire() on line 10:10 in this effect (11:11)
34
+ 12 | }
35
+ 13 |
36
+ 14 | nested();
37
+```
38
+
39
+
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/error.invalid-mix-fire-and-no-fire.js
new
+18
@@ -0,0 +1,18 @@
1
+// @enableFire
2
+import {fire} from 'react';
3
+
4
+function Component(props) {
5
+ const foo = props => {
6
+ console.log(props);
7
+ };
8
+ useEffect(() => {
9
+ function nested() {
10
+ fire(foo(props));
11
+ foo(props);
12
+ }
13
+
14
+ nested();
15
+ });
16
+
17
+ return null;
18
+}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/error.invalid-outside-effect.expect.md
new
+38
@@ -0,0 +1,38 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+// @enableFire
6
+import {fire, useCallback} from 'react';
7
+
8
+function Component({props, bar}) {
9
+ const foo = () => {
10
+ console.log(props);
11
+ };
12
+ fire(foo(props));
13
+
14
+ useCallback(() => {
15
+ fire(foo(props));
16
+ }, [foo, props]);
17
+
18
+ return null;
19
+}
20
+
21
+```
22
+
23
+
24
+## Error
25
+
26
+```
27
+ 6 | console.log(props);
28
+ 7 | };
29
+> 8 | fire(foo(props));
30
+ | ^^^^ Invariant: Cannot compile `fire`. Cannot use `fire` outside of a useEffect function (8:8)
31
+
32
+Invariant: Cannot compile `fire`. Cannot use `fire` outside of a useEffect function (11:11)
33
+ 9 |
34
+ 10 | useCallback(() => {
35
+ 11 | fire(foo(props));
36
+```
37
+
38
+
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/error.invalid-outside-effect.js
new
+15
@@ -0,0 +1,15 @@
1
+// @enableFire
2
+import {fire, useCallback} from 'react';
3
+
4
+function Component({props, bar}) {
5
+ const foo = () => {
6
+ console.log(props);
7
+ };
8
+ fire(foo(props));
9
+
10
+ useCallback(() => {
11
+ fire(foo(props));
12
+ }, [foo, props]);
13
+
14
+ return null;
15
+}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/multiple-scope.expect.md
+1
-1
@@ -29,7 +29,7 @@ function Component(props) {
29
## Code
30
31
```javascript
32
-import { useFire } from "react";
32
+import { useFire } from "react/compiler-runtime";
33
import { c as _c } from "react/compiler-runtime"; // @enableFire
34
import { fire } from "react";
35
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/repeated-calls.expect.md
+1
-1
@@ -22,7 +22,7 @@ function Component(props) {
22
## Code
23
24
```javascript
25
-import { useFire } from "react";
25
+import { useFire } from "react/compiler-runtime";
26
import { c as _c } from "react/compiler-runtime"; // @enableFire
27
import { fire } from "react";
28
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/shared-hook-calls.expect.md
+1
-1
@@ -26,7 +26,7 @@ function Component({bar, baz}) {
26
## Code
27
28
```javascript
29
-import { useFire } from "react";
29
+import { useFire } from "react/compiler-runtime";
30
import { c as _c } from "react/compiler-runtime"; // @enableFire
31
import { fire } from "react";
32