From 9a863aa0120638873f8d26f69daf06ee84c531a8 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:14:03 +0000 Subject: [PATCH] fix(rrweb): observe each iframe document once Generated-By: PostHog Desktop Task-Id: a31bd01c-7078-480e-ae93-f1f446e128c0 --- .changeset/fix-repeated-iframe-observers.md | 5 + packages/rrweb/rrweb/src/record/index.ts | 13 +- .../record/iframe-observer-lifecycle.test.ts | 130 ++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-repeated-iframe-observers.md create mode 100644 packages/rrweb/rrweb/test/record/iframe-observer-lifecycle.test.ts diff --git a/.changeset/fix-repeated-iframe-observers.md b/.changeset/fix-repeated-iframe-observers.md new file mode 100644 index 0000000000..eefbdbf041 --- /dev/null +++ b/.changeset/fix-repeated-iframe-observers.md @@ -0,0 +1,5 @@ +--- +'posthog-js': patch +--- + +Record each iframe mutation once after repeated full snapshots. diff --git a/packages/rrweb/rrweb/src/record/index.ts b/packages/rrweb/rrweb/src/record/index.ts index c64a9b0baf..3b03971842 100644 --- a/packages/rrweb/rrweb/src/record/index.ts +++ b/packages/rrweb/rrweb/src/record/index.ts @@ -463,6 +463,7 @@ function record( let incrementalSnapshotCount = 0; // Set per id — one iframe id can collect several cleanups across loads. const iframeObserverCleanups = new Map>(); + const observedIframeDocuments = new Map(); // Forward-declared; assigned inside the try{} block where `handlers` is // in scope. Optional-typed so a premature call is a no-op rather than a @@ -1036,8 +1037,15 @@ function record( const loadListener = (iframeEl: HTMLIFrameElement) => { try { const iframeId = mirror.getId(iframeEl); - const cleanup = observe(iframeEl.contentDocument!); - if (typeof cleanup === 'function') { + const iframeDocument = iframeEl.contentDocument; + if (!iframeDocument || observedIframeDocuments.has(iframeDocument)) return; + const stopObserving = observe(iframeDocument); + if (typeof stopObserving === 'function') { + const cleanup = () => { + observedIframeDocuments.delete(iframeDocument); + stopObserving(); + }; + observedIframeDocuments.set(iframeDocument, cleanup); handlers.push(cleanup); // Accumulate cleanups across iframe navigations. if (iframeId !== -1) { @@ -1171,6 +1179,7 @@ function record( iframeManager.removeLoadListener(); iframeManager.destroy(); iframeObserverCleanups.clear(); + observedIframeDocuments.clear(); // Global shadow teardown belongs to the recording lifecycle, not per-buffer reset() which would fire on every iframe teardown. shadowDomManager.reset(); mirror.reset(); diff --git a/packages/rrweb/rrweb/test/record/iframe-observer-lifecycle.test.ts b/packages/rrweb/rrweb/test/record/iframe-observer-lifecycle.test.ts new file mode 100644 index 0000000000..62af41e690 --- /dev/null +++ b/packages/rrweb/rrweb/test/record/iframe-observer-lifecycle.test.ts @@ -0,0 +1,130 @@ +import * as fs from 'fs' +import * as path from 'path' +import type * as puppeteer from 'puppeteer' +import { vi } from 'vitest' +import type { eventWithTime, listenerHandler, mutationData } from '@posthog/rrweb-types' +import { EventType, IncrementalSource } from '@posthog/rrweb-types' +import type { recordOptions } from '../../src/types' +import { launchPuppeteer, waitForRAF } from '../utils' + +interface IWindow extends Window { + rrweb: { + record: ((options: recordOptions) => listenerHandler | undefined) & { + takeFullSnapshot: (isCheckout?: boolean) => void + } + } + emit: (event: eventWithTime) => void + stopRecording?: listenerHandler + iframeDocument: Document +} + +describe('same-origin iframe observer lifecycle', () => { + vi.setConfig({ testTimeout: 100_000 }) + + let browser: puppeteer.Browser + let page: puppeteer.Page + let code: string + let events: eventWithTime[] + + beforeAll(async () => { + browser = await launchPuppeteer() + code = fs.readFileSync(path.resolve(__dirname, '../../dist/rrweb.umd.cjs'), 'utf8') + }) + + afterAll(async () => { + await browser?.close() + }) + + beforeEach(async () => { + page = await browser.newPage() + await page.goto('about:blank') + await page.setContent('') + await page.evaluate(code) + events = [] + await page.exposeFunction('emit', (event: eventWithTime) => { + events.push(event) + }) + await page.evaluate(() => { + const win = window as unknown as IWindow + win.iframeDocument = document.querySelector('iframe')!.contentDocument! + win.stopRecording = win.rrweb.record({ emit: win.emit }) + }) + await waitForRAF(page) + }) + + afterEach(async () => { + await page?.close() + }) + + const attributeChanges = () => + events + .filter( + (event) => + event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation + ) + .flatMap((event) => (event.data as mutationData).attributes) + .filter((change) => 'data-observer-check' in change.attributes) + + const changeAttribute = async (currentDocument = false) => { + events = [] + await page.evaluate((useCurrentDocument) => { + const win = window as unknown as IWindow + const doc = useCurrentDocument ? document.querySelector('iframe')!.contentDocument! : win.iframeDocument + doc.body.setAttribute('data-observer-check', String(Date.now())) + }, currentDocument) + await waitForRAF(page) + } + + it('emits one mutation after three full snapshots of the same document', async () => { + await page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb + record.takeFullSnapshot(true) + record.takeFullSnapshot(true) + record.takeFullSnapshot(true) + }) + await waitForRAF(page) + + await changeAttribute() + expect(attributeChanges()).toHaveLength(1) + }) + + it('stops observing a removed iframe document', async () => { + await page.evaluate(() => document.querySelector('iframe')!.remove()) + await waitForRAF(page) + + await changeAttribute() + expect(attributeChanges()).toHaveLength(0) + }) + + it('observes the new document after navigation and releases the old one', async () => { + await page.evaluate( + () => + new Promise((resolve) => { + const iframe = document.querySelector('iframe')! + iframe.addEventListener('load', () => resolve(), { once: true }) + iframe.srcdoc = '

navigated

' + }) + ) + await waitForRAF(page) + + await changeAttribute() + expect(attributeChanges()).toHaveLength(0) + + await changeAttribute(true) + expect(attributeChanges()).toHaveLength(1) + }) + + it('re-observes the document after recorder stop and restart', async () => { + await page.evaluate(() => (window as unknown as IWindow).stopRecording?.()) + await changeAttribute() + expect(attributeChanges()).toHaveLength(0) + + await page.evaluate(() => { + const win = window as unknown as IWindow + win.stopRecording = win.rrweb.record({ emit: win.emit }) + }) + await waitForRAF(page) + await changeAttribute() + expect(attributeChanges()).toHaveLength(1) + }) +})