diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 8c29fa0d7f..5a89b9a66e 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,19 +396,30 @@ function onceStylesheetLoaded( } if (styleSheetLoaded) return; + if (signal.aborted) return; + + const handlers: listenerHandler[] = []; + const removeEventListener = () => handlers.forEach((h) => h()); + handlers.push(() => signal.removeEventListener('abort', removeEventListener)); + signal.addEventListener('abort', removeEventListener); const timer = setTimeout(() => { + removeEventListener(); if (!fired) { listener(); fired = true; } }, styleSheetLoadTimeout); + handlers.push(() => clearTimeout(timer)); - link.addEventListener('load', () => { - clearTimeout(timer); + const onStylesheetLoaded = () => { + removeEventListener(); fired = true; listener(); - }); + }; + + link.addEventListener('load', onStylesheetLoaded); + handlers.push(() => link.removeEventListener('load', onStylesheetLoaded)); } function serializeNode( @@ -907,6 +943,7 @@ export function serializeNodeWithId( maskTextSelector: string | null; skipChild: boolean; inlineStylesheet: boolean; + signal: AbortSignal; newlyAddedElement?: boolean; maskInputOptions?: MaskInputOptions; needsMask?: boolean; @@ -956,6 +993,7 @@ export function serializeNodeWithId( keepIframeSrcFn = () => false, newlyAddedElement = false, cssCaptured = false, + signal, } = options; let { needsMask } = options; let { preserveWhiteSpace = true } = options; @@ -1052,6 +1090,7 @@ export function serializeNodeWithId( maskTextSelector, skipChild, inlineStylesheet, + signal, maskInputOptions, maskTextFn, maskInputFn, @@ -1128,6 +1167,7 @@ export function serializeNodeWithId( maskTextSelector, skipChild: false, inlineStylesheet, + signal, maskInputOptions, maskTextFn, maskInputFn, @@ -1153,6 +1193,7 @@ export function serializeNodeWithId( } }, iframeLoadTimeout, + signal, ); } @@ -1180,6 +1221,7 @@ export function serializeNodeWithId( maskTextSelector, skipChild: false, inlineStylesheet, + signal, maskInputOptions, maskTextFn, maskInputFn, @@ -1205,6 +1247,7 @@ export function serializeNodeWithId( } }, stylesheetLoadTimeout, + signal, ); } @@ -1240,6 +1283,7 @@ function snapshot( ) => unknown; stylesheetLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; + signal?: AbortSignal; }, ): serializedNodeWithId | null { const { @@ -1249,6 +1293,7 @@ function snapshot( maskTextClass = 'rr-mask', maskTextSelector = null, inlineStylesheet = true, + signal = new AbortController().signal, inlineImages = false, recordCanvas = false, maskAllInputs = false, @@ -1316,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..3f05292ae4 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({ @@ -521,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,