Skip to content

feat(js_analyze): implement react leaked rules#10175

Draft
Netail wants to merge 3 commits into
biomejs:mainfrom
Netail:feat/react-leaked-rules
Draft

feat(js_analyze): implement react leaked rules#10175
Netail wants to merge 3 commits into
biomejs:mainfrom
Netail:feat/react-leaked-rules

Conversation

@Netail
Copy link
Copy Markdown
Member

@Netail Netail commented Apr 30, 2026

Summary

5 new rules for the React domain to prevent memory leaks (4 ported over from react xyz, 1 biome exclusive based on those)

AI disclosure: Generated with the help of co-pilot

Test Plan

Added unit tests

Docs

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 30, 2026

🦋 Changeset detected

Latest commit: 51dbcf3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@biomejs/biome Patch
@biomejs/cli-win32-x64 Patch
@biomejs/cli-win32-arm64 Patch
@biomejs/cli-darwin-x64 Patch
@biomejs/cli-darwin-arm64 Patch
@biomejs/cli-linux-x64 Patch
@biomejs/cli-linux-arm64 Patch
@biomejs/cli-linux-x64-musl Patch
@biomejs/cli-linux-arm64-musl Patch
@biomejs/wasm-web Patch
@biomejs/wasm-bundler Patch
@biomejs/wasm-nodejs Patch
@biomejs/backend-jsonrpc Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added A-Linter Area: linter L-JavaScript Language: JavaScript and super languages A-Diagnostic Area: diagnostocis labels Apr 30, 2026
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 30, 2026

Merging this PR will not alter performance

✅ 179 untouched benchmarks
⏩ 70 skipped benchmarks1


Comparing Netail:feat/react-leaked-rules (51dbcf3) with main (64aee45)

Open in CodSpeed

Footnotes

  1. 70 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@Netail Netail force-pushed the feat/react-leaked-rules branch 3 times, most recently from 33dcfeb to 2871ff4 Compare May 4, 2026 17:35
@Netail Netail force-pushed the feat/react-leaked-rules branch from 2871ff4 to 0112fc8 Compare May 4, 2026 17:40
@github-actions github-actions Bot added A-CLI Area: CLI A-Project Area: project labels May 4, 2026
@Netail Netail marked this pull request as ready for review May 4, 2026 18:06
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

Walkthrough

This pull request adds five new nursery-level lint rules to Biome that enforce proper cleanup of asynchronous and observer APIs in React components: noReactLeakedEventListener, noReactLeakedIntersectionObserver, noReactLeakedInterval, noReactLeakedTimeout, and noReactLeakedResizeObserver. Each rule detects when a corresponding cleanup (e.g., removeEventListener, disconnect(), clearInterval) is missing from useEffect cleanup functions. The implementation includes a new RuleSource::EslintReactWebApi enum variant, React utility helpers for effect analysis, TypeScript expression unwrapping, and comprehensive test fixtures covering both valid and invalid patterns.

Possibly Related PRs

Suggested Reviewers

  • dyc3
  • ematipico
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main changeset: implementing five new React linter rules to prevent memory leaks.
Description check ✅ Passed The description clearly relates to the changeset, explaining the addition of five new React-domain rules to prevent memory leaks with unit tests included.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (1)
.changeset/no-react-leaked-intersection-observer.md (1)

5-5: ⚡ Quick win

Consider “reports” instead of “enforces” for accuracy.

This phrasing may imply error-level enforcement; for nursery rules, default severity is informational unless configured otherwise, so “reports” is usually clearer for users.

