Compare commits

...

10 Commits

Author SHA1 Message Date
Mike Vitousek
5d2236196a Update on "[compiler] Bail out and log calls that likely have side effects"
[ghstack-poisoned]
2024-08-02 01:55:32 -07:00
Mike Vitousek
5fb783ce58 Update base for Update on "[compiler] Bail out and log calls that likely have side effects"
[ghstack-poisoned]
2024-08-02 01:55:31 -07:00
Mike Vitousek
ef8833306c Update on "[compiler] Bail out and log calls that likely have side effects"
[ghstack-poisoned]
2024-08-02 00:09:39 -07:00
Mike Vitousek
076abcf35a Update base for Update on "[compiler] Bail out and log calls that likely have side effects"
[ghstack-poisoned]
2024-08-02 00:09:38 -07:00
Mike Vitousek
4052937195 Update on "[compiler] Bail out and log calls that likely have side effects"
[ghstack-poisoned]
2024-08-01 16:53:17 -07:00
Mike Vitousek
d4ce56ffc6 Update on "[compiler] Bail out and log calls that likely have side effects"
[ghstack-poisoned]
2024-08-01 15:38:56 -07:00
Mike Vitousek
2e114b0b2e Update on "[compiler] Bail out and log calls that likely have side effects"
[ghstack-poisoned]
2024-08-01 14:05:37 -07:00
Mike Vitousek
3affe0339f Update on "[compiler] Bail out and log calls that likely have side effects"
[ghstack-poisoned]
2024-08-01 13:25:32 -07:00
Mike Vitousek
129abed386 Update on "[compiler] Bail out and log calls that likely have side effects"
[ghstack-poisoned]
2024-08-01 11:35:26 -07:00
Mike Vitousek
22b97005ee [compiler] Bail out and log calls that likely have side effects
[ghstack-poisoned]
2024-08-01 11:24:45 -07:00
15 changed files with 617 additions and 91 deletions

View File

