Skip to main content

code_baseline/rules/ast/
no_cascading_set_state.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::parse_file;
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5/// Flags `useEffect` callbacks that call too many setState functions.
6///
7/// Multiple `set*` calls inside a single `useEffect` often cause cascading
8/// re-renders.  This rule counts calls to functions matching `set[A-Z]*`
9/// within each `useEffect` callback body and flags when the count reaches
10/// `max_count` (default 3).
11pub struct NoCascadingSetStateRule {
12    id: String,
13    severity: Severity,
14    message: String,
15    suggest: Option<String>,
16    glob: Option<String>,
17    max_count: usize,
18}
19
20impl NoCascadingSetStateRule {
21    pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
22        Ok(Self {
23            id: config.id.clone(),
24            severity: config.severity,
25            message: config.message.clone(),
26            suggest: config.suggest.clone(),
27            glob: config.glob.clone(),
28            max_count: config.max_count.unwrap_or(3),
29        })
30    }
31}
32
33impl Rule for NoCascadingSetStateRule {
34    fn id(&self) -> &str {
35        &self.id
36    }
37
38    fn severity(&self) -> Severity {
39        self.severity
40    }
41
42    fn file_glob(&self) -> Option<&str> {
43        self.glob.as_deref()
44    }
45
46    fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
47        let mut violations = Vec::new();
48        let tree = match parse_file(ctx.file_path, ctx.content) {
49            Some(t) => t,
50            None => return violations,
51        };
52        let source = ctx.content.as_bytes();
53        self.visit(tree.root_node(), source, ctx, &mut violations);
54        violations
55    }
56}
57
58impl NoCascadingSetStateRule {
59    fn visit(
60        &self,
61        node: tree_sitter::Node,
62        source: &[u8],
63        ctx: &ScanContext,
64        violations: &mut Vec<Violation>,
65    ) {
66        // Look for useEffect(...) call expressions.
67        if node.kind() == "call_expression" {
68            if let Some(func) = node.child_by_field_name("function") {
69                if func.kind() == "identifier" {
70                    if let Ok(name) = func.utf8_text(source) {
71                        if name == "useEffect" {
72                            if let Some(args) = node.child_by_field_name("arguments") {
73                                if let Some(callback) = args.named_child(0) {
74                                    let count = count_set_state_calls(callback, source);
75                                    if count >= self.max_count {
76                                        let line = node.start_position().row;
77                                        violations.push(Violation {
78                                            rule_id: self.id.clone(),
79                                            severity: self.severity,
80                                            file: ctx.file_path.to_path_buf(),
81                                            line: Some(line + 1),
82                                            column: Some(node.start_position().column + 1),
83                                            message: self.message.clone(),
84                                            suggest: self.suggest.clone(),
85                                            source_line: ctx
86                                                .content
87                                                .lines()
88                                                .nth(line)
89                                                .map(String::from),
90                                            fix: None,
91                                        });
92                                    }
93                                }
94                            }
95                        }
96                    }
97                }
98            }
99        }
100
101        for i in 0..node.child_count() {
102            if let Some(child) = node.child(i) {
103                self.visit(child, source, ctx, violations);
104            }
105        }
106    }
107}
108
109/// Count calls to `set*` functions (PascalCase after `set`) in a subtree.
110fn count_set_state_calls(node: tree_sitter::Node, source: &[u8]) -> usize {
111    let mut count = 0;
112
113    if node.kind() == "call_expression" {
114        if let Some(func) = node.child_by_field_name("function") {
115            if func.kind() == "identifier" {
116                if let Ok(name) = func.utf8_text(source) {
117                    if is_set_state_name(name) {
118                        count += 1;
119                    }
120                }
121            }
122        }
123    }
124
125    for i in 0..node.child_count() {
126        if let Some(child) = node.child(i) {
127            count += count_set_state_calls(child, source);
128        }
129    }
130
131    count
132}
133
134/// Returns true for names like `setFoo`, `setIsLoading`, etc.
135fn is_set_state_name(name: &str) -> bool {
136    if let Some(rest) = name.strip_prefix("set") {
137        rest.starts_with(|c: char| c.is_ascii_uppercase())
138    } else {
139        false
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use std::path::Path;
147
148    fn make_config(max_count: usize) -> RuleConfig {
149        RuleConfig {
150            id: "no-cascading-set-state".into(),
151            severity: Severity::Warning,
152            message: format!("useEffect has {}+ setState calls", max_count),
153            suggest: Some("Consider useReducer".into()),
154            glob: Some("**/*.tsx".into()),
155            max_count: Some(max_count),
156            ..Default::default()
157        }
158    }
159
160    fn check(rule: &NoCascadingSetStateRule, content: &str) -> Vec<Violation> {
161        let ctx = ScanContext {
162            file_path: Path::new("test.tsx"),
163            content,
164        };
165        rule.check_file(&ctx)
166    }
167
168    #[test]
169    fn under_threshold_no_violation() {
170        let rule = NoCascadingSetStateRule::new(&make_config(3)).unwrap();
171        let content = "\
172function MyComponent() {
173  const [a, setA] = useState(0);
174  const [b, setB] = useState(0);
175  useEffect(() => {
176    setA(1);
177    setB(2);
178  }, []);
179  return <div />;
180}";
181        let violations = check(&rule, content);
182        assert!(violations.is_empty());
183    }
184
185    #[test]
186    fn at_threshold_triggers() {
187        let rule = NoCascadingSetStateRule::new(&make_config(3)).unwrap();
188        let content = "\
189function MyComponent() {
190  const [a, setA] = useState(0);
191  const [b, setB] = useState(0);
192  const [c, setC] = useState(0);
193  useEffect(() => {
194    setA(1);
195    setB(2);
196    setC(3);
197  }, []);
198  return <div />;
199}";
200        let violations = check(&rule, content);
201        assert_eq!(violations.len(), 1);
202    }
203
204    #[test]
205    fn over_threshold_triggers() {
206        let rule = NoCascadingSetStateRule::new(&make_config(2)).unwrap();
207        let content = "\
208function MyComponent() {
209  useEffect(() => {
210    setName('test');
211    setEmail('test@test.com');
212    setPhone('123');
213  }, []);
214  return <div />;
215}";
216        let violations = check(&rule, content);
217        assert_eq!(violations.len(), 1);
218    }
219
220    #[test]
221    fn separate_effects_counted_independently() {
222        let rule = NoCascadingSetStateRule::new(&make_config(3)).unwrap();
223        // Two effects each with 2 setState — neither reaches 3
224        let content = "\
225function MyComponent() {
226  useEffect(() => {
227    setA(1);
228    setB(2);
229  }, []);
230  useEffect(() => {
231    setC(3);
232    setD(4);
233  }, []);
234  return <div />;
235}";
236        let violations = check(&rule, content);
237        assert!(violations.is_empty());
238    }
239
240    #[test]
241    fn non_set_state_calls_ignored() {
242        let rule = NoCascadingSetStateRule::new(&make_config(2)).unwrap();
243        let content = "\
244function MyComponent() {
245  useEffect(() => {
246    console.log('hi');
247    fetchData();
248    setup();
249    setA(1);
250  }, []);
251  return <div />;
252}";
253        let violations = check(&rule, content);
254        assert!(violations.is_empty());
255    }
256
257    #[test]
258    fn lowercase_set_not_counted() {
259        let rule = NoCascadingSetStateRule::new(&make_config(2)).unwrap();
260        // `settings()` and `setup()` start with "set" but next char is lowercase
261        let content = "\
262function MyComponent() {
263  useEffect(() => {
264    settings();
265    setup();
266    setA(1);
267  }, []);
268  return <div />;
269}";
270        let violations = check(&rule, content);
271        assert!(violations.is_empty());
272    }
273
274    #[test]
275    fn function_expression_callback() {
276        let rule = NoCascadingSetStateRule::new(&make_config(2)).unwrap();
277        let content = "\
278function MyComponent() {
279  useEffect(function() {
280    setA(1);
281    setB(2);
282  }, []);
283  return <div />;
284}";
285        let violations = check(&rule, content);
286        assert_eq!(violations.len(), 1);
287    }
288
289    #[test]
290    fn default_max_count() {
291        let config = RuleConfig {
292            id: "test".into(),
293            severity: Severity::Warning,
294            message: "test".into(),
295            ..Default::default()
296        };
297        let rule = NoCascadingSetStateRule::new(&config).unwrap();
298        assert_eq!(rule.max_count, 3);
299    }
300
301    #[test]
302    fn non_tsx_file_skipped() {
303        let rule = NoCascadingSetStateRule::new(&make_config(1)).unwrap();
304        let ctx = ScanContext {
305            file_path: Path::new("test.rs"),
306            content: "fn main() {}",
307        };
308        assert!(rule.check_file(&ctx).is_empty());
309    }
310
311    #[test]
312    fn is_set_state_name_cases() {
313        assert!(is_set_state_name("setA"));
314        assert!(is_set_state_name("setName"));
315        assert!(is_set_state_name("setIsLoading"));
316        assert!(!is_set_state_name("set"));
317        assert!(!is_set_state_name("setup"));
318        assert!(!is_set_state_name("settings"));
319        assert!(!is_set_state_name("reset"));
320        assert!(!is_set_state_name("useState"));
321    }
322}