Skip to content

Commit 14c0d24

Browse files
committed
Avoid some memory leaks when disposing Webamp
1 parent e52900d commit 14c0d24

File tree

5 files changed

+140
-62
lines changed

5 files changed

+140
-62
lines changed

packages/webamp/js/fileUtils.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,23 @@ export function genMediaDuration(url: string): Promise<number> {
5858
// got the duration?
5959
const audio = document.createElement("audio");
6060
audio.crossOrigin = "anonymous";
61+
6162
const durationChange = () => {
6263
resolve(audio.duration);
6364
audio.removeEventListener("durationchange", durationChange);
65+
audio.removeEventListener("error", errorHandler);
6466
audio.src = "";
6567
// TODO: Not sure if this really gets cleaned up.
6668
};
67-
audio.addEventListener("durationchange", durationChange);
68-
audio.addEventListener("error", (e) => {
69+
70+
const errorHandler = (e: Event) => {
71+
audio.removeEventListener("durationchange", durationChange);
72+
audio.removeEventListener("error", errorHandler);
6973
reject(e);
70-
});
74+
};
75+
76+
audio.addEventListener("durationchange", durationChange);
77+
audio.addEventListener("error", errorHandler);
7178
audio.src = url;
7279
});
7380
}

packages/webamp/js/hooks.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,39 @@ export function useWindowSize() {
6666
}
6767

6868
const cursorPositionRef = { current: { pageX: 0, pageY: 0 } };
69-
window.document.addEventListener("mousemove", ({ pageX, pageY }) => {
69+
let listenerRefCount = 0;
70+
71+
// Global mousemove listener - managed with reference counting
72+
const globalMouseMoveHandler = ({ pageX, pageY }: MouseEvent) => {
7073
cursorPositionRef.current = { pageX, pageY };
71-
});
74+
};
75+
76+
// Add a reference to the global mouse listener
77+
function addGlobalMouseListener() {
78+
if (listenerRefCount === 0) {
79+
window.document.addEventListener("mousemove", globalMouseMoveHandler);
80+
}
81+
listenerRefCount++;
82+
}
7283

73-
// We use a single global event listener because there is no way to get the
74-
// mouse position aside from an event. Ideally we could create/clean up the
75-
// event listener in the hook, but in the case where we want to check the cursor
76-
// position on mount, that we wouldn't have had time to capture an event.
84+
// Remove a reference to the global mouse listener
85+
function removeGlobalMouseListener() {
86+
listenerRefCount--;
87+
if (listenerRefCount === 0) {
88+
window.document.removeEventListener("mousemove", globalMouseMoveHandler);
89+
}
90+
}
91+
92+
// We use a single global event listener with reference counting because there is no way to get the
93+
// mouse position aside from an event. The listener is only active when at least one component needs it.
7794
function useCursorPositionRef() {
95+
useEffect(() => {
96+
addGlobalMouseListener();
97+
return () => {
98+
removeGlobalMouseListener();
99+
};
100+
}, []);
101+
78102
return cursorPositionRef;
79103
}
80104

packages/webamp/js/media/elementSource.ts

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Emitter from "../emitter";
2+
import Disposable from "../Disposable";
23
import { clamp } from "../utils";
34
import { MEDIA_STATUS } from "../constants";
45
import { MediaStatus } from "../types";
@@ -11,6 +12,7 @@ export default class ElementSource {
1112
_audio: HTMLAudioElement;
1213
_stalled: boolean;
1314
_status: MediaStatus;
15+
_disposable: Disposable;
1416

1517
on(eventType: string, cb: (...args: any[]) => void) {
1618
return this._emitter.on(eventType, cb);
@@ -24,32 +26,44 @@ export default class ElementSource {
2426
this._audio.crossOrigin = "anonymous";
2527
this._stalled = false;
2628
this._status = MEDIA_STATUS.STOPPED;
29+
this._disposable = new Disposable();
2730

28-
// TODO: #leak
29-
this._audio.addEventListener("suspend", () => {
31+
// Create event handlers and register cleanup
32+
const suspendHandler = () => {
3033
this._setStalled(true);
31-
});
34+
};
35+
this._audio.addEventListener("suspend", suspendHandler);
36+
this._disposable.add(() =>
37+
this._audio.removeEventListener("suspend", suspendHandler)
38+
);
3239

33-
// TODO: #leak
34-
this._audio.addEventListener("durationchange", () => {
40+
const durationChangeHandler = () => {
3541
this._emitter.trigger("loaded");
3642
this._setStalled(false);
37-
});
43+
};
44+
this._audio.addEventListener("durationchange", durationChangeHandler);
45+
this._disposable.add(() =>
46+
this._audio.removeEventListener("durationchange", durationChangeHandler)
47+
);
3848

39-
// TODO: #leak
40-
this._audio.addEventListener("ended", () => {
49+
const endedHandler = () => {
4150
this._emitter.trigger("ended");
4251
this._setStatus(MEDIA_STATUS.STOPPED);
43-
});
52+
};
53+
this._audio.addEventListener("ended", endedHandler);
54+
this._disposable.add(() =>
55+
this._audio.removeEventListener("ended", endedHandler)
56+
);
4457

45-
// TODO: Throttle to 50 (if needed)
46-
// TODO: #leak
47-
this._audio.addEventListener("timeupdate", () => {
58+
const timeUpdateHandler = () => {
4859
this._emitter.trigger("positionChange");
49-
});
60+
};
61+
this._audio.addEventListener("timeupdate", timeUpdateHandler);
62+
this._disposable.add(() =>
63+
this._audio.removeEventListener("timeupdate", timeUpdateHandler)
64+
);
5065

51-
// TODO: #leak
52-
this._audio.addEventListener("error", (e) => {
66+
const errorHandler = (e: Event) => {
5367
switch (this._audio.error!.code) {
5468
case 1:
5569
// The fetching of the associated resource was aborted by the user's request.
@@ -77,7 +91,11 @@ export default class ElementSource {
7791

7892
this._emitter.trigger("ended");
7993
this._setStatus(MEDIA_STATUS.STOPPED);
80-
});
94+
};
95+
this._audio.addEventListener("error", errorHandler);
96+
this._disposable.add(() =>
97+
this._audio.removeEventListener("error", errorHandler)
98+
);
8199

82100
this._source = this._context.createMediaElementSource(this._audio);
83101
this._source.connect(destination);
@@ -159,7 +177,9 @@ export default class ElementSource {
159177
}
160178

161179
dispose() {
162-
// TODO: Dispose subscriptions to this.audio
180+
// Clean up all event listeners via disposable
181+
this._disposable.dispose();
182+
163183
this.stop();
164184
this._emitter.dispose();
165185
}

packages/webamp/js/media/index.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { BANDS, MEDIA_STATUS } from "../constants";
33
import { Band } from "../types";
44
import Emitter from "../emitter";
5+
import Disposable from "../Disposable";
56
import StereoBalanceNode from "./StereoBalanceNode";
67
import ElementSource from "./elementSource";
78

@@ -100,32 +101,39 @@ export default class Media implements IMedia {
100101
_gainNode: GainNode;
101102
_source: ElementSource;
102103
_bands: { [band: number]: BiquadFilterNode };
104+
_disposable: Disposable;
103105

104106
constructor() {
105107
this._emitter = new Emitter();
108+
this._disposable = new Disposable();
106109
// @ts-ignore Typescript does not know about webkitAudioContext
107110
this._context = new (window.AudioContext || window.webkitAudioContext)();
108111
// Fix for iOS and Chrome (Canary) which require that the context be created
109112
// or resumed by a user interaction.
110113
// https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
111114
// https://gist.github.com/laziel/7aefabe99ee57b16081c
112115
// Via: https://stackoverflow.com/a/43395068/1263117
113-
// TODO #leak
114116
if (this._context.state === "suspended") {
115-
const resume = async () => {
117+
const resumeHandler = async () => {
116118
await this._context.resume();
117119

118120
if (this._context.state === "running") {
119-
// TODO: Add this to the disposable
120-
document.body.removeEventListener("touchend", resume, false);
121-
document.body.removeEventListener("click", resume, false);
122-
document.body.removeEventListener("keydown", resume, false);
121+
document.body.removeEventListener("touchend", resumeHandler, false);
122+
document.body.removeEventListener("click", resumeHandler, false);
123+
document.body.removeEventListener("keydown", resumeHandler, false);
123124
}
124125
};
125126

126-
document.body.addEventListener("touchend", resume, false);
127-
document.body.addEventListener("click", resume, false);
128-
document.body.addEventListener("keydown", resume, false);
127+
document.body.addEventListener("touchend", resumeHandler, false);
128+
document.body.addEventListener("click", resumeHandler, false);
129+
document.body.addEventListener("keydown", resumeHandler, false);
130+
131+
// Add cleanup for resume handlers
132+
this._disposable.add(() => {
133+
document.body.removeEventListener("touchend", resumeHandler, false);
134+
document.body.removeEventListener("click", resumeHandler, false);
135+
document.body.removeEventListener("keydown", resumeHandler, false);
136+
});
129137
}
130138

131139
// TODO: Maybe we can get rid of this now that we are using AudioAbstraction?
@@ -318,6 +326,9 @@ export default class Media implements IMedia {
318326
}
319327

320328
dispose() {
329+
// Clean up all event listeners via disposable
330+
this._disposable.dispose();
331+
321332
this._source.dispose();
322333
this._emitter.dispose();
323334
}

packages/webamp/js/webampLazy.tsx

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,7 @@ class Webamp {
406406
*/
407407
onTrackDidChange(cb: (trackInfo: LoadedURLTrack | null) => void): () => void {
408408
let previousTrackId: number | null = null;
409-
// TODO #leak.
410-
return this.store.subscribe(() => {
409+
const unsubscribe = this.store.subscribe(() => {
411410
const state = this.store.getState();
412411
const trackId = Selectors.getCurrentlyPlayingTrackIdIfLoaded(state);
413412
if (trackId === previousTrackId) {
@@ -416,6 +415,11 @@ class Webamp {
416415
previousTrackId = trackId;
417416
cb(trackId == null ? null : Selectors.getCurrentTrackInfo(state));
418417
});
418+
419+
// Register cleanup with disposable
420+
this._disposable.add(unsubscribe);
421+
422+
return unsubscribe;
419423
}
420424

421425
/**
@@ -445,8 +449,7 @@ class Webamp {
445449
*/
446450
async skinIsLoaded(): Promise<void> {
447451
// Wait for the skin to load.
448-
// TODO #leak.
449-
await storeHas(this.store, (state) => !state.display.loading);
452+
await this.storeHas((state) => !state.display.loading);
450453
// We attempt to pre-resolve these promises before we declare the skin
451454
// loaded. That's because `<EqGraph>` needs these in order to render fully.
452455
// As long as these are resolved before we attempt to render, we can ensure
@@ -514,8 +517,6 @@ class Webamp {
514517
* attempt to clean itself up to avoid memory leaks.
515518
*/
516519
dispose(): void {
517-
// TODO: Clean up store subscription in onTrackDidChange.
518-
// TODO: Every storeHas call represents a potential race condition.
519520
this.media.dispose();
520521
this._actionEmitter.dispose();
521522
this._disposable.dispose();
@@ -530,8 +531,42 @@ class Webamp {
530531
}
531532

532533
__onStateChange(cb: () => void): () => void {
533-
// TODO #leak.
534-
return this.store.subscribe(cb);
534+
const unsubscribe = this.store.subscribe(cb);
535+
536+
// Register cleanup with disposable
537+
this._disposable.add(unsubscribe);
538+
539+
return unsubscribe;
540+
}
541+
542+
/**
543+
* Wait for the store to match a predicate condition.
544+
* Returns a promise that resolves when the condition is met.
545+
* If the instance is disposed, the promise will be rejected.
546+
*/
547+
private storeHas(predicate: (state: AppState) => boolean): Promise<void> {
548+
let unsubscribed = false;
549+
return new Promise((resolve, reject) => {
550+
if (predicate(this.store.getState())) {
551+
resolve();
552+
return;
553+
}
554+
const unsubscribe = this.store.subscribe(() => {
555+
if (predicate(this.store.getState())) {
556+
unsubscribed = true;
557+
unsubscribe();
558+
resolve();
559+
}
560+
});
561+
562+
// Register cleanup with disposable
563+
this._disposable.add(() => {
564+
if (!unsubscribed) {
565+
unsubscribe();
566+
reject(new Error("Store was disposed before condition was met."));
567+
}
568+
});
569+
});
535570
}
536571

537572
_bufferTracks(tracks: Track[]): void {
@@ -542,25 +577,6 @@ class Webamp {
542577
}
543578
}
544579

545-
// Return a promise that resolves when the store matches a predicate.
546-
// TODO #leak.
547-
const storeHas = (
548-
store: Store,
549-
predicate: (state: AppState) => boolean
550-
): Promise<void> =>
551-
new Promise((resolve) => {
552-
if (predicate(store.getState())) {
553-
resolve();
554-
return;
555-
}
556-
const unsubscribe = store.subscribe(() => {
557-
if (predicate(store.getState())) {
558-
resolve();
559-
unsubscribe();
560-
}
561-
});
562-
});
563-
564580
// @ts-ignore
565581
window.Webamp = Webamp;
566582

0 commit comments

Comments
 (0)