Files
react/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateMemoizedEffectDependencies.ts
Joe Savona f283c6860c [compiler] Remove local CompilerError accumulators, emit directly to env.recordError()
Removes unnecessary indirection in 17 compiler passes that previously
accumulated errors in a local `CompilerError` instance before flushing
them to `env.recordErrors()` at the end of each pass. Errors are now
emitted directly via `env.recordError()` as they're discovered.

For passes with recursive error-detection patterns (ValidateNoRefAccessInRender,
ValidateNoSetStateInRender), the internal accumulator is kept but flushed
via individual `recordError()` calls. For InferMutationAliasingRanges,
a `shouldRecordErrors` flag preserves the conditional suppression logic.
For TransformFire, the throw-based error propagation is replaced with
direct recording plus an early-exit check in Pipeline.ts.
2026-02-20 17:24:38 -08:00

132 lines
4.4 KiB
TypeScript

/**
* 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 {CompilerErrorDetail, ErrorCategory} from '../CompilerError';
import {
Identifier,
Instruction,
ReactiveFunction,
ReactiveInstruction,
ReactiveScopeBlock,
ScopeId,
isUseEffectHookType,
isUseInsertionEffectHookType,
isUseLayoutEffectHookType,
} from '../HIR';
import {Environment} from '../HIR/Environment';
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
import {
ReactiveFunctionVisitor,
visitReactiveFunction,
} from '../ReactiveScopes/visitors';
/**
* Validates that all known effect dependencies are memoized. The algorithm checks two things:
* - Disallow effect dependencies that should be memoized (have a reactive scope assigned) but
* where that reactive scope does not exist. This checks for cases where a reactive scope was
* pruned for some reason, such as spanning a hook.
* - Disallow effect dependencies whose a mutable range that encompasses the effect call.
*
* This latter check 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 {
visitReactiveFunction(fn, new Visitor(), fn.env);
}
class Visitor extends ReactiveFunctionVisitor<Environment> {
scopes: Set<ScopeId> = new Set();
override visitScope(
scopeBlock: ReactiveScopeBlock,
state: Environment,
): void {
this.traverseScope(scopeBlock, state);
/*
* Record scopes that exist in the AST so we can later check to see if
* effect dependencies which should be memoized (have a scope assigned)
* actually are memoized (that scope exists).
* However, we only record scopes if *their* dependencies are also
* memoized, allowing a transitive memoization check.
*/
let areDependenciesMemoized = true;
for (const dep of scopeBlock.scope.dependencies) {
if (isUnmemoized(dep.identifier, this.scopes)) {
areDependenciesMemoized = false;
break;
}
}
if (areDependenciesMemoized) {
this.scopes.add(scopeBlock.scope.id);
for (const id of scopeBlock.scope.merged) {
this.scopes.add(id);
}
}
}
override visitInstruction(
instruction: ReactiveInstruction,
state: Environment,
): 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' &&
/*
* TODO: isMutable is not safe to call here as it relies on identifier mutableRange which is no longer valid at this point
* in the pipeline
*/
(isMutable(instruction as Instruction, deps) ||
isUnmemoized(deps.identifier, this.scopes))
) {
state.recordError(
new CompilerErrorDetail({
category: ErrorCategory.EffectDependencies,
reason:
'React Compiler has skipped optimizing this component because the effect dependencies could not be memoized. Unmemoized effect dependencies can trigger an infinite loop or other unexpected behavior',
description: null,
loc: typeof instruction.loc !== 'symbol' ? instruction.loc : null,
suggestions: null,
}),
);
}
}
}
}
function isUnmemoized(operand: Identifier, scopes: Set<ScopeId>): boolean {
return operand.scope != null && !scopes.has(operand.scope.id);
}
export function isEffectHook(identifier: Identifier): boolean {
return (
isUseEffectHookType(identifier) ||
isUseLayoutEffectHookType(identifier) ||
isUseInsertionEffectHookType(identifier)
);
}