Skip to content

fix(forms): remove animationstart listener on component destroy to prevent memory leak#68170

Open
arturovt wants to merge 1 commit intoangular:mainfrom
arturovt:fix/forms_rm_animationstart
Open

fix(forms): remove animationstart listener on component destroy to prevent memory leak#68170
arturovt wants to merge 1 commit intoangular:mainfrom
arturovt:fix/forms_rm_animationstart

Conversation

@arturovt
Copy link
Copy Markdown
Contributor

The watchValidity method in AnimationInputValidityMonitor was registering an anonymous arrow function via addEventListener with no corresponding removeEventListener call.

In V8, each closure is represented as a JSFunction holding a strong pointer to a heap-allocated Context object containing captured variables (VariableLocation::CONTEXT slots, decided at parse time by Scope::MustAllocateInContext). In Blink, DOM event listeners are stored in the element's EventTargetData::event_listener_map as JSEventListener wrappers backed by a v8::Persistent<JSFunction> handle — a strong cross-heap reference that keeps the function alive as long as the element is alive.

Because the callback passed to watchValidity closes over the calling component/directive (which itself holds a reference back to the element), this produced a cross-heap reference cycle:

  HTMLInputElement (Blink/Oilpan)
    └── EventTargetData → JSEventListener → v8::Persistent<JSFunction>
          └── Context → callback closure
                └── component → HTMLInputElement  ← cycle

Neither V8's nor Blink's GC could independently break this cycle because it crosses the V8/Oilpan heap boundary. The element was therefore never collected after being removed from the DOM.

The fix stores the listener in a named local variable and registers its removal via DestroyRef.onDestroy, tying cleanup to the lifetime of the component that owns the element. This ensures removeEventListener is called with the exact same JSFunction reference, causing Blink to drop the v8::Persistent handle and allowing both the function and the element to become GC-eligible.

@pullapprove pullapprove bot requested a review from atscott April 13, 2026 16:20
@ngbot ngbot bot added this to the Backlog milestone Apr 13, 2026
Copy link
Copy Markdown
Contributor

@thePunderWoman thePunderWoman left a comment

Choose a reason for hiding this comment

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

LGTM

@thePunderWoman thePunderWoman removed the request for review from atscott April 13, 2026 16:33
@arturovt
Copy link
Copy Markdown
Contributor Author

@thePunderWoman not sure if CI failing is related.

…event memory leak

The `watchValidity` method in `AnimationInputValidityMonitor` was registering
an anonymous arrow function via `addEventListener` with no corresponding
`removeEventListener` call.

In V8, each closure is represented as a `JSFunction` holding a strong pointer
to a heap-allocated `Context` object containing captured variables
(`VariableLocation::CONTEXT` slots, decided at parse time by
`Scope::MustAllocateInContext`). In Blink, DOM event listeners are stored in
the element's `EventTargetData::event_listener_map` as `JSEventListener`
wrappers backed by a `v8::Persistent<JSFunction>` handle — a strong cross-heap
reference that keeps the function alive as long as the element is alive.

Because the callback passed to `watchValidity` closes over the calling
component/directive (which itself holds a reference back to the element), this
produced a cross-heap reference cycle:

```
  HTMLInputElement (Blink/Oilpan)
    └── EventTargetData → JSEventListener → v8::Persistent<JSFunction>
          └── Context → callback closure
                └── component → HTMLInputElement  ← cycle
```

Neither V8's nor Blink's GC could independently break this cycle because it
crosses the V8/Oilpan heap boundary. The element was therefore never collected
after being removed from the DOM.

The fix stores the listener in a named local variable and registers its removal
via `DestroyRef.onDestroy`, tying cleanup to the lifetime of the component that
owns the element. This ensures `removeEventListener` is called with the exact
same `JSFunction` reference, causing Blink to drop the `v8::Persistent` handle
and allowing both the function and the element to become GC-eligible.
@thePunderWoman thePunderWoman force-pushed the fix/forms_rm_animationstart branch from fe82db1 to bdd008b Compare April 14, 2026 22:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants