From b3fb1f13ba7f433022d84d6d3884641445148349 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] fix: custom style rules don't get inserted into some iframe elements (#823) * fix: custom style rules don't get inserted into some iframe elements * code style tweak --- packages/rrweb/src/replay/index.ts | 19 +- packages/rrweb/test/events/iframe.ts | 591 +++++++++++++++++++++++++++ packages/rrweb/test/replayer.test.ts | 110 +++++ packages/rrweb/typings/types.d.ts | 1 + 4 files changed, 709 insertions(+), 12 deletions(-) create mode 100644 packages/rrweb/test/events/iframe.ts diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index bfa85ee9..ed01a271 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -660,10 +660,6 @@ export class Replayer { this.newDocumentQueue = this.newDocumentQueue.filter( (m) => m !== mutationInQueue, ); - if (builtNode.contentDocument) { - const { documentElement, head } = builtNode.contentDocument; - this.insertStyleRules(documentElement, head); - } } const { documentElement, head } = this.iframe.contentDocument; this.insertStyleRules(documentElement, head); @@ -726,6 +722,13 @@ export class Replayer { skipChild: false, afterAppend: (builtNode) => { this.collectIframeAndAttachDocument(collected, builtNode); + if ( + builtNode.__sn.type === NodeType.Element && + builtNode.__sn.tagName.toUpperCase() === 'HTML' + ) { + const { documentElement, head } = iframeEl.contentDocument!; + this.insertStyleRules(documentElement, head); + } }, cache: this.cache, }); @@ -734,10 +737,6 @@ export class Replayer { this.newDocumentQueue = this.newDocumentQueue.filter( (m) => m !== mutationInQueue, ); - if (builtNode.contentDocument) { - const { documentElement, head } = builtNode.contentDocument; - this.insertStyleRules(documentElement, head); - } } } @@ -1482,10 +1481,6 @@ export class Replayer { (m) => m !== mutationInQueue, ); } - if (target.contentDocument) { - const { documentElement, head } = target.contentDocument; - this.insertStyleRules(documentElement, head); - } } if (mutation.previousId || mutation.nextId) { diff --git a/packages/rrweb/test/events/iframe.ts b/packages/rrweb/test/events/iframe.ts new file mode 100644 index 00000000..d4110d20 --- /dev/null +++ b/packages/rrweb/test/events/iframe.ts @@ -0,0 +1,591 @@ +import { EventType, eventWithTime, IncrementalSource } from '../../src/types'; + +const now = Date.now(); + +const events: eventWithTime[] = [ + { + type: EventType.DomContentLoaded, + data: {}, + timestamp: now, + }, + { + type: EventType.Load, + data: {}, + timestamp: now + 100, + }, + { + type: EventType.Meta, + data: { + href: 'http://localhost', + width: 1200, + height: 500, + }, + timestamp: now + 100, + }, + { + type: EventType.FullSnapshot, + data: { + node: { + id: 1, + type: 0, + childNodes: [ + { id: 2, name: 'html', type: 1, publicId: '', systemId: '' }, + { + id: 3, + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + id: 4, + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + }, + { + id: 5, + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [], + }, + ], + }, + ], + }, + initialOffset: { top: 0, left: 0 }, + }, + timestamp: now + 200, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 5, + nextId: null, + node: { + type: 2, + tagName: 'iframe', + attributes: { id: 'one' }, + childNodes: [], + id: 6, + }, + }, + ], + }, + timestamp: now + 500, + }, + // add iframe one + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + adds: [ + { + parentId: 6, + nextId: null, + node: { + type: 0, + childNodes: [ + { + type: 1, + name: 'html', + publicId: '', + systemId: '', + rootId: 7, + id: 8, + }, + { + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + rootId: 7, + id: 10, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: '\n\t\tiframe 1\n\t', + rootId: 7, + id: 13, + }, + ], + rootId: 7, + id: 12, + }, + { type: 3, textContent: '\n\t', rootId: 7, id: 14 }, + { + type: 2, + tagName: 'script', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'SCRIPT_PLACEHOLDER', + rootId: 7, + id: 16, + }, + ], + rootId: 7, + id: 15, + }, + { type: 3, textContent: '\t\n', rootId: 7, id: 17 }, + ], + rootId: 7, + id: 11, + }, + ], + rootId: 7, + id: 9, + }, + ], + id: 7, + }, + }, + ], + removes: [], + texts: [], + attributes: [], + isAttachIframe: true, + }, + timestamp: now + 500, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 5, + nextId: null, + node: { + type: 2, + tagName: 'iframe', + attributes: { id: 'two' }, + childNodes: [], + id: 38, + }, + }, + ], + }, + timestamp: now + 1000, + }, + // add iframe two + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + adds: [ + { + parentId: 38, + nextId: null, + node: { + type: 0, + childNodes: [ + { + type: 1, + name: 'html', + publicId: '', + systemId: '', + rootId: 39, + id: 40, + }, + { + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { type: 3, textContent: '\n ', rootId: 39, id: 43 }, + { + type: 2, + tagName: 'meta', + attributes: { charset: 'UTF-8' }, + childNodes: [], + rootId: 39, + id: 44, + }, + { type: 3, textContent: '\n ', rootId: 39, id: 45 }, + { + type: 2, + tagName: 'meta', + attributes: { + name: 'viewport', + content: 'width=device-width, initial-scale=1.0', + }, + childNodes: [], + rootId: 39, + id: 46, + }, + { type: 3, textContent: '\n ', rootId: 39, id: 47 }, + { + type: 2, + tagName: 'title', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'iframe 2', + rootId: 39, + id: 49, + }, + ], + rootId: 39, + id: 48, + }, + { type: 3, textContent: '\n ', rootId: 39, id: 50 }, + ], + rootId: 39, + id: 42, + }, + { type: 3, textContent: '\n ', rootId: 39, id: 51 }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: '\n iframe 2\n ', + rootId: 39, + id: 53, + }, + { + type: 2, + tagName: 'iframe', + attributes: { id: 'three', frameborder: '0' }, + childNodes: [], + rootId: 39, + id: 54, + }, + { type: 3, textContent: '\n ', rootId: 39, id: 55 }, + { + type: 2, + tagName: 'iframe', + attributes: { id: 'four', frameborder: '0' }, + childNodes: [], + rootId: 39, + id: 56, + }, + { type: 3, textContent: '\n \n\n', rootId: 39, id: 57 }, + ], + rootId: 39, + id: 52, + }, + ], + rootId: 39, + id: 41, + }, + ], + id: 39, + }, + }, + ], + removes: [], + texts: [], + attributes: [], + isAttachIframe: true, + }, + timestamp: now + 1000, + }, + // add iframe three + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + adds: [ + { + parentId: 54, + nextId: null, + node: { + type: 0, + childNodes: [ + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + rootId: 58, + id: 60, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [], + rootId: 58, + id: 61, + }, + ], + rootId: 58, + id: 59, + }, + ], + id: 58, + }, + }, + ], + removes: [], + texts: [], + attributes: [], + isAttachIframe: true, + }, + timestamp: now + 1000, + }, + // add iframe four + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + adds: [ + { + parentId: 56, + nextId: null, + node: { + type: 0, + childNodes: [ + { + type: 1, + name: 'html', + publicId: '', + systemId: '', + rootId: 62, + id: 63, + }, + { + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { type: 3, textContent: '\n ', rootId: 62, id: 66 }, + { + type: 2, + tagName: 'meta', + attributes: { charset: 'UTF-8' }, + childNodes: [], + rootId: 62, + id: 67, + }, + { type: 3, textContent: '\n ', rootId: 62, id: 68 }, + { + type: 2, + tagName: 'meta', + attributes: { + name: 'viewport', + content: 'width=device-width, initial-scale=1.0', + }, + childNodes: [], + rootId: 62, + id: 69, + }, + { type: 3, textContent: '\n ', rootId: 62, id: 70 }, + { + type: 2, + tagName: 'title', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'iframe 4', + rootId: 62, + id: 72, + }, + ], + rootId: 62, + id: 71, + }, + { type: 3, textContent: '\n ', rootId: 62, id: 73 }, + ], + rootId: 62, + id: 65, + }, + { type: 3, textContent: '\n ', rootId: 62, id: 74 }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: '\n iframe 4\n \n ', + rootId: 62, + id: 76, + }, + { + type: 2, + tagName: 'script', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'SCRIPT_PLACEHOLDER', + rootId: 62, + id: 78, + }, + ], + rootId: 62, + id: 77, + }, + { type: 3, textContent: '\n\n', rootId: 62, id: 79 }, + ], + rootId: 62, + id: 75, + }, + ], + rootId: 62, + id: 64, + }, + ], + id: 62, + }, + }, + ], + removes: [], + texts: [], + attributes: [], + isAttachIframe: true, + }, + timestamp: now + 1500, + }, + // add iframe five + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + adds: [ + { + parentId: 80, + nextId: null, + node: { + type: 0, + childNodes: [ + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + rootId: 81, + id: 83, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'script', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'SCRIPT_PLACEHOLDER', + rootId: 81, + id: 86, + }, + ], + rootId: 81, + id: 85, + }, + ], + rootId: 81, + id: 84, + }, + ], + rootId: 81, + id: 82, + }, + ], + id: 81, + }, + }, + ], + removes: [], + texts: [], + attributes: [], + isAttachIframe: true, + }, + timestamp: now + 2000, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 75, + nextId: null, + node: { + type: 2, + tagName: 'iframe', + attributes: { id: 'five' }, + childNodes: [], + rootId: 62, + id: 80, + }, + }, + ], + }, + timestamp: now + 2000, + }, + // remove the html element of iframe four + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [], + removes: [{ parentId: 62, id: 64 }], + adds: [], + }, + timestamp: now + 2500, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index ef5b949b..cd35fb2a 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -11,6 +11,7 @@ import { } from './utils'; import styleSheetRuleEvents from './events/style-sheet-rule-events'; import orderingEvents from './events/ordering'; +import iframeEvents from './events/iframe'; interface ISuite { code: string; @@ -222,6 +223,115 @@ describe('replayer', function () { expect(result).toEqual(false); }); + it('can fast-forward mutation events containing nested iframe elements', async () => { + await page.evaluate(` + events = ${JSON.stringify(iframeEvents)}; + const { Replayer } = rrweb; + var replayer = new Replayer(events,{showDebug:true}); + replayer.pause(250); + `); + const iframe = await page.$('iframe'); + const contentDocument = await iframe!.contentFrame()!; + expect(await contentDocument!.$('iframe')).toBeNull(); + + const delay = 50; + // restart the replayer + await page.evaluate('replayer.play(0);'); + await page.waitForTimeout(delay); + await page.evaluate('replayer.pause(550);'); // add 'iframe one' at 500 + expect(await contentDocument!.$('iframe')).not.toBeNull(); + const iframeOneDocument = await (await contentDocument!.$( + 'iframe', + ))!.contentFrame(); + expect(iframeOneDocument).not.toBeNull(); + expect(await iframeOneDocument!.$('noscript')).not.toBeNull(); + // make sure custom style rules are inserted rules + expect((await iframeOneDocument!.$$('style')).length).toBe(1); + expect( + await iframeOneDocument!.$eval( + 'noscript', + (element) => window.getComputedStyle(element).display, + ), + ).toEqual('none'); + + // add 'iframe two' and 'iframe three' at 1000 + await page.evaluate('replayer.play(0);'); + await page.waitForTimeout(delay); + await page.evaluate('replayer.pause(1050);'); + expect((await contentDocument!.$$('iframe')).length).toEqual(2); + let iframeTwoDocument = await ( + await contentDocument!.$$('iframe') + )[1]!.contentFrame(); + expect(iframeTwoDocument).not.toBeNull(); + expect((await iframeTwoDocument!.$$('iframe')).length).toEqual(2); + let iframeThreeDocument = await ( + await iframeTwoDocument!.$$('iframe') + )[0]!.contentFrame(); + let iframeFourDocument = await ( + await iframeTwoDocument!.$$('iframe') + )[1]!.contentFrame(); + expect(iframeThreeDocument).not.toBeNull(); + expect(iframeFourDocument).not.toBeNull(); + + // add 'iframe four' at 1500 + await page.evaluate('replayer.play(0);'); + await page.waitForTimeout(delay); + await page.evaluate('replayer.pause(1550);'); + iframeTwoDocument = await ( + await contentDocument!.$$('iframe') + )[1]!.contentFrame(); + iframeFourDocument = await ( + await iframeTwoDocument!.$$('iframe') + )[1]!.contentFrame(); + expect(await iframeFourDocument!.$('iframe')).toBeNull(); + expect(await iframeFourDocument!.$('style')).not.toBeNull(); + expect(await iframeFourDocument!.title()).toEqual('iframe 4'); + + // add 'iframe five' at 2000 + await page.evaluate('replayer.play(0);'); + await page.waitForTimeout(delay); + await page.evaluate('replayer.pause(2050);'); + iframeTwoDocument = await ( + await contentDocument!.$$('iframe') + )[1]!.contentFrame(); + iframeFourDocument = await ( + await iframeTwoDocument!.$$('iframe') + )[1]!.contentFrame(); + expect(await iframeFourDocument!.$('iframe')).not.toBeNull(); + const iframeFiveDocument = await (await iframeFourDocument!.$( + 'iframe', + ))!.contentFrame(); + expect(iframeFiveDocument).not.toBeNull(); + expect((await iframeFiveDocument!.$$('style')).length).toBe(1); + expect(await iframeFiveDocument!.$('noscript')).not.toBeNull(); + expect( + await iframeFiveDocument!.$eval( + 'noscript', + (element) => window.getComputedStyle(element).display, + ), + ).toEqual('none'); + + // remove the html element of 'iframe four' at 2500 + await page.evaluate('replayer.play(0);'); + await page.waitForTimeout(delay); + await page.evaluate('replayer.pause(2550);'); + iframeTwoDocument = await ( + await contentDocument!.$$('iframe') + )[1]!.contentFrame(); + iframeFourDocument = await ( + await iframeTwoDocument!.$$('iframe') + )[1]!.contentFrame(); + // the html element should be removed + expect(await iframeFourDocument!.$('html')).toBeNull(); + // the doctype should still exist + expect( + await iframeTwoDocument!.evaluate( + (iframe) => (iframe as HTMLIFrameElement)!.contentDocument!.doctype, + (await iframeTwoDocument!.$$('iframe'))[1], + ), + ).not.toBeNull(); + }); + it('can stream events in live mode', async () => { const status = await page.evaluate(` const { Replayer } = rrweb; diff --git a/packages/rrweb/typings/types.d.ts b/packages/rrweb/typings/types.d.ts index 6cf36aea..181d54e5 100644 --- a/packages/rrweb/typings/types.d.ts +++ b/packages/rrweb/typings/types.d.ts @@ -1,3 +1,4 @@ +/// import { serializedNodeWithId, idNodeMap, INode, MaskInputOptions, SlimDOMOptions, MaskInputFn, MaskTextFn } from 'rrweb-snapshot'; import { PackFn, UnpackFn } from './packer/base'; import { IframeManager } from './record/iframe-manager';