Skip to content

Address Iframe memory leaks#1791

Open
dhaval-valotia wants to merge 8 commits intorrweb-io:masterfrom
mixpanel:drv-sr-memory-leaks
Open

Address Iframe memory leaks#1791
dhaval-valotia wants to merge 8 commits intorrweb-io:masterfrom
mixpanel:drv-sr-memory-leaks

Conversation

@dhaval-valotia
Copy link
Copy Markdown

Fix memory leaks when iframes are removed from DOM (SRFE-8790)

  • Add proper cleanup for iframe content documents, mutation observers, and stylesheets when iframes are removed from the DOM
  • Add removeNodeFromMapPermanently to Mirror class for permanent node removal (vs temporary removal for moved nodes)

Problem: When an iframe is removed from the DOM, several resources were not being cleaned up:

  1. Mirror maps: idNodeMap had strong references to iframe's contentDocument and all its child nodes, preventing GC
  2. MutationBuffer: The buffer for the iframe's document remained in the mutationBuffers array
  3. StyleSheetMirror: Stylesheets from the iframe remained tracked, holding references to the removed document
  4. Observer handlers: The iframe's mutation observer handler remained in the handlers array

Validated against a local site that creates/destroys iframes. Noticed a ton of references being held on to stylesheets, mutation observers in as shown in the screenshots below

Screenshot 2026-02-03 at 10 08 07 AM Screenshot 2026-02-03 at 9 51 07 AM

After fix the memory is stable
Screenshot 2026-02-04 at 11 50 40 AM

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Feb 17, 2026

⚠️ No Changeset found

Latest commit: 78b1bdd

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@dhaval-valotia dhaval-valotia marked this pull request as ready for review February 17, 2026 18:48
Copilot AI review requested due to automatic review settings February 17, 2026 18:48
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 17, 2026

Size Change: +44.9 kB (+0.43%)

Total Size: 10.4 MB