Based on learnings: when a lint rule does not explicitly set severity in declare_lint_rule!, the default is Severity::Information.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/no-react-leaked-intersection-observer.md at line 5, Change the
wording in the changelog entry for the new nursery rule
noReactLeakedIntersectionObserver from “enforces” to “reports” to accurately
reflect that nursery rules default to informational severity; mention that
unless the rule sets a severity in declare_lint_rule! it will be reported at
Severity::Information so users understand the rule is informational by default.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/no-react-leaked-resize-observer.md:
- Line 5: Update the changeset wording to match the implemented rule
noReactLeakedResizeObserver by stating that cleanup must call either disconnect
or unobserve on the ResizeObserver (not only disconnect), and rephrase the
message to be user-facing and concise (e.g., "Ensure ResizeObservers in React
components/hooks are cleaned up with disconnect or unobserve to avoid memory
leaks"). Also ensure the changeset follows the project's guideline of focusing
only on user-facing changes and remove implementation detail language.

In @.changeset/no-react-leaked-timeout.md:
- Line 5: The changelog sentence overstates the rule scope; update the line
referencing the new nursery rule noReactLeakedTimeout so it explicitly says the
rule enforces that every setTimeout inside useEffect callbacks (in components or
custom hooks) must have a corresponding clearTimeout in the effect cleanup,
rather than saying “every setTimeout in a React component or custom hook.” Edit
the .changeset/no-react-leaked-timeout.md diff text to replace the broader
phrase with “inside useEffect callbacks” and keep the link and rationale intact.

In `@crates/biome_js_analyze/src/lint/nursery/no_react_leaked_event_listener.rs`:
- Around line 79-125: The scan is too broad: local_add_entries currently
collects addEventListener calls from all descendants of the effect callback and
remove_entries collects removes from any effect in the same owner scope, causing
false positives/negatives. Limit the add collection to the current effect’s
executed body (not nested helper declarations) by scanning only the callback’s
executed body/statements and returned cleanup by parsing the callback’s return
expression (the cleanup function) and scanning its descendants for
EventListenerMethod::Remove entries; replace the cross-effect loop that builds
remove_entries (root.descendants()/is_effect_call/enclosing_owner_scope_range)
with a parse of the current effect’s returned cleanup function and use
extract_event_listener_entry on that AST subtree, keeping identifiers
effect_callback, extract_event_listener_entry, EventListenerMethod::Add/Remove,
local_add_entries, and remove_entries to locate the changes.

In
`@crates/biome_js_analyze/src/lint/nursery/no_react_leaked_intersection_observer.rs`:
- Around line 100-149: The analysis is currently global per effect: the loop
over observer_ranges replays all observe/unobserve/disconnect calls for every
observer, so calls for observerA can incorrectly clear observerB; scope the
analysis to only calls that target the specific observer represented by
observer_range by filtering callback_node.descendants() (and observer_call_kind
results) to the matching observer identifier/target for that observer_range
before counting; update the logic in the observer_ranges loop (references:
observer_ranges, callback_node.descendants(), observer_call_kind,
is_dynamic_observe_call, observe_count/unobserve_count, has_disconnect, and
NoReactLeakedIntersectionObserverState) to compute counts per-observer and to
correlate returned cleanup with the matching observer; finally add regression
snapshot tests for multi-observer effects and wrong-target cleanup under
tests/specs/{group}/{rule}/ as required.

In `@crates/biome_js_analyze/src/lint/nursery/no_react_leaked_interval.rs`:
- Around line 158-199: The helper has_clear_interval_in_cleanup currently scans
the whole effect callback; restrict it to only inspect clearInterval calls
inside the returned cleanup function: locate JsReturnStatement nodes inside
callback_node, get each return's argument expression, and if that argument is a
JsFunctionExpression or JsArrowFunctionExpression (or a parenthesized wrapper),
descend into that function's body and run the existing clearInterval detection
logic there (handle arrow-expression bodies and block bodies). Keep the same
var_name matching behavior, and add a snapshot regression test "cleared outside
cleanup" under tests/specs/{group}/{rule}/ for this rule.

In `@crates/biome_js_analyze/src/lint/nursery/no_react_leaked_resize_observer.rs`:
- Around line 78-149: The analysis currently saves only observer_ranges and
re-scans the entire callback for each observer which allows one observer's
disconnect or unrelated objects to affect others; instead capture the observer's
bound identifier/variable declarator when you find the new ResizeObserver (use
the JsVariableDeclarator ancestor of is_new_resize_observer/new expression) and
store that binding (e.g., the JsIdentifier or the declarator node) alongside its
range; then when scanning calls use observer_call_kind/JsCallExpression but
additionally verify the call's receiver resolves to that specific binding
(compare the member expression/object node or resolved binding node) and only
count observe/unobserve/disconnect for that bound observer; return
NoReactLeakedResizeObserverState with the stored observer binding/range when
that specific observer fails the checks.
- Around line 208-219: observer_call_kind currently only strips parentheses then
tries as_js_static_member_expression, so casted forms like (observer.observe as
any)(...) are missed; update observer_call_kind to repeatedly unwrap TS/JS "as"
casts and parenthesized expressions from the callee (after the initial
omit_parentheses()) until you reach a JsStaticMemberExpression, then extract
member.member().value_token().text_trimmed() as before (i.e. keep the match on
"observe"/"unobserve"/"disconnect"); refer to the observer_call_kind function,
the callee variable, and the member_name extraction when making this change.

In `@crates/biome_js_analyze/src/lint/nursery/no_react_leaked_timeout.rs`:
- Around line 158-199: The function has_clear_timeout_in_cleanup currently scans
all descendants of the effect callback (callback_node), which can wrongly pick
up clearTimeout calls outside the returned cleanup; change it to locate the
actual returned cleanup function/body and only search that node for
JsCallExpression with callee name "clearTimeout" (e.g., inspect return
statements or arrow-function concise bodies returned by the callback, unwrap the
returned function expression before calling descendants()), keep the existing
arg-name matching/unwrapping logic (unwrap_typescript_expression) when var_name
is provided, and add a snapshot spec fixture under
tests/specs/nursery/no_react_leaked_timeout/ demonstrating a case where
clearTimeout is called outside the cleanup (so the rule still triggers) to
prevent regressions.

In `@crates/biome_js_analyze/src/react.rs`:
- Around line 359-373: is_effect_call currently treats any member named
useEffect/useLayoutEffect/useInsertionEffect as a React hook; change it to only
match genuine React hooks by rejecting member expressions and resolving the
callee binding: first ensure the call's callee is an Identifier (instead of
using callee.get_callee_member_name()), then resolve that identifier's
binding/definition via your semantic resolver/scope (the same resolver used
elsewhere in the crate) and only return true if the identifier name is one of
"useEffect" | "useLayoutEffect" | "useInsertionEffect" and its resolved binding
comes from the React import (e.g., a named import from "react" or a local
binding that originates from that import); leave calls like foo.useEffect(...)
and other non-React bindings unmatched.

