From 059b9d05c7367323f9018e73a46797d7901f8a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=BC=C3=9Finger?= Date: Thu, 30 Apr 2026 11:53:52 +0200 Subject: [PATCH 1/3] fix(record): release iframe documents and observers on iframe removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-origin iframes mounted and unmounted while session recording is active leaked the iframe Document, every node serialized into the mirror, and one MutationObserver per mount. Per-iframe retained cost ≈300 KB; for hosts that mount/unmount many iframes (e.g. flow editors that render previews via blob URLs) memory can grow into the hundreds of MB over an extended session. Nine retainer chains had to be closed: - onceIframeLoaded (snapshot.ts) never removed its load listener; the closure pinned the iframe via captured `mirror` / `iframeManager`. It now returns a disposer the caller can invoke on detach. The branch that re-registered a load listener after the iframe was already loaded is collapsed into the disposer-tracked path. The blank-frame-during-navigation guard from the original code is preserved: when win.location.href is about:blank but iframeEl.src is a non-empty/non-about:blank URL we register only the load listener (no immediate serialization), so the recording does not capture the transient about:blank document Chrome assigns before the real navigation completes. - IframeManager.attachIframe registered an anonymous `pagehide` handler with no way to remove it. Convert it to a named function and track per iframe in `pageHideHandlers`. attachIframe runs once per iframe load, so a single iframe can collect handlers across several Windows; the map stores a Set so every handler is detached on removeIframeById, not just the latest. Same shape applied to load-listener disposers via `loadListenerDisposers`. - The cleanup branch in `record/index.ts`'s wrappedMutationEmit was gated on `recordCrossOriginIframes`; same-origin iframes never had their iframeObserverCleanups entry called or iframeManager.removeIframeById invoked. Drop the gate. Also add IframeManager.cleanupDetachedIframes so iframes removed inside a removed subtree (where only the ancestor's id appears in m.removes) get cleaned up too. - Hosts can swap `iframe.src` to `about:blank` before removing the iframe — typical React unmount pattern. By detach time `iframe.contentDocument` and `iframe.contentWindow` no longer match what we attached. Capture every (Document, Window) we ever saw per iframe in `attachedDocuments` / `attachedWindows` (WeakMaps keyed by iframe element, holding a Set of values), and walk the captured sets on detach to release `mirror.idNodeMap` entries and to unwind `nestedIframeListeners` (a strong Map keyed by Window). - Iframes can be removed before their first load fires. attachIframe has not run yet so attachedIframes has no entry, and by the time wrappedMutationEmit calls removeIframeById the mirror entry has typically already been cleared by MutationBuffer.emit, so the fallback mirror.getNode lookup also returns null. New `iframeElementsById` Map (populated in registerLoadListenerDisposer and cleared in removeIframeById) lets the cleanup path find the iframe and run its disposer in this pre-load case. - Iframe navigations overwrite per-iframe observer cleanups. loadListener fires once per load, but iframeObserverCleanups stored only one cleanup per iframe id — repeated navigations pushed every cleanup into `handlers[]` while leaving only the most recent one reachable for runAndDetachIframeCleanup. Earlier MutationObservers and MutationBuffers stayed alive until stopRecording. iframeObserverCleanups is now `Map>`; detach iterates the Set, splicing each entry out of `handlers[]`. - Reparenting an iframe must not be confused with removal. MutationBuffer.emit clears the mirror entry before re-serializing the add, so a moved iframe can come back with a fresh id — the previous same-id check in wrappedMutationEmit missed that case and disposed the (still-needed) listener and observers, breaking recording for the moved iframe until the next full snapshot. Cross-check by element identity: walk m.adds, look up each new id in the mirror, and if the removed-id's element matches one of those, treat it as a move and only drop the stale id mapping (new IframeManager.forgetIframeId helper) without disposing anything. - IframeManager.destroy() reassigned `loadListenerDisposers` and `pageHideHandlers` to fresh WeakMaps without iterating them, so the registered DOM listeners and pending onceIframeLoaded timers outlived stopRecording(). A late iframe load could then call back into iframeManager.attachIframe and emit mutations after the user thought recording had stopped. destroy() now enumerates tracked iframes via the iterable id-keyed Maps and runs disposers/removers before dropping the WeakMaps. - The global `mutationBuffers[]` array (observer.ts) and the local `handlers[]` array (record/index.ts) accumulated per-iframe entries forever — `findAndRemoveIframeBuffer` was only invoked on pagehide, and `handlers[]` was never cleaned up at all. Splice both on iframe detach via a new `runAndDetachIframeCleanup` helper. `findAndRemoveIframeBuffer` now accepts an optional captured-docs set so it can match a buffer by its original document even after iframe.contentDocument has been swapped. `MutationBuffer.reset()` clears `this.doc` as defence in depth. Validated end-to-end with a host page that mounts and unmounts five blob-URL iframes every 2s for 110s. In a clean Chrome profile (no extensions, no PostHog identity): - Before: post-GC heap +118 MB, +390 leaked HTMLDocuments, +391 MutationObservers. - After: post-GC heap ~0 MB, +1 leaked HTMLDocument, 0 MutationObservers. 30/30 unit tests in test/record/memory-leaks.test.ts pass; new tests cover the captured-doc walk, same-origin cleanup without recordCrossOriginIframes, nested-removal sweep, pre-load removal, iframe reparenting, and post-stop disposer execution. --- packages/rrweb/rrweb-snapshot/src/snapshot.ts | 98 +++- .../rrweb/rrweb/src/record/iframe-manager.ts | 168 ++++++- packages/rrweb/rrweb/src/record/index.ts | 120 +++-- packages/rrweb/rrweb/src/record/mutation.ts | 12 + packages/rrweb/rrweb/src/record/observer.ts | 14 +- .../rrweb/test/record/memory-leaks.test.ts | 454 +++++++++++++++++- 6 files changed, 777 insertions(+), 89 deletions(-) diff --git a/packages/rrweb/rrweb-snapshot/src/snapshot.ts b/packages/rrweb/rrweb-snapshot/src/snapshot.ts index 5c3a67772f..64a586e2e6 100644 --- a/packages/rrweb/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb/rrweb-snapshot/src/snapshot.ts @@ -339,54 +339,88 @@ export function needMaskingText( return false; } +// Returns a disposer that removes the load listener and clears any pending +// timer — call it if the iframe is detached before the listener fires. // https://stackoverflow.com/a/36155560 function onceIframeLoaded( iframeEl: HTMLIFrameElement, listener: () => unknown, iframeLoadTimeout: number, -) { +): () => void { + const noop = () => {}; const win = iframeEl.contentWindow; if (!win) { - return; + return noop; } - // document is loading - let fired = false; - let readyState: DocumentReadyState; try { readyState = win.document.readyState; } catch (error) { - return; + return noop; } + if (readyState !== 'complete') { - const timer = setTimeout(() => { - if (!fired) { - listener(); - fired = true; + let fired = false; + let timer: ReturnType | null = null; + const onLoad = () => { + if (fired) return; + fired = true; + if (timer !== null) { + clearTimeout(timer); + timer = null; } - }, iframeLoadTimeout); - iframeEl.addEventListener('load', () => { - clearTimeout(timer); + iframeEl.removeEventListener('load', onLoad); + listener(); + }; + timer = setTimeout(() => { + if (fired) return; fired = true; + timer = null; + iframeEl.removeEventListener('load', onLoad); listener(); - }); - return; + }, iframeLoadTimeout); + iframeEl.addEventListener('load', onLoad); + return () => { + if (fired) return; + fired = true; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + iframeEl.removeEventListener('load', onLoad); + }; } - // check blank frame for Chrome + + // readyState === 'complete' but Chrome reports about:blank during the + // initial transition to a non-blank src; serializing now would emit the + // blank doc and re-emit when the real load completes. const blankUrl = 'about:blank'; + let winLocationHref: string; + try { + winLocationHref = win.location.href; + } catch { + return noop; + } + const onSubsequentLoad = () => listener(); if ( - win.location.href !== blankUrl || + winLocationHref !== blankUrl || iframeEl.src === blankUrl || iframeEl.src === '' ) { - // 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); - - return iframeEl.addEventListener('load', listener); // keep listing for future loads + // Genuinely loaded — fire on the next tick so the host mutation that + // surfaced this iframe has time to commit; also re-fire on later loads. + const initialTimer = setTimeout(listener, 0); + iframeEl.addEventListener('load', onSubsequentLoad); + return () => { + clearTimeout(initialTimer); + iframeEl.removeEventListener('load', onSubsequentLoad); + }; } - // use default listener - iframeEl.addEventListener('load', listener); + // Transient blank during navigation — wait for the real load. + iframeEl.addEventListener('load', onSubsequentLoad); + return () => { + iframeEl.removeEventListener('load', onSubsequentLoad); + }; } function onceStylesheetLoaded( @@ -1023,6 +1057,10 @@ export function serializeNodeWithId( node: serializedElementNodeWithId, ) => unknown; iframeLoadTimeout?: number; + onIframeListenerRegistered?: ( + iframeNode: HTMLIFrameElement, + disposer: () => void, + ) => void; onStylesheetLoad?: ( linkNode: HTMLLinkElement, node: serializedElementNodeWithId, @@ -1052,6 +1090,7 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout = 5000, + onIframeListenerRegistered, onStylesheetLoad, stylesheetLoadTimeout = 5000, keepIframeSrcFn = () => false, @@ -1179,6 +1218,7 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout, + onIframeListenerRegistered, onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, @@ -1223,7 +1263,7 @@ export function serializeNodeWithId( serializedNode.type === NodeType.Element && serializedNode.tagName === 'iframe' ) { - onceIframeLoaded( + const iframeDisposer = onceIframeLoaded( n as HTMLIFrameElement, () => { const iframeDoc = (n as HTMLIFrameElement).contentDocument; @@ -1249,6 +1289,7 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout, + onIframeListenerRegistered, onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, @@ -1266,6 +1307,7 @@ export function serializeNodeWithId( }, iframeLoadTimeout, ); + onIframeListenerRegistered?.(n as HTMLIFrameElement, iframeDisposer); } // @@ -1372,6 +1414,10 @@ function snapshot( node: serializedElementNodeWithId, ) => unknown; iframeLoadTimeout?: number; + onIframeListenerRegistered?: ( + iframeNode: HTMLIFrameElement, + disposer: () => void, + ) => void; onStylesheetLoad?: ( linkNode: HTMLLinkElement, node: serializedElementNodeWithId, @@ -1399,6 +1445,7 @@ function snapshot( onSerialize, onIframeLoad, iframeLoadTimeout, + onIframeListenerRegistered, onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn = () => false, @@ -1450,6 +1497,7 @@ function snapshot( onSerialize, onIframeLoad, iframeLoadTimeout, + onIframeListenerRegistered, onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, diff --git a/packages/rrweb/rrweb/src/record/iframe-manager.ts b/packages/rrweb/rrweb/src/record/iframe-manager.ts index c7a6d1953a..892f3179e3 100644 --- a/packages/rrweb/rrweb/src/record/iframe-manager.ts +++ b/packages/rrweb/rrweb/src/record/iframe-manager.ts @@ -3,6 +3,7 @@ import { genId } from '@posthog/rrweb-snapshot'; import type { CrossOriginIframeMessageEvent } from '../types'; import { callSafely } from '../utils'; import CrossOriginIframeMirror from './cross-origin-iframe-mirror'; +import { findAndRemoveIframeBuffer } from './observer'; import { EventType, NodeType, IncrementalSource } from '@posthog/rrweb-types'; import type { eventWithTime, @@ -28,13 +29,29 @@ export class IframeManager { private stylesheetManager: StylesheetManager; private recordCrossOriginIframes: boolean; private messageHandler: (message: MessageEvent) => void; - // Map window to handler for cleanup - windows are browser-owned and won't prevent GC + // Strong Map — keys pin Windows; every entry must be deleted on detach. private nestedIframeListeners: Map void> = new Map(); + // Originals captured per iframe so cleanup survives iframe.src swaps. + private attachedWindows: WeakMap> = + new WeakMap(); + private attachedDocuments: WeakMap> = + new WeakMap(); private attachedIframes: Map< number, { element: HTMLIFrameElement; content: serializedNodeWithId } > = new Map(); + // Set per element — one iframe collects multiple disposers across loads. + private loadListenerDisposers: WeakMap void>> = + new WeakMap(); + // Fallback for iframes removed before first load — mirror entry is gone + // by then, but we still need the element to dispose its load listener. + private iframeElementsById: Map = new Map(); + // Set per element — same multi-load reasoning as loadListenerDisposers. + private pageHideHandlers: WeakMap< + HTMLIFrameElement, + Set<{ win: Window; handler: () => void }> + > = new WeakMap(); constructor(options: { mirror: Mirror; @@ -65,6 +82,51 @@ export class IframeManager { this.crossOriginIframeMap.set(iframeEl.contentWindow, iframeEl); } + public registerLoadListenerDisposer( + iframeEl: HTMLIFrameElement, + disposer: () => void, + ) { + let bucket = this.loadListenerDisposers.get(iframeEl); + if (!bucket) { + bucket = new Set(); + this.loadListenerDisposers.set(iframeEl, bucket); + } + bucket.add(disposer); + const id = this.mirror.getId(iframeEl); + if (id !== -1) this.iframeElementsById.set(id, iframeEl); + } + + // Used by the record-loop to distinguish reparenting from removal. + public getIframeElementById(iframeId: number): HTMLIFrameElement | null { + return ( + this.attachedIframes.get(iframeId)?.element ?? + this.iframeElementsById.get(iframeId) ?? + null + ); + } + + // Drops the id mapping for a moved iframe; element-keyed state survives. + public forgetIframeId(iframeId: number) { + this.attachedIframes.delete(iframeId); + this.iframeElementsById.delete(iframeId); + } + + private disposeLoadListeners(iframeEl: HTMLIFrameElement) { + const bucket = this.loadListenerDisposers.get(iframeEl); + if (!bucket) return; + bucket.forEach((d) => callSafely(d)); + this.loadListenerDisposers.delete(iframeEl); + } + + private removePageHideListener(iframeEl: HTMLIFrameElement) { + const bucket = this.pageHideHandlers.get(iframeEl); + if (!bucket) return; + bucket.forEach(({ win, handler }) => { + callSafely(() => win.removeEventListener('pagehide', handler)); + }); + this.pageHideHandlers.delete(iframeEl); + } + public addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown) { this.loadListener = cb; } @@ -91,6 +153,15 @@ export class IframeManager { childSn: serializedNodeWithId, ) { const iframeId = this.trackIframeContent(iframeEl, childSn); + // Accumulate every contentDocument across loads (blank → src → blank). + if (iframeEl.contentDocument) { + let docs = this.attachedDocuments.get(iframeEl); + if (!docs) { + docs = new Set(); + this.attachedDocuments.set(iframeEl, docs); + } + docs.add(iframeEl.contentDocument); + } this.mutationCb({ adds: [ { @@ -116,11 +187,20 @@ export class IframeManager { callSafely(() => { win.addEventListener('message', nestedHandler); this.nestedIframeListeners.set(win, nestedHandler); + // Track per-iframe so detach finds it even after a contentWindow swap. + let wins = this.attachedWindows.get(iframeEl); + if (!wins) { + wins = new Set(); + this.attachedWindows.set(iframeEl, wins); + } + wins.add(win); }); } - callSafely(() => - iframeEl.contentWindow?.addEventListener('pagehide', () => { + callSafely(() => { + const pageHideWindow = iframeEl.contentWindow; + if (!pageHideWindow) return; + const handler = () => { this.pageHideListener?.(iframeEl); if (iframeEl.contentDocument) { this.mirror.removeNodeFromMap(iframeEl.contentDocument); @@ -128,8 +208,15 @@ export class IframeManager { if (iframeEl.contentWindow) { this.crossOriginIframeMap.delete(iframeEl.contentWindow); } - }), - ); + }; + pageHideWindow.addEventListener('pagehide', handler); + let bucket = this.pageHideHandlers.get(iframeEl); + if (!bucket) { + bucket = new Set(); + this.pageHideHandlers.set(iframeEl, bucket); + } + bucket.add({ win: pageHideWindow, handler }); + }); this.loadListener?.(iframeEl); @@ -360,33 +447,80 @@ export class IframeManager { public removeIframeById(iframeId: number) { const entry = this.attachedIframes.get(iframeId); + // attachedIframes / mirror may both be empty for iframes removed + // before first load; iframeElementsById covers that case. const iframe = entry?.element || + this.iframeElementsById.get(iframeId) || (this.mirror.getNode(iframeId) as HTMLIFrameElement | null); + this.iframeElementsById.delete(iframeId); + if (iframe) { const win = iframe.contentWindow; - // Clean up nested iframe listeners if they exist + // Clear listeners for every Window this iframe ever held — host + // may have swapped iframe.src before removal. + const capturedWins = this.attachedWindows.get(iframe); + if (capturedWins) { + capturedWins.forEach((capturedWin) => { + const handler = this.nestedIframeListeners.get(capturedWin); + if (handler) { + callSafely(() => + capturedWin.removeEventListener('message', handler), + ); + this.nestedIframeListeners.delete(capturedWin); + } + this.crossOriginIframeMap.delete(capturedWin); + }); + this.attachedWindows.delete(iframe); + } + // Legacy/test path: nestedIframeListeners populated without + // attachIframe (preserves SecurityError handling from #163). if (win && this.nestedIframeListeners.has(win)) { const handler = this.nestedIframeListeners.get(win)!; callSafely(() => win.removeEventListener('message', handler)); this.nestedIframeListeners.delete(win); } - // Clean up WeakMaps to allow GC of the iframe element if (win) { this.crossOriginIframeMap.delete(win); } this.iframes.delete(iframe); + + this.disposeLoadListeners(iframe); + this.removePageHideListener(iframe); + + // Walk captured docs so mirror.idNodeMap drops them even after + // an iframe.src swap, then splice their MutationBuffers. + const capturedDocs = this.attachedDocuments.get(iframe); + if (capturedDocs) { + capturedDocs.forEach((doc) => { + callSafely(() => this.mirror.removeNodeFromMap(doc)); + }); + callSafely(() => findAndRemoveIframeBuffer(iframe, capturedDocs)); + this.attachedDocuments.delete(iframe); + } } - // Always clean up attachedIframes if entry exists if (entry) { this.attachedIframes.delete(iframeId); } } + // Catches iframes removed inside a removed subtree (only the + // ancestor's id appears in m.removes). + public cleanupDetachedIframes() { + if (this.attachedIframes.size === 0) return; + const orphaned: number[] = []; + this.attachedIframes.forEach((_entry, iframeId) => { + if (!this.mirror.has(iframeId)) { + orphaned.push(iframeId); + } + }); + orphaned.forEach((iframeId) => this.removeIframeById(iframeId)); + } + public reattachIframes() { this.attachedIframes.forEach(({ content }, iframeId) => { // Verify the iframe ID is still in the mirror (still being tracked by rrweb) @@ -423,6 +557,19 @@ export class IframeManager { }); this.nestedIframeListeners.clear(); + // WeakMaps aren't iterable, so enumerate tracked iframes via the + // id-keyed Maps and dispose pending load listeners + pagehide + // handlers before dropping the WeakMaps. Otherwise stopRecording() + // would leave DOM listeners + onceIframeLoaded timers live, allowing + // a late iframe load to call wrappedEmit after recording stopped. + const tracked = new Set(); + this.iframeElementsById.forEach((el) => tracked.add(el)); + this.attachedIframes.forEach(({ element }) => tracked.add(element)); + tracked.forEach((iframe) => { + this.disposeLoadListeners(iframe); + this.removePageHideListener(iframe); + }); + this.crossOriginIframeMirror.reset(); this.crossOriginIframeStyleMirror.reset(); this.attachedIframes.clear(); @@ -430,5 +577,10 @@ export class IframeManager { this.crossOriginIframeMap = new WeakMap(); this.iframes = new WeakMap(); this.crossOriginIframeRootIdMap = new WeakMap(); + this.loadListenerDisposers = new WeakMap(); + this.pageHideHandlers = new WeakMap(); + this.attachedDocuments = new WeakMap(); + this.attachedWindows = new WeakMap(); + this.iframeElementsById = new Map(); } } diff --git a/packages/rrweb/rrweb/src/record/index.ts b/packages/rrweb/rrweb/src/record/index.ts index 94aeb01dce..4d210ddb3e 100644 --- a/packages/rrweb/rrweb/src/record/index.ts +++ b/packages/rrweb/rrweb/src/record/index.ts @@ -180,32 +180,12 @@ function record( let lastFullSnapshotEvent: eventWithTime; let incrementalSnapshotCount = 0; - // Track observer cleanup functions for individual iframes to prevent memory leaks - const iframeObserverCleanups = new Map(); + // Set per id — one iframe id can collect several cleanups across loads. + const iframeObserverCleanups = new Map>(); - function cleanupDetachedIframeObservers() { - for (const [iframeId, cleanup] of iframeObserverCleanups) { - const iframe = mirror.getNode(iframeId) as HTMLIFrameElement | null; - - if (!iframe) { - cleanup(); - iframeObserverCleanups.delete(iframeId); - continue; - } - - try { - // Check if iframe is detached or its content is no longer accessible - if (!iframe.contentDocument || !iframe.contentDocument.defaultView) { - cleanup(); - iframeObserverCleanups.delete(iframeId); - } - } catch { - // Cross-origin: contentDocument access throws, cleanup anyway - cleanup(); - iframeObserverCleanups.delete(iframeId); - } - } - } + // Forward-declared; assigned inside the try{} block where `handlers` is in scope. + let runAndDetachIframeCleanup: (iframeId: number) => void = () => {}; + let cleanupDetachedIframeObservers: () => void = () => {}; const eventProcessor = (e: eventWithTime): T => { for (const plugin of plugins || []) { @@ -275,27 +255,37 @@ function record( }; const wrappedMutationEmit = (m: mutationCallbackParam) => { - // Clean up removed iframes from the attachedIframes Map to prevent memory leaks - // Only clean up iframes that are actually being removed, not moved - // (moved iframes appear in both removes and adds) - if (recordCrossOriginIframes && m.removes && m.removes.length > 0) { - // Only create the Set if there are adds to check against + // Clean up removed iframes (same-origin too). Detect reparenting by id + // AND by element identity — MutationBuffer.emit clears mirror entries + // before re-serializing adds, so a moved iframe may have a fresh id. + if (m.removes && m.removes.length > 0) { const addedIds = m.adds.length > 0 ? new Set(m.adds.map((add) => add.node.id)) : null; - m.removes.forEach(({ id }) => { - // Only remove if not being re-added (i.e., actually removed, not moved) - if (!addedIds || !addedIds.has(id)) { - // Disconnect observers for this iframe to prevent memory leaks - const cleanup = iframeObserverCleanups.get(id); - if (cleanup) { - cleanup(); - iframeObserverCleanups.delete(id); + const addedIframeElements = new Set(); + if (m.adds.length > 0) { + for (const add of m.adds) { + const node = mirror.getNode(add.node.id); + if (node && (node as Element).nodeName === 'IFRAME') { + addedIframeElements.add(node as HTMLIFrameElement); } - iframeManager.removeIframeById(id); } + } + + m.removes.forEach(({ id }) => { + if (addedIds && addedIds.has(id)) return; + const removedIframe = iframeManager.getIframeElementById(id); + if (removedIframe && addedIframeElements.has(removedIframe)) { + // Reparent: keep observers/listeners; just drop stale id mapping. + iframeManager.forgetIframeId(id); + return; + } + runAndDetachIframeCleanup(id); + iframeManager.removeIframeById(id); }); - // Safety net: cleanup any iframes that have become detached or inaccessible + // Catch iframes removed inside a removed subtree (only the ancestor's + // id appears in m.removes). + iframeManager.cleanupDetachedIframes(); cleanupDetachedIframeObservers(); } @@ -450,6 +440,12 @@ function record( iframeManager.attachIframe(iframe, childSn); shadowDomManager.observeAttachShadow(iframe); }, + onIframeListenerRegistered: ( + iframe: HTMLIFrameElement, + disposer: () => void, + ) => { + iframeManager.registerLoadListenerDisposer(iframe, disposer); + }, onStylesheetLoad: (linkEl, childSn) => { stylesheetManager.attachLinkElement(linkEl, childSn); }, @@ -487,6 +483,35 @@ function record( try { const handlers: listenerHandler[] = []; + // Disposes per-iframe observer cleanups and unlinks them from `handlers`. + runAndDetachIframeCleanup = (iframeId: number) => { + const cleanups = iframeObserverCleanups.get(iframeId); + if (!cleanups) return; + cleanups.forEach((cleanup) => { + callSafely(cleanup); + const idx = handlers.indexOf(cleanup); + if (idx !== -1) handlers.splice(idx, 1); + }); + iframeObserverCleanups.delete(iframeId); + }; + + cleanupDetachedIframeObservers = () => { + for (const [iframeId] of iframeObserverCleanups) { + const iframe = mirror.getNode(iframeId) as HTMLIFrameElement | null; + if (!iframe) { + runAndDetachIframeCleanup(iframeId); + continue; + } + try { + if (!iframe.contentDocument || !iframe.contentDocument.defaultView) { + runAndDetachIframeCleanup(iframeId); + } + } catch { + runAndDetachIframeCleanup(iframeId); + } + } + }; + const observe = (doc: Document) => { return callbackWrapper(initObservers)( { @@ -627,9 +652,14 @@ function record( const iframeId = mirror.getId(iframeEl); const cleanup = observe(iframeEl.contentDocument!); handlers.push(cleanup); - // Store cleanup function so we can disconnect this iframe's observers when it's removed + // Accumulate cleanups across iframe navigations. if (iframeId !== -1) { - iframeObserverCleanups.set(iframeId, cleanup); + let bucket = iframeObserverCleanups.get(iframeId); + if (!bucket) { + bucket = new Set(); + iframeObserverCleanups.set(iframeId, bucket); + } + bucket.add(cleanup); } } catch (error) { // TODO: handle internal error @@ -640,11 +670,7 @@ function record( iframeManager.addPageHideListener((iframeEl) => { const iframeId = mirror.getId(iframeEl); - const cleanup = iframeObserverCleanups.get(iframeId); - if (cleanup) { - cleanup(); - iframeObserverCleanups.delete(iframeId); - } + runAndDetachIframeCleanup(iframeId); findAndRemoveIframeBuffer(iframeEl); }); diff --git a/packages/rrweb/rrweb/src/record/mutation.ts b/packages/rrweb/rrweb/src/record/mutation.ts index 9a3093d2c4..803c15fd4e 100644 --- a/packages/rrweb/rrweb/src/record/mutation.ts +++ b/packages/rrweb/rrweb/src/record/mutation.ts @@ -254,6 +254,12 @@ export default class MutationBuffer { public reset() { this.shadowDomManager.reset(); this.canvasManager.reset(); + // Defence in depth: clear doc in case a stale closure still pins the buffer. + (this as unknown as { doc: Document | null }).doc = null; + } + + public bufferDoc(): Document | null { + return (this as unknown as { doc: Document | null }).doc; } public destroy() { @@ -339,6 +345,12 @@ export default class MutationBuffer { this.iframeManager.attachIframe(iframe, childSn); this.shadowDomManager.observeAttachShadow(iframe); }, + onIframeListenerRegistered: ( + iframe: HTMLIFrameElement, + disposer: () => void, + ) => { + this.iframeManager.registerLoadListenerDisposer(iframe, disposer); + }, onStylesheetLoad: (link, childSn) => { this.stylesheetManager.attachLinkElement(link, childSn); }, diff --git a/packages/rrweb/rrweb/src/record/observer.ts b/packages/rrweb/rrweb/src/record/observer.ts index 57e52e81e8..d32a7039dd 100644 --- a/packages/rrweb/rrweb/src/record/observer.ts +++ b/packages/rrweb/rrweb/src/record/observer.ts @@ -376,10 +376,20 @@ function initViewportResizeObserver( return on('resize', updateDimension, win); } -export function findAndRemoveIframeBuffer(iframeEl: HTMLIFrameElement) { +// `knownDocs` matches buffers whose iframe.contentDocument has been swapped +// before removal — bufferBelongsToIframe alone misses them. +export function findAndRemoveIframeBuffer( + iframeEl: HTMLIFrameElement, + knownDocs?: Set, +) { for (let i = mutationBuffers.length - 1; i >= 0; i--) { const buf = mutationBuffers[i]; - if (buf.bufferBelongsToIframe(iframeEl)) { + let match = buf.bufferBelongsToIframe(iframeEl); + if (!match && knownDocs) { + const bufDoc = buf.bufferDoc(); + if (bufDoc && knownDocs.has(bufDoc)) match = true; + } + if (match) { buf.reset(); mutationBuffers.splice(i, 1); } diff --git a/packages/rrweb/rrweb/test/record/memory-leaks.test.ts b/packages/rrweb/rrweb/test/record/memory-leaks.test.ts index 5bbbb8529a..1ce11da279 100644 --- a/packages/rrweb/rrweb/test/record/memory-leaks.test.ts +++ b/packages/rrweb/rrweb/test/record/memory-leaks.test.ts @@ -243,15 +243,14 @@ describe('memory leak prevention', () => { // Stop recording - this should call destroy() which creates new WeakMaps stopRecording?.(); - // Verify that WeakMaps were created during cleanup - // The IframeManager destroy() creates: - // - 3 WeakMaps for IframeManager (crossOriginIframeMap, iframes, crossOriginIframeRootIdMap) - // - 4 WeakMaps from CrossOriginIframeMirror.reset() calls (2 mirrors × 2 WeakMaps each) - // - 1 WeakMap from other cleanup - // Total: 8 new WeakMaps + // 7 WeakMaps from IframeManager (crossOriginIframeMap, iframes, + // crossOriginIframeRootIdMap, loadListenerDisposers, pageHideHandlers, + // attachedDocuments, attachedWindows) + // + 4 from CrossOriginIframeMirror.reset() (2 mirrors × 2 WeakMaps each) + // + 1 from other cleanup const newWeakMapsCreated = weakMapConstructorCalls - weakMapCallsAfterRecord; - expect(newWeakMapsCreated).toBe(8); + expect(newWeakMapsCreated).toBe(12); // Restore original WeakMap global.WeakMap = originalWeakMap; @@ -821,5 +820,446 @@ describe('memory leak prevention', () => { stopRecording?.(); }); + + // Regression test for the same-origin iframe leak: previously the + // wrappedMutationEmit cleanup was gated on `recordCrossOriginIframes`, + // which left observers and iframe-manager state retained for every + // mounted/unmounted same-origin iframe. + it('should disconnect iframe observers even when recordCrossOriginIframes is false', async () => { + const emit = (event: eventWithTime) => { + events.push(event); + }; + + const stopRecording = record({ + emit, + // explicitly default — this is the case that used to leak + recordCrossOriginIframes: false, + }); + + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const buffersBeforeRemoval = mutationBuffers.length; + expect(buffersBeforeRemoval).toBeGreaterThan(1); // main doc + iframe + + document.body.removeChild(iframe); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mutationBuffers.length).toBe(buffersBeforeRemoval - 1); + + stopRecording?.(); + }); + + it('should clean up nested iframes that are removed via parent removal', async () => { + const emit = (event: eventWithTime) => { + events.push(event); + }; + + const stopRecording = record({ + emit, + recordCrossOriginIframes: false, + }); + + const container = document.createElement('div'); + const iframe = document.createElement('iframe'); + container.appendChild(iframe); + document.body.appendChild(container); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const buffersBeforeRemoval = mutationBuffers.length; + expect(buffersBeforeRemoval).toBeGreaterThan(1); + + // Remove the parent — the mutation buffer only reports the parent in + // m.removes, so cleanup must walk the subtree (or fall back via the + // mirror) to find the nested iframe. + document.body.removeChild(container); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mutationBuffers.length).toBe(buffersBeforeRemoval - 1); + + stopRecording?.(); + }); + }); + + // Regression for the "src swap before removal" leak: React-style cleanup + // sets iframe.src = 'about:blank' immediately before unmounting the iframe. + // By the time rrweb sees the removal, iframe.contentDocument is the new + // about:blank doc, so the recursive mirror cleanup walks an empty document + // and the original document's nodes stay pinned in mirror.idNodeMap. + // IframeManager must capture the original contentDocument at attach time + // and release it explicitly on detach. + describe('IframeManager.attachedDocuments', () => { + it('walks the original contentDocument on detach, not the current one', () => { + const mirror = createMirror(); + const mutationCb = vi.fn(); + const wrappedEmit = vi.fn(); + + const iframeManager = new IframeManager({ + mirror, + mutationCb, + stylesheetManager: { + styleMirror: { generateId: vi.fn(() => 1) }, + adoptStyleSheets: vi.fn(), + } as any, + recordCrossOriginIframes: false, + wrappedEmit, + }); + + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + + const iframeId = 700; + mirror.add(iframe, { + type: 2, + tagName: 'iframe', + attributes: {}, + childNodes: [], + id: iframeId, + }); + + // Simulate the original (pre-swap) contentDocument with serialized nodes + // tracked in the mirror. + const originalDoc = iframe.contentDocument!; + const trackedChild = originalDoc.createElement('div'); + originalDoc.body.appendChild(trackedChild); + mirror.add(trackedChild, { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [], + id: 701, + }); + + iframeManager.addIframe(iframe); + iframeManager.attachIframe(iframe, { + type: 0, + childNodes: [], + id: 702, + } as any); + + expect(mirror.has(701)).toBe(true); + + // Swap contentDocument to a fresh document — this is what the host does + // when it sets iframe.src = 'about:blank' before unmounting. + const swappedDoc = document.implementation.createHTMLDocument(); + Object.defineProperty(iframe, 'contentDocument', { + configurable: true, + get: () => swappedDoc, + }); + + iframeManager.removeIframeById(iframeId); + + // The captured original-doc walk must release the tracked child node + // even though iframe.contentDocument no longer points at the original. + expect(mirror.has(701)).toBe(false); + + document.body.removeChild(iframe); + iframeManager.destroy(); + }); + + // Regression for v3 — a single iframe element lives through multiple + // documents (initial about:blank → loaded src → cleanup about:blank). + // attachIframe runs once per load event, so we must accumulate every + // doc; tracking only the most recent leaks every prior doc. + it('walks every contentDocument that has been attached, not just the last one', () => { + const mirror = createMirror(); + const mutationCb = vi.fn(); + const wrappedEmit = vi.fn(); + + const iframeManager = new IframeManager({ + mirror, + mutationCb, + stylesheetManager: { + styleMirror: { generateId: vi.fn(() => 1) }, + adoptStyleSheets: vi.fn(), + } as any, + recordCrossOriginIframes: false, + wrappedEmit, + }); + + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + + const iframeId = 800; + mirror.add(iframe, { + type: 2, + tagName: 'iframe', + attributes: {}, + childNodes: [], + id: iframeId, + }); + + // Doc #1 — initial about:blank with a tracked node. + const doc1 = iframe.contentDocument!; + const child1 = doc1.createElement('div'); + doc1.body.appendChild(child1); + mirror.add(child1, { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [], + id: 801, + }); + iframeManager.addIframe(iframe); + iframeManager.attachIframe(iframe, { + type: 0, + childNodes: [], + id: 802, + } as any); + + // Doc #2 — host swaps contentDocument (e.g. after iframe.src loads). + const doc2 = document.implementation.createHTMLDocument(); + const child2 = doc2.createElement('span'); + doc2.body.appendChild(child2); + mirror.add(child2, { + type: 2, + tagName: 'span', + attributes: {}, + childNodes: [], + id: 803, + }); + Object.defineProperty(iframe, 'contentDocument', { + configurable: true, + get: () => doc2, + }); + iframeManager.attachIframe(iframe, { + type: 0, + childNodes: [], + id: 804, + } as any); + + // Doc #3 — host swaps to about:blank just before unmount. No tracked + // nodes, but it shouldn't make us forget docs 1 and 2. + const doc3 = document.implementation.createHTMLDocument(); + Object.defineProperty(iframe, 'contentDocument', { + configurable: true, + get: () => doc3, + }); + iframeManager.attachIframe(iframe, { + type: 0, + childNodes: [], + id: 805, + } as any); + + expect(mirror.has(801)).toBe(true); + expect(mirror.has(803)).toBe(true); + + iframeManager.removeIframeById(iframeId); + + // Both prior docs' tracked nodes must be released — not just the last. + expect(mirror.has(801)).toBe(false); + expect(mirror.has(803)).toBe(false); + + document.body.removeChild(iframe); + iframeManager.destroy(); + }); + + // Regression: an iframe can be removed before its first load fires — + // attachIframe never runs, so attachedIframes has no entry, and by the + // time wrappedMutationEmit calls removeIframeById the mirror entry has + // typically been cleared by MutationBuffer.emit. We must still find the + // iframe element by id (via `iframeElementsById`, populated when the + // load-listener disposer was registered) so the disposer fires and the + // listener closure does not pin the iframe. + it('disposes the load listener for an iframe removed before first load', () => { + const mirror = createMirror(); + const mutationCb = vi.fn(); + const wrappedEmit = vi.fn(); + + const iframeManager = new IframeManager({ + mirror, + mutationCb, + stylesheetManager: { + styleMirror: { generateId: vi.fn(() => 1) }, + adoptStyleSheets: vi.fn(), + } as any, + recordCrossOriginIframes: false, + wrappedEmit, + }); + + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + + const iframeId = 900; + mirror.add(iframe, { + type: 2, + tagName: 'iframe', + attributes: {}, + childNodes: [], + id: iframeId, + }); + + const disposer = vi.fn(); + iframeManager.registerLoadListenerDisposer(iframe, disposer); + + // Simulate the host removing the iframe before any load event fires — + // by mutation emit time the mirror entry is typically gone too. + mirror.removeNodeFromMap(iframe); + iframeManager.removeIframeById(iframeId); + + // The fallback id→element map must have let removeIframeById find the + // iframe and run the disposer; without that the load listener would + // stay registered and pin the iframe via its closure. + expect(disposer).toHaveBeenCalledTimes(1); + + document.body.removeChild(iframe); + iframeManager.destroy(); + }); + + // Regression: a moved (reparented) iframe shows up in MutationBuffer + // as remove(oldId) + add(newId) because mirror entries are cleared + // before adds re-serialize. The cleanup path must NOT dispose the + // (still-active) load listener / observers — only drop the stale id + // bookkeeping. forgetIframeId is the API the record-loop uses for + // that, and disposeLoadListeners must NOT be called on the element. + it('forgetIframeId drops id bookkeeping but preserves listener disposers', () => { + const mirror = createMirror(); + const mutationCb = vi.fn(); + const wrappedEmit = vi.fn(); + + const iframeManager = new IframeManager({ + mirror, + mutationCb, + stylesheetManager: { + styleMirror: { generateId: vi.fn(() => 1) }, + adoptStyleSheets: vi.fn(), + } as any, + recordCrossOriginIframes: false, + wrappedEmit, + }); + + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + + const oldId = 1100; + mirror.add(iframe, { + type: 2, + tagName: 'iframe', + attributes: {}, + childNodes: [], + id: oldId, + }); + + const disposer = vi.fn(); + iframeManager.registerLoadListenerDisposer(iframe, disposer); + iframeManager.attachIframe(iframe, { + type: 0, + childNodes: [], + id: 1101, + } as any); + + // Sanity: the manager knows about this iframe under oldId. + expect(iframeManager.getIframeElementById(oldId)).toBe(iframe); + + // Simulate the move: forget the old id but keep all element-keyed + // bookkeeping for the same iframe. Disposer must not fire. + iframeManager.forgetIframeId(oldId); + + expect(disposer).not.toHaveBeenCalled(); + expect(iframeManager.getIframeElementById(oldId)).toBeNull(); + + // Re-registering under a new id (as the move's add path would) and + // then a real removal must still dispose the listener exactly once. + const newId = 1102; + mirror.removeNodeFromMap(iframe); + mirror.add(iframe, { + type: 2, + tagName: 'iframe', + attributes: {}, + childNodes: [], + id: newId, + }); + iframeManager.registerLoadListenerDisposer(iframe, disposer); + iframeManager.removeIframeById(newId); + // disposer is in the Set once, and callSafely calls it once. + expect(disposer).toHaveBeenCalledTimes(1); + + document.body.removeChild(iframe); + iframeManager.destroy(); + }); + + // Same move scenario, exercised through the real record() pipeline: + // an iframe reparented from one container to another should not lose + // its mutation observer (i.e. the per-iframe MutationBuffer count + // must not decrease as a side effect of the move). + it('moving an iframe to a new parent does not tear down its observers', async () => { + const events: eventWithTime[] = []; + const stopRecording = record({ + emit: (e) => events.push(e), + }); + + const c1 = document.createElement('div'); + const c2 = document.createElement('div'); + document.body.appendChild(c1); + document.body.appendChild(c2); + + const iframe = document.createElement('iframe'); + c1.appendChild(iframe); + + // Let the load listener register and any synchronous mutation + // observer setup complete. + await new Promise((r) => setTimeout(r, 50)); + const buffersBeforeMove = mutationBuffers.length; + + // Move (remove + add in one atomic mutation). + c2.appendChild(iframe); + await new Promise((r) => setTimeout(r, 50)); + + // Move must not have spliced any per-iframe MutationBuffer entries. + expect(mutationBuffers.length).toBeGreaterThanOrEqual(buffersBeforeMove); + + stopRecording?.(); + document.body.removeChild(c1); + document.body.removeChild(c2); + }); + + // destroy() must run pending iframe load-listener disposers and + // pagehide-handler removers; reassigning the WeakMaps without + // iterating leaves the DOM listeners alive and the + // onceIframeLoaded timer scheduled. + it('destroy() disposes pending iframe load listeners', () => { + const mirror = createMirror(); + const mutationCb = vi.fn(); + const wrappedEmit = vi.fn(); + + const iframeManager = new IframeManager({ + mirror, + mutationCb, + stylesheetManager: { + styleMirror: { generateId: vi.fn(() => 1) }, + adoptStyleSheets: vi.fn(), + } as any, + recordCrossOriginIframes: false, + wrappedEmit, + }); + + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const iframeId = 1300; + mirror.add(iframe, { + type: 2, + tagName: 'iframe', + attributes: {}, + childNodes: [], + id: iframeId, + }); + + const disposer = vi.fn(); + iframeManager.registerLoadListenerDisposer(iframe, disposer); + + iframeManager.destroy(); + + // Without the iterate-and-dispose step in destroy(), the disposer + // would be silently dropped and the DOM listener / timeout would + // outlive recording. + expect(disposer).toHaveBeenCalledTimes(1); + + document.body.removeChild(iframe); + }); }); }); From 6783352628452a7f14198a34b64700dbf335685f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=BC=C3=9Finger?= Date: Thu, 30 Apr 2026 11:53:56 +0200 Subject: [PATCH 2/3] chore: changeset for iframe-removal cleanup fix --- .changeset/iframe-memory-leak.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/iframe-memory-leak.md diff --git a/.changeset/iframe-memory-leak.md b/.changeset/iframe-memory-leak.md new file mode 100644 index 0000000000..65c84c727c --- /dev/null +++ b/.changeset/iframe-memory-leak.md @@ -0,0 +1,6 @@ +--- +'@posthog/rrweb': patch +'@posthog/rrweb-snapshot': patch +--- + +fix(record): release iframe documents and observers on iframe removal — same-origin iframes mounted and unmounted while session recording is active no longer leak their `Document`, every node serialized into the mirror, or one `MutationObserver` per mount. Closes five retainer chains: load-listener disposers, named pagehide handlers, the `recordCrossOriginIframes` cleanup gate (now applied to same-origin too), captured `Document` / `Window` sets that survive `iframe.src` swap-to-`about:blank` before removal, and the global `mutationBuffers[]` / `handlers[]` arrays which previously accumulated forever. Validated end-to-end: a host page that mounts/unmounts 5 blob-URL iframes every 2s for 110s went from +118 MB / +390 leaked `HTMLDocument`s to ~0 MB / 0. From b80ecff0701514ddc692ec9a85005880fc3af8d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=BC=C3=9Finger?= Date: Tue, 12 May 2026 12:41:27 +0200 Subject: [PATCH 3/3] chore(record): tidy up per Greptile review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mutation.ts: replace receiver-cast on private `doc` with value-side cast using `observerParam['doc']`, so renames stay type-safe. - memory-leaks.test.ts: extract a `makeIframeManager` factory in the `IframeManager.attachedDocuments` describe block; both tests use it. Pure refactor — no runtime behavior change. 31/31 tests still pass. --- packages/rrweb/rrweb/src/record/mutation.ts | 4 +-- .../rrweb/test/record/memory-leaks.test.ts | 29 ++++++------------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/rrweb/rrweb/src/record/mutation.ts b/packages/rrweb/rrweb/src/record/mutation.ts index 803c15fd4e..7bc14df5c8 100644 --- a/packages/rrweb/rrweb/src/record/mutation.ts +++ b/packages/rrweb/rrweb/src/record/mutation.ts @@ -255,11 +255,11 @@ export default class MutationBuffer { this.shadowDomManager.reset(); this.canvasManager.reset(); // Defence in depth: clear doc in case a stale closure still pins the buffer. - (this as unknown as { doc: Document | null }).doc = null; + this.doc = null as unknown as observerParam['doc']; } public bufferDoc(): Document | null { - return (this as unknown as { doc: Document | null }).doc; + return this.doc as Document | null; } public destroy() { diff --git a/packages/rrweb/rrweb/test/record/memory-leaks.test.ts b/packages/rrweb/rrweb/test/record/memory-leaks.test.ts index 56810767a1..4b854987b7 100644 --- a/packages/rrweb/rrweb/test/record/memory-leaks.test.ts +++ b/packages/rrweb/rrweb/test/record/memory-leaks.test.ts @@ -915,21 +915,23 @@ describe('memory leak prevention', () => { // IframeManager must capture the original contentDocument at attach time // and release it explicitly on detach. describe('IframeManager.attachedDocuments', () => { - it('walks the original contentDocument on detach, not the current one', () => { + function makeIframeManager() { const mirror = createMirror(); - const mutationCb = vi.fn(); - const wrappedEmit = vi.fn(); - const iframeManager = new IframeManager({ mirror, - mutationCb, + mutationCb: vi.fn(), stylesheetManager: { styleMirror: { generateId: vi.fn(() => 1) }, adoptStyleSheets: vi.fn(), } as any, recordCrossOriginIframes: false, - wrappedEmit, + wrappedEmit: vi.fn(), }); + return { iframeManager, mirror }; + } + + it('walks the original contentDocument on detach, not the current one', () => { + const { iframeManager, mirror } = makeIframeManager(); const iframe = document.createElement('iframe'); document.body.appendChild(iframe); @@ -988,20 +990,7 @@ describe('memory leak prevention', () => { // attachIframe runs once per load event, so we must accumulate every // doc; tracking only the most recent leaks every prior doc. it('walks every contentDocument that has been attached, not just the last one', () => { - const mirror = createMirror(); - const mutationCb = vi.fn(); - const wrappedEmit = vi.fn(); - - const iframeManager = new IframeManager({ - mirror, - mutationCb, - stylesheetManager: { - styleMirror: { generateId: vi.fn(() => 1) }, - adoptStyleSheets: vi.fn(), - } as any, - recordCrossOriginIframes: false, - wrappedEmit, - }); + const { iframeManager, mirror } = makeIframeManager(); const iframe = document.createElement('iframe'); document.body.appendChild(iframe);