use std::collections::{HashMap, HashSet};
use react_compiler_diagnostics::{
CompilerDiagnostic, CompilerDiagnosticDetail, CompilerSuggestion,
CompilerSuggestionOperation, ErrorCategory, SourceLocation,
};
use react_compiler_hir::environment::Environment;
use react_compiler_hir::environment_config::ExhaustiveEffectDepsMode;
use react_compiler_hir::{
ArrayElement, BlockId, DependencyPathEntry, HirFunction, Identifier, IdentifierId,
InstructionKind, InstructionValue, ManualMemoDependency, ManualMemoDependencyRoot,
NonLocalBinding, ParamPattern, Place, PlaceOrSpread, PropertyLiteral, Terminal, Type,
};
use react_compiler_hir::visitors::{
each_instruction_value_lvalue, each_instruction_value_operand_with_functions,
each_terminal_operand,
};
/// Port of ValidateExhaustiveDependencies.ts
///
/// Validates that existing manual memoization is exhaustive and does not
/// have extraneous dependencies. The goal is to ensure auto-memoization
/// will not substantially change program behavior.
///
/// Note: takes `&mut HirFunction` (deviating from the read-only validation convention)
/// because it sets `has_invalid_deps` on StartMemoize instructions when validation
/// errors are found, so that ValidatePreservedManualMemoization can skip those blocks.
pub fn validate_exhaustive_dependencies(func: &mut HirFunction, env: &mut Environment) -> Result<(), CompilerDiagnostic> {
let reactive = collect_reactive_identifiers(func, &env.functions);
let validate_memo = env.config.validate_exhaustive_memoization_dependencies;
let validate_effect = env.config.validate_exhaustive_effect_dependencies.clone();
let mut temporaries: HashMap<IdentifierId, Temporary> = HashMap::new();
for param in &func.params {
let place = match param {
ParamPattern::Place(p) => p,
ParamPattern::Spread(s) => &s.place,
};
temporaries.insert(
place.identifier,
Temporary::Local {
identifier: place.identifier,
path: Vec::new(),
context: false,
loc: place.loc,
},
);
}
let mut start_memo: Option<StartMemoInfo> = None;
let mut memo_locals: HashSet<IdentifierId> = HashSet::new();
// Callbacks struct holding the mutable state
let mut callbacks = Callbacks {
start_memo: &mut start_memo,
memo_locals: &mut memo_locals,
validate_memo,
validate_effect: validate_effect.clone(),
reactive: &reactive,
diagnostics: Vec::new(),
invalid_memo_ids: HashSet::new(),
};
collect_dependencies(
func,
&env.identifiers,
&env.types,
&env.functions,
&mut temporaries,
&mut Some(&mut callbacks),
false,
)?;
// Set has_invalid_deps on StartMemoize instructions that had validation errors
if !callbacks.invalid_memo_ids.is_empty() {
for instr in func.instructions.iter_mut() {
if let InstructionValue::StartMemoize { manual_memo_id, has_invalid_deps, .. } = &mut instr.value {
if callbacks.invalid_memo_ids.contains(manual_memo_id) {
*has_invalid_deps = true;
}
}
}
}
// Record all diagnostics on the environment
for diagnostic in callbacks.diagnostics {
env.record_diagnostic(diagnostic);
}
Ok(())
}
// =============================================================================
// Internal types
// =============================================================================
/// Info extracted from a StartMemoize instruction
struct StartMemoInfo {
manual_memo_id: u32,
deps: Option<Vec<ManualMemoDependency>>,
deps_loc: Option<Option<SourceLocation>>,
#[allow(dead_code)]
loc: Option<SourceLocation>,
}
/// A temporary value tracked during dependency collection
#[derive(Debug, Clone)]
enum Temporary {
Local {
identifier: IdentifierId,
path: Vec<DependencyPathEntry>,
context: bool,
loc: Option<SourceLocation>,
},
Global {
binding: NonLocalBinding,
},
Aggregate {
dependencies: Vec<InferredDependency>,
loc: Option<SourceLocation>,
},
}
/// An inferred dependency (Local or Global)
#[derive(Debug, Clone)]
enum InferredDependency {
Local {
identifier: IdentifierId,
path: Vec<DependencyPathEntry>,
#[allow(dead_code)]
context: bool,
loc: Option<SourceLocation>,
},
Global {
binding: NonLocalBinding,
},
}
/// Hashable key for deduplicating inferred dependencies in a Set
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum InferredDependencyKey {
Local {
identifier: IdentifierId,
path_key: String,
},
Global {
name: String,
},
}
fn dep_to_key(dep: &InferredDependency) -> InferredDependencyKey {
match dep {
InferredDependency::Local {
identifier, path, ..
} => InferredDependencyKey::Local {
identifier: *identifier,
path_key: path_to_string(path),
},
InferredDependency::Global { binding } => InferredDependencyKey::Global {
name: binding.name().to_string(),
},
}
}
fn path_to_string(path: &[DependencyPathEntry]) -> String {
path.iter()
.map(|p| {
format!(
"{}{}",
if p.optional { "?." } else { "." },
p.property
)
})
.collect::<Vec<_>>()
.join("")
}
/// Callbacks for StartMemoize/FinishMemoize/Effect events
struct Callbacks<'a> {
start_memo: &'a mut Option<StartMemoInfo>,
#[allow(dead_code)]
memo_locals: &'a mut HashSet<IdentifierId>,
validate_memo: bool,
validate_effect: ExhaustiveEffectDepsMode,
reactive: &'a HashSet<IdentifierId>,
diagnostics: Vec<CompilerDiagnostic>,
/// manual_memo_ids that had validation errors (to set has_invalid_deps)
invalid_memo_ids: HashSet<u32>,
}
// =============================================================================
// Helper: type checking functions
// =============================================================================
fn is_effect_event_function_type(ty: &Type) -> bool {
matches!(ty, Type::Function { shape_id: Some(id), .. } if id == "BuiltInEffectEventFunction")
}
fn is_stable_type(ty: &Type) -> bool {
match ty {
Type::Function {
shape_id: Some(id), ..
} => matches!(
id.as_str(),
"BuiltInSetState"
| "BuiltInSetActionState"
| "BuiltInDispatch"
| "BuiltInStartTransition"
| "BuiltInSetOptimistic"
),
Type::Object {
shape_id: Some(id),
} => matches!(id.as_str(), "BuiltInUseRefId"),
_ => false,
}
}
fn is_effect_hook(ty: &Type) -> bool {
matches!(ty, Type::Function { shape_id: Some(id), .. }
if id == "BuiltInUseEffectHook"
|| id == "BuiltInUseLayoutEffectHook"
|| id == "BuiltInUseInsertionEffectHook"
)
}
fn is_primitive_type(ty: &Type) -> bool {
matches!(ty, Type::Primitive)
}
fn is_use_ref_type(ty: &Type) -> bool {
matches!(ty, Type::Object { shape_id: Some(id) } if id == "BuiltInUseRefId")
}
fn get_identifier_type<'a>(
id: IdentifierId,
identifiers: &'a [Identifier],
types: &'a [Type],
) -> &'a Type {
let ident = &identifiers[id.0 as usize];
&types[ident.type_.0 as usize]
}
fn get_identifier_name(id: IdentifierId, identifiers: &[Identifier]) -> Option<String> {
identifiers[id.0 as usize]
.name
.as_ref()
.map(|n| n.value().to_string())
}
// =============================================================================
// Path helpers (matching TS areEqualPaths, isSubPath, isSubPathIgnoringOptionals)
// =============================================================================
fn are_equal_paths(a: &[DependencyPathEntry], b: &[DependencyPathEntry]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b.iter())
.all(|(ai, bi)| ai.property == bi.property && ai.optional == bi.optional)
}
fn is_sub_path(subpath: &[DependencyPathEntry], path: &[DependencyPathEntry]) -> bool {
subpath.len() <= path.len()
&& subpath
.iter()
.zip(path.iter())
.all(|(a, b)| a.property == b.property && a.optional == b.optional)
}
fn is_sub_path_ignoring_optionals(
subpath: &[DependencyPathEntry],
path: &[DependencyPathEntry],
) -> bool {
subpath.len() <= path.len()
&& subpath
.iter()
.zip(path.iter())
.all(|(a, b)| a.property == b.property)
}
// =============================================================================
// Collect reactive identifiers
// =============================================================================
fn collect_reactive_identifiers(func: &HirFunction, functions: &[HirFunction]) -> HashSet<IdentifierId> {
let mut reactive = HashSet::new();
for (_block_id, block) in &func.body.blocks {
for &instr_id in &block.instructions {
let instr = &func.instructions[instr_id.0 as usize];
// Check instruction lvalue
if instr.lvalue.reactive {
reactive.insert(instr.lvalue.identifier);
}
// Check inner lvalues (Destructure patterns, StoreLocal, DeclareLocal, etc.)
// Matches TS eachInstructionLValue which yields both instr.lvalue and
// eachInstructionValueLValue(instr.value)
for lvalue in each_instruction_value_lvalue(&instr.value) {
if lvalue.reactive {
reactive.insert(lvalue.identifier);
}
}
for operand in each_instruction_value_operand_with_functions(&instr.value, functions) {
if operand.reactive {
reactive.insert(operand.identifier);
}
}
}
for operand in each_terminal_operand(&block.terminal) {
if operand.reactive {
reactive.insert(operand.identifier);
}
}
}
reactive
}
// =============================================================================
// findOptionalPlaces
// =============================================================================
fn find_optional_places(func: &HirFunction) -> HashMap<IdentifierId, bool> {
let mut optionals: HashMap<IdentifierId, bool> = HashMap::new();
let mut visited: HashSet<BlockId> = HashSet::new();
for (_block_id, block) in &func.body.blocks {
if visited.contains(&block.id) {
continue;
}
if let Terminal::Optional {
test,
fallthrough: optional_fallthrough,
optional,
..
} = &block.terminal
{
visited.insert(block.id);
let mut test_block_id = *test;
let mut queue: Vec<Option<bool>> = vec![Some(*optional)];
'outer: loop {
let test_block = &func.body.blocks[&test_block_id];
visited.insert(test_block.id);
match &test_block.terminal {
Terminal::Branch {
test: test_place,
consequent,
fallthrough,
..
} => {
let is_optional = queue.pop().expect(
"Expected an optional value for each optional test condition",
);
if let Some(opt) = is_optional {
optionals.insert(test_place.identifier, opt);
}
if fallthrough == optional_fallthrough {
// Found the end of the optional chain
let consequent_block = &func.body.blocks[consequent];
if let Some(last_id) = consequent_block.instructions.last() {
let last_instr =
&func.instructions[last_id.0 as usize];
if let InstructionValue::StoreLocal { value, .. } =
&last_instr.value
{
if let Some(opt) = is_optional {
optionals.insert(value.identifier, opt);
}
}
}
break 'outer;
} else {
test_block_id = *fallthrough;
}
}
Terminal::Optional {
optional: opt,
test: inner_test,
..
} => {
queue.push(Some(*opt));
test_block_id = *inner_test;
}
Terminal::Logical { test: inner_test, .. }
| Terminal::Ternary { test: inner_test, .. } => {
queue.push(None);
test_block_id = *inner_test;
}
Terminal::Sequence { block: seq_block, .. } => {
test_block_id = *seq_block;
}
Terminal::MaybeThrow { continuation, .. } => {
test_block_id = *continuation;
}
_ => {
// Unexpected terminal in optional — skip rather than panic
break 'outer;
}
}
}
// TS asserts queue.length === 0 here, but we skip the assertion
// to avoid panicking on edge cases.
}
}
optionals
}
// =============================================================================
// Dependency collection
// =============================================================================
fn add_dependency(
dep: &Temporary,
dependencies: &mut Vec<InferredDependency>,
dep_keys: &mut HashSet<InferredDependencyKey>,
locals: &HashSet<IdentifierId>,
) {
match dep {
Temporary::Aggregate {
dependencies: agg_deps,
..
} => {
for d in agg_deps {
add_dependency_inferred(d, dependencies, dep_keys, locals);
}
}
Temporary::Global { binding } => {
let inferred = InferredDependency::Global {
binding: binding.clone(),
};
let key = dep_to_key(&inferred);
if dep_keys.insert(key) {
dependencies.push(inferred);
}
}
Temporary::Local {
identifier,
path,
context,
loc,
} => {
if !locals.contains(identifier) {
let inferred = InferredDependency::Local {
identifier: *identifier,
path: path.clone(),
context: *context,
loc: *loc,
};
let key = dep_to_key(&inferred);
if dep_keys.insert(key) {
dependencies.push(inferred);
}
}
}
}
}
fn add_dependency_inferred(
dep: &InferredDependency,
dependencies: &mut Vec<InferredDependency>,
dep_keys: &mut HashSet<InferredDependencyKey>,
locals: &HashSet<IdentifierId>,
) {
match dep {
InferredDependency::Global { .. } => {
let key = dep_to_key(dep);
if dep_keys.insert(key) {
dependencies.push(dep.clone());
}
}
InferredDependency::Local { identifier, .. } => {
if !locals.contains(identifier) {
let key = dep_to_key(dep);
if dep_keys.insert(key) {
dependencies.push(dep.clone());
}
}
}
}
}
fn visit_candidate_dependency(
place: &Place,
temporaries: &HashMap<IdentifierId, Temporary>,
dependencies: &mut Vec<InferredDependency>,
dep_keys: &mut HashSet<InferredDependencyKey>,
locals: &HashSet<IdentifierId>,
) {
if let Some(dep) = temporaries.get(&place.identifier) {
add_dependency(dep, dependencies, dep_keys, locals);
}
}
fn collect_dependencies(
func: &HirFunction,
identifiers: &[Identifier],
types: &[Type],
functions: &[HirFunction],
temporaries: &mut HashMap<IdentifierId, Temporary>,
callbacks: &mut Option<&mut Callbacks<'_>>,
is_function_expression: bool,
) -> Result<Temporary, CompilerDiagnostic> {
let optionals = find_optional_places(func);
let mut locals: HashSet<IdentifierId> = HashSet::new();
if is_function_expression {
for param in &func.params {
let place = match param {
ParamPattern::Place(p) => p,
ParamPattern::Spread(s) => &s.place,
};
locals.insert(place.identifier);
}
}
let mut dependencies: Vec<InferredDependency> = Vec::new();
let mut dep_keys: HashSet<InferredDependencyKey> = HashSet::new();
// Saved state for when we're inside a memo block (StartMemoize..FinishMemoize).
// In TS, `dependencies` and `locals` are shared by reference between the main
// collection loop and the callbacks — StartMemoize clears them, FinishMemoize
// reads and clears them. We simulate this by saving/restoring.
let mut saved_dependencies: Option<Vec<InferredDependency>> = None;
let mut saved_dep_keys: Option<HashSet<InferredDependencyKey>> = None;
let mut saved_locals: Option<HashSet<IdentifierId>> = None;
for (_block_id, block) in &func.body.blocks {
// Process phis
for phi in &block.phis {
let mut deps: Vec<InferredDependency> = Vec::new();
for (_pred_id, operand) in &phi.operands {
if let Some(dep) = temporaries.get(&operand.identifier) {
match dep {
Temporary::Aggregate {
dependencies: agg, ..
} => {
deps.extend(agg.iter().cloned());
}
Temporary::Local {
identifier,
path,
context,
loc,
} => {
deps.push(InferredDependency::Local {
identifier: *identifier,
path: path.clone(),
context: *context,
loc: *loc,
});
}
Temporary::Global { binding } => {
deps.push(InferredDependency::Global {
binding: binding.clone(),
});
}
}
}
}
if deps.is_empty() {
continue;
} else if deps.len() == 1 {
let dep = &deps[0];
match dep {
InferredDependency::Local {
identifier,
path,
context,
loc,
} => {
temporaries.insert(
phi.place.identifier,
Temporary::Local {
identifier: *identifier,
path: path.clone(),
context: *context,
loc: *loc,
},
);
}
InferredDependency::Global { binding } => {
temporaries.insert(
phi.place.identifier,
Temporary::Global {
binding: binding.clone(),
},
);
}
}
} else {
temporaries.insert(
phi.place.identifier,
Temporary::Aggregate {
dependencies: deps,
loc: None,
},
);
}
}
// Process instructions
for &instr_id in &block.instructions {
let instr = &func.instructions[instr_id.0 as usize];
let lvalue_id = instr.lvalue.identifier;
match &instr.value {
InstructionValue::LoadGlobal { binding, .. } => {
temporaries.insert(
lvalue_id,
Temporary::Global {
binding: binding.clone(),
},
);
}
InstructionValue::LoadContext { place, .. }
| InstructionValue::LoadLocal { place, .. } => {
if let Some(temp) = temporaries.get(&place.identifier).cloned() {
match &temp {
Temporary::Local { .. } => {
// Update loc to the load site
let mut updated = temp.clone();
if let Temporary::Local { loc, .. } = &mut updated {
*loc = place.loc;
}
temporaries.insert(lvalue_id, updated);
}
_ => {
temporaries.insert(lvalue_id, temp);
}
}
if locals.contains(&place.identifier) {
locals.insert(lvalue_id);
}
}
}
InstructionValue::DeclareLocal { lvalue: decl_lv, .. } => {
temporaries.insert(
decl_lv.place.identifier,
Temporary::Local {
identifier: decl_lv.place.identifier,
path: Vec::new(),
context: false,
loc: decl_lv.place.loc,
},
);
locals.insert(decl_lv.place.identifier);
}
InstructionValue::StoreLocal {
lvalue: store_lv,
value: store_val,
..
} => {
let has_name = identifiers[store_lv.place.identifier.0 as usize]
.name
.is_some();
if !has_name {
// Unnamed: propagate temporary
if let Some(temp) = temporaries.get(&store_val.identifier).cloned() {
temporaries.insert(store_lv.place.identifier, temp);
}
} else {
// Named: visit the value and create a new local
visit_candidate_dependency(
store_val,
temporaries,
&mut dependencies,
&mut dep_keys,
&locals,
);
if store_lv.kind != InstructionKind::Reassign {
temporaries.insert(
store_lv.place.identifier,
Temporary::Local {
identifier: store_lv.place.identifier,
path: Vec::new(),
context: false,
loc: store_lv.place.loc,
},
);
locals.insert(store_lv.place.identifier);
}
}
}
InstructionValue::DeclareContext { lvalue: decl_lv, .. } => {
temporaries.insert(
decl_lv.place.identifier,
Temporary::Local {
identifier: decl_lv.place.identifier,
path: Vec::new(),
context: true,
loc: decl_lv.place.loc,
},
);
}
InstructionValue::StoreContext {
lvalue: store_lv,
value: store_val,
..
} => {
visit_candidate_dependency(
store_val,
temporaries,
&mut dependencies,
&mut dep_keys,
&locals,
);
if store_lv.kind != InstructionKind::Reassign {
temporaries.insert(
store_lv.place.identifier,
Temporary::Local {
identifier: store_lv.place.identifier,
path: Vec::new(),
context: true,
loc: store_lv.place.loc,
},
);
locals.insert(store_lv.place.identifier);
}
}
InstructionValue::Destructure {
value: destr_val,
lvalue: destr_lv,
..
} => {
visit_candidate_dependency(
destr_val,
temporaries,
&mut dependencies,
&mut dep_keys,
&locals,
);
if destr_lv.kind != InstructionKind::Reassign {
for lv_place in each_instruction_value_lvalue(&instr.value) {
temporaries.insert(
lv_place.identifier,
Temporary::Local {
identifier: lv_place.identifier,
path: Vec::new(),
context: false,
loc: lv_place.loc,
},
);
locals.insert(lv_place.identifier);
}
}
}
InstructionValue::PropertyLoad {
object, property, ..
} => {
// Number properties or ref.current: visit the object directly
let is_numeric = matches!(property, PropertyLiteral::Number(_));
let is_ref_current = is_use_ref_type(get_identifier_type(
object.identifier,
identifiers,
types,
)) && *property == PropertyLiteral::String("current".to_string());
if is_numeric || is_ref_current {
visit_candidate_dependency(
object,
temporaries,
&mut dependencies,
&mut dep_keys,
&locals,
);
} else {
// Extend path
let obj_temp = temporaries.get(&object.identifier).cloned();
if let Some(Temporary::Local {
identifier,
path,
context,
..
}) = obj_temp
{
let optional =
optionals.get(&object.identifier).copied().unwrap_or(false);
let mut new_path = path.clone();
new_path.push(DependencyPathEntry {
optional,
property: property.clone(),
loc: instr.value.loc().copied(),
});
temporaries.insert(
lvalue_id,
Temporary::Local {
identifier,
path: new_path,
context,
loc: instr.value.loc().copied(),
},
);
}
}
}
InstructionValue::FunctionExpression {
lowered_func, ..
}
| InstructionValue::ObjectMethod {
lowered_func, ..
} => {
let inner_func = &functions[lowered_func.func.0 as usize];
let function_deps = collect_dependencies(
inner_func,
identifiers,
types,
functions,
temporaries,
&mut None,
true,
)?;
temporaries.insert(lvalue_id, function_deps.clone());
add_dependency(&function_deps, &mut dependencies, &mut dep_keys, &locals);
}
InstructionValue::StartMemoize {
manual_memo_id,
deps,
deps_loc,
loc,
..
} => {
if let Some(cb) = callbacks.as_mut() {
// onStartMemoize — mirrors TS behavior of clearing dependencies and locals
*cb.start_memo = Some(StartMemoInfo {
manual_memo_id: *manual_memo_id,
deps: deps.clone(),
deps_loc: *deps_loc,
loc: *loc,
});
// Save current state and clear, matching TS which clears the shared
// dependencies/locals sets on StartMemoize
saved_dependencies = Some(std::mem::take(&mut dependencies));
saved_dep_keys = Some(std::mem::take(&mut dep_keys));
saved_locals = Some(std::mem::take(&mut locals));
}
}
InstructionValue::FinishMemoize {
manual_memo_id,
decl,
..
} => {
if let Some(cb) = callbacks.as_mut() {
// onFinishMemoize — mirrors TS behavior
let sm = cb.start_memo.take();
if let Some(sm) = sm {
assert_eq!(
sm.manual_memo_id, *manual_memo_id,
"Found FinishMemoize without corresponding StartMemoize"
);
if cb.validate_memo {
// Visit the decl to add it as a dependency candidate
// (matches TS: visitCandidateDependency(value.decl, ...))
visit_candidate_dependency(
decl,
temporaries,
&mut dependencies,
&mut dep_keys,
&locals,
);
// Use ALL dependencies collected since StartMemoize cleared the set.
// This matches TS: `const inferred = Array.from(dependencies)`
let inferred: Vec<InferredDependency> = dependencies.clone();
let diagnostic = validate_dependencies(
inferred,
&sm.deps.unwrap_or_default(),
cb.reactive,
sm.deps_loc.unwrap_or(None),
ErrorCategory::MemoDependencies,
"all",
identifiers,
types,
)?;
if let Some(diag) = diagnostic {
cb.diagnostics.push(diag);
cb.invalid_memo_ids.insert(sm.manual_memo_id);
}
}
// Restore saved state (matching TS: dependencies.clear(), locals.clear())
// We restore instead of just clearing because we need the outer deps back
if let Some(saved) = saved_dependencies.take() {
// Merge current memo-block deps into the restored outer deps
let memo_deps = std::mem::replace(&mut dependencies, saved);
let _memo_keys = std::mem::replace(
&mut dep_keys,
saved_dep_keys.take().unwrap_or_default(),
);
locals = saved_locals.take().unwrap_or_default();
// Add memo deps to outer deps (they're still valid outer deps)
for d in memo_deps {
let key = dep_to_key(&d);
if dep_keys.insert(key) {
dependencies.push(d);
}
}
}
}
}
}
InstructionValue::ArrayExpression { elements, loc, .. } => {
let mut array_deps: Vec<InferredDependency> = Vec::new();
let mut array_keys: HashSet<InferredDependencyKey> = HashSet::new();
let empty_locals = HashSet::new();
for elem in elements {
let place = match elem {
ArrayElement::Place(p) => Some(p),
ArrayElement::Spread(s) => Some(&s.place),
ArrayElement::Hole => None,
};
if let Some(place) = place {
// Visit with empty locals for manual deps
visit_candidate_dependency(
place,
temporaries,
&mut array_deps,
&mut array_keys,
&empty_locals,
);
// Visit normally
visit_candidate_dependency(
place,
temporaries,
&mut dependencies,
&mut dep_keys,
&locals,
);
}
}
temporaries.insert(
lvalue_id,
Temporary::Aggregate {
dependencies: array_deps,
loc: *loc,
},
);
}
InstructionValue::CallExpression { callee, args, .. } => {
// Check if this is an effect hook call
if let Some(cb) = callbacks.as_mut() {
let callee_ty =
get_identifier_type(callee.identifier, identifiers, types);
if is_effect_hook(callee_ty)
&& !matches!(cb.validate_effect, ExhaustiveEffectDepsMode::Off)
{
if args.len() >= 2 {
let fn_arg = match &args[0] {
PlaceOrSpread::Place(p) => Some(p),
_ => None,
};
let deps_arg = match &args[1] {
PlaceOrSpread::Place(p) => Some(p),
_ => None,
};
if let (Some(fn_place), Some(deps_place)) = (fn_arg, deps_arg) {
let fn_deps = temporaries.get(&fn_place.identifier).cloned();
let manual_deps =
temporaries.get(&deps_place.identifier).cloned();
if let (
Some(Temporary::Aggregate {
dependencies: fn_dep_list,
..
}),
Some(Temporary::Aggregate {
dependencies: manual_dep_list,
loc: manual_loc,
}),
) = (fn_deps, manual_deps)
{
let effect_report_mode = match &cb.validate_effect {
ExhaustiveEffectDepsMode::All => "all",
ExhaustiveEffectDepsMode::MissingOnly => "missing-only",
ExhaustiveEffectDepsMode::ExtraOnly => "extra-only",
ExhaustiveEffectDepsMode::Off => unreachable!(),
};
// Convert manual deps to ManualMemoDependency format
let manual_memo_deps: Vec<ManualMemoDependency> =
manual_dep_list
.iter()
.map(|dep| match dep {
InferredDependency::Local {
identifier,
path,
loc,
..
} => ManualMemoDependency {
root: ManualMemoDependencyRoot::NamedLocal {
value: Place {
identifier: *identifier,
effect:
react_compiler_hir::Effect::Read,
reactive: cb
.reactive
.contains(identifier),
loc: *loc,
},
constant: false,
},
path: path.clone(),
loc: *loc,
},
InferredDependency::Global { binding } => {
ManualMemoDependency {
root:
ManualMemoDependencyRoot::Global {
identifier_name: binding
.name()
.to_string(),
},
path: Vec::new(),
loc: None,
}
}
})
.collect();
let diagnostic = validate_dependencies(
fn_dep_list,
&manual_memo_deps,
cb.reactive,
manual_loc,
ErrorCategory::EffectExhaustiveDependencies,
effect_report_mode,
identifiers,
types,
)?;
if let Some(diag) = diagnostic {
cb.diagnostics.push(diag);
}
}
}
}
}
}
// Visit all operands except for MethodCall's property
for operand in each_instruction_value_operand_with_functions(&instr.value, functions) {
visit_candidate_dependency(
&operand,
temporaries,
&mut dependencies,
&mut dep_keys,
&locals,
);
}
}
InstructionValue::MethodCall {
receiver,
property,
args,
..
} => {
// Check if this is an effect hook call
if let Some(cb) = callbacks.as_mut() {
let prop_ty =
get_identifier_type(property.identifier, identifiers, types);
if is_effect_hook(prop_ty)
&& !matches!(cb.validate_effect, ExhaustiveEffectDepsMode::Off)
{
if args.len() >= 2 {
let fn_arg = match &args[0] {
PlaceOrSpread::Place(p) => Some(p),
_ => None,
};
let deps_arg = match &args[1] {
PlaceOrSpread::Place(p) => Some(p),
_ => None,
};
if let (Some(fn_place), Some(deps_place)) = (fn_arg, deps_arg) {
let fn_deps = temporaries.get(&fn_place.identifier).cloned();
let manual_deps =
temporaries.get(&deps_place.identifier).cloned();
if let (
Some(Temporary::Aggregate {
dependencies: fn_dep_list,
..
}),
Some(Temporary::Aggregate {
dependencies: manual_dep_list,
loc: manual_loc,
}),
) = (fn_deps, manual_deps)
{
let effect_report_mode = match &cb.validate_effect {
ExhaustiveEffectDepsMode::All => "all",
ExhaustiveEffectDepsMode::MissingOnly => "missing-only",
ExhaustiveEffectDepsMode::ExtraOnly => "extra-only",
ExhaustiveEffectDepsMode::Off => unreachable!(),
};
let manual_memo_deps: Vec<ManualMemoDependency> =
manual_dep_list
.iter()
.map(|dep| match dep {
InferredDependency::Local {
identifier,
path,
loc,
..
} => ManualMemoDependency {
root: ManualMemoDependencyRoot::NamedLocal {
value: Place {
identifier: *identifier,
effect:
react_compiler_hir::Effect::Read,
reactive: cb
.reactive
.contains(identifier),
loc: *loc,
},
constant: false,
},
path: path.clone(),
loc: *loc,
},
InferredDependency::Global { binding } => {
ManualMemoDependency {
root:
ManualMemoDependencyRoot::Global {
identifier_name: binding
.name()
.to_string(),
},
path: Vec::new(),
loc: None,
}
}
})
.collect();
let diagnostic = validate_dependencies(
fn_dep_list,
&manual_memo_deps,
cb.reactive,
manual_loc,
ErrorCategory::EffectExhaustiveDependencies,
effect_report_mode,
identifiers,
types,
)?;
if let Some(diag) = diagnostic {
cb.diagnostics.push(diag);
}
}
}
}
}
}
// Visit operands, skipping the method property itself
visit_candidate_dependency(
receiver,
temporaries,
&mut dependencies,
&mut dep_keys,
&locals,
);
// Skip property — matches TS behavior
for arg in args {
let place = match arg {
PlaceOrSpread::Place(p) => p,
PlaceOrSpread::Spread(s) => &s.place,
};
visit_candidate_dependency(
place,
temporaries,
&mut dependencies,
&mut dep_keys,
&locals,
);
}
}
_ => {
// Default: visit all operands
for operand in each_instruction_value_operand_with_functions(&instr.value, functions) {
visit_candidate_dependency(
&operand,
temporaries,
&mut dependencies,
&mut dep_keys,
&locals,
);
}
// Track lvalues as locals
for lv in each_instruction_lvalue_ids(&instr.value, lvalue_id) {
locals.insert(lv);
}
}
}
}
// Terminal operands
for operand in &each_terminal_operand(&block.terminal) {
if optionals.contains_key(&operand.identifier) {
continue;
}
visit_candidate_dependency(
operand,
temporaries,
&mut dependencies,
&mut dep_keys,
&locals,
);
}
}
Ok(Temporary::Aggregate {
dependencies,
loc: None,
})
}
// =============================================================================
// validateDependencies
// =============================================================================
fn validate_dependencies(
mut inferred: Vec<InferredDependency>,
manual_dependencies: &[ManualMemoDependency],
reactive: &HashSet<IdentifierId>,
manual_memo_loc: Option<SourceLocation>,
category: ErrorCategory,
exhaustive_deps_report_mode: &str,
identifiers: &[Identifier],
types: &[Type],
) -> Result<Option<CompilerDiagnostic>, CompilerDiagnostic> {
// Sort dependencies by name and path
inferred.sort_by(|a, b| {
match (a, b) {
(InferredDependency::Global { binding: ab }, InferredDependency::Global { binding: bb }) => {
ab.name().cmp(bb.name())
}
(
InferredDependency::Local {
identifier: a_id,
path: a_path,
..
},
InferredDependency::Local {
identifier: b_id,
path: b_path,
..
},
) => {
let a_name = get_identifier_name(*a_id, identifiers);
let b_name = get_identifier_name(*b_id, identifiers);
match (a_name.as_deref(), b_name.as_deref()) {
(Some(an), Some(bn)) => {
if *a_id != *b_id {
an.cmp(bn)
} else if a_path.len() != b_path.len() {
a_path.len().cmp(&b_path.len())
} else {
// Compare path entries
for (ap, bp) in a_path.iter().zip(b_path.iter()) {
let a_opt = if ap.optional { 0i32 } else { 1 };
let b_opt = if bp.optional { 0i32 } else { 1 };
if a_opt != b_opt {
return a_opt.cmp(&b_opt);
}
let prop_cmp = ap.property.to_string().cmp(&bp.property.to_string());
if prop_cmp != std::cmp::Ordering::Equal {
return prop_cmp;
}
}
std::cmp::Ordering::Equal
}
}
_ => std::cmp::Ordering::Equal,
}
}
(InferredDependency::Global { binding: ab }, InferredDependency::Local { identifier: b_id, .. }) => {
let a_name = ab.name();
let b_name = get_identifier_name(*b_id, identifiers);
match b_name.as_deref() {
Some(bn) => a_name.cmp(bn),
None => std::cmp::Ordering::Equal,
}
}
(InferredDependency::Local { identifier: a_id, .. }, InferredDependency::Global { binding: bb }) => {
let a_name = get_identifier_name(*a_id, identifiers);
let b_name = bb.name();
match a_name.as_deref() {
Some(an) => an.cmp(b_name),
None => std::cmp::Ordering::Equal,
}
}
}
});
// Remove redundant inferred dependencies
// retainWhere logic: keep dep[ix] only if no earlier entry is equal or a subpath prefix
// Mirrors TS: retainWhere(inferred, (dep, ix) => {
// const match = inferred.findIndex(prevDep => isEqualTemporary(prevDep, dep) || ...);
// return match === -1 || match >= ix;
// })
{
let snapshot = inferred.clone();
let mut write_index = 0;
for ix in 0..snapshot.len() {
let dep = &snapshot[ix];
let first_match = snapshot.iter().position(|prev_dep| {
is_equal_temporary(prev_dep, dep)
|| (matches!(
(prev_dep, dep),
(
InferredDependency::Local { .. },
InferredDependency::Local { .. }
)
) && {
if let (
InferredDependency::Local {
identifier: prev_id,
path: prev_path,
..
},
InferredDependency::Local {
identifier: dep_id,
path: dep_path,
..
},
) = (prev_dep, dep)
{
prev_id == dep_id && is_sub_path(prev_path, dep_path)
} else {
false
}
})
});
let keep = match first_match {
None => true,
Some(m) => m >= ix,
};
if keep {
inferred[write_index] = snapshot[ix].clone();
write_index += 1;
}
}
inferred.truncate(write_index);
}
// Validate manual deps
let mut matched: HashSet<usize> = HashSet::new(); // indices into manual_dependencies
let mut missing: Vec<&InferredDependency> = Vec::new();
let mut extra: Vec<&ManualMemoDependency> = Vec::new();
for inferred_dep in &inferred {
match inferred_dep {
InferredDependency::Global { binding } => {
for (i, manual_dep) in manual_dependencies.iter().enumerate() {
if let ManualMemoDependencyRoot::Global { identifier_name } = &manual_dep.root {
if identifier_name == binding.name() {
matched.insert(i);
extra.push(manual_dep);
}
}
}
continue;
}
InferredDependency::Local {
identifier,
path,
loc: _,
..
} => {
// Skip effect event functions
let ty = get_identifier_type(*identifier, identifiers, types);
if is_effect_event_function_type(ty) {
continue;
}
let mut has_matching = false;
for (i, manual_dep) in manual_dependencies.iter().enumerate() {
if let ManualMemoDependencyRoot::NamedLocal { value, .. } = &manual_dep.root {
if value.identifier == *identifier
&& (are_equal_paths(&manual_dep.path, path)
|| is_sub_path_ignoring_optionals(&manual_dep.path, path))
{
has_matching = true;
matched.insert(i);
}
}
}
if has_matching
|| is_optional_dependency(*identifier, reactive, identifiers, types)
{
continue;
}
missing.push(inferred_dep);
}
}
}
// Check for extra dependencies
for (i, dep) in manual_dependencies.iter().enumerate() {
if matched.contains(&i) {
continue;
}
if let ManualMemoDependencyRoot::NamedLocal { constant, value, .. } = &dep.root {
if *constant {
let dep_ty = get_identifier_type(value.identifier, identifiers, types);
// Constant-folded primitives: skip
if !value.reactive && is_primitive_type(dep_ty) {
continue;
}
}
}
extra.push(dep);
}
// Filter based on report mode
let filtered_missing: Vec<&InferredDependency> = if exhaustive_deps_report_mode == "extra-only"
{
Vec::new()
} else {
missing
};
let filtered_extra: Vec<&ManualMemoDependency> =
if exhaustive_deps_report_mode == "missing-only" {
Vec::new()
} else {
extra
};
if filtered_missing.is_empty() && filtered_extra.is_empty() {
return Ok(None);
}
// Build suggestion when we have valid index info (matches TS behavior)
let suggestion = manual_memo_loc.and_then(|loc| {
let start_index = loc.start.index?;
let end_index = loc.end.index?;
let text = format!(
"[{}]",
inferred
.iter()
.filter(|dep| {
match dep {
InferredDependency::Local { identifier, .. } => {
let ty = get_identifier_type(*identifier, identifiers, types);
!is_optional_dependency(*identifier, reactive, identifiers, types)
&& !is_effect_event_function_type(ty)
}
InferredDependency::Global { .. } => false,
}
})
.map(|dep| print_inferred_dependency(dep, identifiers))
.collect::<Vec<_>>()
.join(", ")
);
Some(CompilerSuggestion {
op: CompilerSuggestionOperation::Replace,
range: (start_index as usize, end_index as usize),
description: "Update dependencies".to_string(),
text: Some(text),
})
});
let mut diagnostic = create_diagnostic(
category,
&filtered_missing,
&filtered_extra,
suggestion,
identifiers,
)?;
// Add detail items for missing deps
for dep in &filtered_missing {
if let InferredDependency::Local {
identifier, path: _, loc, ..
} = dep
{
let mut hint = String::new();
let ty = get_identifier_type(*identifier, identifiers, types);
if is_stable_type(ty) {
hint = ". Refs, setState functions, and other \"stable\" values generally do not need to be added as dependencies, but this variable may change over time to point to different values".to_string();
}
let dep_str = print_inferred_dependency(dep, identifiers);
diagnostic.details.push(CompilerDiagnosticDetail::Error {
loc: *loc,
message: Some(format!("Missing dependency `{dep_str}`{hint}")),
identifier_name: None,
});
}
}
// Add detail items for extra deps
for dep in &filtered_extra {
match &dep.root {
ManualMemoDependencyRoot::Global { .. } => {
let dep_str = print_manual_memo_dependency(dep, identifiers);
diagnostic.details.push(CompilerDiagnosticDetail::Error {
loc: dep.loc.or(manual_memo_loc),
message: Some(format!(
"Unnecessary dependency `{dep_str}`. Values declared outside of a component/hook should not be listed as dependencies as the component will not re-render if they change"
)),
identifier_name: None,
});
}
ManualMemoDependencyRoot::NamedLocal { value, .. } => {
// Check if there's a matching inferred dep
let matching_inferred = inferred.iter().find(|inf_dep| {
if let InferredDependency::Local {
identifier: inf_id,
path: inf_path,
..
} = inf_dep
{
*inf_id == value.identifier
&& is_sub_path_ignoring_optionals(inf_path, &dep.path)
} else {
false
}
});
if let Some(matching) = matching_inferred {
if let InferredDependency::Local { identifier, .. } = matching {
let matching_ty =
get_identifier_type(*identifier, identifiers, types);
if is_effect_event_function_type(matching_ty) {
let dep_str = print_manual_memo_dependency(dep, identifiers);
diagnostic.details.push(CompilerDiagnosticDetail::Error {
loc: dep.loc.or(manual_memo_loc),
message: Some(format!(
"Functions returned from `useEffectEvent` must not be included in the dependency array. Remove `{dep_str}` from the dependencies."
)),
identifier_name: None,
});
} else if !is_optional_dependency_inferred(
matching,
reactive,
identifiers,
types,
) {
let dep_str = print_manual_memo_dependency(dep, identifiers);
let inferred_str =
print_inferred_dependency(matching, identifiers);
diagnostic.details.push(CompilerDiagnosticDetail::Error {
loc: dep.loc.or(manual_memo_loc),
message: Some(format!(
"Overly precise dependency `{dep_str}`, use `{inferred_str}` instead"
)),
identifier_name: None,
});
} else {
let dep_str = print_manual_memo_dependency(dep, identifiers);
diagnostic.details.push(CompilerDiagnosticDetail::Error {
loc: dep.loc.or(manual_memo_loc),
message: Some(format!("Unnecessary dependency `{dep_str}`")),
identifier_name: None,
});
}
}
} else {
let dep_str = print_manual_memo_dependency(dep, identifiers);
diagnostic.details.push(CompilerDiagnosticDetail::Error {
loc: dep.loc.or(manual_memo_loc),
message: Some(format!("Unnecessary dependency `{dep_str}`")),
identifier_name: None,
});
}
}
}
}
// Add hint showing inferred dependencies when a suggestion was generated
// (matches TS: only adds hint when suggestion != null, using suggestion.text)
if let Some(ref suggestions) = diagnostic.suggestions {
if let Some(suggestion) = suggestions.first() {
if let Some(ref text) = suggestion.text {
diagnostic.details.push(CompilerDiagnosticDetail::Hint {
message: format!("Inferred dependencies: `{text}`"),
});
}
}
}
Ok(Some(diagnostic))
}
// =============================================================================
// Printing helpers
// =============================================================================
fn print_inferred_dependency(dep: &InferredDependency, identifiers: &[Identifier]) -> String {
match dep {
InferredDependency::Global { binding } => binding.name().to_string(),
InferredDependency::Local {
identifier, path, ..
} => {
let name = get_identifier_name(*identifier, identifiers)
.unwrap_or_else(|| "<unnamed>".to_string());
let path_str: String = path
.iter()
.map(|p| {
format!(
"{}.{}",
if p.optional { "?" } else { "" },
p.property
)
})
.collect();
format!("{name}{path_str}")
}
}
}
fn print_manual_memo_dependency(dep: &ManualMemoDependency, identifiers: &[Identifier]) -> String {
let name = match &dep.root {
ManualMemoDependencyRoot::Global { identifier_name } => identifier_name.clone(),
ManualMemoDependencyRoot::NamedLocal { value, .. } => {
get_identifier_name(value.identifier, identifiers)
.unwrap_or_else(|| "<unnamed>".to_string())
}
};
let path_str: String = dep
.path
.iter()
.map(|p| {
format!(
"{}.{}",
if p.optional { "?" } else { "" },
p.property
)
})
.collect();
format!("{name}{path_str}")
}
// =============================================================================
// Optional dependency check
// =============================================================================
fn is_optional_dependency(
identifier: IdentifierId,
reactive: &HashSet<IdentifierId>,
identifiers: &[Identifier],
types: &[Type],
) -> bool {
if reactive.contains(&identifier) {
return false;
}
let ty = get_identifier_type(identifier, identifiers, types);
is_stable_type(ty) || is_primitive_type(ty)
}
fn is_optional_dependency_inferred(
dep: &InferredDependency,
reactive: &HashSet<IdentifierId>,
identifiers: &[Identifier],
types: &[Type],
) -> bool {
match dep {
InferredDependency::Local { identifier, .. } => {
is_optional_dependency(*identifier, reactive, identifiers, types)
}
InferredDependency::Global { .. } => false,
}
}
// =============================================================================
// Equality check for temporaries
// =============================================================================
fn is_equal_temporary(a: &InferredDependency, b: &InferredDependency) -> bool {
match (a, b) {
(InferredDependency::Global { binding: ab }, InferredDependency::Global { binding: bb }) => {
ab.name() == bb.name()
}
(
InferredDependency::Local {
identifier: a_id,
path: a_path,
..
},
InferredDependency::Local {
identifier: b_id,
path: b_path,
..
},
) => a_id == b_id && are_equal_paths(a_path, b_path),
_ => false,
}
}
// =============================================================================
// createDiagnostic
// =============================================================================
fn create_diagnostic(
category: ErrorCategory,
missing: &[&InferredDependency],
extra: &[&ManualMemoDependency],
suggestion: Option<CompilerSuggestion>,
_identifiers: &[Identifier],
) -> Result<CompilerDiagnostic, CompilerDiagnostic> {
let missing_str = if !missing.is_empty() {
Some("missing")
} else {
None
};
let extra_str = if !extra.is_empty() {
Some("extra")
} else {
None
};
let (reason, description) = match category {
ErrorCategory::MemoDependencies => {
let reason_parts: Vec<&str> = [missing_str, extra_str]
.iter()
.filter_map(|x| *x)
.collect();
let reason = format!("Found {} memoization dependencies", reason_parts.join("/"));
let desc_parts: Vec<&str> = [
if !missing.is_empty() {
Some("Missing dependencies can cause a value to update less often than it should, resulting in stale UI")
} else {
None
},
if !extra.is_empty() {
Some("Extra dependencies can cause a value to update more often than it should, resulting in performance problems such as excessive renders or effects firing too often")
} else {
None
},
]
.iter()
.filter_map(|x| *x)
.collect();
let description = desc_parts.join(". ");
(reason, description)
}
ErrorCategory::EffectExhaustiveDependencies => {
let reason_parts: Vec<&str> = [missing_str, extra_str]
.iter()
.filter_map(|x| *x)
.collect();
let reason = format!("Found {} effect dependencies", reason_parts.join("/"));
let desc_parts: Vec<&str> = [
if !missing.is_empty() {
Some("Missing dependencies can cause an effect to fire less often than it should")
} else {
None
},
if !extra.is_empty() {
Some("Extra dependencies can cause an effect to fire more often than it should, resulting in performance problems such as excessive renders and side effects")
} else {
None
},
]
.iter()
.filter_map(|x| *x)
.collect();
let description = desc_parts.join(". ");
(reason, description)
}
_ => {
return Err(CompilerDiagnostic::new(
ErrorCategory::Invariant,
format!("Unexpected error category: {:?}", category),
None,
));
}
};
Ok(CompilerDiagnostic {
category,
reason,
description: Some(description),
details: Vec::new(),
suggestions: suggestion.map(|s| vec![s]),
})
}
/// Collect lvalue identifier ids from instruction value (for the default branch).
/// Thin wrapper around canonical `each_instruction_value_lvalue` that maps to ids.
fn each_instruction_lvalue_ids(
value: &InstructionValue,
lvalue_id: IdentifierId,
) -> Vec<IdentifierId> {
let mut ids = vec![lvalue_id];
for place in each_instruction_value_lvalue(value) {
ids.push(place.identifier);
}
ids
}