---

Nitpick comments:
In @.changeset/no-react-leaked-intersection-observer.md:
- Line 5: Change the wording in the changelog entry for the new nursery rule
noReactLeakedIntersectionObserver from “enforces” to “reports” to accurately
reflect that nursery rules default to informational severity; mention that
unless the rule sets a severity in declare_lint_rule! it will be reported at
Severity::Information so users understand the rule is informational by default.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d2f11dd5-ef94-4f47-a701-b35994c6eff1

📥 Commits

Reviewing files that changed from the base of the PR and between 64aee45 and 51dbcf3.

⛔ Files ignored due to path filters (17)
  • crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs is excluded by !**/migrate/eslint_any_rule_to_biome.rs and included by **
  • crates/biome_configuration/src/analyzer/linter/rules.rs is excluded by !**/rules.rs and included by **
  • crates/biome_configuration/src/generated/domain_selector.rs is excluded by !**/generated/**, !**/generated/** and included by **
  • crates/biome_configuration/src/generated/linter_options_check.rs is excluded by !**/generated/**, !**/generated/** and included by **
  • crates/biome_diagnostics_categories/src/categories.rs is excluded by !**/categories.rs and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedEventListener/invalid.tsx.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedEventListener/valid.tsx.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedIntersectionObserver/invalid.tsx.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedIntersectionObserver/valid.tsx.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedInterval/invalid.tsx.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedInterval/valid.tsx.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedResizeObserver/invalid.tsx.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedResizeObserver/valid.tsx.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedTimeout/invalid.tsx.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedTimeout/valid.tsx.snap is excluded by !**/*.snap and included by **
  • packages/@biomejs/backend-jsonrpc/src/workspace.ts is excluded by !**/backend-jsonrpc/src/workspace.ts and included by **
  • packages/@biomejs/biome/configuration_schema.json is excluded by !**/configuration_schema.json and included by **
