import {
CompilerDiagnostic,
CompilerError,
ErrorCategory,
} from '../CompilerError';
import {
DeclarationId,
Effect,
GeneratedSource,
Identifier,
IdentifierId,
InstructionValue,
ManualMemoDependency,
PrunedReactiveScopeBlock,
ReactiveFunction,
ReactiveInstruction,
ReactiveScopeBlock,
ReactiveScopeDependency,
ReactiveValue,
ScopeId,
SourceLocation,
} from '../HIR';
import {printIdentifier, printManualMemoDependency} from '../HIR/PrintHIR';
import {
eachInstructionValueLValue,
eachInstructionValueOperand,
} from '../HIR/visitors';
import {collectMaybeMemoDependencies} from '../Inference/DropManualMemoization';
import {
ReactiveFunctionVisitor,
visitReactiveFunction,
} from '../ReactiveScopes/visitors';
import {Result} from '../Utils/Result';
import {getOrInsertDefault} from '../Utils/utils';
export function validatePreservedManualMemoization(
fn: ReactiveFunction,
): Result<void, CompilerError> {
const state = {
errors: new CompilerError(),
manualMemoState: null,
};
visitReactiveFunction(fn, new Visitor(), state);
return state.errors.asResult();
}
const DEBUG = false;
type ManualMemoBlockState = {
reassignments: Map<DeclarationId, Set<Identifier>>;
loc: SourceLocation;
decls: Set<DeclarationId>;
depsFromSource: Array<ManualMemoDependency> | null;
manualMemoId: number;
};
type VisitorState = {
errors: CompilerError;
manualMemoState: ManualMemoBlockState | null;
};
function prettyPrintScopeDependency(val: ReactiveScopeDependency): string {
let rootStr;
if (val.identifier.name?.kind === 'named') {
rootStr = val.identifier.name.value;
} else {
rootStr = '[unnamed]';
}
return `${rootStr}${val.path.map(v => `${v.optional ? '?.' : '.'}${v.property}`).join('')}`;
}
enum CompareDependencyResult {
Ok = 0,
RootDifference = 1,
PathDifference = 2,
Subpath = 3,
RefAccessDifference = 4,
}
function merge(
a: CompareDependencyResult,
b: CompareDependencyResult,
): CompareDependencyResult {
return Math.max(a, b);
}
function getCompareDependencyResultDescription(
result: CompareDependencyResult,
): string {
switch (result) {
case CompareDependencyResult.Ok:
return 'Dependencies equal';
case CompareDependencyResult.RootDifference:
case CompareDependencyResult.PathDifference:
return 'Inferred different dependency than source';
case CompareDependencyResult.RefAccessDifference:
return 'Differences in ref.current access';
case CompareDependencyResult.Subpath:
return 'Inferred less specific property than source';
}
}
function compareDeps(
inferred: ManualMemoDependency,
source: ManualMemoDependency,
): CompareDependencyResult {
const rootsEqual =
(inferred.root.kind === 'Global' &&
source.root.kind === 'Global' &&
inferred.root.identifierName === source.root.identifierName) ||
(inferred.root.kind === 'NamedLocal' &&
source.root.kind === 'NamedLocal' &&
inferred.root.value.identifier.id === source.root.value.identifier.id);
if (!rootsEqual) {
return CompareDependencyResult.RootDifference;
}
let isSubpath = true;
for (let i = 0; i < Math.min(inferred.path.length, source.path.length); i++) {
if (inferred.path[i].property !== source.path[i].property) {
isSubpath = false;
break;
} else if (inferred.path[i].optional !== source.path[i].optional) {
return CompareDependencyResult.PathDifference;
}
}
if (
isSubpath &&
(source.path.length === inferred.path.length ||
(inferred.path.length >= source.path.length &&
!inferred.path.some(token => token.property === 'current')))
) {
return CompareDependencyResult.Ok;
} else {
if (isSubpath) {
if (
source.path.some(token => token.property === 'current') ||
inferred.path.some(token => token.property === 'current')
) {
return CompareDependencyResult.RefAccessDifference;
} else {
return CompareDependencyResult.Subpath;
}
} else {
return CompareDependencyResult.PathDifference;
}
}
}
function validateInferredDep(
dep: ReactiveScopeDependency,
temporaries: Map<IdentifierId, ManualMemoDependency>,
declsWithinMemoBlock: Set<DeclarationId>,
validDepsInMemoBlock: Array<ManualMemoDependency>,
errorState: CompilerError,
memoLocation: SourceLocation,
): void {
let normalizedDep: ManualMemoDependency;
const maybeNormalizedRoot = temporaries.get(dep.identifier.id);
if (maybeNormalizedRoot != null) {
normalizedDep = {
root: maybeNormalizedRoot.root,
path: [...maybeNormalizedRoot.path, ...dep.path],
loc: maybeNormalizedRoot.loc,
};
} else {
CompilerError.invariant(dep.identifier.name?.kind === 'named', {
reason:
'ValidatePreservedManualMemoization: expected scope dependency to be named',
loc: GeneratedSource,
});
normalizedDep = {
root: {
kind: 'NamedLocal',
value: {
kind: 'Identifier',
identifier: dep.identifier,
loc: GeneratedSource,
effect: Effect.Read,
reactive: false,
},
constant: false,
},
path: [...dep.path],
loc: GeneratedSource,
};
}
for (const decl of declsWithinMemoBlock) {
if (
normalizedDep.root.kind === 'NamedLocal' &&
decl === normalizedDep.root.value.identifier.declarationId
) {
return;
}
}
let errorDiagnostic: CompareDependencyResult | null = null;
for (const originalDep of validDepsInMemoBlock) {
const compareResult = compareDeps(normalizedDep, originalDep);
if (compareResult === CompareDependencyResult.Ok) {
return;
} else {
errorDiagnostic = merge(errorDiagnostic ?? compareResult, compareResult);
}
}
errorState.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.PreserveManualMemo,
reason: 'Existing memoization could not be preserved',
description: [
'React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. ',
'The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. ',
DEBUG ||
(dep.identifier.name != null && dep.identifier.name.kind === 'named')
? `The inferred dependency was \`${prettyPrintScopeDependency(
dep,
)}\`, but the source dependencies were [${validDepsInMemoBlock
.map(dep => printManualMemoDependency(dep, true))
.join(', ')}]. ${
errorDiagnostic
? getCompareDependencyResultDescription(errorDiagnostic)
: 'Inferred dependency not present in source'
}`
: '',
]
.join('')
.trim(),
suggestions: null,
}).withDetails({
kind: 'error',
loc: memoLocation,
message: 'Could not preserve existing manual memoization',
}),
);
}
class Visitor extends ReactiveFunctionVisitor<VisitorState> {
scopes: Set<ScopeId> = new Set();
prunedScopes: Set<ScopeId> = new Set();
temporaries: Map<IdentifierId, ManualMemoDependency> = new Map();
recordDepsInValue(value: ReactiveValue, state: VisitorState): void {
switch (value.kind) {
case 'SequenceExpression': {
for (const instr of value.instructions) {
this.visitInstruction(instr, state);
}
this.recordDepsInValue(value.value, state);
break;
}
case 'OptionalExpression': {
this.recordDepsInValue(value.value, state);
break;
}
case 'ConditionalExpression': {
this.recordDepsInValue(value.test, state);
this.recordDepsInValue(value.consequent, state);
this.recordDepsInValue(value.alternate, state);
break;
}
case 'LogicalExpression': {
this.recordDepsInValue(value.left, state);
this.recordDepsInValue(value.right, state);
break;
}
default: {
collectMaybeMemoDependencies(value, this.temporaries, false);
if (
value.kind === 'StoreLocal' ||
value.kind === 'StoreContext' ||
value.kind === 'Destructure'
) {
for (const storeTarget of eachInstructionValueLValue(value)) {
state.manualMemoState?.decls.add(
storeTarget.identifier.declarationId,
);
if (storeTarget.identifier.name?.kind === 'named') {
this.temporaries.set(storeTarget.identifier.id, {
root: {
kind: 'NamedLocal',
value: storeTarget,
constant: false,
},
path: [],
loc: storeTarget.loc,
});
}
}
}
break;
}
}
}
recordTemporaries(instr: ReactiveInstruction, state: VisitorState): void {
const temporaries = this.temporaries;
const {lvalue, value} = instr;
const lvalId = lvalue?.identifier.id;
if (lvalId != null && temporaries.has(lvalId)) {
return;
}
const isNamedLocal = lvalue?.identifier.name?.kind === 'named';
if (lvalue !== null && isNamedLocal && state.manualMemoState != null) {
state.manualMemoState.decls.add(lvalue.identifier.declarationId);
}
this.recordDepsInValue(value, state);
if (lvalue != null) {
temporaries.set(lvalue.identifier.id, {
root: {
kind: 'NamedLocal',
value: {...lvalue},
constant: false,
},
path: [],
loc: lvalue.loc,
});
}
}
override visitScope(
scopeBlock: ReactiveScopeBlock,
state: VisitorState,
): void {
this.traverseScope(scopeBlock, state);
if (
state.manualMemoState != null &&
state.manualMemoState.depsFromSource != null
) {
for (const dep of scopeBlock.scope.dependencies) {
validateInferredDep(
dep,
this.temporaries,
state.manualMemoState.decls,
state.manualMemoState.depsFromSource,
state.errors,
state.manualMemoState.loc,
);
}
}
this.scopes.add(scopeBlock.scope.id);
for (const id of scopeBlock.scope.merged) {
this.scopes.add(id);
}
}
override visitPrunedScope(
scopeBlock: PrunedReactiveScopeBlock,
state: VisitorState,
): void {
this.traversePrunedScope(scopeBlock, state);
this.prunedScopes.add(scopeBlock.scope.id);
}
override visitInstruction(
instruction: ReactiveInstruction,
state: VisitorState,
): void {
this.recordTemporaries(instruction, state);
const value = instruction.value;
if (
value.kind === 'StoreLocal' &&
value.lvalue.kind === 'Reassign' &&
state.manualMemoState != null
) {
const ids = getOrInsertDefault(
state.manualMemoState.reassignments,
value.lvalue.place.identifier.declarationId,
new Set(),
);
ids.add(value.value.identifier);
}
if (
value.kind === 'LoadLocal' &&
value.place.identifier.scope != null &&
instruction.lvalue != null &&
instruction.lvalue.identifier.scope == null &&
state.manualMemoState != null
) {
const ids = getOrInsertDefault(
state.manualMemoState.reassignments,
instruction.lvalue.identifier.declarationId,
new Set(),
);
ids.add(value.place.identifier);
}
if (value.kind === 'StartMemoize') {
let depsFromSource: Array<ManualMemoDependency> | null = null;
if (value.deps != null) {
depsFromSource = value.deps;
}
CompilerError.invariant(state.manualMemoState == null, {
reason: 'Unexpected nested StartMemoize instructions',
description: `Bad manual memoization ids: ${state.manualMemoState?.manualMemoId}, ${value.manualMemoId}`,
loc: value.loc,
});
state.manualMemoState = {
loc: instruction.loc,
decls: new Set(),
depsFromSource,
manualMemoId: value.manualMemoId,
reassignments: new Map(),
};
for (const {identifier, loc} of eachInstructionValueOperand(
value as InstructionValue,
)) {
if (
identifier.scope != null &&
!this.scopes.has(identifier.scope.id) &&
!this.prunedScopes.has(identifier.scope.id)
) {
state.errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.PreserveManualMemo,
reason: 'Existing memoization could not be preserved',
description: [
'React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. ',
'This dependency may be mutated later, which could cause the value to change unexpectedly',
].join(''),
}).withDetails({
kind: 'error',
loc,
message: 'This dependency may be modified later',
}),
);
}
}
}
if (value.kind === 'FinishMemoize') {
CompilerError.invariant(
state.manualMemoState != null &&
state.manualMemoState.manualMemoId === value.manualMemoId,
{
reason: 'Unexpected mismatch between StartMemoize and FinishMemoize',
description: `Encountered StartMemoize id=${state.manualMemoState?.manualMemoId} followed by FinishMemoize id=${value.manualMemoId}`,
loc: value.loc,
},
);
const reassignments = state.manualMemoState.reassignments;
state.manualMemoState = null;
if (!value.pruned) {
for (const {identifier, loc} of eachInstructionValueOperand(
value as InstructionValue,
)) {
let decls;
if (identifier.scope == null) {
decls = reassignments.get(identifier.declarationId) ?? [identifier];
} else {
decls = [identifier];
}
for (const identifier of decls) {
if (isUnmemoized(identifier, this.scopes)) {
state.errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.PreserveManualMemo,
reason: 'Existing memoization could not be preserved',
description: [
'React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output',
DEBUG
? `${printIdentifier(identifier)} was not memoized.`
: '',
]
.join('')
.trim(),
}).withDetails({
kind: 'error',
loc,
message: 'Could not preserve existing memoization',
}),
);
}
}
}
}
}
}
}
function isUnmemoized(operand: Identifier, scopes: Set<ScopeId>): boolean {
return operand.scope != null && !scopes.has(operand.scope.id);
}