Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix: clear mutation buffer and remove mirror node on iframe pagehide …
…event
  • Loading branch information
megboehlert committed Oct 27, 2025
commit dae99fd57612589c70e5f5f61565e4d80402467c
5 changes: 5 additions & 0 deletions .changeset/eleven-knives-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"rrweb": patch
---

To prevent mem leak, reset and clear mutation buffers and remove iframe node from mirror on iframes pagehide event
10 changes: 10 additions & 0 deletions packages/rrweb/src/record/iframe-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
private mutationCb: mutationCallBack;
private wrappedEmit: (e: eventWithoutTime, isCheckout?: boolean) => void;
private loadListener?: (iframeEl: HTMLIFrameElement) => unknown;
private pageHideListener?: (iframeEl: HTMLIFrameElement) => unknown;
private stylesheetManager: StylesheetManager;
private recordCrossOriginIframes: boolean;

Expand Down Expand Up @@ -58,6 +59,10 @@
this.loadListener = cb;
}

public addPageHideListener(cb: (iframeEl: HTMLIFrameElement) => unknown) {
this.pageHideListener = cb;
}

public attachIframe(
iframeEl: HTMLIFrameElement,
childSn: serializedNodeWithId,
Expand All @@ -83,6 +88,11 @@
this.handleMessage.bind(this),
);

iframeEl.contentWindow?.addEventListener('pagehide', () => {
this.pageHideListener?.(iframeEl);
this.mirror.removeNodeFromMap(iframeEl.contentDocument!);

Check warning on line 93 in packages/rrweb/src/record/iframe-manager.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/record/iframe-manager.ts#L93

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
this.crossOriginIframeMap.delete(iframeEl.contentWindow!);

Check warning on line 94 in packages/rrweb/src/record/iframe-manager.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/record/iframe-manager.ts#L94

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
});
Comment on lines +91 to +95
Copy link

Copilot AI Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pagehide event listener is added but never removed, which could cause memory leaks if the iframe element is reused or if the manager is disposed. Consider storing the listener function reference and providing a cleanup mechanism to remove it when the iframe is detached.

Copilot uses AI. Check for mistakes.
this.loadListener?.(iframeEl);

if (
Expand Down
18 changes: 16 additions & 2 deletions packages/rrweb/src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
type SlimDOMOptions,
createMirror,
} from 'rrweb-snapshot';
import { initObservers, mutationBuffers } from './observer';
import { initObservers, mutationBuffers, findAndRemoveIframeBuffer } from './observer';
import {
on,
getWindowWidth,
Expand Down Expand Up @@ -437,6 +437,10 @@

try {
const handlers: listenerHandler[] = [];
const iframeHandlersMap = new Map<
HTMLIFrameElement,
listenerHandler
>();

const observe = (doc: Document) => {
return callbackWrapper(initObservers)(
Expand Down Expand Up @@ -557,7 +561,7 @@
plugins
?.filter((p) => p.observer)
?.map((p) => ({
observer: p.observer!,

Check warning on line 564 in packages/rrweb/src/record/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/record/index.ts#L564

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
options: p.options,
callback: (payload: object) =>
wrappedEmit({
Expand All @@ -575,13 +579,22 @@

iframeManager.addLoadListener((iframeEl) => {
try {
handlers.push(observe(iframeEl.contentDocument!));
iframeHandlersMap.set(iframeEl, observe(iframeEl.contentDocument!));

Check warning on line 582 in packages/rrweb/src/record/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/record/index.ts#L582

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
Copy link

Copilot AI Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler is added to iframeHandlersMap but not to the handlers array. This creates an inconsistency where the cleanup in the return function iterates both collections separately. Consider adding the handler to both collections or documenting why this handler should only be in the map.

Suggested change
iframeHandlersMap.set(iframeEl, observe(iframeEl.contentDocument!));
const handler = observe(iframeEl.contentDocument!);
iframeHandlersMap.set(iframeEl, handler);
handlers.push(handler);

Copilot uses AI. Check for mistakes.
} catch (error) {
// TODO: handle internal error
console.warn(error);
}
});

iframeManager.addPageHideListener((iframeEl) => {
const iframeHandler = iframeHandlersMap.get(iframeEl);
if (iframeHandler) {
iframeHandler();
iframeHandlersMap.delete(iframeEl);
}
findAndRemoveIframeBuffer(iframeEl);
});

const init = () => {
takeFullSnapshot();
handlers.push(observe(document));
Expand Down Expand Up @@ -618,6 +631,7 @@
}
return () => {
handlers.forEach((h) => h());
iframeHandlersMap.forEach((h) => h());
processedNodeManager.destroy();
recording = false;
unregisterErrorHandler();
Expand Down
4 changes: 4 additions & 0 deletions packages/rrweb/src/record/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,13 +363,13 @@
};

while (this.mapRemoves.length) {
this.mirror.removeNodeFromMap(this.mapRemoves.shift()!);

Check warning on line 366 in packages/rrweb/src/record/mutation.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/record/mutation.ts#L366

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
}

for (const n of this.movedSet) {
if (
isParentRemoved(this.removesSubTreeCache, n, this.mirror) &&
!this.movedSet.has(dom.parentNode(n)!)

Check warning on line 372 in packages/rrweb/src/record/mutation.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/record/mutation.ts#L372

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
) {
continue;
}
Expand Down Expand Up @@ -521,6 +521,10 @@
this.mutationCb(payload);
};

public bufferBelongsToIframe = (iframeEl: HTMLIFrameElement) => {
return this.doc === iframeEl.contentDocument;
};

private genTextAreaValueMutation = (textarea: HTMLTextAreaElement) => {
let item = this.attributeMap.get(textarea);
if (!item) {
Expand Down Expand Up @@ -836,7 +840,7 @@
function _isParentRemoved(
removes: Set<Node>,
n: Node,
_mirror: Mirror,

Check warning on line 843 in packages/rrweb/src/record/mutation.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/record/mutation.ts#L843

[@typescript-eslint/no-unused-vars] '_mirror' is defined but never used.
): boolean {
const node: ParentNode | null = dom.parentNode(n);
if (!node) return false;
Expand Down
10 changes: 10 additions & 0 deletions packages/rrweb/src/record/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@
| IncrementalSource.TouchMove
| IncrementalSource.Drag,
) => {
const totalOffset = Date.now() - timeBaseline!;

Check warning on line 134 in packages/rrweb/src/record/observer.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/record/observer.ts#L134

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
mousemoveCb(
positions.map((p) => {
p.timeOffset -= totalOffset;
Expand Down Expand Up @@ -376,6 +376,16 @@
return on('resize', updateDimension, win);
}

export function findAndRemoveIframeBuffer(iframeEl: HTMLIFrameElement) {
for (let i = mutationBuffers.length - 1; i >= 0; i--) {
const buf = mutationBuffers[i];
if (buf.bufferBelongsToIframe(iframeEl)) {
buf.reset();
mutationBuffers.splice(i, 1);
}
}
}

export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
function initInputObserver({
Expand Down
Loading