📒 Files selected for processing (30)
  • .changeset/no-react-leaked-event-listener.md
  • .changeset/no-react-leaked-intersection-observer.md
  • .changeset/no-react-leaked-interval.md
  • .changeset/no-react-leaked-resize-observer.md
  • .changeset/no-react-leaked-timeout.md
  • crates/biome_analyze/src/rule.rs
  • crates/biome_js_analyze/src/lib.rs
  • crates/biome_js_analyze/src/lint/nursery/no_react_leaked_event_listener.rs
  • crates/biome_js_analyze/src/lint/nursery/no_react_leaked_intersection_observer.rs
  • crates/biome_js_analyze/src/lint/nursery/no_react_leaked_interval.rs
  • crates/biome_js_analyze/src/lint/nursery/no_react_leaked_resize_observer.rs
  • crates/biome_js_analyze/src/lint/nursery/no_react_leaked_timeout.rs
  • crates/biome_js_analyze/src/react.rs
  • crates/biome_js_analyze/src/typescript.rs
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedEventListener/invalid.tsx
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedEventListener/valid.tsx
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedIntersectionObserver/invalid.tsx
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedIntersectionObserver/valid.tsx
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedInterval/invalid.tsx
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedInterval/valid.tsx
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedResizeObserver/invalid.tsx
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedResizeObserver/valid.tsx
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedTimeout/invalid.tsx
  • crates/biome_js_analyze/tests/specs/nursery/noReactLeakedTimeout/valid.tsx
  • crates/biome_rule_options/src/lib.rs
  • crates/biome_rule_options/src/no_react_leaked_event_listener.rs
  • crates/biome_rule_options/src/no_react_leaked_intersection_observer.rs
  • crates/biome_rule_options/src/no_react_leaked_interval.rs
  • crates/biome_rule_options/src/no_react_leaked_resize_observer.rs
  • crates/biome_rule_options/src/no_react_leaked_timeout.rs

"@biomejs/biome": patch
---

