From 93630359e8edc3a339d6af41711a78003ad95fd7 Mon Sep 17 00:00:00 2001 From: Maksim Ryzhikov Date: Fri, 20 Jun 2025 15:12:42 +0300 Subject: [PATCH 1/3] fix: memory leak event listener on stylesheet load (#1707) --- packages/rrweb-snapshot/src/snapshot.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 8c29fa0d7f..ba0d58e0e3 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -372,18 +372,22 @@ function onceStylesheetLoaded( if (styleSheetLoaded) return; + const onStylesheetLoaded = () => { + link.removeEventListener('load', onStylesheetLoaded); + clearTimeout(timer); + fired = true; + listener(); + }; + const timer = setTimeout(() => { + link.removeEventListener('load', onStylesheetLoaded); if (!fired) { listener(); fired = true; } }, styleSheetLoadTimeout); - link.addEventListener('load', () => { - clearTimeout(timer); - fired = true; - listener(); - }); + link.addEventListener('load', onStylesheetLoaded); } function serializeNode( From 1760cf8962284b8285d92e6bef6b155385748f9b Mon Sep 17 00:00:00 2001 From: Maksim Ryzhikov Date: Mon, 23 Jun 2025 10:48:17 +0300 Subject: [PATCH 2/3] fix: memory leak setTimeout on stop/start record (#1707) Properly cleanup delayed snapshot work when record is stopped to prevent leaking timeout --- packages/rrweb-snapshot/src/snapshot.ts | 68 ++++++++++++++++++++----- packages/rrweb/src/record/index.ts | 6 +++ packages/rrweb/src/record/mutation.ts | 5 ++ 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index ba0d58e0e3..2d5475fa55 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -16,6 +16,7 @@ import type { attributes, mediaAttributes, DataURLOptions, + listenerHandler, } from '@rrweb/types'; import { Mirror, @@ -312,6 +313,7 @@ function onceIframeLoaded( iframeEl: HTMLIFrameElement, listener: () => unknown, iframeLoadTimeout: number, + signal: AbortSignal, ) { const win = iframeEl.contentWindow; if (!win) { @@ -326,22 +328,40 @@ function onceIframeLoaded( } catch (error) { return; } + + if (signal.aborted) return; + + const handlers: listenerHandler[] = []; + const removeEventListener = () => handlers.forEach((h) => h()) + handlers.push(() => signal.removeEventListener('abort', removeEventListener)); + signal.addEventListener('abort', removeEventListener); + if (readyState !== 'complete') { const timer = setTimeout(() => { + removeEventListener(); if (!fired) { listener(); fired = true; } }, iframeLoadTimeout); - iframeEl.addEventListener('load', () => { - clearTimeout(timer); + handlers.push(() => clearTimeout(timer)); + + const onIframeLoaded = () => { + removeEventListener(); fired = true; listener(); - }); + }; + handlers.push(() => iframeEl.removeEventListener('load', onIframeLoaded)); + + iframeEl.addEventListener('load', onIframeLoaded); return; } // check blank frame for Chrome const blankUrl = 'about:blank'; + const onIframeLoaded = () => { + removeEventListener(); + listener(); + }; if ( win.location.href !== blankUrl || iframeEl.src === blankUrl || @@ -349,18 +369,23 @@ function onceIframeLoaded( ) { // iframe was already loaded, make sure we wait to trigger the listener // till _after_ the mutation that found this iframe has had time to process - setTimeout(listener, 0); + const timer = setTimeout(() => { + removeEventListener(); + listener(); + }, 0); + handlers.push(() => clearTimeout(timer)); - return iframeEl.addEventListener('load', listener); // keep listing for future loads + return iframeEl.addEventListener('load', onIframeLoaded); // keep listing for future loads } // use default listener - iframeEl.addEventListener('load', listener); + iframeEl.addEventListener('load', onIframeLoaded); } function onceStylesheetLoaded( link: HTMLLinkElement, listener: () => unknown, styleSheetLoadTimeout: number, + signal: AbortSignal, ) { let fired = false; let styleSheetLoaded: StyleSheet | null; @@ -371,23 +396,30 @@ function onceStylesheetLoaded( } if (styleSheetLoaded) return; + if (signal.aborted) return; - const onStylesheetLoaded = () => { - link.removeEventListener('load', onStylesheetLoaded); - clearTimeout(timer); - fired = true; - listener(); - }; + const handlers: listenerHandler[] = []; + const removeEventListener = () => handlers.forEach((h) => h()) + handlers.push(() => signal.removeEventListener('abort', removeEventListener)); + signal.addEventListener('abort', removeEventListener); const timer = setTimeout(() => { - link.removeEventListener('load', onStylesheetLoaded); + removeEventListener(); if (!fired) { listener(); fired = true; } }, styleSheetLoadTimeout); + handlers.push(() => clearTimeout(timer)); + + const onStylesheetLoaded = () => { + removeEventListener(); + fired = true; + listener(); + }; link.addEventListener('load', onStylesheetLoaded); + handlers.push(() => link.removeEventListener('load', onStylesheetLoaded)); } function serializeNode( @@ -911,6 +943,7 @@ export function serializeNodeWithId( maskTextSelector: string | null; skipChild: boolean; inlineStylesheet: boolean; + signal: AbortSignal; newlyAddedElement?: boolean; maskInputOptions?: MaskInputOptions; needsMask?: boolean; @@ -960,6 +993,7 @@ export function serializeNodeWithId( keepIframeSrcFn = () => false, newlyAddedElement = false, cssCaptured = false, + signal, } = options; let { needsMask } = options; let { preserveWhiteSpace = true } = options; @@ -1056,6 +1090,7 @@ export function serializeNodeWithId( maskTextSelector, skipChild, inlineStylesheet, + signal, maskInputOptions, maskTextFn, maskInputFn, @@ -1132,6 +1167,7 @@ export function serializeNodeWithId( maskTextSelector, skipChild: false, inlineStylesheet, + signal, maskInputOptions, maskTextFn, maskInputFn, @@ -1157,6 +1193,7 @@ export function serializeNodeWithId( } }, iframeLoadTimeout, + signal, ); } @@ -1184,6 +1221,7 @@ export function serializeNodeWithId( maskTextSelector, skipChild: false, inlineStylesheet, + signal, maskInputOptions, maskTextFn, maskInputFn, @@ -1209,6 +1247,7 @@ export function serializeNodeWithId( } }, stylesheetLoadTimeout, + signal, ); } @@ -1244,6 +1283,7 @@ function snapshot( ) => unknown; stylesheetLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; + signal?: AbortSignal; }, ): serializedNodeWithId | null { const { @@ -1253,6 +1293,7 @@ function snapshot( maskTextClass = 'rr-mask', maskTextSelector = null, inlineStylesheet = true, + signal = new AbortController().signal, inlineImages = false, recordCanvas = false, maskAllInputs = false, @@ -1320,6 +1361,7 @@ function snapshot( maskTextSelector, skipChild: false, inlineStylesheet, + signal, maskInputOptions, maskTextFn, maskInputFn, diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 1308c378a6..dc9a645114 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -353,6 +353,8 @@ function record( mirror, }); + let abortController = new AbortController(); + takeFullSnapshot = (isCheckout = false) => { if (!recordDOM) { return; @@ -375,6 +377,8 @@ function record( shadowDomManager.init(); mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting + abortController.abort(); + abortController = new AbortController(); const node = snapshot(document, { mirror, blockClass, @@ -409,6 +413,7 @@ function record( stylesheetManager.attachLinkElement(linkEl, childSn); }, keepIframeSrcFn, + signal: abortController.signal, }); if (!node) { @@ -618,6 +623,7 @@ function record( } return () => { handlers.forEach((h) => h()); + abortController.abort(); processedNodeManager.destroy(); recording = false; unregisterErrorHandler(); diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 08e927a98f..16fb1159fa 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -193,6 +193,7 @@ export default class MutationBuffer { private canvasManager: observerParam['canvasManager']; private processedNodeManager: observerParam['processedNodeManager']; private unattachedDoc: HTMLDocument; + private serializeAbortController: AbortController = new AbortController(); public init(options: MutationBufferParam) { ( @@ -262,6 +263,9 @@ export default class MutationBuffer { }; public emit = () => { + this.serializeAbortController.abort(); + this.serializeAbortController = new AbortController(); + if (this.frozen || this.locked) { return; } @@ -351,6 +355,7 @@ export default class MutationBuffer { this.stylesheetManager.attachLinkElement(link, childSn); }, cssCaptured, + signal: this.serializeAbortController.signal, }); if (sn) { adds.push({ From d1386ac786563781bc16e9d34a0cd42118df7238 Mon Sep 17 00:00:00 2001 From: Maksim Ryzhikov Date: Mon, 23 Jun 2025 13:47:06 +0300 Subject: [PATCH 3/3] fix: memory leak mutation buffer on stop/start record (#1707) --- packages/rrweb-snapshot/src/snapshot.ts | 4 ++-- packages/rrweb/src/record/mutation.ts | 4 ++++ packages/rrweb/src/record/observer.ts | 18 +++++++++++++----- .../rrweb/src/record/shadow-dom-manager.ts | 4 ++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 2d5475fa55..5a89b9a66e 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -332,7 +332,7 @@ function onceIframeLoaded( if (signal.aborted) return; const handlers: listenerHandler[] = []; - const removeEventListener = () => handlers.forEach((h) => h()) + const removeEventListener = () => handlers.forEach((h) => h()); handlers.push(() => signal.removeEventListener('abort', removeEventListener)); signal.addEventListener('abort', removeEventListener); @@ -399,7 +399,7 @@ function onceStylesheetLoaded( if (signal.aborted) return; const handlers: listenerHandler[] = []; - const removeEventListener = () => handlers.forEach((h) => h()) + const removeEventListener = () => handlers.forEach((h) => h()); handlers.push(() => signal.removeEventListener('abort', removeEventListener)); signal.addEventListener('abort', removeEventListener); diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 16fb1159fa..3f05292ae4 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -526,6 +526,10 @@ export default class MutationBuffer { this.mutationCb(payload); }; + public destroy() { + this.serializeAbortController.abort(); + } + private genTextAreaValueMutation = (textarea: HTMLTextAreaElement) => { let item = this.attributeMap.get(textarea); if (!item) { diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 8326d79651..1ea727b3cd 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -81,7 +81,7 @@ function getEventTarget(event: Event | NonStandardEvent): EventTarget | null { export function initMutationObserver( options: MutationBufferParam, rootEl: Node, -): MutationObserver { +): listenerHandler { const mutationBuffer = new MutationBuffer(); mutationBuffers.push(mutationBuffer); // see mutation.ts for details @@ -99,7 +99,15 @@ export function initMutationObserver( childList: true, subtree: true, }); - return observer; + + return () => { + observer.disconnect(); + mutationBuffer.destroy(); + const idx = mutationBuffers.indexOf(mutationBuffer); + if (idx > -1) { + mutationBuffers.splice(idx, 1); + } + }; } function initMoveObserver({ @@ -1306,9 +1314,9 @@ export function initObservers( } mergeHooks(o, hooks); - let mutationObserver: MutationObserver | undefined; + let removeMutationObserver: listenerHandler | undefined; if (o.recordDOM) { - mutationObserver = initMutationObserver(o, o.doc); + removeMutationObserver = initMutationObserver(o, o.doc); } const mousemoveHandler = initMoveObserver(o); const mouseInteractionHandler = initMouseInteractionObserver(o); @@ -1350,7 +1358,7 @@ export function initObservers( return callbackWrapper(() => { mutationBuffers.forEach((b) => b.reset()); - mutationObserver?.disconnect(); + removeMutationObserver?.(); mousemoveHandler(); mouseInteractionHandler(); scrollHandler(); diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 35affb729b..d307b13e20 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -53,7 +53,7 @@ export class ShadowDomManager { if (!isNativeShadowDom(shadowRoot)) return; if (this.shadowDoms.has(shadowRoot)) return; this.shadowDoms.add(shadowRoot); - const observer = initMutationObserver( + const removeObserver = initMutationObserver( { ...this.bypassOptions, doc, @@ -63,7 +63,7 @@ export class ShadowDomManager { }, shadowRoot, ); - this.restoreHandlers.push(() => observer.disconnect()); + this.restoreHandlers.push(() => removeObserver()); this.restoreHandlers.push( initScrollObserver({ ...this.bypassOptions,