[RFC] useEffect dependency memoization check

This is one approach to testing whether useEffect dependencies are memoized. The 
idea is based off the observation that the only reason dependencies wouldn't be 
memoized (other than compiler bugs) is that they are mutated later. If they're 
mutated later, then the dep array will have a mutable range which encompasses 
the InstructionId of the useEffect call. So we look for that pattern and throw a 
validation error. 

The downside of this approach is that we might reject code that happens to be 
valid: specifically, that the lack of memoization isn't a problem in practice 
because the effect won't trigger a loop. But (per test plan) this doesn't seem 
to introduce that many new bailouts on www. Rather than implement a complex 
validation that checks whether we un-memoized something that was memoized in the 
input, it seems more practical to: 

1. Enable this more comprehensive validation against any form of un-memo'd 
effect dependency 

2. Flip the default for hooks (to assume they follow the rules), which will fix 
the primary cause of Forget pessimistically not memoizing dependencies. 

## Test Plan 

Synced to www and checked output via the upgrade script: a few components stop 
getting memoized bc they have un-memoized effect dependencies. Let's chat!
This commit is contained in:
Joe Savona
2023-11-27 10:20:43 -08:00
parent 5b8e94c6d8
commit d7db416167
13 changed files with 277 additions and 24 deletions
@@ -71,6 +71,7 @@ import { assertExhaustive } from "../Utils/utils";
import {
validateFrozenLambdas,
validateHooksUsage,
validateMemoizedEffectDependencies,
validateNoRefAccessInRender,
validateNoSetStateInRender,
validateUnconditionalHooks,
@@ -358,6 +359,10 @@ function* runWithEnvironment(
value: reactiveFunction,
});
if (env.config.validateMemoizedEffectDependencies) {
validateMemoizedEffectDependencies(reactiveFunction);
}
const ast = codegenReactiveFunction(reactiveFunction).unwrap();
yield log({ kind: "ast", name: "Codegen", value: ast });
@@ -142,6 +142,16 @@ const EnvironmentConfigSchema = z.object({
*/
validateNoSetStateInRender: z.boolean().default(false),
/**
* Validates that the dependencies of all effect hooks are memoized. This helps ensure
* that Forget does not introduce infinite renders caused by a dependency changing,
* triggering an effect, which triggers re-rendering, which causes a dependency to change,
* triggering the effect, etc.
*
* Covers useEffect, useLayoutEffect, useInsertionEffect.
*/
validateMemoizedEffectDependencies: z.boolean().default(false),
/*
* When enabled, the compiler assumes that hooks follow the Rules of React:
* - Hooks may memoize computation based on any of their parameters, thus
@@ -9,6 +9,9 @@ import { Effect, ValueKind } from "./HIR";
import {
BUILTIN_SHAPES,
BuiltInArrayId,
BuiltInUseEffectHookId,
BuiltInUseInsertionEffectHookId,
BuiltInUseLayoutEffectHookId,
BuiltInUseRefId,
BuiltInUseStateId,
ShapeRegistry,
@@ -294,36 +297,51 @@ const BUILTIN_HOOKS: Array<[string, BuiltInType]> = [
],
[
"useEffect",
addHook(DEFAULT_SHAPES, [], {
positionalParams: [],
restParam: Effect.Freeze,
returnType: { kind: "Primitive" },
calleeEffect: Effect.Read,
hookKind: "useEffect",
returnValueKind: ValueKind.Frozen,
}),
addHook(
DEFAULT_SHAPES,
[],
{
positionalParams: [],
restParam: Effect.Freeze,
returnType: { kind: "Primitive" },
calleeEffect: Effect.Read,
hookKind: "useEffect",
returnValueKind: ValueKind.Frozen,
},
BuiltInUseEffectHookId
),
],
[
"useLayoutEffect",
addHook(DEFAULT_SHAPES, [], {
positionalParams: [],
restParam: Effect.Freeze,
returnType: { kind: "Poly" },
calleeEffect: Effect.Read,
hookKind: "useLayoutEffect",
returnValueKind: ValueKind.Frozen,
}),
addHook(
DEFAULT_SHAPES,
[],
{
positionalParams: [],
restParam: Effect.Freeze,
returnType: { kind: "Poly" },
calleeEffect: Effect.Read,
hookKind: "useLayoutEffect",
returnValueKind: ValueKind.Frozen,
},
BuiltInUseLayoutEffectHookId
),
],
[
"useInsertionEffect",
addHook(DEFAULT_SHAPES, [], {
positionalParams: [],
restParam: Effect.Freeze,
returnType: { kind: "Poly" },
calleeEffect: Effect.Read,
hookKind: "useLayoutEffect",
returnValueKind: ValueKind.Frozen,
}),
addHook(
DEFAULT_SHAPES,
[],
{
positionalParams: [],
restParam: Effect.Freeze,
returnType: { kind: "Poly" },
calleeEffect: Effect.Read,
hookKind: "useLayoutEffect",
returnValueKind: ValueKind.Frozen,
},
BuiltInUseInsertionEffectHookId
),
],
];
@@ -1126,6 +1126,24 @@ export function isSetStateType(id: Identifier): boolean {
return id.type.kind === "Function" && id.type.shapeId === "BuiltInSetState";
}
export function isUseEffectHookType(id: Identifier): boolean {
return (
id.type.kind === "Function" && id.type.shapeId === "BuiltInUseEffectHook"
);
}
export function isUseLayoutEffectHookType(id: Identifier): boolean {
return (
id.type.kind === "Function" &&
id.type.shapeId === "BuiltInUseLayoutEffectHook"
);
}
export function isUseInsertionEffectHookType(id: Identifier): boolean {
return (
id.type.kind === "Function" &&
id.type.shapeId === "BuiltInUseInsertionEffectHook"
);
}
export function getHookKind(env: Environment, id: Identifier): HookKind | null {
const idType = id.type;
if (idType.kind === "Function") {
@@ -174,6 +174,9 @@ export const BuiltInSetStateId = "BuiltInSetState";
export const BuiltInUseRefId = "BuiltInUseRefId";
export const BuiltInRefValueId = "BuiltInRefValue";
export const BuiltInMixedReadonlyId = "BuiltInMixedReadonly";
export const BuiltInUseEffectHookId = "BuiltInUseEffectHook";
export const BuiltInUseLayoutEffectHookId = "BuiltInUseLayoutEffectHook";
export const BuiltInUseInsertionEffectHookId = "BuiltInUseInsertionEffectHook";
// ShapeRegistry with default definitions for built-ins.
export const BUILTIN_SHAPES: ShapeRegistry = new Map();
@@ -0,0 +1,87 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import { CompilerError, ErrorSeverity } from "..";
import {
Identifier,
Instruction,
ReactiveFunction,
ReactiveInstruction,
isUseEffectHookType,
isUseInsertionEffectHookType,
isUseLayoutEffectHookType,
} from "../HIR";
import { isMutable } from "../ReactiveScopes/InferReactiveScopeVariables";
import {
ReactiveFunctionVisitor,
visitReactiveFunction,
} from "../ReactiveScopes/visitors";
/**
* Validates that all known effect dependencies are memoized. The algorithm does not directly check
* for memoization but instead uses an inverted test: it reports any effects whose dependency arrays
* are mutable for a range that encompasses the effect call. This corresponds to any values which
* Forget knows may be mutable and may be mutated after the effect. Note that it's possible Forget
* may miss not memoize a value for some other reason, but in general this is a bug. The only reason
* Forget would _choose_ to skip memoization of an effect dependency is because it's mutated later.
*
* Example:
*
* ```javascript
* const object = {}; // mutable range starts here...
*
* useEffect(() => {
* console.log('hello');
* }, [object]); // the dependency array picks up the mutable range of its mutable contents
*
* mutate(object); // ... mutable range ends here after this mutation
* ```
*/
export function validateMemoizedEffectDependencies(fn: ReactiveFunction): void {
const errors = new CompilerError();
visitReactiveFunction(fn, new Visitor(), errors);
if (errors.hasErrors()) {
throw errors;
}
}
class Visitor extends ReactiveFunctionVisitor<CompilerError> {
override visitInstruction(
instruction: ReactiveInstruction,
state: CompilerError
): void {
this.traverseInstruction(instruction, state);
if (
instruction.value.kind === "CallExpression" &&
isEffectHook(instruction.value.callee.identifier) &&
instruction.value.args.length >= 2
) {
const deps = instruction.value.args[1]!;
if (
deps.kind === "Identifier" &&
isMutable(instruction as Instruction, deps)
) {
state.push({
reason:
"This effect may trigger an infinite loop: one or more of its dependencies could not be memoized due to a later mutation",
description: null,
severity: ErrorSeverity.InvalidReact,
loc: typeof instruction.loc !== "symbol" ? instruction.loc : null,
suggestions: null,
});
}
}
}
}
function isEffectHook(identifier: Identifier): boolean {
return (
isUseEffectHookType(identifier) ||
isUseLayoutEffectHookType(identifier) ||
isUseInsertionEffectHookType(identifier)
);
}
@@ -7,6 +7,7 @@
export { validateFrozenLambdas } from "./ValidateFrozenLambdas";
export { validateHooksUsage } from "./ValidateHooksUsage";
export { validateMemoizedEffectDependencies } from "./ValidateMemoizedEffectDependencies";
export { validateNoRefAccessInRender } from "./ValidateNoRefAccesInRender";
export { validateNoSetStateInRender } from "./ValidateNoSetStateInRender";
export { validateUnconditionalHooks } from "./ValidateUnconditionalHooks";
@@ -0,0 +1,26 @@
## Input
```javascript
// @validateMemoizedEffectDependencies
import { useEffect } from "react";
function Component(props) {
const data = {};
useEffect(() => {
console.log(props.value);
}, [data]);
mutate(data);
return data;
}
```
## Error
```
[ReactForget] InvalidReact: This effect may trigger an infinite loop: one or more of its dependencies could not be memoized due to a later mutation (6:8)
```
@@ -0,0 +1,11 @@
// @validateMemoizedEffectDependencies
import { useEffect } from "react";
function Component(props) {
const data = {};
useEffect(() => {
console.log(props.value);
}, [data]);
mutate(data);
return data;
}
@@ -0,0 +1,26 @@
## Input
```javascript
// @validateMemoizedEffectDependencies
import { useInsertionEffect } from "react";
function Component(props) {
const data = {};
useInsertionEffect(() => {
console.log(props.value);
}, [data]);
mutate(data);
return data;
}
```
## Error
```
[ReactForget] InvalidReact: This effect may trigger an infinite loop: one or more of its dependencies could not be memoized due to a later mutation (6:8)
```
@@ -0,0 +1,11 @@
// @validateMemoizedEffectDependencies
import { useInsertionEffect } from "react";
function Component(props) {
const data = {};
useInsertionEffect(() => {
console.log(props.value);
}, [data]);
mutate(data);
return data;
}
@@ -0,0 +1,26 @@
## Input
```javascript
// @validateMemoizedEffectDependencies
import { useLayoutEffect } from "react";
function Component(props) {
const data = {};
useLayoutEffect(() => {
console.log(props.value);
}, [data]);
mutate(data);
return data;
}
```
## Error
```
[ReactForget] InvalidReact: This effect may trigger an infinite loop: one or more of its dependencies could not be memoized due to a later mutation (6:8)
```
@@ -0,0 +1,11 @@
// @validateMemoizedEffectDependencies
import { useLayoutEffect } from "react";
function Component(props) {
const data = {};
useLayoutEffect(() => {
console.log(props.value);
}, [data]);
mutate(data);
return data;
}