Conversation
… into drv-sr-memory-leaks
|
|
Size Change: +44.9 kB (+0.43%) Total Size: 10.4 MB
ℹ️ View Unchanged
|
There was a problem hiding this comment.
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.removeNodeFromMapwith an optionalpermanentflag and implement permanent removal in rrweb-snapshot’sMirror. - 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.
| try { | ||
| iframeHandler(); | ||
| const idx = handlers.indexOf(iframeHandler); | ||
| if (idx !== -1) handlers.splice(idx, 1); | ||
| removeMutationBufferForDoc(iframeDoc); | ||
| } catch (e) { | ||
| // Ignore errors during cleanup |
There was a problem hiding this comment.
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.
| 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 | |
| } |
| 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 |
There was a problem hiding this comment.
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.
| 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 | |
| } | |
| }); |
|
|
||
| this.iframes.delete(iframeEl); | ||
| this.iframeContentDocumentMap.delete(iframeEl); | ||
|
|
There was a problem hiding this comment.
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)).
| const contentWindow = iframeEl.contentWindow; | |
| if (contentWindow && this.crossOriginIframeMap) { | |
| this.crossOriginIframeMap.delete(contentWindow); | |
| } |
Fix memory leaks when iframes are removed from DOM (SRFE-8790)
Problem: When an iframe is removed from the DOM, several resources were not being cleaned up:
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
After fix the memory is stable
