Skip to content

Commit fe82db1

Browse files
committed
fix(forms): remove animationstart listener on component destroy to prevent 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.
1 parent 337e6e7 commit fe82db1

File tree

2 files changed

+26
-8
lines changed

2 files changed

+26
-8
lines changed

packages/forms/signals/src/directive/control_native.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function nativeControlCreate(
5959

6060
// TODO: move extraction to first update pass?
6161
if (isInput(input) && inputRequiresValidityTracking(input)) {
62-
validityMonitor.watchValidity(input, () => parser.setRawValue(undefined));
62+
validityMonitor.watchValidity(parent.destroyRef, input, () => parser.setRawValue(undefined));
6363
}
6464

6565
parent.registerAsBinding();

packages/forms/signals/src/directive/input_validity_monitor.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {DOCUMENT, isPlatformBrowser} from '@angular/common';
10-
import {Injectable, CSP_NONCE, inject, OnDestroy, PLATFORM_ID, forwardRef} from '@angular/core';
9+
import {DOCUMENT} from '@angular/common';
10+
import {
11+
Injectable,
12+
CSP_NONCE,
13+
inject,
14+
type OnDestroy,
15+
forwardRef,
16+
type DestroyRef,
17+
} from '@angular/core';
1118

1219
/**
1320
* Service that monitors validity state changes on native form elements.
@@ -18,20 +25,27 @@ import {Injectable, CSP_NONCE, inject, OnDestroy, PLATFORM_ID, forwardRef} from
1825
*/
1926
@Injectable({providedIn: 'root', useClass: forwardRef(() => AnimationInputValidityMonitor)})
2027
export abstract class InputValidityMonitor {
21-
abstract watchValidity(element: HTMLInputElement, callback: () => void): void;
28+
abstract watchValidity(
29+
destroyRef: DestroyRef,
30+
element: HTMLInputElement,
31+
callback: () => void,
32+
): void;
2233
abstract isBadInput(element: HTMLInputElement): boolean;
2334
}
2435

2536
@Injectable()
2637
export class AnimationInputValidityMonitor extends InputValidityMonitor implements OnDestroy {
2738
private readonly document = inject(DOCUMENT);
2839
private readonly cspNonce = inject(CSP_NONCE, {optional: true});
29-
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
3040
private readonly injectedStyles = new WeakMap<Document | ShadowRoot, HTMLStyleElement>();
3141

3242
/** Starts watching the given element for validity state changes. */
33-
override watchValidity(element: HTMLInputElement, callback: () => void): void {
34-
if (!this.isBrowser) {
43+
override watchValidity(
44+
destroyRef: DestroyRef,
45+
element: HTMLInputElement,
46+
callback: () => void,
47+
): void {
48+
if (typeof ngServerMode === 'undefined' || ngServerMode) {
3549
return;
3650
}
3751

@@ -40,14 +54,18 @@ export class AnimationInputValidityMonitor extends InputValidityMonitor implemen
4054
this.injectedStyles.set(rootNode, this.createTransitionStyle(rootNode));
4155
}
4256

43-
element.addEventListener('animationstart', (event: Event) => {
57+
const onAnimationStart = (event: Event) => {
4458
const animationEvent = event as AnimationEvent;
4559
if (
4660
animationEvent.animationName === 'ng-valid' ||
4761
animationEvent.animationName === 'ng-invalid'
4862
) {
4963
callback();
5064
}
65+
};
66+
element.addEventListener('animationstart', onAnimationStart);
67+
destroyRef.onDestroy(() => {
68+
element.removeEventListener('animationstart', onAnimationStart);
5169
});
5270
}
5371

0 commit comments

Comments
 (0)