Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-repeated-iframe-observers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': patch
---

Record each iframe mutation once after repeated full snapshots.
13 changes: 11 additions & 2 deletions packages/rrweb/rrweb/src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ function record<T = eventWithTime>(
let incrementalSnapshotCount = 0;
// Set per id — one iframe id can collect several cleanups across loads.
const iframeObserverCleanups = new Map<number, Set<listenerHandler>>();
const observedIframeDocuments = new Map<Document, listenerHandler>();

// 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
Expand Down Expand Up @@ -1036,8 +1037,15 @@ function record<T = eventWithTime>(
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) {
Expand Down Expand Up @@ -1171,6 +1179,7 @@ function record<T = eventWithTime>(
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();
Expand Down
130 changes: 130 additions & 0 deletions packages/rrweb/rrweb/test/record/iframe-observer-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<eventWithTime>) => 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('<iframe srcdoc="<p>initial</p>"></iframe>')
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<void>((resolve) => {
const iframe = document.querySelector('iframe')!
iframe.addEventListener('load', () => resolve(), { once: true })
iframe.srcdoc = '<p>navigated</p>'
})
)
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)
})
})
Loading