@@ -294,6 +294,13 @@ export type HIRFunction = {
};
export type FunctionEffect =
| {
kind: 'ImmutableFunctionCall';
loc: SourceLocation;
lvalue: IdentifierId;
callee: IdentifierId;
global: boolean;
}
| {
kind: 'GlobalMutation';
error: CompilerErrorDetailOptions;

View File

@@ -26,10 +26,12 @@ import {
Type,
ValueKind,
ValueReason,
getHookKind,
isArrayType,
isMutableEffect,
isObjectType,
isRefValueType,
isSetStateType,
isUseRefType,
} from '../HIR/HIR';
import {FunctionSignature} from '../HIR/ObjectShape';
@@ -48,7 +50,6 @@ import {
eachTerminalSuccessor,
} from '../HIR/visitors';
import {assertExhaustive} from '../Utils/utils';
import {isEffectHook} from '../Validation/ValidateMemoizedEffectDependencies';
const UndefinedValue: InstructionValue = {
kind: 'Primitive',
@@ -251,6 +252,10 @@ export default function inferReferenceEffects(
loc: eff.loc,
});
}
case 'ImmutableFunctionCall': {
// Handled below
break;
}
default:
assertExhaustive(
eff,
@@ -258,9 +263,122 @@ export default function inferReferenceEffects(
);
}
});
} else {
fn.effects = functionEffects;
if (functionEffects.length > 0) {
const error = new CompilerError();
let usedIdentifiers = new Set<IdentifierId>();
let names = new Map<IdentifierId, string | undefined>();
function visitFunction(fn: HIRFunction): void {
for (const [, block] of fn.body.blocks) {
for (const instr of block.instructions) {
switch (instr.value.kind) {
case 'FunctionExpression':
case 'ObjectMethod':
names.set(
instr.lvalue.identifier.id,
instr.value.loweredFunc.func.id ?? '(anonymous function)',
);
visitFunction(instr.value.loweredFunc.func);
break;
case 'LoadGlobal':
names.set(instr.lvalue.identifier.id, instr.value.binding.name);
break;
case 'LoadLocal':
case 'LoadContext':
names.set(
instr.lvalue.identifier.id,
instr.value.place.identifier.name?.value ??
names.get(instr.value.place.identifier.id),
);
break;
case 'StoreContext':
case 'StoreLocal':
names.set(
instr.lvalue.identifier.id,
instr.value.value.identifier.name?.value ??
names.get(instr.value.value.identifier.id),
);
names.set(
instr.value.lvalue.place.identifier.id,
instr.value.value.identifier.name?.value ??
names.get(instr.value.value.identifier.id),
);
break;
case 'PropertyLoad':
names.set(
instr.lvalue.identifier.id,
`${instr.value.object.identifier.name?.value ?? names.get(instr.value.object.identifier.id) ?? '(unknown)'}.${instr.value.property}`,
);
break;
case 'ComputedLoad':
names.set(
instr.lvalue.identifier.id,
`${instr.value.object.identifier.name?.value ?? names.get(instr.value.object.identifier.id) ?? '(unknown)'}[...]`,
);
break;
case 'Destructure': {
const destructuredName =
instr.value.value.identifier.name?.value ??
names.get(instr.value.value.identifier.id);
const destructuredMsg = destructuredName
? `(destructured from \`${destructuredName}\`)`
: '(destructured)';
Array.from(
eachPatternOperand(instr.value.lvalue.pattern),
).forEach(place =>
names.set(
place.identifier.id,
`${place.identifier.name?.value ?? 'value'} ${destructuredMsg}`,
),
);
}
}
Array.from(eachInstructionOperand(instr)).forEach(operand =>
usedIdentifiers.add(operand.identifier.id),
);
}
for (const phi of block.phis) {
Array.from(phi.operands.values()).forEach(operand =>
usedIdentifiers.add(operand.id),
);
}
Array.from(eachTerminalOperand(block.terminal)).forEach(operand =>
usedIdentifiers.add(operand.identifier.id),
);
}
}
visitFunction(fn);
const allowedNames = new Set(['invariant', 'recoverableViolation']);
for (const effect of functionEffects) {
CompilerError.invariant(effect.kind === 'ImmutableFunctionCall', {
reason:
'All effects other than ImmutableFunctionCall should have been handled earlier',
loc: null,
});
if (
!usedIdentifiers.has(effect.lvalue) &&
(!effect.global ||
!names.has(effect.callee) ||
!allowedNames.has(names.get(effect.callee)!))
) {
const name = names.get(effect.callee) ?? '(unknown)';
error.push({
reason: `Function \'${name}\' is called with arguments that React Compiler expects to be immutable and its return value is ignored. This call is likely to perform unsafe side effects, which violates the rules of React.`,
loc: effect.loc,
severity: ErrorSeverity.InvalidReact,
});
}
}
if (error.hasErrors()) {
throw error;
}
}
}
fn.effects = functionEffects;
}
// Maintains a mapping of top-level variables to the kind of value they hold
@@ -276,6 +394,8 @@ class InferenceState {
*/
#variables: Map<IdentifierId, Set<InstructionValue>>;
#functionEffects: Map<InstructionValue, Array<FunctionEffect>> = new Map();
constructor(
env: Environment,
values: Map<InstructionValue, AbstractValue>,
@@ -302,6 +422,19 @@ class InferenceState {
this.#values.set(value, kind);
}
setFunctionEffects(value: InstructionValue, effect: Array<FunctionEffect>): void {
CompilerError.invariant(value.kind !== 'LoadLocal', {
reason:
'Expected all top-level identifiers to be defined as variables, not values',
description: null,
loc: value.loc,
suggestions: null,
});
const curEffects = this.#functionEffects.get(value) ?? [];
curEffects.push(...effect);
this.#functionEffects.set(value, curEffects);
}
values(place: Place): Array<InstructionValue> {
const values = this.#variables.get(place.identifier.id);
CompilerError.invariant(values != null, {
@@ -400,50 +533,58 @@ class InferenceState {
}
// Propagate effects of function expressions to the outer (ie current) effect context
const dependentEffects = [];
for (const value of values) {
if (
(value.kind === 'FunctionExpression' ||
value.kind === 'ObjectMethod') &&
value.loweredFunc.func.effects != null
value.kind === 'ObjectMethod')
) {
for (const effect of value.loweredFunc.func.effects) {
if (
effect.kind === 'GlobalMutation' ||
effect.kind === 'ReactMutation'
) {
// Known effects are always propagated upwards
functionEffects.push(effect);
} else {
/**
* Contextual effects need to be replayed against the current inference
* state, which may know more about the value to which the effect applied.
* The main cases are:
* 1. The mutated context value is _still_ a context value in the current scope,
* so we have to continue propagating the original context mutation.
* 2. The mutated context value is a mutable value in the current scope,
* so the context mutation was fine and we can skip propagating the effect.
* 3. The mutated context value is an immutable value in the current scope,
* resulting in a non-ContextMutation FunctionEffect. We propagate that new,
* more detailed effect to the current function context.
*/
for (const place of effect.places) {
if (this.isDefined(place)) {
const replayedEffect = this.reference(
{...place, loc: effect.loc},
effect.effect,
reason,
);
if (replayedEffect != null) {
if (replayedEffect.kind === 'ContextMutation') {
// Case 1, still a context value so propagate the original effect
functionEffects.push(effect);
} else {
// Case 3, immutable value so propagate the more precise effect
functionEffects.push(replayedEffect);
}
} // else case 2, local mutable value so this effect was fine
dependentEffects.push(...value.loweredFunc.func.effects ?? []);
}
const knownEffects = this.#functionEffects.get(value);
if (knownEffects != null) {
dependentEffects.push(...knownEffects)
}
}
for (const effect of dependentEffects) {
if (
effect.kind === 'GlobalMutation' ||
effect.kind === 'ReactMutation' ||
effect.kind === 'ImmutableFunctionCall'
) {
// Known effects are always propagated upwards
functionEffects.push(effect);
} else if (effect.kind === 'ContextMutation') {
/**
* Contextual effects need to be replayed against the current inference
* state, which may know more about the value to which the effect applied.
* The main cases are:
* 1. The mutated context value is _still_ a context value in the current scope,
* so we have to continue propagating the original context mutation.
* 2. The mutated context value is a mutable value in the current scope,
* so the context mutation was fine and we can skip propagating the effect.
* 3. The mutated context value is an immutable value in the current scope,
* resulting in a non-ContextMutation FunctionEffect. We propagate that new,
* more detailed effect to the current function context.
*/
for (const place of effect.places) {
if (this.isDefined(place)) {
const replayedEffect = this.reference(
{...place, loc: effect.loc},
effect.effect,
reason,
);
if (replayedEffect != null) {
if (replayedEffect.kind === 'ContextMutation') {
// Case 1, still a context value so propagate the original effect
functionEffects.push(effect);
} else {
// Case 3, immutable value so propagate the more precise effect
functionEffects.push(replayedEffect);
}
}
} // else case 2, local mutable value so this effect was fine
}
}
}
@@ -995,8 +1136,22 @@ function inferBlock(
context: new Set(),
};
effect = {kind: Effect.Capture, reason: ValueReason.Other};
lvalueEffect = Effect.Store;
break;
const arrEffects: Array<FunctionEffect> = [];
for (const operand of eachInstructionOperand(instr)) {
state.referenceAndRecordEffects(
operand,
effect.kind,
effect.reason,
arrEffects,
);
}
state.initialize(instrValue, valueKind);
state.define(instr.lvalue, instrValue);
instr.lvalue.effect = Effect.Store;
state.setFunctionEffects(instrValue, arrEffects);
continue;
}
case 'NewExpression': {
/**
@@ -1039,6 +1194,7 @@ function inferBlock(
continue;
}
case 'ObjectExpression': {
const objEffects: Array<FunctionEffect> = [];
valueKind = hasContextRefOperand(state, instrValue)
? {
kind: ValueKind.Context,
@@ -1060,7 +1216,7 @@ function inferBlock(
property.key.name,
Effect.Freeze,
ValueReason.Other,
functionEffects,
objEffects,
);
}
// Object construction captures but does not modify the key/property values
@@ -1068,7 +1224,7 @@ function inferBlock(
property.place,
Effect.Capture,
ValueReason.Other,
functionEffects,
objEffects,
);
break;
}
@@ -1078,7 +1234,7 @@ function inferBlock(
property.place,
Effect.Capture,
ValueReason.Other,
functionEffects,
objEffects,
);
break;
}
@@ -1092,6 +1248,7 @@ function inferBlock(
}
state.initialize(instrValue, valueKind);
state.setFunctionEffects(instrValue, objEffects);
state.define(instr.lvalue, instrValue);
instr.lvalue.effect = Effect.Store;
continue;
@@ -1151,7 +1308,7 @@ function inferBlock(
);
functionEffects.push(
...propEffects.filter(
propEffect => propEffect.kind !== 'GlobalMutation',
effect => !isEffectSafeOutsideRender(effect),
),
);
}
@@ -1330,7 +1487,7 @@ function inferBlock(
context: new Set(),
};
let hasCaptureArgument = false;
let isUseEffect = isEffectHook(instrValue.callee.identifier);
let isHook = getHookKind(env, instrValue.callee.identifier) != null;
for (let i = 0; i < instrValue.args.length; i++) {
const argumentEffects: Array<FunctionEffect> = [];
const arg = instrValue.args[i];
@@ -1356,8 +1513,7 @@ function inferBlock(
*/
functionEffects.push(
...argumentEffects.filter(
argEffect =>
!isUseEffect || i !== 0 || argEffect.kind !== 'GlobalMutation',
argEffect => !isHook || !isEffectSafeOutsideRender(argEffect),
),
);
hasCaptureArgument ||= place.effect === Effect.Capture;
@@ -1379,6 +1535,32 @@ function inferBlock(
}
hasCaptureArgument ||= instrValue.callee.effect === Effect.Capture;
if (
!isSetStateType(instrValue.callee.identifier) &&
instrValue.callee.effect === Effect.Read &&
signature?.hookKind == null
) {
const allRead = instrValue.args.every(arg => {
switch (arg.kind) {
case 'Identifier':
return arg.effect === Effect.Read;
case 'Spread':
return arg.place.effect === Effect.Read;
default:
assertExhaustive(arg, 'Unexpected arg kind');
}
});
if (allRead) {
functionEffects.push({
kind: 'ImmutableFunctionCall',
lvalue: instr.lvalue.identifier.id,
callee: instrValue.callee.identifier.id,
loc: instrValue.loc,
global: state.kind(instrValue.callee).kind === ValueKind.Global,
});
}
}
state.initialize(instrValue, returnValueKind);
state.define(instr.lvalue, instrValue);
instr.lvalue.effect = hasCaptureArgument
@@ -1455,7 +1637,7 @@ function inferBlock(
const effects =
signature !== null ? getFunctionEffects(instrValue, signature) : null;
let hasCaptureArgument = false;
let isUseEffect = isEffectHook(instrValue.property.identifier);
let isHook = getHookKind(env, instrValue.property.identifier) != null;
for (let i = 0; i < instrValue.args.length; i++) {
const argumentEffects: Array<FunctionEffect> = [];
const arg = instrValue.args[i];
@@ -1485,8 +1667,7 @@ function inferBlock(
*/
functionEffects.push(
...argumentEffects.filter(
argEffect =>
!isUseEffect || i !== 0 || argEffect.kind !== 'GlobalMutation',
argEffect => !isHook || !isEffectSafeOutsideRender(argEffect),
),
);
hasCaptureArgument ||= place.effect === Effect.Capture;
@@ -1508,6 +1689,33 @@ function inferBlock(
}
hasCaptureArgument ||= instrValue.receiver.effect === Effect.Capture;
if (
!isSetStateType(instrValue.property.identifier) &&
instrValue.receiver.effect === Effect.Read &&
instrValue.property.effect === Effect.Read &&
signature?.hookKind == null
) {
const allRead = instrValue.args.every(arg => {
switch (arg.kind) {
case 'Identifier':
return arg.effect === Effect.Read;
case 'Spread':
return arg.place.effect === Effect.Read;
default:
assertExhaustive(arg, 'Unexpected arg kind');
}
});
if (allRead) {
functionEffects.push({
kind: 'ImmutableFunctionCall',
lvalue: instr.lvalue.identifier.id,
callee: instrValue.property.identifier.id,
loc: instrValue.loc,
global: state.kind(instrValue.property).kind === ValueKind.Global,
});
}
}
state.initialize(instrValue, returnValueKind);
state.define(instr.lvalue, instrValue);
instr.lvalue.effect = hasCaptureArgument
@@ -1549,14 +1757,16 @@ function inferBlock(
break;
}
case 'PropertyLoad': {
const loadEffects: Array<FunctionEffect> = [];
state.referenceAndRecordEffects(
instrValue.object,
Effect.Read,
ValueReason.Other,
functionEffects,
loadEffects,
);
const lvalue = instr.lvalue;
lvalue.effect = Effect.ConditionallyMutate;
state.setFunctionEffects(instrValue, loadEffects);
state.initialize(instrValue, state.kind(instrValue.object));
state.define(lvalue, instrValue);
continue;
@@ -1613,22 +1823,24 @@ function inferBlock(
continue;
}
case 'ComputedLoad': {
const loadEffects: Array<FunctionEffect> = [];
state.referenceAndRecordEffects(
instrValue.object,
Effect.Read,
ValueReason.Other,
functionEffects,
loadEffects,
);
state.referenceAndRecordEffects(
instrValue.property,
Effect.Read,
ValueReason.Other,
functionEffects,
loadEffects,
);
const lvalue = instr.lvalue;
lvalue.effect = Effect.ConditionallyMutate;
state.initialize(instrValue, state.kind(instrValue.object));
state.define(lvalue, instrValue);
state.setFunctionEffects(instrValue, loadEffects);
continue;
}
case 'Await': {
@@ -2010,11 +2222,15 @@ function inferBlock(
} else {
effect = Effect.Read;
}
const propEffects: Array<FunctionEffect> = [];
state.referenceAndRecordEffects(
operand,
effect,
ValueReason.Other,
functionEffects,
propEffects,
);
functionEffects.push(
...propEffects.filter(effect => !isEffectSafeOutsideRender(effect)),
);
}
}
@@ -2128,6 +2344,12 @@ function areArgumentsImmutableAndNonMutating(
return true;
}
function isEffectSafeOutsideRender(effect: FunctionEffect): boolean {
return (
effect.kind === 'GlobalMutation' || effect.kind === 'ImmutableFunctionCall'
);
}
function getWriteErrorReason(abstractValue: AbstractValue): string {
if (abstractValue.reason.has(ValueReason.Global)) {
return 'Writing to a variable defined outside a component or hook is not allowed. Consider using an effect';

View File

@@ -1,33 +0,0 @@
## Input
```javascript
function Foo() {
const x = () => {
window.href = 'foo';
};
const y = {x};
return <Bar y={y} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Foo,
params: [],
};
```
## Error
```
1 | function Foo() {
2 | const x = () => {
> 3 | window.href = 'foo';
| ^^^^^^ InvalidReact: Writing to a variable defined outside a component or hook is not allowed. Consider using an effect (3:3)
4 | };
5 | const y = {x};
6 | return <Bar y={y} />;
```

View File

@@ -0,0 +1,36 @@
## Input
```javascript
let b = 1;
export default function MyApp() {
const fn = () => {
b = 2;
};
return foo(fn);
}
function foo(fn) {}
export const FIXTURE_ENTRYPOINT = {
fn: MyApp,
params: [],
};
```
## Error
```
3 | export default function MyApp() {
4 | const fn = () => {
> 5 | b = 2;
| ^ 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) (5:5)
6 | };
7 | return foo(fn);
8 | }
```

View File

@@ -0,0 +1,15 @@
let b = 1;
export default function MyApp() {
const fn = () => {
b = 2;
};
return foo(fn);
}
function foo(fn) {}
export const FIXTURE_ENTRYPOINT = {
fn: MyApp,
params: [],
};

View File

@@ -0,0 +1,35 @@
## Input
```javascript
let b = 1;
export default function useMyHook() {
const fn = () => {
b = 2;
};
const obj = { fn };
obj.fn();
}
export const FIXTURE_ENTRYPOINT = {
fn: useMyHook,
params: [],
};
```
## Error
```
3 | export default function useMyHook() {
4 | const fn = () => {
> 5 | b = 2;
| ^ 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) (5:5)
6 | };
7 | const obj = { fn };
8 | obj.fn();
```

View File

@@ -0,0 +1,15 @@
let b = 1;
export default function useMyHook() {
const fn = () => {
b = 2;
};
const obj = { fn };
const arr = [obj];
foo(arr);
}
export const FIXTURE_ENTRYPOINT = {
fn: useMyHook,
params: [],
};

View File

@@ -0,0 +1,49 @@
## Input
```javascript
function Foo() {
const x = () => {
window.href = 'foo';
};
const y = {x};
return <Bar y={y} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Foo,
params: [],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
function Foo() {
const $ = _c(1);
const x = _temp;
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
const y = { x };
t0 = <Bar y={y} />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
function _temp() {
window.href = "foo";
}
export const FIXTURE_ENTRYPOINT = {
fn: Foo,
params: [],
};
```
### Eval output
(kind: exception) Bar is not defined

View File

@@ -0,0 +1,46 @@
## Input
```javascript
let b = 1;
export default function MyApp() {
const fn = () => {
b = 2;
};
return useFoo(fn);
}
function useFoo(fn) {}
export const FIXTURE_ENTRYPOINT = {
fn: MyApp,
params: [],
};
```
## Code
```javascript
let b = 1;
export default function MyApp() {
const fn = _temp;
return useFoo(fn);
}
function _temp() {
b = 2;
}
function useFoo(fn) {}
export const FIXTURE_ENTRYPOINT = {
fn: MyApp,
params: [],
};
```
### Eval output
(kind: ok)

View File

@@ -0,0 +1,15 @@
let b = 1;
export default function MyApp() {
const fn = () => {
b = 2;
};
return useFoo(fn);
}
function useFoo(fn) {}
export const FIXTURE_ENTRYPOINT = {
fn: MyApp,
params: [],
};

View File

@@ -0,0 +1,51 @@
## Input
```javascript
let b = 1;
export default function useMyHook() {
const fn = () => {
b = 2;
};
return { fn };
}
export const FIXTURE_ENTRYPOINT = {
fn: useMyHook,
params: [],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
let b = 1;
export default function useMyHook() {
const $ = _c(1);
const fn = _temp;
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = { fn };
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
function _temp() {
b = 2;
}
export const FIXTURE_ENTRYPOINT = {
fn: useMyHook,
params: [],
};
```
### Eval output
(kind: ok) {"fn":"[[ function params=0 ]]"}

View File

@@ -0,0 +1,13 @@
let b = 1;
export default function useMyHook() {
const fn = () => {
b = 2;
};
return { fn };
}
export const FIXTURE_ENTRYPOINT = {
fn: useMyHook,
params: [],
};

View File

@@ -0,0 +1,42 @@
## Input
```javascript
let b = 1;
export default function useMyHook() {
const fn = () => {
b = 2;
};
return fn;
}
export const FIXTURE_ENTRYPOINT = {
fn: useMyHook,
params: [],
};
```
## Code
```javascript
let b = 1;
export default function useMyHook() {
const fn = _temp;
return fn;
}
function _temp() {
b = 2;
}
export const FIXTURE_ENTRYPOINT = {
fn: useMyHook,
params: [],
};
```
### Eval output
(kind: ok) "[[ function params=0 ]]"

View File

@@ -0,0 +1,13 @@
let b = 1;
export default function useMyHook() {
const fn = () => {
b = 2;
};
return fn;
}
export const FIXTURE_ENTRYPOINT = {
fn: useMyHook,
params: [],
};