Skip to content
5 changes: 5 additions & 0 deletions .changeset/iframe-memory-leak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': 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 eight 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.
102 changes: 76 additions & 26 deletions packages/rrweb/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,54 +346,90 @@ 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;
}

// Re-fire on any later load so iframe.src navigations get their fresh
// contentDocument serialized too. Used by both branches below.
const onSubsequentLoad = () => listener();

if (readyState !== 'complete') {
const timer = setTimeout(() => {
if (!fired) {
listener();
fired = true;
}
}, iframeLoadTimeout);
iframeEl.addEventListener('load', () => {
clearTimeout(timer);
let fired = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const fireOnce = () => {
if (fired) return;
fired = true;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
iframeEl.removeEventListener('load', onInitialLoad);
iframeEl.addEventListener('load', onSubsequentLoad);
listener();
});
return;
};
const onInitialLoad = () => fireOnce();
timer = setTimeout(fireOnce, iframeLoadTimeout);
iframeEl.addEventListener('load', onInitialLoad);
return () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
if (fired) {
iframeEl.removeEventListener('load', onSubsequentLoad);
} else {
fired = true;
iframeEl.removeEventListener('load', onInitialLoad);
}
};
}
// 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;
}
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);
};
}

const stylesheetLoadTracked = new Map<HTMLLinkElement, AbortController>();
Expand Down Expand Up @@ -1069,6 +1105,10 @@ export function serializeNodeWithId(
node: serializedElementNodeWithId,
) => unknown;
iframeLoadTimeout?: number;
onIframeListenerRegistered?: (
iframeNode: HTMLIFrameElement,
disposer: () => void,
) => void;
onStylesheetLoad?: (
linkNode: HTMLLinkElement,
node: serializedElementNodeWithId,
Expand Down Expand Up @@ -1098,6 +1138,7 @@ export function serializeNodeWithId(
onSerialize,
onIframeLoad,
iframeLoadTimeout = 5000,
onIframeListenerRegistered,
onStylesheetLoad,
stylesheetLoadTimeout = 5000,
keepIframeSrcFn = () => false,
Expand Down Expand Up @@ -1225,6 +1266,7 @@ export function serializeNodeWithId(
onSerialize,
onIframeLoad,
iframeLoadTimeout,
onIframeListenerRegistered,
onStylesheetLoad,
stylesheetLoadTimeout,
keepIframeSrcFn,
Expand Down Expand Up @@ -1269,7 +1311,7 @@ export function serializeNodeWithId(
serializedNode.type === NodeType.Element &&
serializedNode.tagName === 'iframe'
) {
onceIframeLoaded(
const iframeDisposer = onceIframeLoaded(
n as HTMLIFrameElement,
() => {
const iframeDoc = (n as HTMLIFrameElement).contentDocument;
Expand All @@ -1295,6 +1337,7 @@ export function serializeNodeWithId(
onSerialize,
onIframeLoad,
iframeLoadTimeout,
onIframeListenerRegistered,
onStylesheetLoad,
stylesheetLoadTimeout,
keepIframeSrcFn,
Expand All @@ -1312,6 +1355,7 @@ export function serializeNodeWithId(
},
iframeLoadTimeout,
);
onIframeListenerRegistered?.(n as HTMLIFrameElement, iframeDisposer);
}

// <link rel=stylesheet href=...>
Expand Down Expand Up @@ -1414,6 +1458,10 @@ function snapshot(
node: serializedElementNodeWithId,
) => unknown;
iframeLoadTimeout?: number;
onIframeListenerRegistered?: (
iframeNode: HTMLIFrameElement,
disposer: () => void,
) => void;
onStylesheetLoad?: (
linkNode: HTMLLinkElement,
node: serializedElementNodeWithId,
Expand Down Expand Up @@ -1441,6 +1489,7 @@ function snapshot(
onSerialize,
onIframeLoad,
iframeLoadTimeout,
onIframeListenerRegistered,
onStylesheetLoad,
stylesheetLoadTimeout,
keepIframeSrcFn = () => false,
Expand Down Expand Up @@ -1492,6 +1541,7 @@ function snapshot(
onSerialize,
onIframeLoad,
iframeLoadTimeout,
onIframeListenerRegistered,
onStylesheetLoad,
stylesheetLoadTimeout,
keepIframeSrcFn,
Expand Down
Loading
Loading