From 86f7c913f3c5c23a858407aca13ad1ea677b3f9b Mon Sep 17 00:00:00 2001 From: frostfire575 <191205836+frostfire575@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:25:21 +0600 Subject: [PATCH 1/2] fix(rrweb): release removed shadow DOM hosts from the recorder Port the shadow-root release from rrweb-io/rrweb#1958 (as shipped in rrweb 2.2.0), adapted to the fork's per-document teardown. Fixes #5419. --- .changeset/release-detached-shadow-roots.md | 5 + packages/rrweb/rrweb/src/record/mutation.ts | 4 + .../rrweb/src/record/shadow-dom-manager.ts | 100 +++++++----- packages/rrweb/rrweb/test/record.test.ts | 41 +++++ .../test/record/shadow-dom-manager.test.ts | 70 ++++++++ .../test/record/shadow-root-release.test.ts | 154 ++++++++++++++++++ 6 files changed, 337 insertions(+), 37 deletions(-) create mode 100644 .changeset/release-detached-shadow-roots.md create mode 100644 packages/rrweb/rrweb/test/record/shadow-root-release.test.ts diff --git a/.changeset/release-detached-shadow-roots.md b/.changeset/release-detached-shadow-roots.md new file mode 100644 index 0000000000..ecea585763 --- /dev/null +++ b/.changeset/release-detached-shadow-roots.md @@ -0,0 +1,5 @@ +--- +'posthog-js': patch +--- + +Session replay releases a removed shadow DOM host as soon as it leaves the page, instead of keeping it and its detached subtree in memory until the next full snapshot. diff --git a/packages/rrweb/rrweb/src/record/mutation.ts b/packages/rrweb/rrweb/src/record/mutation.ts index 971cc9b7f5..9fbac856a2 100644 --- a/packages/rrweb/rrweb/src/record/mutation.ts +++ b/packages/rrweb/rrweb/src/record/mutation.ts @@ -882,6 +882,10 @@ export default class MutationBuffer { m.addedNodes.forEach((n) => this.genAdds(n, m.target)); m.removedNodes.forEach((n) => { + // Still attached by the time the batch is processed means moved, not removed. + if (!inDom(n)) { + this.shadowDomManager.removeShadowRootsIn(n); + } const nodeId = this.mirror.getId(n); const parentId = isShadowRoot(m.target) ? this.mirror.getId(dom.host(m.target)) diff --git a/packages/rrweb/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/rrweb/src/record/shadow-dom-manager.ts index 6bc47381c1..2154704190 100644 --- a/packages/rrweb/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/rrweb/src/record/shadow-dom-manager.ts @@ -1,5 +1,6 @@ import type { MutationBufferParam } from '../types'; import type { + listenerHandler, mutationCallBack, scrollCallback, SamplingStrategy, @@ -22,14 +23,17 @@ type BypassOptions = Omit< sampling: SamplingStrategy; }; +type ObservedShadowRoot = { doc: Document; handlers: listenerHandler[] }; + export class ShadowDomManager { - private shadowDoms = new WeakSet(); + // Strong refs, so each root's entry must be dropped when its host is removed. + private shadowDoms = new Map(); private mutationCb: mutationCallBack; private scrollCb: scrollCallback; private bypassOptions: BypassOptions; private mirror: Mirror; - // Handlers are tagged with the document that owns their shadow root so a - // single iframe can be torn down without disconnecting the rest of the page. + // attachShadow patches, tagged with the document they patch so a single + // iframe can be torn down without disconnecting the rest of the page. private restoreHandlers: { doc: Document; handler: () => void }[] = []; constructor(options: { @@ -55,11 +59,12 @@ export class ShadowDomManager { public addShadowRoot(shadowRoot: ShadowRoot, doc: Document): void { if (!isNativeShadowDom(shadowRoot)) return; if (this.shadowDoms.has(shadowRoot)) return; - this.shadowDoms.add(shadowRoot); // Derive the owning document from the host so a shadow root nested in an // iframe is keyed to that iframe's document, not whatever the caller passed // (takeFullSnapshot's onSerialize hands us the top-level document). const ownerDoc = dom.host(shadowRoot)?.ownerDocument ?? doc; + const observed: ObservedShadowRoot = { doc: ownerDoc, handlers: [] }; + this.shadowDoms.set(shadowRoot, observed); const { observer, buffer } = initMutationObserver( { ...this.bypassOptions, @@ -70,22 +75,18 @@ export class ShadowDomManager { }, shadowRoot, ); - this.restoreHandlers.push({ - doc: ownerDoc, - handler: () => { - observer.disconnect(); - buffer.destroy(); - // Release the canvas directly, not via buffer.reset(), per the recursion-guard unit test. - buffer.releaseCanvasManager(); - const index = mutationBuffers.indexOf(buffer); - if (index !== -1) { - mutationBuffers.splice(index, 1); - } - }, + observed.handlers.push(() => { + observer.disconnect(); + buffer.destroy(); + // Release the canvas directly, not via buffer.reset(), per the recursion-guard unit test. + buffer.releaseCanvasManager(); + const index = mutationBuffers.indexOf(buffer); + if (index !== -1) { + mutationBuffers.splice(index, 1); + } }); - this.restoreHandlers.push({ - doc: ownerDoc, - handler: initScrollObserver({ + observed.handlers.push( + initScrollObserver({ ...this.bypassOptions, scrollCb: this.scrollCb, // https://gist.github.com/praveenpuglia/0832da687ed5a5d7a0907046c9ef1813 @@ -93,9 +94,11 @@ export class ShadowDomManager { doc: shadowRoot as unknown as Document, mirror: this.mirror, }), - }); + ); // Defer this to avoid adoptedStyleSheet events being created before the full snapshot is created or attachShadow action is recorded. setTimeout(() => { + // The root was released, or the manager reset, in the meantime. + if (this.shadowDoms.get(shadowRoot) !== observed) return; if ( shadowRoot.adoptedStyleSheets && shadowRoot.adoptedStyleSheets.length > 0 @@ -104,19 +107,37 @@ export class ShadowDomManager { shadowRoot.adoptedStyleSheets, this.mirror.getId(dom.host(shadowRoot)), ); - this.restoreHandlers.push({ - doc: ownerDoc, - handler: initAdoptedStyleSheetObserver( + observed.handlers.push( + initAdoptedStyleSheetObserver( { mirror: this.mirror, stylesheetManager: this.bypassOptions.stylesheetManager, }, shadowRoot, ), - }); + ); }, 0); } + // Release every observed shadow root in a subtree that has left the DOM. + public removeShadowRootsIn(n: Node): void { + if (this.shadowDoms.size === 0) return; + const shadowRoot = dom.shadowRoot(n); + if (shadowRoot) { + if (this.shadowDoms.has(shadowRoot)) { + this.removeShadowRoot(shadowRoot); + } + this.removeShadowRootsIn(shadowRoot); + } + dom.childNodes(n).forEach((child) => this.removeShadowRootsIn(child)); + } + + private removeShadowRoot(shadowRoot: ShadowRoot): void { + const observed = this.shadowDoms.get(shadowRoot); + this.shadowDoms.delete(shadowRoot); + observed?.handlers.forEach(runHandler); + } + /** * Monkey patch 'attachShadow' of an IFrameElement to observe newly added shadow doms. */ @@ -163,15 +184,11 @@ export class ShadowDomManager { } public reset(): void { - this.restoreHandlers.forEach(({ handler }) => { - try { - handler(); - } catch (e) { - // - } - }); + this.restoreHandlers.forEach(({ handler }) => runHandler(handler)); this.restoreHandlers = []; - this.shadowDoms = new WeakSet(); + const shadowDoms = this.shadowDoms; + this.shadowDoms = new Map(); + shadowDoms.forEach(({ handlers }) => handlers.forEach(runHandler)); } // Tear down only the shadow observers owned by `doc` (e.g. one iframe being removed), leaving the rest of the page's shadow observation intact. @@ -179,15 +196,24 @@ export class ShadowDomManager { const remaining: { doc: Document; handler: () => void }[] = []; for (const entry of this.restoreHandlers) { if (entry.doc === doc) { - try { - entry.handler(); - } catch (e) { - // - } + runHandler(entry.handler); } else { remaining.push(entry); } } this.restoreHandlers = remaining; + this.shadowDoms.forEach((observed, shadowRoot) => { + if (observed.doc === doc) { + this.removeShadowRoot(shadowRoot); + } + }); + } +} + +function runHandler(handler: listenerHandler) { + try { + handler(); + } catch (e) { + // } } diff --git a/packages/rrweb/rrweb/test/record.test.ts b/packages/rrweb/rrweb/test/record.test.ts index 2b06f61007..c28f1e54d8 100644 --- a/packages/rrweb/rrweb/test/record.test.ts +++ b/packages/rrweb/rrweb/test/record.test.ts @@ -1317,6 +1317,47 @@ describe('record', function (this: ISuite) { expect(serializedEvents).not.toContain('updated-private-value'); }); + it('keeps recording shadow content of a host that is moved or re-inserted', async () => { + await ctx.page.evaluate(async () => { + const { record } = (window as unknown as IWindow).rrweb; + record({ emit: (window as unknown as IWindow).emit }); + const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); + + const host = document.createElement('div'); + host.attachShadow({ mode: 'open' }).innerHTML = 'initial'; + const other = document.createElement('section'); + document.body.append(host, other); + await settle(); + const span = host.shadowRoot!.querySelector('span')!; + + // Removed and re-attached within one mutation batch. + host.remove(); + other.appendChild(host); + await settle(); + span.textContent = 'after-move'; + await settle(); + + // Removed, then re-inserted in a later batch. + host.remove(); + await settle(); + document.body.appendChild(host); + await settle(); + span.textContent = 'after-reinsert'; + await settle(); + + host.remove(); + await settle(); + span.textContent = 'after-final-removal'; + await settle(); + }); + await waitForRAF(ctx.page); + + const serializedEvents = JSON.stringify(ctx.events); + expect(serializedEvents).toContain('after-move'); + expect(serializedEvents).toContain('after-reinsert'); + expect(serializedEvents).not.toContain('after-final-removal'); + }); + it('records mouse movement inside a same-origin iframe as MouseMove', async () => { await ctx.page.evaluate(async () => { const iframe = document.createElement('iframe'); diff --git a/packages/rrweb/rrweb/test/record/shadow-dom-manager.test.ts b/packages/rrweb/rrweb/test/record/shadow-dom-manager.test.ts index 0f712fe8fc..2289954be9 100644 --- a/packages/rrweb/rrweb/test/record/shadow-dom-manager.test.ts +++ b/packages/rrweb/rrweb/test/record/shadow-dom-manager.test.ts @@ -216,4 +216,74 @@ describe('ShadowDomManager', () => { manager.resetForDoc(iframeDoc); expect(mutationBuffers.length).toBe(buffersBeforeAdd); }); + + it('removeShadowRootsIn() releases only the roots inside the removed subtree', () => { + const manager = createManager(); + + const removed = document.createElement('div'); + const kept = document.createElement('div'); + elements.push(removed, kept); + document.body.append(removed, kept); + + const buffersBeforeAdd = mutationBuffers.length; + removed.attachShadow({ mode: 'open' }); + kept.attachShadow({ mode: 'open' }); + expect(mutationBuffers.length).toBe(buffersBeforeAdd + 2); + + removed.remove(); + manager.removeShadowRootsIn(removed); + expect(mutationBuffers.length).toBe(buffersBeforeAdd + 1); + + // A second pass over the same subtree is a no-op. + manager.removeShadowRootsIn(removed); + expect(mutationBuffers.length).toBe(buffersBeforeAdd + 1); + }); + + it('resetForDoc() still releases iframe roots after a top-level host was released', () => { + const manager = createManager(); + + const topHost = document.createElement('div'); + const iframe = document.createElement('iframe'); + elements.push(topHost, iframe); + document.body.append(topHost, iframe); + const iframeDoc = iframe.contentDocument as Document; + const iframeHost = iframeDoc.createElement('div'); + iframeDoc.body.appendChild(iframeHost); + + const buffersBeforeAdd = mutationBuffers.length; + manager.addShadowRoot(topHost.attachShadow({ mode: 'open' }), document); + manager.addShadowRoot(iframeHost.attachShadow({ mode: 'open' }), document); + expect(mutationBuffers.length).toBe(buffersBeforeAdd + 2); + + topHost.remove(); + manager.removeShadowRootsIn(topHost); + expect(mutationBuffers.length).toBe(buffersBeforeAdd + 1); + + manager.resetForDoc(iframeDoc); + expect(mutationBuffers.length).toBe(buffersBeforeAdd); + }); + + it('resetForDoc() leaves top-level roots observed when an iframe is torn down', () => { + const manager = createManager(); + + const topHost = document.createElement('div'); + const iframe = document.createElement('iframe'); + elements.push(topHost, iframe); + document.body.append(topHost, iframe); + const iframeDoc = iframe.contentDocument as Document; + const iframeHost = iframeDoc.createElement('div'); + iframeDoc.body.appendChild(iframeHost); + + const buffersBeforeAdd = mutationBuffers.length; + manager.addShadowRoot(topHost.attachShadow({ mode: 'open' }), document); + manager.addShadowRoot(iframeHost.attachShadow({ mode: 'open' }), document); + + manager.resetForDoc(iframeDoc); + expect(mutationBuffers.length).toBe(buffersBeforeAdd + 1); + + // The top-level root is still tracked, so it can still be released on removal. + topHost.remove(); + manager.removeShadowRootsIn(topHost); + expect(mutationBuffers.length).toBe(buffersBeforeAdd); + }); }); diff --git a/packages/rrweb/rrweb/test/record/shadow-root-release.test.ts b/packages/rrweb/rrweb/test/record/shadow-root-release.test.ts new file mode 100644 index 0000000000..d52ee83aa1 --- /dev/null +++ b/packages/rrweb/rrweb/test/record/shadow-root-release.test.ts @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// jsdom's ShadowRoot doesn't pass isNativeShadowDom's toString check. +vi.mock('@posthog/rrweb-snapshot', async () => { + const actual = await vi.importActual('@posthog/rrweb-snapshot'); + return { + ...actual, + isNativeShadowDom: () => true, + }; +}); + +import { EventType, IncrementalSource } from '@posthog/rrweb-types'; +import type { eventWithTime } from '@posthog/rrweb-types'; +import record from '../../src/record'; +import { mutationBuffers } from '../../src/record/observer'; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); + +// jsdom also observes rrweb's untainted-prototype helper iframes, so only +// count buffers recording the top-level document (its own and its shadow roots). +const pageBuffers = () => + mutationBuffers.filter((buffer) => buffer.bufferDoc() === document); + +function createHost(): HTMLElement { + const host = document.createElement('price-tag'); + host.attachShadow({ mode: 'open' }).innerHTML = '$10'; + return host; +} + +describe('releasing removed shadow hosts', () => { + let stop: (() => void) | undefined; + let events: eventWithTime[]; + + const addedTexts = () => + events.flatMap((event) => + event.type === EventType.IncrementalSnapshot && + event.data.source === IncrementalSource.Mutation + ? event.data.adds.map((add) => + 'textContent' in add.node ? add.node.textContent : '', + ) + : [], + ); + + beforeEach(async () => { + document.body.innerHTML = + '
'; + events = []; + stop = record({ emit: (event) => events.push(event) }); + await settle(); + }); + + afterEach(() => { + stop?.(); + stop = undefined; + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('releases the shadow observer when a top-level host is removed', async () => { + const baseline = pageBuffers().length; + const host = createHost(); + document.getElementById('page')!.appendChild(host); + await settle(); + expect(pageBuffers().length).toBe(baseline + 1); + const shadowBuffer = pageBuffers().at(-1); + + host.remove(); + await settle(); + + expect(pageBuffers()).not.toContain(shadowBuffer); + expect(pageBuffers().length).toBe(baseline); + }); + + it('does not grow one observer per removed host across repeated mounts', async () => { + const baseline = pageBuffers().length; + const page = document.getElementById('page')!; + for (let i = 0; i < 10; i++) { + const host = createHost(); + page.appendChild(host); + await settle(); + host.remove(); + await settle(); + } + expect(pageBuffers().length).toBe(baseline); + }); + + it('releases shadow observers nested inside a removed host', async () => { + const baseline = pageBuffers().length; + const host = createHost(); + host.shadowRoot!.appendChild(createHost()); + document.getElementById('page')!.appendChild(host); + await settle(); + expect(pageBuffers().length).toBe(baseline + 2); + + host.remove(); + await settle(); + + expect(pageBuffers().length).toBe(baseline); + }); + + it('keeps observing a host moved within the same mutation batch', async () => { + const baseline = pageBuffers().length; + const host = createHost(); + document.getElementById('page')!.appendChild(host); + await settle(); + const shadowBuffer = pageBuffers().at(-1); + + // Removal and insertion land in one MutationObserver callback. + host.remove(); + document.getElementById('other')!.appendChild(host); + await settle(); + + expect(pageBuffers()).toContain(shadowBuffer); + expect(pageBuffers().length).toBe(baseline + 1); + + host.shadowRoot!.appendChild(document.createTextNode('after-move')); + await settle(); + expect(addedTexts()).toContain('after-move'); + }); + + it('observes a host again when it is re-inserted after removal', async () => { + const baseline = pageBuffers().length; + const host = createHost(); + const page = document.getElementById('page')!; + page.appendChild(host); + await settle(); + + host.remove(); + await settle(); + expect(pageBuffers().length).toBe(baseline); + + page.appendChild(host); + await settle(); + expect(pageBuffers().length).toBe(baseline + 1); + + host.shadowRoot!.appendChild(document.createTextNode('after-reinsert')); + await settle(); + expect(addedTexts()).toContain('after-reinsert'); + }); + + it('releases a removed host even when it was moved earlier in the same batch', async () => { + const baseline = pageBuffers().length; + const host = createHost(); + document.getElementById('page')!.appendChild(host); + await settle(); + + document.getElementById('other')!.appendChild(host); + host.remove(); + await settle(); + + expect(pageBuffers().length).toBe(baseline); + }); +}); From 6b588a3b5a6b4968615eb8e5c5b98465061c0ed1 Mon Sep 17 00:00:00 2001 From: frostfire575 <191205836+frostfire575@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:38:28 +0600 Subject: [PATCH 2/2] fix(rrweb): release shadow hosts removed from blocked containers Also adds removeShadowRootsIn to the shadowDomManager test mocks and a frozen remove/re-insert recording test. --- packages/rrweb/rrweb/src/record/mutation.ts | 13 ++++--- packages/rrweb/rrweb/test/record.test.ts | 35 ++++++++++++++++--- .../test/record/move-observer-source.test.ts | 1 + .../test/record/observer-teardown.test.ts | 1 + .../test/record/shadow-root-release.test.ts | 16 +++++++++ 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/rrweb/rrweb/src/record/mutation.ts b/packages/rrweb/rrweb/src/record/mutation.ts index 9fbac856a2..188318a918 100644 --- a/packages/rrweb/rrweb/src/record/mutation.ts +++ b/packages/rrweb/rrweb/src/record/mutation.ts @@ -868,6 +868,15 @@ export default class MutationBuffer { break; } case 'childList': { + // Runs before the blocked-parent return so hosts removed from a + // blocked container are released too. Still attached by the time the + // batch is processed means moved, not removed. + m.removedNodes.forEach((n) => { + if (!inDom(n)) { + this.shadowDomManager.removeShadowRootsIn(n); + } + }); + /** * Parent is blocked, ignore all child mutations */ @@ -882,10 +891,6 @@ export default class MutationBuffer { m.addedNodes.forEach((n) => this.genAdds(n, m.target)); m.removedNodes.forEach((n) => { - // Still attached by the time the batch is processed means moved, not removed. - if (!inDom(n)) { - this.shadowDomManager.removeShadowRootsIn(n); - } const nodeId = this.mirror.getId(n); const parentId = isShadowRoot(m.target) ? this.mirror.getId(dom.host(m.target)) diff --git a/packages/rrweb/rrweb/test/record.test.ts b/packages/rrweb/rrweb/test/record.test.ts index c28f1e54d8..a39d29d8f6 100644 --- a/packages/rrweb/rrweb/test/record.test.ts +++ b/packages/rrweb/rrweb/test/record.test.ts @@ -1344,18 +1344,45 @@ describe('record', function (this: ISuite) { await settle(); span.textContent = 'after-reinsert'; await settle(); + }); + await waitForRAF(ctx.page); + + const serializedEvents = JSON.stringify(ctx.events); + expect(serializedEvents).toContain('after-move'); + expect(serializedEvents).toContain('after-reinsert'); + }); + + it('keeps shadow changes made while frozen on a host removed and re-inserted', async () => { + await ctx.page.evaluate(async () => { + const { record, freezePage } = (window as unknown as IWindow).rrweb; + record({ emit: (window as unknown as IWindow).emit }); + const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); + + const host = document.createElement('div'); + host.attachShadow({ mode: 'open' }).innerHTML = 'initial'; + document.body.appendChild(host); + await settle(); + const span = host.shadowRoot!.querySelector('span')!; + freezePage(); + span.textContent = 'while-frozen'; + await settle(); host.remove(); await settle(); - span.textContent = 'after-final-removal'; + document.body.appendChild(host); + await settle(); + // 'unfreeze' happens upon a user event + document.body.click(); + await settle(); + + span.textContent = 'after-unfreeze'; await settle(); }); await waitForRAF(ctx.page); const serializedEvents = JSON.stringify(ctx.events); - expect(serializedEvents).toContain('after-move'); - expect(serializedEvents).toContain('after-reinsert'); - expect(serializedEvents).not.toContain('after-final-removal'); + expect(serializedEvents).toContain('while-frozen'); + expect(serializedEvents).toContain('after-unfreeze'); }); it('records mouse movement inside a same-origin iframe as MouseMove', async () => { diff --git a/packages/rrweb/rrweb/test/record/move-observer-source.test.ts b/packages/rrweb/rrweb/test/record/move-observer-source.test.ts index 65121d9b48..33365fd519 100644 --- a/packages/rrweb/rrweb/test/record/move-observer-source.test.ts +++ b/packages/rrweb/rrweb/test/record/move-observer-source.test.ts @@ -53,6 +53,7 @@ const createOptions = (doc: Document = document): observerParam => addShadowRoot: vi.fn(), observeAttachShadow: vi.fn(), resetForDoc: vi.fn(), + removeShadowRootsIn: vi.fn(), }, canvasManager: { acquire: vi.fn(), diff --git a/packages/rrweb/rrweb/test/record/observer-teardown.test.ts b/packages/rrweb/rrweb/test/record/observer-teardown.test.ts index 64b7156d57..110c1ecc89 100644 --- a/packages/rrweb/rrweb/test/record/observer-teardown.test.ts +++ b/packages/rrweb/rrweb/test/record/observer-teardown.test.ts @@ -52,6 +52,7 @@ const createOptions = (doc: Document = document): observerParam => addShadowRoot: vi.fn(), observeAttachShadow: vi.fn(), resetForDoc: vi.fn(), + removeShadowRootsIn: vi.fn(), }, canvasManager: { acquire: vi.fn(), diff --git a/packages/rrweb/rrweb/test/record/shadow-root-release.test.ts b/packages/rrweb/rrweb/test/record/shadow-root-release.test.ts index d52ee83aa1..6865c9fb1f 100644 --- a/packages/rrweb/rrweb/test/record/shadow-root-release.test.ts +++ b/packages/rrweb/rrweb/test/record/shadow-root-release.test.ts @@ -139,6 +139,22 @@ describe('releasing removed shadow hosts', () => { expect(addedTexts()).toContain('after-reinsert'); }); + it('releases a host removed from inside a blocked container', async () => { + const baseline = pageBuffers().length; + const container = document.getElementById('page')!; + const host = createHost(); + container.appendChild(host); + await settle(); + expect(pageBuffers().length).toBe(baseline + 1); + + container.classList.add('rr-block'); + await settle(); + host.remove(); + await settle(); + + expect(pageBuffers().length).toBe(baseline); + }); + it('releases a removed host even when it was moved earlier in the same batch', async () => { const baseline = pageBuffers().length; const host = createHost();