use crate::cross_file::diagnostics::{
CrossFileDiagnostic, CrossFileDiagnosticKind, DiagnosticSeverity,
};
use crate::cross_file::graph::DependencyGraph;
use crate::cross_file::registry::{FileId, ModuleRegistry};
use crate::reactivity::ReactiveKind;
use vize_carton::{cstr, CompactString, FxHashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReactivityIssueKind {
DestructuredReactive {
source_name: CompactString,
destructured_props: Vec<CompactString>,
},
DestructuredRef { ref_name: CompactString },
ReactivityLost {
value_name: CompactString,
context: CompactString,
},
MissingValueAccess { ref_name: CompactString },
ShouldUseToRefs { source_name: CompactString },
ReactiveToPlain {
source_name: CompactString,
target_name: CompactString,
},
ShouldUseStoreToRefs { store_name: CompactString },
ComputedWithoutReturn { computed_name: CompactString },
NonReactiveWatchSource { source_expression: CompactString },
PropPassedToRef { prop_name: CompactString },
}
#[derive(Debug, Clone)]
pub struct ReactivityIssue {
pub file_id: FileId,
pub kind: ReactivityIssueKind,
pub offset: u32,
pub source: Option<CompactString>,
}
pub fn analyze_reactivity(
registry: &ModuleRegistry,
_graph: &DependencyGraph,
) -> (Vec<ReactivityIssue>, Vec<CrossFileDiagnostic>) {
let mut issues = Vec::new();
let mut diagnostics = Vec::new();
for entry in registry.vue_components() {
let analysis = &entry.analysis;
let file_id = entry.id;
let component_issues = analyze_component_reactivity(analysis);
for issue in component_issues {
let diag = create_diagnostic(file_id, &issue);
diagnostics.push(diag);
issues.push(ReactivityIssue {
file_id,
kind: issue.kind,
offset: issue.offset,
source: issue.source,
});
}
}
(issues, diagnostics)
}
struct InternalIssue {
kind: ReactivityIssueKind,
offset: u32,
end_offset: Option<u32>,
source: Option<CompactString>,
}
#[inline]
fn analyze_component_reactivity(analysis: &crate::Croquis) -> Vec<InternalIssue> {
let mut issues = Vec::new();
let vue_imports = extract_vue_imports(analysis);
for inject in analysis.provide_inject.injects() {
use crate::provide::InjectPattern;
match &inject.pattern {
InjectPattern::ObjectDestructure(props) => {
issues.push(InternalIssue {
kind: ReactivityIssueKind::DestructuredReactive {
source_name: inject.local_name.clone(),
destructured_props: props.clone(),
},
offset: inject.start,
end_offset: None,
source: Some(inject.local_name.clone()),
});
}
InjectPattern::ArrayDestructure(_items) => {
issues.push(InternalIssue {
kind: ReactivityIssueKind::DestructuredReactive {
source_name: inject.local_name.clone(),
destructured_props: vec![CompactString::new("(array items)")],
},
offset: inject.start,
end_offset: None,
source: Some(inject.local_name.clone()),
});
}
InjectPattern::IndirectDestructure {
inject_var,
props,
offset,
} => {
issues.push(InternalIssue {
kind: ReactivityIssueKind::DestructuredReactive {
source_name: inject_var.clone(),
destructured_props: props.clone(),
},
offset: *offset,
end_offset: None,
source: Some(inject_var.clone()),
});
}
InjectPattern::Simple => {
}
}
}
let torefs_sources: FxHashSet<&str> = analysis
.reactivity
.sources()
.iter()
.filter(|s| matches!(s.kind, ReactiveKind::ToRef | ReactiveKind::ToRefs))
.map(|s| s.name.as_str())
.collect();
let _reactive_sources: FxHashSet<&str> = analysis
.reactivity
.sources()
.iter()
.map(|s| s.name.as_str())
.collect();
let props: FxHashSet<&str> = analysis
.macros
.props()
.iter()
.map(|p| p.name.as_str())
.collect();
if let Some(props_destructure) = analysis.macros.props_destructure() {
for (key, _binding) in props_destructure.bindings.iter() {
if !torefs_sources.contains(key.as_str()) {
}
}
}
for loss in analysis.reactivity.losses() {
use crate::reactivity::ReactivityLossKind;
match &loss.kind {
ReactivityLossKind::ReactiveDestructure {
source_name,
destructured_props,
} => {
issues.push(InternalIssue {
kind: ReactivityIssueKind::DestructuredReactive {
source_name: source_name.clone(),
destructured_props: destructured_props.clone(),
},
offset: loss.start,
end_offset: Some(loss.end),
source: Some(source_name.clone()),
});
}
ReactivityLossKind::RefValueDestructure {
source_name,
destructured_props,
} => {
issues.push(InternalIssue {
kind: ReactivityIssueKind::DestructuredRef {
ref_name: source_name.clone(),
},
offset: loss.start,
end_offset: Some(loss.end),
source: Some(cstr!(
"{}.value (destructured: {})",
source_name,
destructured_props.join(", ")
)),
});
}
ReactivityLossKind::RefValueExtract {
source_name,
target_name,
} => {
issues.push(InternalIssue {
kind: ReactivityIssueKind::ReactiveToPlain {
source_name: cstr!("{source_name}.value"),
target_name: target_name.clone(),
},
offset: loss.start,
end_offset: Some(loss.end),
source: Some(source_name.clone()),
});
}
ReactivityLossKind::ReactiveSpread { source_name } => {
issues.push(InternalIssue {
kind: ReactivityIssueKind::ShouldUseToRefs {
source_name: source_name.clone(),
},
offset: loss.start,
end_offset: Some(loss.end),
source: Some(source_name.clone()),
});
}
ReactivityLossKind::ReactiveReassign { source_name } => {
let original_type = analysis
.reactivity
.lookup(source_name.as_str())
.map(|s| match s.kind {
ReactiveKind::Ref => "ref",
ReactiveKind::ShallowRef => "shallowRef",
ReactiveKind::Reactive => "reactive",
ReactiveKind::ShallowReactive => "shallowReactive",
ReactiveKind::Computed => "computed",
ReactiveKind::Readonly => "readonly",
ReactiveKind::ShallowReadonly => "shallowReadonly",
ReactiveKind::ToRef => "toRef",
ReactiveKind::ToRefs => "toRefs",
})
.unwrap_or("reactive");
issues.push(InternalIssue {
kind: ReactivityIssueKind::ReactivityLost {
value_name: source_name.clone(),
context: CompactString::new(original_type),
},
offset: loss.start,
end_offset: Some(loss.end),
source: Some(source_name.clone()),
});
}
}
}
if !vue_imports.is_empty() {
for source in analysis.reactivity.sources() {
let function_name = match source.kind {
ReactiveKind::Ref => "ref",
ReactiveKind::ShallowRef => "shallowRef",
ReactiveKind::Reactive => "reactive",
ReactiveKind::ShallowReactive => "shallowReactive",
ReactiveKind::Computed => "computed",
ReactiveKind::Readonly => "readonly",
ReactiveKind::ShallowReadonly => "shallowReadonly",
ReactiveKind::ToRef => "toRef",
ReactiveKind::ToRefs => "toRefs",
};
if !vue_imports.contains(function_name) {
}
}
}
for source in analysis.reactivity.sources() {
if source.kind == ReactiveKind::Ref {
if props.contains(source.name.as_str()) {
issues.push(InternalIssue {
kind: ReactivityIssueKind::PropPassedToRef {
prop_name: source.name.clone(),
},
offset: source.declaration_offset,
end_offset: None,
source: Some(source.name.clone()),
});
}
}
}
issues
}
fn extract_vue_imports(analysis: &crate::Croquis) -> FxHashSet<&str> {
use crate::scope::ScopeKind;
let mut vue_imports = FxHashSet::default();
for scope in analysis.scopes.iter() {
if scope.kind == ScopeKind::ExternalModule {
if let crate::scope::ScopeData::ExternalModule(data) = scope.data() {
if data.source.as_str() == "vue" || data.source.starts_with("vue/") {
for (name, _) in scope.bindings() {
vue_imports.insert(name);
}
}
}
}
}
vue_imports
}
fn create_diagnostic(file_id: FileId, issue: &InternalIssue) -> CrossFileDiagnostic {
match &issue.kind {
ReactivityIssueKind::DestructuredReactive {
source_name,
destructured_props,
} => {
let mut diag = CrossFileDiagnostic::new(
CrossFileDiagnosticKind::DestructuringBreaksReactivity {
source_name: source_name.clone(),
destructured_keys: destructured_props.clone(),
suggestion: CompactString::new("toRefs"),
},
DiagnosticSeverity::Warning,
file_id,
issue.offset,
cstr!(
"Destructuring reactive object '{}' breaks reactivity connection",
source_name
),
)
.with_suggestion(cstr!(
"Use toRefs({}) or access properties directly as {}.prop",
source_name,
source_name
));
if let Some(end) = issue.end_offset {
diag = diag.with_end_offset(end);
}
diag
}
ReactivityIssueKind::DestructuredRef { ref_name } => {
let mut diag = CrossFileDiagnostic::new(
CrossFileDiagnosticKind::DestructuringBreaksReactivity {
source_name: ref_name.clone(),
destructured_keys: vec![CompactString::new("value")],
suggestion: CompactString::new("computed"),
},
DiagnosticSeverity::Warning,
file_id,
issue.offset,
cstr!(
"Destructuring ref '{}' creates a non-reactive copy",
ref_name
),
)
.with_suggestion(cstr!(
"Access {}.value directly or use computed(() => {}.value.prop)",
ref_name,
ref_name
));
if let Some(end) = issue.end_offset {
diag = diag.with_end_offset(end);
}
diag
}
ReactivityIssueKind::ReactivityLost {
value_name,
context,
} => {
let is_reassignment = matches!(
context.as_str(),
"ref"
| "shallowRef"
| "reactive"
| "shallowReactive"
| "computed"
| "readonly"
| "shallowReadonly"
| "toRef"
| "toRefs"
);
if is_reassignment {
let mut diag = CrossFileDiagnostic::new(
CrossFileDiagnosticKind::ReassignmentBreaksReactivity {
variable_name: value_name.clone(),
original_type: context.clone(),
},
DiagnosticSeverity::Warning,
file_id,
issue.offset,
cstr!("Reassigning '{value_name}' breaks reactivity tracking",),
)
.with_suggestion(
"Mutate the object's properties instead, or use ref() for replaceable values",
);
if let Some(end) = issue.end_offset {
diag = diag.with_end_offset(end);
}
diag
} else {
CrossFileDiagnostic::new(
CrossFileDiagnosticKind::HydrationMismatchRisk {
reason: cstr!("'{value_name}' loses reactivity in {context}",),
},
DiagnosticSeverity::Warning,
file_id,
issue.offset,
cstr!(
"Reactive value '{value_name}' loses reactivity when passed to {context}",
),
)
}
}
ReactivityIssueKind::MissingValueAccess { ref_name } => CrossFileDiagnostic::new(
CrossFileDiagnosticKind::HydrationMismatchRisk {
reason: cstr!("Ref '{ref_name}' used without .value"),
},
DiagnosticSeverity::Error,
file_id,
issue.offset,
cstr!("Ref '{ref_name}' should be accessed with .value in script context",),
)
.with_suggestion(cstr!("Use {ref_name}.value instead of {ref_name}",)),
ReactivityIssueKind::ShouldUseToRefs { source_name } => {
let mut diag = CrossFileDiagnostic::new(
CrossFileDiagnosticKind::SpreadBreaksReactivity {
source_name: source_name.clone(),
source_type: CompactString::new("reactive"),
},
DiagnosticSeverity::Warning,
file_id,
issue.offset,
cstr!("Spreading '{source_name}' creates a non-reactive copy"),
)
.with_suggestion(cstr!(
"Use toRefs({source_name}) to maintain reactivity, or toRaw({source_name}) for intentional copy",
));
if let Some(end) = issue.end_offset {
diag = diag.with_end_offset(end);
}
diag
}
ReactivityIssueKind::ReactiveToPlain {
source_name,
target_name,
} => {
let mut diag = CrossFileDiagnostic::new(
CrossFileDiagnosticKind::ValueExtractionBreaksReactivity {
source_name: source_name.clone(),
extracted_value: target_name.clone(),
},
DiagnosticSeverity::Warning,
file_id,
issue.offset,
cstr!(
"Assigning reactive '{}' to '{}' creates a non-reactive copy",
source_name,
target_name
),
)
.with_suggestion("Use computed() or keep the reactive reference");
if let Some(end) = issue.end_offset {
diag = diag.with_end_offset(end);
}
diag
}
ReactivityIssueKind::ShouldUseStoreToRefs { store_name } => CrossFileDiagnostic::new(
CrossFileDiagnosticKind::DestructuringBreaksReactivity {
source_name: store_name.clone(),
destructured_keys: vec![],
suggestion: CompactString::new("storeToRefs"),
},
DiagnosticSeverity::Warning,
file_id,
issue.offset,
cstr!(
"Destructuring Pinia store '{store_name}' - use storeToRefs() for state/getters"
),
)
.with_suggestion(cstr!(
"const {{ state, getter }} = storeToRefs({store_name})"
)),
ReactivityIssueKind::ComputedWithoutReturn { computed_name } => CrossFileDiagnostic::new(
CrossFileDiagnosticKind::HydrationMismatchRisk {
reason: cstr!("Computed '{computed_name}' may not return value"),
},
DiagnosticSeverity::Warning,
file_id,
issue.offset,
cstr!("Computed property '{computed_name}' should return a value"),
),
ReactivityIssueKind::NonReactiveWatchSource { source_expression } => {
CrossFileDiagnostic::new(
CrossFileDiagnosticKind::HydrationMismatchRisk {
reason: cstr!("Watch source '{source_expression}' is not reactive"),
},
DiagnosticSeverity::Warning,
file_id,
issue.offset,
cstr!(
"Watch source '{source_expression}' is not reactive, changes won't trigger the callback"
),
)
.with_suggestion("Use () => value or a ref/reactive object as the watch source")
}
ReactivityIssueKind::PropPassedToRef { prop_name } => CrossFileDiagnostic::new(
CrossFileDiagnosticKind::HydrationMismatchRisk {
reason: cstr!("Prop '{prop_name}' passed to ref() creates a copy"),
},
DiagnosticSeverity::Warning,
file_id,
issue.offset,
cstr!("Passing prop '{prop_name}' to ref() creates a non-reactive copy"),
)
.with_suggestion(cstr!(
"Use toRef(props, '{prop_name}') or computed(() => props.{prop_name})"
)),
}
}
#[cfg(test)]
mod tests {
use super::ReactivityIssueKind;
use vize_carton::CompactString;
#[test]
fn test_reactivity_issue_kind() {
let kind = ReactivityIssueKind::DestructuredReactive {
source_name: CompactString::new("state"),
destructured_props: vec![CompactString::new("count")],
};
match kind {
ReactivityIssueKind::DestructuredReactive { source_name, .. } => {
assert_eq!(source_name.as_str(), "state");
}
_ => panic!("Wrong kind"),
}
}
}