Filename Size Change
packages/all/dist/all.cjs 561 kB +4.01 kB (+0.72%)
packages/all/dist/all.js 560 kB +4.01 kB (+0.72%)
packages/all/dist/all.umd.cjs 564 kB +4.01 kB (+0.72%)
packages/all/dist/all.umd.min.cjs 274 kB +1.9 kB (+0.7%)
packages/record/dist/record.cjs 381 kB +3.91 kB (+1.04%)
packages/record/dist/record.js 381 kB +3.91 kB (+1.04%)
packages/record/dist/record.umd.cjs 384 kB +3.91 kB (+1.03%)
packages/record/dist/record.umd.min.cjs 184 kB +1.86 kB (+1.02%)
packages/replay/dist/replay.cjs 390 kB +409 B (+0.1%)
packages/replay/dist/replay.js 390 kB +409 B (+0.11%)
packages/replay/dist/replay.umd.cjs 393 kB +409 B (+0.1%)
packages/replay/dist/replay.umd.min.cjs 194 kB +195 B (+0.1%)
packages/rrdom/dist/rrdom.cjs 150 kB +100 B (+0.07%)
packages/rrdom/dist/rrdom.js 149 kB +100 B (+0.07%)
packages/rrdom/dist/rrdom.umd.cjs 152 kB +100 B (+0.07%)
packages/rrdom/dist/rrdom.umd.min.cjs 74.1 kB +39 B (+0.05%)
packages/rrweb-player/dist/rrweb-player.cjs 462 kB +409 B (+0.09%)
packages/rrweb-player/dist/rrweb-player.js 461 kB +409 B (+0.09%)
packages/rrweb-player/dist/rrweb-player.umd.cjs 464 kB +409 B (+0.09%)
packages/rrweb-player/dist/rrweb-player.umd.min.cjs 224 kB +195 B (+0.09%)
packages/rrweb-snapshot/dist/rrweb-snapshot.cjs 165 kB +88 B (+0.05%)
packages/rrweb-snapshot/dist/rrweb-snapshot.js 164 kB +88 B (+0.05%)
packages/rrweb-snapshot/dist/rrweb-snapshot.umd.cjs 167 kB +88 B (+0.05%)
packages/rrweb-snapshot/dist/rrweb-snapshot.umd.min.cjs 82 kB +37 B (+0.05%)
packages/rrweb/dist/rrweb.cjs 543 kB +4.01 kB (+0.74%)
packages/rrweb/dist/rrweb.js 543 kB +4.01 kB (+0.74%)
packages/rrweb/dist/rrweb.umd.cjs 545 kB +4.01 kB (+0.74%)
packages/rrweb/dist/rrweb.umd.min.cjs 264 kB +1.9 kB (+0.72%)
ℹ️ View Unchanged
Filename Size
packages/packer/dist/base-B40z8PPs.cjs 18.3 kB
packages/packer/dist/base-B40z8PPs.umd.cjs 19.4 kB
packages/packer/dist/base-B40z8PPs.umd.min.cjs 10.1 kB
packages/packer/dist/base-BrE4jft0.js 18.2 kB
packages/packer/dist/pack.cjs 347 B
packages/packer/dist/pack.js 285 B
packages/packer/dist/pack.umd.cjs 2.25 kB
packages/packer/dist/pack.umd.min.cjs 1.73 kB
packages/packer/dist/packer.cjs 257 B
packages/packer/dist/packer.js 136 B
packages/packer/dist/packer.umd.cjs 1.28 kB
packages/packer/dist/packer.umd.min.cjs 1.25 kB
packages/packer/dist/unpack.cjs 769 B
packages/packer/dist/unpack.js 702 B
packages/packer/dist/unpack.umd.cjs 1.79 kB
packages/packer/dist/unpack.umd.min.cjs 1.57 kB
packages/plugins/rrweb-plugin-canvas-webrtc-record/dist/rrweb-plugin-canvas-webrtc-record.cjs 37.6 kB
packages/plugins/rrweb-plugin-canvas-webrtc-record/dist/rrweb-plugin-canvas-webrtc-record.js 37.5 kB
packages/plugins/rrweb-plugin-canvas-webrtc-record/dist/rrweb-plugin-canvas-webrtc-record.umd.cjs 38.7 kB
packages/plugins/rrweb-plugin-canvas-webrtc-record/dist/rrweb-plugin-canvas-webrtc-record.umd.min.cjs 22.9 kB
packages/plugins/rrweb-plugin-canvas-webrtc-replay/dist/rrweb-plugin-canvas-webrtc-replay.cjs 34.3 kB
packages/plugins/rrweb-plugin-canvas-webrtc-replay/dist/rrweb-plugin-canvas-webrtc-replay.js 34.2 kB
packages/plugins/rrweb-plugin-canvas-webrtc-replay/dist/rrweb-plugin-canvas-webrtc-replay.umd.cjs 35.4 kB
packages/plugins/rrweb-plugin-canvas-webrtc-replay/dist/rrweb-plugin-canvas-webrtc-replay.umd.min.cjs 21.2 kB
packages/plugins/rrweb-plugin-console-record/dist/rrweb-plugin-console-record.cjs 15 kB
packages/plugins/rrweb-plugin-console-record/dist/rrweb-plugin-console-record.js 14.9 kB
packages/plugins/rrweb-plugin-console-record/dist/rrweb-plugin-console-record.umd.cjs 16.1 kB
packages/plugins/rrweb-plugin-console-record/dist/rrweb-plugin-console-record.umd.min.cjs 8.03 kB
packages/plugins/rrweb-plugin-console-replay/dist/rrweb-plugin-console-replay.cjs 5.01 kB
packages/plugins/rrweb-plugin-console-replay/dist/rrweb-plugin-console-replay.js 4.9 kB
packages/plugins/rrweb-plugin-console-replay/dist/rrweb-plugin-console-replay.umd.cjs 6.07 kB
packages/plugins/rrweb-plugin-console-replay/dist/rrweb-plugin-console-replay.umd.min.cjs 3.27 kB
packages/plugins/rrweb-plugin-sequential-id-record/dist/rrweb-plugin-sequential-id-record.cjs 681 B
packages/plugins/rrweb-plugin-sequential-id-record/dist/rrweb-plugin-sequential-id-record.js 548 B
packages/plugins/rrweb-plugin-sequential-id-record/dist/rrweb-plugin-sequential-id-record.umd.cjs 1.76 kB
packages/plugins/rrweb-plugin-sequential-id-record/dist/rrweb-plugin-sequential-id-record.umd.min.cjs 1.47 kB
packages/plugins/rrweb-plugin-sequential-id-replay/dist/rrweb-plugin-sequential-id-replay.cjs 933 B
packages/plugins/rrweb-plugin-sequential-id-replay/dist/rrweb-plugin-sequential-id-replay.js 820 B
packages/plugins/rrweb-plugin-sequential-id-replay/dist/rrweb-plugin-sequential-id-replay.umd.cjs 2.01 kB
packages/plugins/rrweb-plugin-sequential-id-replay/dist/rrweb-plugin-sequential-id-replay.umd.min.cjs 1.61 kB
packages/replay/dist/style.css 2.45 kB
packages/replay/dist/style.min.css 1.97 kB
packages/rrdom-nodejs/dist/rrdom-nodejs.cjs 132 kB
packages/rrdom-nodejs/dist/rrdom-nodejs.js 131 kB
packages/rrdom-nodejs/dist/rrdom-nodejs.umd.cjs 134 kB
packages/rrdom-nodejs/dist/rrdom-nodejs.umd.min.cjs 66.9 kB
packages/rrweb-player/dist/events.js 159 kB
packages/rrweb-player/dist/global.css 240 B
packages/rrweb-player/dist/style.css 5.57 kB
packages/rrweb-player/dist/style.min.css 5 kB
packages/rrweb/dist/style.css 2.45 kB
packages/rrweb/dist/style.min.css 1.97 kB
packages/types/dist/types.cjs 5.63 kB
packages/types/dist/types.js 5.38 kB
packages/types/dist/types.umd.cjs 6.66 kB
packages/types/dist/types.umd.min.cjs 3.41 kB
packages/utils/dist/utils.cjs 6.07 kB
packages/utils/dist/utils.js 5.57 kB
packages/utils/dist/utils.umd.cjs 7.1 kB
packages/utils/dist/utils.umd.min.cjs 3.98 kB

