diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index 70e86a7142..357cd2fb3c 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -47,6 +47,37 @@ describe('rebuild', function () { }); }); + describe('shadowDom', function () { + it('rebuild shadowRoot without siblings', function () { + const node = buildNodeWithSN( + { + id: 1, + tagName: 'div', + type: NodeType.Element, + attributes: {}, + childNodes: [ + { + id: 2, + tagName: 'div', + type: NodeType.Element, + attributes: {}, + childNodes: [], + isShadow: true, + }, + ], + isShadowHost: true, + }, + { + doc: document, + mirror, + hackCss: false, + cache, + }, + ) as HTMLDivElement; + expect(node.shadowRoot?.childNodes.length).toBe(1); + }); + }); + describe('add hover class to hover selector related rules', function () { it('will do nothing to css text without :hover', () => { const cssText = 'body { color: white }'; diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 6228d54217..513fb2f8b0 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -4,7 +4,11 @@ import { SlimDOMOptions, createMirror, } from 'rrweb-snapshot'; -import { initObservers, mutationBuffers } from './observer'; +import { + initObservers, + mutationBuffers, + processedNodeManager, +} from './observer'; import { on, getWindowWidth, @@ -316,6 +320,7 @@ function record( stylesheetManager, canvasManager, keepIframeSrcFn, + processedNodeManager, }, mirror, }); @@ -526,6 +531,7 @@ function record( iframeManager, stylesheetManager, shadowDomManager, + processedNodeManager, canvasManager, ignoreCSSAttributes, plugins: diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 179272c098..bcc316ea1d 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -26,6 +26,7 @@ import { hasShadowRoot, isSerializedIframe, isSerializedStylesheet, + inDom, } from '../utils'; type DoubleLinkedListNode = { @@ -175,6 +176,7 @@ export default class MutationBuffer { private stylesheetManager: observerParam['stylesheetManager']; private shadowDomManager: observerParam['shadowDomManager']; private canvasManager: observerParam['canvasManager']; + private processedNodeManager: observerParam['processedNodeManager']; public init(options: MutationBufferParam) { ([ @@ -198,6 +200,7 @@ export default class MutationBuffer { 'stylesheetManager', 'shadowDomManager', 'canvasManager', + 'processedNodeManager', ] as const).forEach((key) => { // just a type trick, the runtime result is correct this[key] = options[key] as never; @@ -271,19 +274,8 @@ export default class MutationBuffer { (n.getRootNode() as ShadowRoot).host ) shadowHost = (n.getRootNode() as ShadowRoot).host; - // If n is in a nested shadow dom. - let rootShadowHost = shadowHost; - while ( - rootShadowHost?.getRootNode?.()?.nodeType === - Node.DOCUMENT_FRAGMENT_NODE && - (rootShadowHost.getRootNode() as ShadowRoot).host - ) - rootShadowHost = (rootShadowHost.getRootNode() as ShadowRoot).host; - // ensure contains is passed a Node, or it will throw an error - const notInDoc = - !this.doc.contains(n) && - (!rootShadowHost || !this.doc.contains(rootShadowHost)); - if (!n.parentNode || notInDoc) { + + if (!n.parentNode || !inDom(n)) { return; } const parentId = isShadowRoot(n.parentNode) @@ -647,6 +639,9 @@ export default class MutationBuffer { * Make sure you check if `n`'s parent is blocked before calling this function * */ private genAdds = (n: Node, target?: Node) => { + // this node was already recorded in other buffer, ignore it + if (this.processedNodeManager.inOtherBuffer(n, this)) return; + if (this.mirror.hasNode(n)) { if (isIgnored(n, this.mirror)) { return; @@ -666,8 +661,15 @@ export default class MutationBuffer { // if this node is blocked `serializeNode` will turn it into a placeholder element // but we have to remove it's children otherwise they will be added as placeholders too - if (!isBlocked(n, this.blockClass, this.blockSelector, false)) + if (!isBlocked(n, this.blockClass, this.blockSelector, false)) { n.childNodes.forEach((childN) => this.genAdds(childN)); + if (hasShadowRoot(n)) { + n.shadowRoot.childNodes.forEach((childN) => { + this.processedNodeManager.add(childN, this); + this.genAdds(childN, n); + }); + } + } }; } diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index f4989f1011..cfc17a7e79 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -40,6 +40,7 @@ import { selectionCallback, } from '@rrweb/types'; import MutationBuffer from './mutation'; +import ProcessedNodeManager from './processed-node-manager'; type WindowWithStoredMutationObserver = IWindow & { __rrMutationObserver?: MutationObserver; @@ -51,6 +52,7 @@ type WindowWithAngularZone = IWindow & { }; export const mutationBuffers: MutationBuffer[] = []; +export const processedNodeManager = new ProcessedNodeManager(); const isCSSGroupingRuleSupported = typeof CSSGroupingRule !== 'undefined'; const isCSSMediaRuleSupported = typeof CSSMediaRule !== 'undefined'; diff --git a/packages/rrweb/src/record/processed-node-manager.ts b/packages/rrweb/src/record/processed-node-manager.ts new file mode 100644 index 0000000000..60b939bb12 --- /dev/null +++ b/packages/rrweb/src/record/processed-node-manager.ts @@ -0,0 +1,34 @@ +import type MutationBuffer from './mutation'; + +/** + * Keeps a log of nodes that could show up in multiple mutation buffer but shouldn't be handled twice. + */ +export default class ProcessedNodeManager { + private nodeMap: WeakMap> = new WeakMap(); + + constructor() { + this.periodicallyClear(); + } + + private periodicallyClear() { + requestAnimationFrame(() => { + this.clear(); + this.periodicallyClear(); + }); + } + + public inOtherBuffer(node: Node, thisBuffer: MutationBuffer) { + const buffers = this.nodeMap.get(node); + return ( + buffers && Array.from(buffers).some((buffer) => buffer !== thisBuffer) + ); + } + + public add(node: Node, buffer: MutationBuffer) { + this.nodeMap.set(node, (this.nodeMap.get(node) || new Set()).add(buffer)); + } + + private clear() { + this.nodeMap = new WeakMap(); + } +} diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 807f6220c9..9bf8187501 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -9,7 +9,7 @@ import { initScrollObserver, initAdoptedStyleSheetObserver, } from './observer'; -import { patch } from '../utils'; +import { patch, inDom } from '../utils'; import type { Mirror } from 'rrweb-snapshot'; import { isNativeShadowDom } from 'rrweb-snapshot'; @@ -49,7 +49,11 @@ export class ShadowDomManager { function (original: (init: ShadowRootInit) => ShadowRoot) { return function (this: HTMLElement, option: ShadowRootInit) { const shadowRoot = original.call(this, option); - if (this.shadowRoot) + + // For the shadow dom elements in the document, monitor their dom mutations. + // For shadow dom elements that aren't in the document yet, + // we start monitoring them once their shadow dom host is appended to the document. + if (this.shadowRoot && inDom(this)) manager.addShadowRoot(this.shadowRoot, this.ownerDocument); return shadowRoot; }; diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 4548717cd6..b204e97407 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -37,6 +37,7 @@ import type { styleSheetRuleCallback, viewportResizeCallback, } from '@rrweb/types'; +import type ProcessedNodeManager from './record/processed-node-manager'; export type recordOptions = { emit?: (e: T, isCheckout?: boolean) => void; @@ -105,6 +106,7 @@ export type observerParam = { stylesheetManager: StylesheetManager; shadowDomManager: ShadowDomManager; canvasManager: CanvasManager; + processedNodeManager: ProcessedNodeManager; ignoreCSSAttributes: Set; plugins: Array<{ observer: ( @@ -139,6 +141,7 @@ export type MutationBufferParam = Pick< | 'stylesheetManager' | 'shadowDomManager' | 'canvasManager' + | 'processedNodeManager' >; export type ReplayPlugin = { diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 6ff10b27df..9636fc6b94 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -518,3 +518,30 @@ export class StyleSheetMirror { return this.id++; } } + +export function getRootShadowHost(n: Node): Node | null { + const shadowHost = (n.getRootNode() as ShadowRoot).host; + // If n is in a nested shadow dom. + let rootShadowHost = shadowHost; + + while ( + rootShadowHost?.getRootNode?.()?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && + (rootShadowHost.getRootNode() as ShadowRoot).host + ) + rootShadowHost = (rootShadowHost.getRootNode() as ShadowRoot).host; + + return rootShadowHost; +} + +export function shadowHostInDom(n: Node): boolean { + const doc = n.ownerDocument; + if (!doc) return false; + const shadowHost = getRootShadowHost(n); + return Boolean(shadowHost && doc.contains(shadowHost)); +} + +export function inDom(n: Node): boolean { + const doc = n.ownerDocument; + if (!doc) return false; + return doc.contains(n) || shadowHostInDom(n); +} diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index f2d86c86bb..631580b1aa 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -11514,6 +11514,358 @@ exports[`record integration tests should record input userTriggered values if us ]" `; +exports[`record integration tests should record moved shadow DOM 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 3 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 7 + } + ], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 8 + } + ], + \\"id\\": 4 + } + ], + \\"id\\": 2 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 4, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 9, + \\"isShadowHost\\": true + } + }, + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 10, + \\"isShadow\\": true + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [ + { + \\"parentId\\": 4, + \\"id\\": 9 + } + ], + \\"adds\\": [ + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 10, + \\"isShadow\\": true + } + }, + { + \\"parentId\\": 4, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 11 + } + }, + { + \\"parentId\\": 11, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 9, + \\"isShadowHost\\": true + } + } + ] + } + } +]" +`; + +exports[`record integration tests should record moved shadow DOM 2 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 3 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 7 + } + ], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 8 + } + ], + \\"id\\": 4 + } + ], + \\"id\\": 2 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 4, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"id\\": \\"newEl\\" + }, + \\"childNodes\\": [], + \\"id\\": 9 + } + }, + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"id\\": \\"el\\" + }, + \\"childNodes\\": [], + \\"id\\": 10, + \\"isShadowHost\\": true + } + }, + { + \\"parentId\\": 10, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"p\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 11, + \\"isShadow\\": true + } + }, + { + \\"parentId\\": 10, + \\"nextId\\": 11, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"span\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 12, + \\"isShadow\\": true + } + }, + { + \\"parentId\\": 12, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 13 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [ + { + \\"parentId\\": 12, + \\"id\\": 13 + } + ], + \\"adds\\": [ + { + \\"parentId\\": 11, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 13 + } + } + ] + } + } +]" +`; + exports[`record integration tests should record mutations in iframes accross pages 1`] = ` "[ { @@ -12620,6 +12972,242 @@ exports[`record integration tests should record shadow DOM 1`] = ` ]" `; +exports[`record integration tests should record shadow DOM 2 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 3 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 7 + } + ], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 8 + } + ], + \\"id\\": 4 + } + ], + \\"id\\": 2 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 4, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 9, + \\"isShadowHost\\": true + } + }, + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 10, + \\"isShadow\\": true + } + } + ] + } + } +]" +`; + +exports[`record integration tests should record shadow DOM 3 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 3 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 7 + } + ], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 8 + } + ], + \\"id\\": 4 + } + ], + \\"id\\": 2 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 4, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 9, + \\"isShadowHost\\": true + } + }, + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 10, + \\"isShadow\\": true + } + } + ] + } + } +]" +`; + exports[`record integration tests should record shadow doms polyfilled by shadydom 1`] = ` "[ { @@ -13121,18 +13709,7 @@ exports[`record integration tests should record shadow doms polyfilled by synthe \\"id\\": 31, \\"isShadowHost\\": true } - } - ] - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [], - \\"removes\\": [], - \\"adds\\": [ + }, { \\"parentId\\": 31, \\"nextId\\": null, diff --git a/packages/rrweb/test/html/blank.html b/packages/rrweb/test/html/blank.html new file mode 100644 index 0000000000..80e6028fd4 --- /dev/null +++ b/packages/rrweb/test/html/blank.html @@ -0,0 +1,3 @@ + + + diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 87e4fd2016..516d3a289a 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -667,6 +667,119 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots); }); + it('should record shadow DOM 2', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, 'blank.html')); + await page.evaluate(() => { + return new Promise((resolve) => { + const el = document.createElement('div') as HTMLDivElement; + el.attachShadow({ mode: 'open' }); + (el.shadowRoot as ShadowRoot).appendChild( + document.createElement('input'), + ); + setTimeout(() => { + document.body.append(el); + resolve(null); + }, 10); + }); + }); + await waitForRAF(page); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('should record shadow DOM 3', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, 'blank.html')); + + await page.evaluate(() => { + const el = document.createElement('div') as HTMLDivElement; + el.attachShadow({ mode: 'open' }); + (el.shadowRoot as ShadowRoot).appendChild( + document.createElement('input'), + ); + document.body.append(el); + }); + await waitForRAF(page); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('should record moved shadow DOM', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, 'blank.html')); + + await page.evaluate(() => { + return new Promise((resolve) => { + const el = document.createElement('div') as HTMLDivElement; + el.attachShadow({ mode: 'open' }); + (el.shadowRoot as ShadowRoot).appendChild( + document.createElement('input'), + ); + document.body.append(el); + setTimeout(() => { + const newEl = document.createElement('div') as HTMLDivElement; + document.body.append(newEl); + newEl.append(el); + resolve(null); + }, 50); + }); + }); + await waitForRAF(page); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('should record moved shadow DOM 2', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, 'blank.html')); + + await page.evaluate(() => { + const el = document.createElement('div') as HTMLDivElement; + el.id = 'el'; + el.attachShadow({ mode: 'open' }); + (el.shadowRoot as ShadowRoot).appendChild( + document.createElement('input'), + ); + document.body.append(el); + (el.shadowRoot as ShadowRoot).appendChild(document.createElement('span')); + (el.shadowRoot as ShadowRoot).appendChild(document.createElement('p')); + const newEl = document.createElement('div') as HTMLDivElement; + newEl.id = 'newEl'; + document.body.append(newEl); + newEl.append(el); + const input = el.shadowRoot?.children[0] as HTMLInputElement; + const span = el.shadowRoot?.children[1] as HTMLSpanElement; + const p = el.shadowRoot?.children[2] as HTMLParagraphElement; + input.remove(); + span.append(input); + p.append(input); + span.append(input); + setTimeout(() => { + p.append(input); + }, 0); + }); + await waitForRAF(page); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + it('should record nested iframes and shadow doms', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank');