Skip to content

Commit f83fc00

Browse files
committed
[compiler][newinference] Impure/Render effects
Adds two new effects: - `Impure` is for builtin functions that are known to be impure and not safe to call during render. This is already implemented via an optional `impure` flag on function declarations and ValidateNoImpureFunctionsInRender. That validation still exists, but we can avoid the need for the special validation pass by relying on the function effect. - `Render` is an effect that indicates a value is assumed to be called at render time, even if it would not normally appear to be called. Examples are JSX tags and children — if these values are functions, they are definitely called at render (tag) or almost certainly called in render (children). Any function that transitively flows into a Render effect must not have MutateGlobal or Impure effects. We can also extend this to include other APIs that should be pure, for example we could consider adding it to `useReducer()` callbacks or `useState()` initializers. ghstack-source-id: 8367f46 Pull Request resolved: #33488
1 parent 42e1039 commit f83fc00

13 files changed

+185
-55
lines changed

compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [
416416
into: signatureArgument(4),
417417
signature: null,
418418
mutatesFunction: false,
419+
loc: GeneratedSource,
419420
},
420421
// captures the result of the callback into the return array
421422
{

compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,12 @@ export function printAliasingEffect(effect: AliasingEffect): string {
10151015
case 'MutateGlobal': {
10161016
return `MutateGlobal ${printPlaceForAliasEffect(effect.place)} reason=${JSON.stringify(effect.error.reason)}`;
10171017
}
1018+
case 'Impure': {
1019+
return `Impure ${printPlaceForAliasEffect(effect.place)} reason=${JSON.stringify(effect.error.reason)}`;
1020+
}
1021+
case 'Render': {
1022+
return `Render ${printPlaceForAliasEffect(effect.place)}`;
1023+
}
10181024
default: {
10191025
assertExhaustive(effect, `Unexpected kind '${(effect as any).kind}'`);
10201026
}

compiler/packages/babel-plugin-react-compiler/src/Inference/AnalyseFunctions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ function lowerWithMutationAliasing(fn: HIRFunction): void {
100100
capturedOrMutated.add(effect.value.identifier.id);
101101
break;
102102
}
103+
case 'Impure':
104+
case 'Render':
103105
case 'MutateFrozen':
104106
case 'MutateGlobal':
105107
case 'CreateFunction':

compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
CompilerErrorDetailOptions,
1111
Effect,
1212
ErrorSeverity,
13+
SourceLocation,
1314
ValueKind,
1415
} from '..';
1516
import {
@@ -380,10 +381,7 @@ function applySignature(
380381
context: new Set(),
381382
});
382383
effects.push({
383-
kind:
384-
value.kind === ValueKind.Frozen
385-
? 'MutateFrozen'
386-
: 'MutateGlobal',
384+
kind: 'MutateFrozen',
387385
place: effect.value,
388386
error: {
389387
severity: ErrorSeverity.InvalidReact,
@@ -546,7 +544,10 @@ function applyEffect(
546544
const hasTrackedSideEffects =
547545
effect.function.loweredFunc.func.aliasingEffects?.some(
548546
effect =>
549-
effect.kind === 'MutateFrozen' || effect.kind === 'MutateGlobal',
547+
// TODO; include "render" here?
548+
effect.kind === 'MutateFrozen' ||
549+
effect.kind === 'MutateGlobal' ||
550+
effect.kind === 'Impure',
550551
);
551552
// For legacy compatibility
552553
const capturesRef = effect.function.loweredFunc.func.context.some(
@@ -721,6 +722,7 @@ function applyEffect(
721722
effect.receiver,
722723
effect.args,
723724
functionValues[0].loweredFunc.func.context,
725+
effect.loc,
724726
);
725727
if (signatureEffects != null) {
726728
if (DEBUG) {
@@ -747,6 +749,8 @@ function applyEffect(
747749
effect.into,
748750
effect.receiver,
749751
effect.args,
752+
[],
753+
effect.loc,
750754
)
751755
: null;
752756
if (signatureEffects != null) {
@@ -766,6 +770,7 @@ function applyEffect(
766770
effect.into,
767771
effect.receiver,
768772
effect.args,
773+
effect.loc,
769774
);
770775
for (const legacyEffect of legacyEffects) {
771776
applyEffect(context, state, legacyEffect, aliased, effects);
@@ -899,6 +904,8 @@ function applyEffect(
899904
}
900905
break;
901906
}
907+
case 'Impure':
908+
case 'Render':
902909
case 'MutateFrozen':
903910
case 'MutateGlobal': {
904911
effects.push(effect);
@@ -1452,6 +1459,7 @@ function computeSignatureForInstruction(
14521459
args: value.args,
14531460
into: lvalue,
14541461
signature,
1462+
loc: value.loc,
14551463
});
14561464
break;
14571465
}
@@ -1631,6 +1639,24 @@ function computeSignatureForInstruction(
16311639
into: lvalue,
16321640
});
16331641
}
1642+
if (value.kind === 'JsxExpression') {
1643+
if (value.tag.kind === 'Identifier') {
1644+
// Tags are render function, by definition they're called during render
1645+
effects.push({
1646+
kind: 'Render',
1647+
place: value.tag,
1648+
});
1649+
}
1650+
if (value.children != null) {
1651+
// Children are typically called during render, not used as an event/effect callback
1652+
for (const child of value.children) {
1653+
effects.push({
1654+
kind: 'Render',
1655+
place: child,
1656+
});
1657+
}
1658+
}
1659+
}
16341660
break;
16351661
}
16361662
case 'DeclareLocal': {
@@ -1861,6 +1887,7 @@ function computeEffectsForLegacySignature(
18611887
lvalue: Place,
18621888
receiver: Place,
18631889
args: Array<Place | SpreadPattern | Hole>,
1890+
loc: SourceLocation,
18641891
): Array<AliasingEffect> {
18651892
const returnValueReason = signature.returnValueReason ?? ValueReason.Other;
18661893
const effects: Array<AliasingEffect> = [];
@@ -1870,6 +1897,23 @@ function computeEffectsForLegacySignature(
18701897
value: signature.returnValueKind,
18711898
reason: returnValueReason,
18721899
});
1900+
if (signature.impure && state.env.config.validateNoImpureFunctionsInRender) {
1901+
effects.push({
1902+
kind: 'Impure',
1903+
place: receiver,
1904+
error: {
1905+
reason:
1906+
'Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent)',
1907+
description:
1908+
signature.canonicalName != null
1909+
? `\`${signature.canonicalName}\` is an impure function whose results may change on every call`
1910+
: null,
1911+
severity: ErrorSeverity.InvalidReact,
1912+
loc,
1913+
suggestions: null,
1914+
},
1915+
});
1916+
}
18731917
const stores: Array<Place> = [];
18741918
const captures: Array<Place> = [];
18751919
function visit(place: Place, effect: Effect): void {
@@ -2083,6 +2127,7 @@ function computeEffectsForSignature(
20832127
args: Array<Place | SpreadPattern | Hole>,
20842128
// Used for signatures constructed dynamically which reference context variables
20852129
context: Array<Place> = [],
2130+
loc: SourceLocation,
20862131
): Array<AliasingEffect> | null {
20872132
if (
20882133
// Not enough args
@@ -2163,6 +2208,7 @@ function computeEffectsForSignature(
21632208
}
21642209
break;
21652210
}
2211+
case 'Impure':
21662212
case 'MutateFrozen':
21672213
case 'MutateGlobal': {
21682214
const values = substitutions.get(effect.place.identifier.id) ?? [];
@@ -2171,6 +2217,13 @@ function computeEffectsForSignature(
21712217
}
21722218
break;
21732219
}
2220+
case 'Render': {
2221+
const values = substitutions.get(effect.place.identifier.id) ?? [];
2222+
for (const value of values) {
2223+
effects.push({kind: effect.kind, place: value});
2224+
}
2225+
break;
2226+
}
21742227
case 'Mutate':
21752228
case 'MutateTransitive':
21762229
case 'MutateTransitiveConditionally':
@@ -2254,6 +2307,7 @@ function computeEffectsForSignature(
22542307
function: applyFunction[0],
22552308
into: applyInto[0],
22562309
signature: effect.signature,
2310+
loc,
22572311
});
22582312
break;
22592313
}
@@ -2468,6 +2522,7 @@ export type AliasingEffect =
24682522
args: Array<Place | SpreadPattern | Hole>;
24692523
into: Place;
24702524
signature: FunctionSignature | null;
2525+
loc: SourceLocation;
24712526
}
24722527
/**
24732528
* Constructs a function value with the given captures. The mutability of the function
@@ -2490,6 +2545,20 @@ export type AliasingEffect =
24902545
kind: 'MutateGlobal';
24912546
place: Place;
24922547
error: CompilerErrorDetailOptions;
2548+
}
2549+
/**
2550+
* Indicates a side-effect that is not safe during render
2551+
*/
2552+
| {kind: 'Impure'; place: Place; error: CompilerErrorDetailOptions}
2553+
/**
2554+
* Indicates that a given place is accessed during render. Used to distingush
2555+
* hook arguments that are known to be called immediately vs those used for
2556+
* event handlers/effects, and for JSX values known to be called during render
2557+
* (tags, children) vs those that may be events/effect (other props).
2558+
*/
2559+
| {
2560+
kind: 'Render';
2561+
place: Place;
24932562
};
24942563

24952564
function hashEffect(effect: AliasingEffect): string {
@@ -2536,6 +2605,8 @@ function hashEffect(effect: AliasingEffect): string {
25362605
case 'Freeze': {
25372606
return [effect.kind, effect.value.identifier.id, effect.reason].join(':');
25382607
}
2608+
case 'Impure':
2609+
case 'Render':
25392610
case 'MutateFrozen':
25402611
case 'MutateGlobal': {
25412612
return [effect.kind, effect.place.identifier.id].join(':');

compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingFunctionEffects.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,9 @@ export function inferMutationAliasingFunctionEffects(
108108
]);
109109
} else if (
110110
effect.kind === 'MutateFrozen' ||
111-
effect.kind === 'MutateGlobal'
111+
effect.kind === 'MutateGlobal' ||
112+
effect.kind === 'Impure' ||
113+
effect.kind === 'Render'
112114
) {
113115
effects.push(effect);
114116
}

compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export function inferMutationAliasingRanges(
6666
kind: MutationKind;
6767
place: Place;
6868
}> = [];
69+
const renders: Array<{index: number; place: Place}> = [];
6970

7071
let index = 0;
7172

@@ -160,9 +161,12 @@ export function inferMutationAliasingRanges(
160161
});
161162
} else if (
162163
effect.kind === 'MutateFrozen' ||
163-
effect.kind === 'MutateGlobal'
164+
effect.kind === 'MutateGlobal' ||
165+
effect.kind === 'Impure'
164166
) {
165167
errors.push(effect.error);
168+
} else if (effect.kind === 'Render') {
169+
renders.push({index: index++, place: effect.place});
166170
}
167171
}
168172
}
@@ -209,6 +213,9 @@ export function inferMutationAliasingRanges(
209213
errors,
210214
);
211215
}
216+
for (const render of renders) {
217+
state.render(render.index, render.place.identifier, errors);
218+
}
212219
if (DEBUG) {
213220
console.log(pretty([...state.nodes.keys()]));
214221
}
@@ -357,6 +364,8 @@ export function inferMutationAliasingRanges(
357364
// no-op, Read is the default
358365
break;
359366
}
367+
case 'Impure':
368+
case 'Render':
360369
case 'MutateFrozen':
361370
case 'MutateGlobal': {
362371
// no-op
@@ -440,6 +449,7 @@ export function inferMutationAliasingRanges(
440449
function appendFunctionErrors(errors: CompilerError, fn: HIRFunction): void {
441450
for (const effect of fn.aliasingEffects ?? []) {
442451
switch (effect.kind) {
452+
case 'Impure':
443453
case 'MutateFrozen':
444454
case 'MutateGlobal': {
445455
errors.push(effect.error);
@@ -530,6 +540,43 @@ class AliasingState {
530540
}
531541
}
532542

543+
render(index: number, start: Identifier, errors: CompilerError): void {
544+
const seen = new Set<Identifier>();
545+
const queue: Array<Identifier> = [start];
546+
while (queue.length !== 0) {
547+
const current = queue.pop()!;
548+
if (seen.has(current)) {
549+
continue;
550+
}
551+
seen.add(current);
552+
const node = this.nodes.get(current);
553+
if (node == null || node.transitive != null || node.local != null) {
554+
continue;
555+
}
556+
if (node.value.kind === 'Function') {
557+
appendFunctionErrors(errors, node.value.function);
558+
}
559+
for (const [alias, when] of node.createdFrom) {
560+
if (when >= index) {
561+
continue;
562+
}
563+
queue.push(alias);
564+
}
565+
for (const [alias, when] of node.aliases) {
566+
if (when >= index) {
567+
continue;
568+
}
569+
queue.push(alias);
570+
}
571+
for (const [capture, when] of node.captures) {
572+
if (when >= index) {
573+
continue;
574+
}
575+
queue.push(capture);
576+
}
577+
}
578+
}
579+
533580
mutate(
534581
index: number,
535582
start: Identifier,

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-global-in-jsx-spread-attribute.expect.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
## Input
33

44
```javascript
5+
// @enableNewMutationAliasingModel:false
56
function Component() {
67
const foo = () => {
78
someGlobal = true;
@@ -15,13 +16,13 @@ function Component() {
1516
## Error
1617

1718
```
18-
1 | function Component() {
19-
2 | const foo = () => {
20-
> 3 | someGlobal = true;
21-
| ^^^^^^^^^^ InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (3:3)
22-
4 | };
23-
5 | return <div {...foo} />;
24-
6 | }
19+
2 | function Component() {
20+
3 | const foo = () => {
21+
> 4 | someGlobal = true;
22+
| ^^^^^^^^^^ InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (4:4)
23+
5 | };
24+
6 | return <div {...foo} />;
25+
7 | }
2526
```
2627
2728

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-global-in-jsx-spread-attribute.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @enableNewMutationAliasingModel:false
12
function Component() {
23
const foo = () => {
34
someGlobal = true;

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-impure-functions-in-render.expect.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function Component() {
2020
2 |
2121
3 | function Component() {
2222
> 4 | const date = Date.now();
23-
| ^^^^^^^^ InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `Date.now` is an impure function whose results may change on every call (4:4)
23+
| ^^^^^^^^^^ InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `Date.now` is an impure function whose results may change on every call (4:4)
2424
2525
InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `performance.now` is an impure function whose results may change on every call (5:5)
2626

0 commit comments

Comments
 (0)