compressed-size-action

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses memory leaks when iframes are removed by ensuring rrweb stops tracking iframe documents and related resources (mirror node references, mutation buffers, observer handlers, and adopted stylesheets).

Changes:

  • Extend IMirror.removeNodeFromMap with an optional permanent flag and implement permanent removal in rrweb-snapshot’s Mirror.
  • Add StyleSheetMirror.remove() and wire stylesheet cleanup into mutation processing and iframe teardown.
  • Add iframe-specific cleanup plumbing (stored iframe documents, observer cleanup callbacks, and mutation buffer removal) plus unit tests for the new mirrors’ behaviors.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/types/src/index.ts Updates IMirror API to support optional permanent removals.
packages/rrweb/src/utils.ts Adds StyleSheetMirror.remove() for releasing stylesheet references.
packages/rrweb/src/record/stylesheet-manager.ts Adds recursive cleanup to untrack stylesheets for removed nodes/docs.
packages/rrweb/src/record/observer.ts Adds removeMutationBufferForDoc() to drop buffers associated with removed iframe documents.
packages/rrweb/src/record/mutation.ts Hooks iframe removal and non-iframe node removal into stylesheet + iframe cleanup paths.
packages/rrweb/src/record/index.ts Tracks per-iframe observer cleanup and attempts to remove handlers/buffers on iframe teardown.
packages/rrweb/src/record/iframe-manager.ts Stores iframe content documents and observer cleanup callbacks; performs iframe teardown.
packages/rrweb/test/util.test.ts Adds unit tests covering StyleSheetMirror.remove().
packages/rrweb-snapshot/src/utils.ts Implements permanent behavior in Mirror.removeNodeFromMap.
packages/rrweb-snapshot/test/utils.test.ts Adds unit tests for Mirror.removeNodeFromMap(..., true) semantics.
packages/rrdom/src/index.ts Adapts rrdom Mirror.removeNodeFromMap signature to match updated IMirror.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +595 to +601
try {
iframeHandler();
const idx = handlers.indexOf(iframeHandler);
if (idx !== -1) handlers.splice(idx, 1);
removeMutationBufferForDoc(iframeDoc);
} catch (e) {
// Ignore errors during cleanup
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

In the iframe observer cleanup, iframeHandler() can throw (e.g., the documented cross-origin transition case). Because handler removal from handlers and removeMutationBufferForDoc(iframeDoc) are inside the same try, an exception prevents cleanup and can leave both the handler and mutation buffer retained. Restructure so array removal + mutationBuffer removal always happen (e.g., try { iframeHandler(); } finally { ... }), and consider removing the handler from handlers before invoking it so it can’t be skipped on error.

Suggested change
try {
iframeHandler();
const idx = handlers.indexOf(iframeHandler);
if (idx !== -1) handlers.splice(idx, 1);
removeMutationBufferForDoc(iframeDoc);
} catch (e) {
// Ignore errors during cleanup
// Remove handler from the list before invoking it so it cannot be skipped on error
const idx = handlers.indexOf(iframeHandler);
if (idx !== -1) handlers.splice(idx, 1);
try {
iframeHandler();
} catch (e) {
// Ignore errors during cleanup
} finally {
// Always remove the mutation buffer for this iframe document
try {
removeMutationBufferForDoc(iframeDoc);
} catch (e) {
// Ignore errors during cleanup
}

Copilot uses AI. Check for mistakes.
Comment on lines +93 to +126
try {
if (removedNode.nodeType === Node.DOCUMENT_NODE) {
const doc = removedNode as Document;
if (doc.adoptedStyleSheets) {
for (const sheet of doc.adoptedStyleSheets) {
this.styleMirror.remove(sheet);
}
}
}

if (removedNode.nodeName === 'STYLE') {
const styleEl = removedNode as HTMLStyleElement;
if (styleEl.sheet) {
this.styleMirror.remove(styleEl.sheet);
}
}

if (
removedNode.nodeName === 'LINK' &&
(removedNode as HTMLLinkElement).rel === 'stylesheet'
) {
const linkEl = removedNode as HTMLLinkElement;
if (linkEl.sheet) {
this.styleMirror.remove(linkEl.sheet);
}
}

if (removedNode.childNodes) {
removedNode.childNodes.forEach((child) => {
this.cleanupStylesheetsForRemovedNode(child);
});
}
} catch (e) {
// Ignore errors
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

cleanupStylesheetsForRemovedNode wraps the entire traversal in a single try/catch. If one access throws (e.g., reading adoptedStyleSheets or sheet), the function returns early and skips cleanup for the rest of the node/subtree, which can undermine the leak fix. Prefer narrower try/catch blocks (or a try/finally for the recursion) so failures in one branch don’t prevent other cleanup steps from running.

Suggested change
try {
if (removedNode.nodeType === Node.DOCUMENT_NODE) {
const doc = removedNode as Document;
if (doc.adoptedStyleSheets) {
for (const sheet of doc.adoptedStyleSheets) {
this.styleMirror.remove(sheet);
}
}
}
if (removedNode.nodeName === 'STYLE') {
const styleEl = removedNode as HTMLStyleElement;
if (styleEl.sheet) {
this.styleMirror.remove(styleEl.sheet);
}
}
if (
removedNode.nodeName === 'LINK' &&
(removedNode as HTMLLinkElement).rel === 'stylesheet'
) {
const linkEl = removedNode as HTMLLinkElement;
if (linkEl.sheet) {
this.styleMirror.remove(linkEl.sheet);
}
}
if (removedNode.childNodes) {
removedNode.childNodes.forEach((child) => {
this.cleanupStylesheetsForRemovedNode(child);
});
}
} catch (e) {
// Ignore errors
if (removedNode.nodeType === Node.DOCUMENT_NODE) {
const doc = removedNode as Document;
try {
const adoptedSheets = (doc as any).adoptedStyleSheets as
| CSSStyleSheet[]
| readonly CSSStyleSheet[]
| undefined;
if (adoptedSheets) {
for (const sheet of adoptedSheets) {
try {
this.styleMirror.remove(sheet);
} catch {
// Ignore errors from removing individual adopted stylesheets
}
}
}
} catch {
// Ignore errors from accessing adoptedStyleSheets
}
}
if (removedNode.nodeName === 'STYLE') {
const styleEl = removedNode as HTMLStyleElement;
try {
const sheet = styleEl.sheet;
if (sheet) {
this.styleMirror.remove(sheet);
}
} catch {
// Ignore errors from accessing or removing style element sheet
}
}
if (
removedNode.nodeName === 'LINK' &&
(removedNode as HTMLLinkElement).rel === 'stylesheet'
) {
const linkEl = removedNode as HTMLLinkElement;
try {
const sheet = linkEl.sheet;
if (sheet) {
this.styleMirror.remove(sheet);
}
} catch {
// Ignore errors from accessing or removing linked stylesheet
}
}
if (removedNode.childNodes) {
removedNode.childNodes.forEach((child) => {
try {
this.cleanupStylesheetsForRemovedNode(child);
} catch {
// Ignore errors from cleaning up a particular child and continue
}
});

Copilot uses AI. Check for mistakes.

this.iframes.delete(iframeEl);
this.iframeContentDocumentMap.delete(iframeEl);

Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

removeIframe clears iframes and other maps, but it doesn’t remove the crossOriginIframeMap entry created in addIframe (contentWindow -> iframeEl). If the iframe’s contentWindow remains reachable for a while after DOM removal, that WeakMap entry will keep a strong reference to the removed iframeEl via the value. Consider deleting the mapping as part of cleanup (e.g., iframeEl.contentWindow && this.crossOriginIframeMap.delete(iframeEl.contentWindow)).

Suggested change
const contentWindow = iframeEl.contentWindow;
if (contentWindow && this.crossOriginIframeMap) {
this.crossOriginIframeMap.delete(contentWindow);
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants