Skip to content

fix(core): cancel stale debounce timers - #70430

Open
rmnsnpk wants to merge 1 commit into
angular:mainfrom
rmnsnpk:fix-debounced-timer-leak
Open

fix(core): cancel stale debounce timers#70430
rmnsnpk wants to merge 1 commit into
angular:mainfrom
rmnsnpk:fix-debounced-timer-leak

Conversation

@rmnsnpk

@rmnsnpk rmnsnpk commented Aug 27, 2026

Copy link
Copy Markdown

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • angular.dev application / infrastructure changes
  • Other... Please describe:

What is the current behavior?

When debounced() is given a numeric wait, the setTimeout scheduled on each source
change is never cancelled. Superseded timers stay queued until they fire, at which point
the active === result guard discards their result. The resource reports the correct
value, so there is no incorrect state — but the timers outlive their usefulness:

  • A timer still pending when the injector is destroyed stays queued for the remainder of
    its delay, retaining its callback closure and the captured value. In a fakeAsync test
    this fails the spec with 1 timer(s) still in the queue., which is what a consumer hits
    when a component holding a debounced() is torn down mid-wait.
  • Rapid source changes queue one timer per change instead of one per wait window. Each
    surplus timer wakes up only to be discarded by the guard.

Issue Number: N/A

What is the new behavior?

The pending timer is tracked and cleared once it becomes obsolete, in three places:

  • when a newer value supersedes it,
  • when the source throws and the resource enters the error state,
  • when the injector is destroyed.

The timer id is also released when the timer fires, so a completed timer is never cleared
afterwards — in a browser its id may already have been reused by an unrelated timer.

Cancellation deliberately happens after the equality check that returns early, so a
value that is equal to the one already pending leaves the running timer counting down.
This is why the cleanup is explicit rather than done through the effect's onCleanup:
the effect re-runs whenever the source notifies, including for equal values, and
cancelling on that path would kill a live debounce with nothing rescheduled, leaving the
resource stuck in loading.

No public API or observable state transitions change.

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

Four specs were added under a timer cleanup block in debounce_spec.ts. Three of them
fail without the fix:

Spec Without the fix
should clear the previous timer when a newer value supersedes it FAIL — timer for "a" not cleared, Expected 0 to be 2
should clear the pending timer when the injector is destroyed FAIL — timer outlived the injector
should clear the pending timer when the source throws FAIL — timer survived the error
should not clear a timer that has already fired passes (regression guard)

They assert on clearTimeout via a spy, comparing timer ids by identity — in Node a timer
id is a Timeout object, which no deep-equality matcher should try to serialize.

Verified with:

pnpm test //packages/core/test/resource:resource

70 specs, 0 failures with the fix; 3 failures on the parent commit.

@pullapprove
pullapprove Bot requested a review from JeanMeche August 27, 2026 11:48
@angular-robot angular-robot Bot added the area: core Issues related to the framework runtime label Aug 27, 2026
@ngbot ngbot Bot added this to the Backlog milestone Aug 27, 2026
@JeanMeche JeanMeche closed this Aug 27, 2026
@JeanMeche JeanMeche reopened this Aug 27, 2026

@JeanMeche JeanMeche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great catch and set of tests !

@angular-robot
angular-robot Bot requested a review from JeanMeche August 27, 2026 12:01
@JeanMeche

Copy link
Copy Markdown
Member

Can you make sure the squash all the commit together in the fix(core): ... commit.

@rmnsnpk

rmnsnpk commented Aug 27, 2026

Copy link
Copy Markdown
Author

Can you make sure the squash all the commit together in the fix(core): ... commit.

Sure

The debounce() utility scheduled a setTimeout when using a numeric wait value, but the scheduled timer was never cleared when a new value arrived, the observable threw, or the injector was destroyed. This caused pending timers to fire after invalidation and leak beyond the owner's lifecycle.

Refactor timer cancellation into a dedicated helper and track the pending timer id so any stale timer is cleared on new values or teardown. Adds tests covering cancellation on new values, errors, and injector destruction.
@rmnsnpk
rmnsnpk force-pushed the fix-debounced-timer-leak branch from d0f948a to 9adcde8 Compare August 27, 2026 12:07
@JeanMeche JeanMeche added action: merge The PR is ready for merge by the caretaker target: patch This PR is targeted for the next patch release labels Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action: merge The PR is ready for merge by the caretaker area: core Issues related to the framework runtime target: patch This PR is targeted for the next patch release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants