Skip to content

Commit 1760cf8

Browse files
committed
fix: memory leak setTimeout on stop/start record (#1707)
Properly cleanup delayed snapshot work when record is stopped to prevent leaking timeout
1 parent 9363035 commit 1760cf8

File tree

3 files changed

+66
-13
lines changed

3 files changed

+66
-13
lines changed

packages/rrweb-snapshot/src/snapshot.ts

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
attributes,
1717
mediaAttributes,
1818
DataURLOptions,
19+
listenerHandler,
1920
} from '@rrweb/types';
2021
import {
2122
Mirror,
@@ -312,6 +313,7 @@ function onceIframeLoaded(
312313
iframeEl: HTMLIFrameElement,
313314
listener: () => unknown,
314315
iframeLoadTimeout: number,
316+
signal: AbortSignal,
315317
) {
316318
const win = iframeEl.contentWindow;
317319
if (!win) {
@@ -326,41 +328,64 @@ function onceIframeLoaded(
326328
} catch (error) {
327329
return;
328330
}
331+
332+
if (signal.aborted) return;
333+
334+
const handlers: listenerHandler[] = [];
335+
const removeEventListener = () => handlers.forEach((h) => h())
336+
handlers.push(() => signal.removeEventListener('abort', removeEventListener));
337+
signal.addEventListener('abort', removeEventListener);
338+
329339
if (readyState !== 'complete') {
330340
const timer = setTimeout(() => {
341+
removeEventListener();
331342
if (!fired) {
332343
listener();
333344
fired = true;
334345
}
335346
}, iframeLoadTimeout);
336-
iframeEl.addEventListener('load', () => {
337-
clearTimeout(timer);
347+
handlers.push(() => clearTimeout(timer));
348+
349+
const onIframeLoaded = () => {
350+
removeEventListener();
338351
fired = true;
339352
listener();
340-
});
353+
};
354+
handlers.push(() => iframeEl.removeEventListener('load', onIframeLoaded));
355+
356+
iframeEl.addEventListener('load', onIframeLoaded);
341357
return;
342358
}
343359
// check blank frame for Chrome
344360
const blankUrl = 'about:blank';
361+
const onIframeLoaded = () => {
362+
removeEventListener();
363+
listener();
364+
};
345365
if (
346366
win.location.href !== blankUrl ||
347367
iframeEl.src === blankUrl ||
348368
iframeEl.src === ''
349369
) {
350370
// iframe was already loaded, make sure we wait to trigger the listener
351371
// till _after_ the mutation that found this iframe has had time to process
352-
setTimeout(listener, 0);
372+
const timer = setTimeout(() => {
373+
removeEventListener();
374+
listener();
375+
}, 0);
376+
handlers.push(() => clearTimeout(timer));
353377

354-
return iframeEl.addEventListener('load', listener); // keep listing for future loads
378+
return iframeEl.addEventListener('load', onIframeLoaded); // keep listing for future loads
355379
}
356380
// use default listener
357-
iframeEl.addEventListener('load', listener);
381+
iframeEl.addEventListener('load', onIframeLoaded);
358382
}
359383

360384
function onceStylesheetLoaded(
361385
link: HTMLLinkElement,
362386
listener: () => unknown,
363387
styleSheetLoadTimeout: number,
388+
signal: AbortSignal,
364389
) {
365390
let fired = false;
366391
let styleSheetLoaded: StyleSheet | null;
@@ -371,23 +396,30 @@ function onceStylesheetLoaded(
371396
}
372397

373398
if (styleSheetLoaded) return;
399+
if (signal.aborted) return;
374400

375-
const onStylesheetLoaded = () => {
376-
link.removeEventListener('load', onStylesheetLoaded);
377-
clearTimeout(timer);
378-
fired = true;
379-
listener();
380-
};
401+
const handlers: listenerHandler[] = [];
402+
const removeEventListener = () => handlers.forEach((h) => h())
403+
handlers.push(() => signal.removeEventListener('abort', removeEventListener));
404+
signal.addEventListener('abort', removeEventListener);
381405

382406
const timer = setTimeout(() => {
383-
link.removeEventListener('load', onStylesheetLoaded);
407+
removeEventListener();
384408
if (!fired) {
385409
listener();
386410
fired = true;
387411
}
388412
}, styleSheetLoadTimeout);
413+
handlers.push(() => clearTimeout(timer));
414+
415+
const onStylesheetLoaded = () => {
416+
removeEventListener();
417+
fired = true;
418+
listener();
419+
};
389420

390421
link.addEventListener('load', onStylesheetLoaded);
422+
handlers.push(() => link.removeEventListener('load', onStylesheetLoaded));
391423
}
392424

393425
function serializeNode(
@@ -911,6 +943,7 @@ export function serializeNodeWithId(
911943
maskTextSelector: string | null;
912944
skipChild: boolean;
913945
inlineStylesheet: boolean;
946+
signal: AbortSignal;
914947
newlyAddedElement?: boolean;
915948
maskInputOptions?: MaskInputOptions;
916949
needsMask?: boolean;
@@ -960,6 +993,7 @@ export function serializeNodeWithId(
960993
keepIframeSrcFn = () => false,
961994
newlyAddedElement = false,
962995
cssCaptured = false,
996+
signal,
963997
} = options;
964998
let { needsMask } = options;
965999
let { preserveWhiteSpace = true } = options;
@@ -1056,6 +1090,7 @@ export function serializeNodeWithId(
10561090
maskTextSelector,
10571091
skipChild,
10581092
inlineStylesheet,
1093+
signal,
10591094
maskInputOptions,
10601095
maskTextFn,
10611096
maskInputFn,
@@ -1132,6 +1167,7 @@ export function serializeNodeWithId(
11321167
maskTextSelector,
11331168
skipChild: false,
11341169
inlineStylesheet,
1170+
signal,
11351171
maskInputOptions,
11361172
maskTextFn,
11371173
maskInputFn,
@@ -1157,6 +1193,7 @@ export function serializeNodeWithId(
11571193
}
11581194
},
11591195
iframeLoadTimeout,
1196+
signal,
11601197
);
11611198
}
11621199

@@ -1184,6 +1221,7 @@ export function serializeNodeWithId(
11841221
maskTextSelector,
11851222
skipChild: false,
11861223
inlineStylesheet,
1224+
signal,
11871225
maskInputOptions,
11881226
maskTextFn,
11891227
maskInputFn,
@@ -1209,6 +1247,7 @@ export function serializeNodeWithId(
12091247
}
12101248
},
12111249
stylesheetLoadTimeout,
1250+
signal,
12121251
);
12131252
}
12141253

@@ -1244,6 +1283,7 @@ function snapshot(
12441283
) => unknown;
12451284
stylesheetLoadTimeout?: number;
12461285
keepIframeSrcFn?: KeepIframeSrcFn;
1286+
signal?: AbortSignal;
12471287
},
12481288
): serializedNodeWithId | null {
12491289
const {
@@ -1253,6 +1293,7 @@ function snapshot(
12531293
maskTextClass = 'rr-mask',
12541294
maskTextSelector = null,
12551295
inlineStylesheet = true,
1296+
signal = new AbortController().signal,
12561297
inlineImages = false,
12571298
recordCanvas = false,
12581299
maskAllInputs = false,
@@ -1320,6 +1361,7 @@ function snapshot(
13201361
maskTextSelector,
13211362
skipChild: false,
13221363
inlineStylesheet,
1364+
signal,
13231365
maskInputOptions,
13241366
maskTextFn,
13251367
maskInputFn,

packages/rrweb/src/record/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,8 @@ function record<T = eventWithTime>(
353353
mirror,
354354
});
355355

356+
let abortController = new AbortController();
357+
356358
takeFullSnapshot = (isCheckout = false) => {
357359
if (!recordDOM) {
358360
return;
@@ -375,6 +377,8 @@ function record<T = eventWithTime>(
375377
shadowDomManager.init();
376378

377379
mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting
380+
abortController.abort();
381+
abortController = new AbortController();
378382
const node = snapshot(document, {
379383
mirror,
380384
blockClass,
@@ -409,6 +413,7 @@ function record<T = eventWithTime>(
409413
stylesheetManager.attachLinkElement(linkEl, childSn);
410414
},
411415
keepIframeSrcFn,
416+
signal: abortController.signal,
412417
});
413418

414419
if (!node) {
@@ -618,6 +623,7 @@ function record<T = eventWithTime>(
618623
}
619624
return () => {
620625
handlers.forEach((h) => h());
626+
abortController.abort();
621627
processedNodeManager.destroy();
622628
recording = false;
623629
unregisterErrorHandler();

packages/rrweb/src/record/mutation.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ export default class MutationBuffer {
193193
private canvasManager: observerParam['canvasManager'];
194194
private processedNodeManager: observerParam['processedNodeManager'];
195195
private unattachedDoc: HTMLDocument;
196+
private serializeAbortController: AbortController = new AbortController();
196197

197198
public init(options: MutationBufferParam) {
198199
(
@@ -262,6 +263,9 @@ export default class MutationBuffer {
262263
};
263264

264265
public emit = () => {
266+
this.serializeAbortController.abort();
267+
this.serializeAbortController = new AbortController();
268+
265269
if (this.frozen || this.locked) {
266270
return;
267271
}
@@ -351,6 +355,7 @@ export default class MutationBuffer {
351355
this.stylesheetManager.attachLinkElement(link, childSn);
352356
},
353357
cssCaptured,
358+
signal: this.serializeAbortController.signal,
354359
});
355360
if (sn) {
356361
adds.push({

0 commit comments

Comments
 (0)