Skip to main content

code_baseline/rules/ast/
no_object_dep_array.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::parse_file;
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5/// Flags object or array literals inside `useEffect`/`useMemo`/`useCallback`
6/// dependency arrays.
7///
8/// Object and array literals create new references on every render, defeating
9/// the purpose of the dependency array.
10pub struct NoObjectDepArrayRule {
11    id: String,
12    severity: Severity,
13    message: String,
14    suggest: Option<String>,
15    glob: Option<String>,
16}
17
18impl NoObjectDepArrayRule {
19    pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
20        Ok(Self {
21            id: config.id.clone(),
22            severity: config.severity,
23            message: config.message.clone(),
24            suggest: config.suggest.clone(),
25            glob: config.glob.clone(),
26        })
27    }
28}
29
30const HOOKS_WITH_DEPS: &[&str] = &["useEffect", "useMemo", "useCallback"];
31
32impl Rule for NoObjectDepArrayRule {
33    fn id(&self) -> &str {
34        &self.id
35    }
36    fn severity(&self) -> Severity {
37        self.severity
38    }
39    fn file_glob(&self) -> Option<&str> {
40        self.glob.as_deref()
41    }
42    fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
43        let mut violations = Vec::new();
44        let tree = match parse_file(ctx.file_path, ctx.content) {
45            Some(t) => t,
46            None => return violations,
47        };
48        let source = ctx.content.as_bytes();
49        self.visit(tree.root_node(), source, ctx, &mut violations);
50        violations
51    }
52}
53
54impl NoObjectDepArrayRule {
55    fn visit(
56        &self,
57        node: tree_sitter::Node,
58        source: &[u8],
59        ctx: &ScanContext,
60        violations: &mut Vec<Violation>,
61    ) {
62        if node.kind() == "call_expression" {
63            if let Some(func) = node.child_by_field_name("function") {
64                if func.kind() == "identifier" {
65                    if let Ok(name) = func.utf8_text(source) {
66                        if HOOKS_WITH_DEPS.contains(&name) {
67                            if let Some(args) = node.child_by_field_name("arguments") {
68                                // 2nd argument is the dep array (index 1)
69                                if let Some(dep_array) = args.named_child(1) {
70                                    if dep_array.kind() == "array" {
71                                        self.check_dep_array(
72                                            &dep_array, source, ctx, violations,
73                                        );
74                                    }
75                                }
76                            }
77                        }
78                    }
79                }
80            }
81        }
82
83        for i in 0..node.child_count() {
84            if let Some(child) = node.child(i) {
85                self.visit(child, source, ctx, violations);
86            }
87        }
88    }
89
90    fn check_dep_array(
91        &self,
92        array_node: &tree_sitter::Node,
93        _source: &[u8],
94        ctx: &ScanContext,
95        violations: &mut Vec<Violation>,
96    ) {
97        for i in 0..array_node.named_child_count() {
98            if let Some(elem) = array_node.named_child(i) {
99                if elem.kind() == "object" || elem.kind() == "array" {
100                    let line = elem.start_position().row;
101                    violations.push(Violation {
102                        rule_id: self.id.clone(),
103                        severity: self.severity,
104                        file: ctx.file_path.to_path_buf(),
105                        line: Some(line + 1),
106                        column: Some(elem.start_position().column + 1),
107                        message: self.message.clone(),
108                        suggest: self.suggest.clone(),
109                        source_line: ctx.content.lines().nth(line).map(String::from),
110                        fix: None,
111                    });
112                }
113            }
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::path::Path;
122
123    fn make_rule() -> NoObjectDepArrayRule {
124        NoObjectDepArrayRule::new(&RuleConfig {
125            id: "no-object-dep-array".into(),
126            severity: Severity::Warning,
127            message: "Object/array literal in dependency array".into(),
128            suggest: Some("Extract to useMemo or a ref".into()),
129            glob: Some("**/*.{tsx,jsx}".into()),
130            ..Default::default()
131        })
132        .unwrap()
133    }
134
135    fn check(content: &str) -> Vec<Violation> {
136        let rule = make_rule();
137        let ctx = ScanContext {
138            file_path: Path::new("test.tsx"),
139            content,
140        };
141        rule.check_file(&ctx)
142    }
143
144    #[test]
145    fn object_literal_in_use_effect_dep_flags() {
146        let content = "\
147function MyComponent({ a }) {
148  useEffect(() => {
149    doSomething();
150  }, [{ key: a }]);
151  return <div />;
152}";
153        assert_eq!(check(content).len(), 1);
154    }
155
156    #[test]
157    fn array_literal_in_use_memo_dep_flags() {
158        let content = "\
159function MyComponent({ items }) {
160  const result = useMemo(() => compute(items), [[1, 2, 3]]);
161  return <div />;
162}";
163        assert_eq!(check(content).len(), 1);
164    }
165
166    #[test]
167    fn identifier_deps_no_violation() {
168        let content = "\
169function MyComponent({ data }) {
170  useEffect(() => {
171    process(data);
172  }, [data]);
173  return <div />;
174}";
175        assert!(check(content).is_empty());
176    }
177
178    #[test]
179    fn empty_dep_array_no_violation() {
180        let content = "\
181function MyComponent() {
182  useEffect(() => {
183    init();
184  }, []);
185  return <div />;
186}";
187        assert!(check(content).is_empty());
188    }
189
190    #[test]
191    fn use_callback_with_object_dep_flags() {
192        let content = "\
193function MyComponent({ config }) {
194  const handler = useCallback(() => {
195    process(config);
196  }, [{ ...config }]);
197  return <div />;
198}";
199        assert_eq!(check(content).len(), 1);
200    }
201
202    #[test]
203    fn non_tsx_skipped() {
204        let rule = make_rule();
205        let ctx = ScanContext {
206            file_path: Path::new("test.rs"),
207            content: "fn main() {}",
208        };
209        assert!(rule.check_file(&ctx).is_empty());
210    }
211}