Skip to content

Commit dbb6dc0

Browse files
authored
fix(material/core): resolve memory leak by removing event listeners from the ripple element (#24663)
1 parent c49f83b commit dbb6dc0

File tree

1 file changed

+35
-10
lines changed

1 file changed

+35
-10
lines changed

src/material/core/ripple/ripple-renderer.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export interface RippleTarget {
2323
rippleDisabled: boolean;
2424
}
2525

26+
/** Interfaces the defines ripple element transition event listeners. */
27+
interface RippleEventListeners {
28+
onTransitionEnd: EventListener;
29+
onTransitionCancel: EventListener;
30+
}
31+
2632
// TODO: import these values from `@material/ripple` eventually.
2733
/**
2834
* Default ripple animation configuration for ripples without an explicit
@@ -65,8 +71,13 @@ export class RippleRenderer implements EventListenerObject {
6571
/** Whether the pointer is currently down or not. */
6672
private _isPointerDown = false;
6773

68-
/** Set of currently active ripple references. */
69-
private _activeRipples = new Set<RippleRef>();
74+
/**
75+
* Map of currently active ripple references.
76+
* The ripple reference is mapped to its element event listeners.
77+
* The reason why `| null` is used is that event listeners are added only
78+
* when the condition is truthy (see the `_startFadeOutTransition` method).
79+
*/
80+
private _activeRipples = new Map<RippleRef, RippleEventListeners | null>();
7081

7182
/** Latest non-persistent ripple that was triggered. */
7283
private _mostRecentTransientRipple: RippleRef | null;
@@ -164,25 +175,30 @@ export class RippleRenderer implements EventListenerObject {
164175

165176
rippleRef.state = RippleState.FADING_IN;
166177

167-
// Add the ripple reference to the list of all active ripples.
168-
this._activeRipples.add(rippleRef);
169-
170178
if (!config.persistent) {
171179
this._mostRecentTransientRipple = rippleRef;
172180
}
173181

182+
let eventListeners: RippleEventListeners | null = null;
183+
174184
// Do not register the `transition` event listener if fade-in and fade-out duration
175185
// are set to zero. The events won't fire anyway and we can save resources here.
176186
if (!animationForciblyDisabledThroughCss && (enterDuration || animationConfig.exitDuration)) {
177187
this._ngZone.runOutsideAngular(() => {
178-
ripple.addEventListener('transitionend', () => this._finishRippleTransition(rippleRef));
188+
const onTransitionEnd = () => this._finishRippleTransition(rippleRef);
189+
const onTransitionCancel = () => this._destroyRipple(rippleRef);
190+
ripple.addEventListener('transitionend', onTransitionEnd);
179191
// If the transition is cancelled (e.g. due to DOM removal), we destroy the ripple
180192
// directly as otherwise we would keep it part of the ripple container forever.
181193
// https://www.w3.org/TR/css-transitions-1/#:~:text=no%20longer%20in%20the%20document.
182-
ripple.addEventListener('transitioncancel', () => this._destroyRipple(rippleRef));
194+
ripple.addEventListener('transitioncancel', onTransitionCancel);
195+
eventListeners = {onTransitionEnd, onTransitionCancel};
183196
});
184197
}
185198

199+
// Add the ripple reference to the list of all active ripples.
200+
this._activeRipples.set(rippleRef, eventListeners);
201+
186202
// In case there is no fade-in transition duration, we need to manually call the transition
187203
// end listener because `transitionend` doesn't fire if there is no transition.
188204
if (animationForciblyDisabledThroughCss || !enterDuration) {
@@ -217,12 +233,12 @@ export class RippleRenderer implements EventListenerObject {
217233

218234
/** Fades out all currently active ripples. */
219235
fadeOutAll() {
220-
this._activeRipples.forEach(ripple => ripple.fadeOut());
236+
this._getActiveRipples().forEach(ripple => ripple.fadeOut());
221237
}
222238

223239
/** Fades out all currently active non-persistent ripples. */
224240
fadeOutAllNonPersistent() {
225-
this._activeRipples.forEach(ripple => {
241+
this._getActiveRipples().forEach(ripple => {
226242
if (!ripple.config.persistent) {
227243
ripple.fadeOut();
228244
}
@@ -296,6 +312,7 @@ export class RippleRenderer implements EventListenerObject {
296312

297313
/** Destroys the given ripple by removing it from the DOM and updating its state. */
298314
private _destroyRipple(rippleRef: RippleRef) {
315+
const eventListeners = this._activeRipples.get(rippleRef) ?? null;
299316
this._activeRipples.delete(rippleRef);
300317

301318
// Clear out the cached bounding rect if we have no more ripples.
@@ -310,6 +327,10 @@ export class RippleRenderer implements EventListenerObject {
310327
}
311328

312329
rippleRef.state = RippleState.HIDDEN;
330+
if (eventListeners !== null) {
331+
rippleRef.element.removeEventListener('transitionend', eventListeners.onTransitionEnd);
332+
rippleRef.element.removeEventListener('transitioncancel', eventListeners.onTransitionCancel);
333+
}
313334
rippleRef.element.remove();
314335
}
315336

@@ -356,7 +377,7 @@ export class RippleRenderer implements EventListenerObject {
356377
this._isPointerDown = false;
357378

358379
// Fade-out all ripples that are visible and not persistent.
359-
this._activeRipples.forEach(ripple => {
380+
this._getActiveRipples().forEach(ripple => {
360381
// By default, only ripples that are completely visible will fade out on pointer release.
361382
// If the `terminateOnPointerUp` option is set, ripples that still fade in will also fade out.
362383
const isVisible =
@@ -378,6 +399,10 @@ export class RippleRenderer implements EventListenerObject {
378399
});
379400
}
380401

402+
private _getActiveRipples(): RippleRef[] {
403+
return Array.from(this._activeRipples.keys());
404+
}
405+
381406
/** Removes previously registered event listeners from the trigger element. */
382407
_removeTriggerEvents() {
383408
if (this._triggerElement) {

0 commit comments

Comments
 (0)