Added the new nursery rule [`noReactLeakedResizeObserver`](https://biomejs.dev/linter/rules/no-react-leaked-resize-observer) rule. This rule enforces that every `ResizeObserver` in a React component or custom hook has a corresponding `disconnect` in the cleanup function of `useEffect`. Forgetting to disconnect an observer can lead to memory leaks and unexpected behavior after component unmounts or dependency changes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Changeset wording is stricter than the implemented rule.

The text says cleanup must include disconnect, but the rule also accepts matching unobserve cleanup. Please align wording with actual behaviour.

Suggested wording
-Added the new nursery rule [`noReactLeakedResizeObserver`](https://biomejs.dev/linter/rules/no-react-leaked-resize-observer) rule. This rule enforces that every `ResizeObserver` in a React component or custom hook has a corresponding `disconnect` in the cleanup function of `useEffect`. Forgetting to disconnect an observer can lead to memory leaks and unexpected behavior after component unmounts or dependency changes.
+Added the new nursery rule [`noReactLeakedResizeObserver`](https://biomejs.dev/linter/rules/no-react-leaked-resize-observer). This rule enforces that every `ResizeObserver` in a React component or custom hook is properly cleaned up in `useEffect` (for example via `disconnect`, or equivalent `unobserve` cleanup). Missing cleanup can lead to memory leaks and unexpected behaviour after unmounts or dependency changes.

As per coding guidelines, "Write changesets with focus on user-facing changes only".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Added the new nursery rule [`noReactLeakedResizeObserver`](https://biomejs.dev/linter/rules/no-react-leaked-resize-observer) rule. This rule enforces that every `ResizeObserver` in a React component or custom hook has a corresponding `disconnect` in the cleanup function of `useEffect`. Forgetting to disconnect an observer can lead to memory leaks and unexpected behavior after component unmounts or dependency changes.
Added the new nursery rule [`noReactLeakedResizeObserver`](https://biomejs.dev/linter/rules/no-react-leaked-resize-observer). This rule enforces that every `ResizeObserver` in a React component or custom hook is properly cleaned up in `useEffect` (for example via `disconnect`, or equivalent `unobserve` cleanup). Missing cleanup can lead to memory leaks and unexpected behaviour after unmounts or dependency changes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/no-react-leaked-resize-observer.md at line 5, Update the
changeset wording to match the implemented rule noReactLeakedResizeObserver by
stating that cleanup must call either disconnect or unobserve on the
ResizeObserver (not only disconnect), and rephrase the message to be user-facing
and concise (e.g., "Ensure ResizeObservers in React components/hooks are cleaned
up with disconnect or unobserve to avoid memory leaks"). Also ensure the
changeset follows the project's guideline of focusing only on user-facing
changes and remove implementation detail language.

"@biomejs/biome": patch
---

Added the new nursery rule [`noReactLeakedTimeout`](https://biomejs.dev/linter/rules/no-react-leaked-timeout) rule. This rule enforces that every `setTimeout` in a React component or custom hook has a corresponding `clearTimeout` in the cleanup function of `useEffect`. Forgetting to clear a timeout can lead to memory leaks and unexpected callback executions after component unmounts or dependency changes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tighten the changelog wording to useEffect.

The rule only analyses timers created inside effect callbacks, so “every setTimeout in a React component or custom hook” overstates the behaviour a bit. Wording it around useEffect will spare users a surprise.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/no-react-leaked-timeout.md at line 5, The changelog sentence
overstates the rule scope; update the line referencing the new nursery rule
noReactLeakedTimeout so it explicitly says the rule enforces that every
setTimeout inside useEffect callbacks (in components or custom hooks) must have
a corresponding clearTimeout in the effect cleanup, rather than saying “every
setTimeout in a React component or custom hook.” Edit the
.changeset/no-react-leaked-timeout.md diff text to replace the broader phrase
with “inside useEffect callbacks” and keep the link and rationale intact.

Comment on lines +79 to +125
let callback = effect_callback(query_call)?;
let local_bindings = collect_bindings(&callback);
let local_add_entries: Vec<_> = callback
.syntax()
.descendants()
.filter_map(JsCallExpression::cast)
.filter_map(|call| extract_event_listener_entry(&call, &local_bindings))
.filter(|entry| entry.kind == EventListenerMethod::Add && !entry.has_signal)
.collect();

if local_add_entries.is_empty() {
return None;
}

let root = query_call.syntax().ancestors().last()?;

let mut remove_entries = Vec::new();
let query_owner_scope = enclosing_owner_scope_range(query_call);

for call in root.descendants().filter_map(JsCallExpression::cast) {
if !is_effect_call(&call) {
continue;
}

if enclosing_owner_scope_range(&call) != query_owner_scope {
continue;
}

let Some(callback) = effect_callback(&call) else {
continue;
};

let bindings = collect_bindings(&callback);
for nested_call in callback
.syntax()
.descendants()
.filter_map(JsCallExpression::cast)
{
let Some(entry) = extract_event_listener_entry(&nested_call, &bindings) else {
continue;
};

if entry.kind == EventListenerMethod::Remove {
remove_entries.push(entry);
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Limit the match to this effect’s executed body and returned cleanup.

The current descendant scans pull addEventListener calls from nested helper functions and removeEventListener calls from any useEffect in the same owner scope. That means a never-called helper can be flagged, while a sibling effect or nested callback can accidentally satisfy the cleanup. Please only collect adds from the current effect body and removes from its returned cleanup function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/biome_js_analyze/src/lint/nursery/no_react_leaked_event_listener.rs`
around lines 79 - 125, The scan is too broad: local_add_entries currently
collects addEventListener calls from all descendants of the effect callback and
remove_entries collects removes from any effect in the same owner scope, causing
false positives/negatives. Limit the add collection to the current effect’s
executed body (not nested helper declarations) by scanning only the callback’s
executed body/statements and returned cleanup by parsing the callback’s return
expression (the cleanup function) and scanning its descendants for
EventListenerMethod::Remove entries; replace the cross-effect loop that builds
remove_entries (root.descendants()/is_effect_call/enclosing_owner_scope_range)
with a parse of the current effect’s returned cleanup function and use
extract_event_listener_entry on that AST subtree, keeping identifiers
effect_callback, extract_event_listener_entry, EventListenerMethod::Add/Remove,
local_add_entries, and remove_entries to locate the changes.

Comment on lines +100 to +149
for observer_range in observer_ranges {
let mut has_disconnect = false;
let mut has_dynamic_observe = false;
let mut observe_count = 0usize;
let mut unobserve_count = 0usize;

for call in callback_node
.descendants()
.filter_map(JsCallExpression::cast)
{
let Some(method_name) = observer_call_kind(&call) else {
continue;
};

match method_name {
"disconnect" => {
has_disconnect = true;
}
"observe" => {
observe_count += 1;

if is_dynamic_observe_call(&call, callback_node) {
has_dynamic_observe = true;
}
}
"unobserve" => {
unobserve_count += 1;
}
_ => {}
}
}

if has_disconnect {
continue;
}

if has_dynamic_observe {
return Some(NoReactLeakedIntersectionObserverState {
range: observer_range,
kind: IntersectionObserverLeakKind::ExpectedDisconnectInControlFlow,
});
}

if observe_count == 0 || observe_count > unobserve_count {
return Some(NoReactLeakedIntersectionObserverState {
range: observer_range,
kind: IntersectionObserverLeakKind::ExpectedDisconnectOrUnobserveInCleanup,
});
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Scope the analysis to the returned cleanup and the matching observer.

This loop replays every observe / unobserve / disconnect under the whole effect for each observer_range, so one disconnect() on observerA, a wrong-target unobserve(), or even a setup-time call can make observerB look clean. That will miss real leaks in multi-observer effects and mismatched cleanups.

Please add regression fixtures for multiple observers and wrong-target cleanup when you tighten this. As per coding guidelines, lint rules require snapshot tests in tests/specs/{group}/{rule}/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@crates/biome_js_analyze/src/lint/nursery/no_react_leaked_intersection_observer.rs`
around lines 100 - 149, The analysis is currently global per effect: the loop
over observer_ranges replays all observe/unobserve/disconnect calls for every
observer, so calls for observerA can incorrectly clear observerB; scope the
analysis to only calls that target the specific observer represented by
observer_range by filtering callback_node.descendants() (and observer_call_kind
results) to the matching observer identifier/target for that observer_range
before counting; update the logic in the observer_ranges loop (references:
observer_ranges, callback_node.descendants(), observer_call_kind,
is_dynamic_observe_call, observe_count/unobserve_count, has_disconnect, and
NoReactLeakedIntersectionObserverState) to compute counts per-observer and to
correlate returned cleanup with the matching observer; finally add regression
snapshot tests for multi-observer effects and wrong-target cleanup under
tests/specs/{group}/{rule}/ as required.

Comment on lines +158 to +199
fn has_clear_interval_in_cleanup(
callback_node: &biome_js_syntax::JsSyntaxNode,
var_name: Option<&str>,
) -> bool {
for call in callback_node
.descendants()
.filter_map(JsCallExpression::cast)
{
let Ok(callee) = call.callee() else {
continue;
};

let Some(name) = callee.get_callee_member_name() else {
continue;
};

if name.text_trimmed() != "clearInterval" {
continue;
}

// If no var_name specified, any clearInterval is good enough (for bare setInterval)
if var_name.is_none() {
return true;
}

// Check if the clearInterval argument matches the variable name
if let Ok(args) = call.arguments() {
let [arg] = args.get_arguments_by_index([0]);
if let Some(arg_option) = arg
&& let Some(expr) = arg_option.as_any_js_expression()
{
let inner = unwrap_typescript_expression(expr.clone());
let arg_text = inner.to_trimmed_string();
if arg_text == var_name.unwrap_or("") {
return true;
}
}
}
}

false
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only count clearInterval calls from the returned cleanup.

This helper scans the whole effect body, so a clearInterval(intervalId) in setup code or another nested callback suppresses the diagnostic even if the effect never returns a cleanup. That undercuts the rule’s advertised contract.

Please add a regression fixture for “cleared outside cleanup” when you patch this. As per coding guidelines, lint rules require snapshot tests in tests/specs/{group}/{rule}/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/biome_js_analyze/src/lint/nursery/no_react_leaked_interval.rs` around
lines 158 - 199, The helper has_clear_interval_in_cleanup currently scans the
whole effect callback; restrict it to only inspect clearInterval calls inside
the returned cleanup function: locate JsReturnStatement nodes inside
callback_node, get each return's argument expression, and if that argument is a
JsFunctionExpression or JsArrowFunctionExpression (or a parenthesized wrapper),
descend into that function's body and run the existing clearInterval detection
logic there (handle arrow-expression bodies and block bodies). Keep the same
var_name matching behavior, and add a snapshot regression test "cleared outside
cleanup" under tests/specs/{group}/{rule}/ for this rule.

Comment on lines +78 to +149
let mut observer_ranges = Vec::new();
for new_expr in callback_node
.descendants()
.filter_map(JsNewExpression::cast)
{
if !is_new_resize_observer(&new_expr) {
continue;
}

if !new_expr
.syntax()
.ancestors()
.any(|ancestor| JsVariableDeclarator::can_cast(ancestor.kind()))
{
return Some(NoReactLeakedResizeObserverState {
range: new_expr.range(),
kind: ResizeObserverLeakKind::UnexpectedFloatingInstance,
});
}

observer_ranges.push(new_expr.range());
}

for observer_range in observer_ranges {
let mut has_disconnect = false;
let mut has_dynamic_observe = false;
let mut observe_count = 0usize;
let mut unobserve_count = 0usize;

for call in callback_node
.descendants()
.filter_map(JsCallExpression::cast)
{
let Some(method_name) = observer_call_kind(&call) else {
continue;
};

match method_name {
"disconnect" => {
has_disconnect = true;
}
"observe" => {
observe_count += 1;

if is_dynamic_observe_call(&call, callback_node) {
has_dynamic_observe = true;
}
}
"unobserve" => {
unobserve_count += 1;
}
_ => {}
}
}

if has_disconnect {
continue;
}

if has_dynamic_observe {
return Some(NoReactLeakedResizeObserverState {
range: observer_range,
kind: ResizeObserverLeakKind::ExpectedDisconnectInControlFlow,
});
}

if observe_count == 0 || observe_count > unobserve_count {
return Some(NoReactLeakedResizeObserverState {
range: observer_range,
kind: ResizeObserverLeakKind::ExpectedDisconnectOrUnobserveInCleanup,
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Track cleanup per observer, not per effect.

This pass stores only the TextRange, then re-counts every observe, unobserve, and disconnect call in the whole callback for each observer. One observer’s disconnect() can therefore pardon a leaking sibling, and an unrelated object with the same method names will skew the result too. Please carry the bound identifier through the analysis and only match calls on that observer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/biome_js_analyze/src/lint/nursery/no_react_leaked_resize_observer.rs`
around lines 78 - 149, The analysis currently saves only observer_ranges and
re-scans the entire callback for each observer which allows one observer's
disconnect or unrelated objects to affect others; instead capture the observer's
bound identifier/variable declarator when you find the new ResizeObserver (use
the JsVariableDeclarator ancestor of is_new_resize_observer/new expression) and
store that binding (e.g., the JsIdentifier or the declarator node) alongside its
range; then when scanning calls use observer_call_kind/JsCallExpression but
additionally verify the call's receiver resolves to that specific binding
(compare the member expression/object node or resolved binding node) and only
count observe/unobserve/disconnect for that bound observer; return
NoReactLeakedResizeObserverState with the stored observer binding/range when
that specific observer fails the checks.

Comment on lines +208 to +219
fn observer_call_kind(call: &JsCallExpression) -> Option<&'static str> {
let callee = call.callee().ok()?.omit_parentheses();
let member = callee.as_js_static_member_expression()?;

let member_name = member.member().ok()?.value_token().ok()?;
match member_name.text_trimmed() {
"observe" => Some("observe"),
"unobserve" => Some("unobserve"),
"disconnect" => Some("disconnect"),
_ => None,
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unwrap casted observer methods before matching them.

observer_call_kind() only sees plain observer.observe(...) / observer.disconnect(). Casted forms like (observer.observe as any)(node) are invisible here, so a valid TS-wrapped cleanup would still be reported. A tiny unwrap would save a lot of grief.

Possible fix
-use crate::react::{effect_callback, is_effect_call};
+use crate::{
+    react::{effect_callback, is_effect_call},
+    typescript::unwrap_typescript_expression,
+};
...
 fn observer_call_kind(call: &JsCallExpression) -> Option<&'static str> {
-    let callee = call.callee().ok()?.omit_parentheses();
+    let callee = unwrap_typescript_expression(call.callee().ok()?.omit_parentheses());
     let member = callee.as_js_static_member_expression()?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn observer_call_kind(call: &JsCallExpression) -> Option<&'static str> {
let callee = call.callee().ok()?.omit_parentheses();
let member = callee.as_js_static_member_expression()?;
let member_name = member.member().ok()?.value_token().ok()?;
match member_name.text_trimmed() {
"observe" => Some("observe"),
"unobserve" => Some("unobserve"),
"disconnect" => Some("disconnect"),
_ => None,
}
}
use crate::{
react::{effect_callback, is_effect_call},
typescript::unwrap_typescript_expression,
};
fn observer_call_kind(call: &JsCallExpression) -> Option<&'static str> {
let callee = unwrap_typescript_expression(call.callee().ok()?.omit_parentheses());
let member = callee.as_js_static_member_expression()?;
let member_name = member.member().ok()?.value_token().ok()?;
match member_name.text_trimmed() {
"observe" => Some("observe"),
"unobserve" => Some("unobserve"),
"disconnect" => Some("disconnect"),
_ => None,
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/biome_js_analyze/src/lint/nursery/no_react_leaked_resize_observer.rs`
around lines 208 - 219, observer_call_kind currently only strips parentheses
then tries as_js_static_member_expression, so casted forms like
(observer.observe as any)(...) are missed; update observer_call_kind to
repeatedly unwrap TS/JS "as" casts and parenthesized expressions from the callee
(after the initial omit_parentheses()) until you reach a
JsStaticMemberExpression, then extract
member.member().value_token().text_trimmed() as before (i.e. keep the match on
"observe"/"unobserve"/"disconnect"); refer to the observer_call_kind function,
the callee variable, and the member_name extraction when making this change.

Comment on lines +158 to +199
fn has_clear_timeout_in_cleanup(
callback_node: &biome_js_syntax::JsSyntaxNode,
var_name: Option<&str>,
) -> bool {
for call in callback_node
.descendants()
.filter_map(JsCallExpression::cast)
{
let Ok(callee) = call.callee() else {
continue;
};

let Some(name) = callee.get_callee_member_name() else {
continue;
};

if name.text_trimmed() != "clearTimeout" {
continue;
}

// If no var_name specified, any clearTimeout is good enough (for bare setTimeout)
if var_name.is_none() {
return true;
}

// Check if the clearTimeout argument matches the variable name
if let Ok(args) = call.arguments() {
let [arg] = args.get_arguments_by_index([0]);
if let Some(arg_option) = arg
&& let Some(expr) = arg_option.as_any_js_expression()
{
// Strip TS type assertions (e.g. `timeoutId as number`) to get the plain name
let inner = unwrap_typescript_expression(expr.clone());
let arg_text = inner.to_trimmed_string();
if arg_text == var_name.unwrap_or("") {
return true;
}
}
}
}

false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only treat clearTimeout inside the returned cleanup as valid.

has_clear_timeout_in_cleanup walks every descendant of the effect callback, not the function returned from it. A clearTimeout(timeoutId) in setup code or another nested callback will therefore silence the diagnostic even when the effect returns no cleanup at all.

Please add a fixture where the timer is cleared outside cleanup so this stays nailed down. As per coding guidelines, lint rules require snapshot tests in tests/specs/{group}/{rule}/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/biome_js_analyze/src/lint/nursery/no_react_leaked_timeout.rs` around
lines 158 - 199, The function has_clear_timeout_in_cleanup currently scans all
descendants of the effect callback (callback_node), which can wrongly pick up
clearTimeout calls outside the returned cleanup; change it to locate the actual
returned cleanup function/body and only search that node for JsCallExpression
with callee name "clearTimeout" (e.g., inspect return statements or
arrow-function concise bodies returned by the callback, unwrap the returned
function expression before calling descendants()), keep the existing arg-name
matching/unwrapping logic (unwrap_typescript_expression) when var_name is
provided, and add a snapshot spec fixture under
tests/specs/nursery/no_react_leaked_timeout/ demonstrating a case where
clearTimeout is called outside the cleanup (so the rule still triggers) to
prevent regressions.

Comment on lines +359 to +373
pub(crate) fn is_effect_call(call: &JsCallExpression) -> bool {
let Ok(callee) = call.callee() else {
return false;
};

let Some(name) = callee.get_callee_member_name() else {
return false;
};

let name = name.text_trimmed().to_string();
matches!(
name.as_str(),
"useEffect" | "useLayoutEffect" | "useInsertionEffect"
)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

is_effect_call currently over-matches non-React APIs.

Matching only the callee member name means calls like foo.useEffect(...) are treated as React effect hooks, which can produce false positives in the leaked-resource rules. Please gate this with React-aware resolution (or equivalent binding checks) rather than name-only matching.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/biome_js_analyze/src/react.rs` around lines 359 - 373, is_effect_call
currently treats any member named useEffect/useLayoutEffect/useInsertionEffect
as a React hook; change it to only match genuine React hooks by rejecting member
expressions and resolving the callee binding: first ensure the call's callee is
an Identifier (instead of using callee.get_callee_member_name()), then resolve
that identifier's binding/definition via your semantic resolver/scope (the same
resolver used elsewhere in the crate) and only return true if the identifier
name is one of "useEffect" | "useLayoutEffect" | "useInsertionEffect" and its
resolved binding comes from the React import (e.g., a named import from "react"
or a local binding that originates from that import); leave calls like
foo.useEffect(...) and other non-React bindings unmatched.

@Netail Netail marked this pull request as draft May 4, 2026 19:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-CLI Area: CLI A-Diagnostic Area: diagnostocis A-Linter Area: linter A-Project Area: project L-JavaScript Language: JavaScript and super languages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant