diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 0854176010..1401347353 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -859,6 +859,7 @@ export function serializeNodeWithId( recordChild = recordChild && !serializedNode.needBlock; // this property was not needed in replay side delete serializedNode.needBlock; + if ((n as HTMLElement).shadowRoot) serializedNode.isShadowHost = true; } if ( (serializedNode.type === NodeType.Document || @@ -903,7 +904,6 @@ export function serializeNodeWithId( } if (isElement(n) && n.shadowRoot) { - serializedNode.isShadowHost = true; for (const childN of Array.from(n.shadowRoot.childNodes)) { const serializedChildNode = serializeNodeWithId(childN, bypassOptions); if (serializedChildNode) { diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 7cacc7e40e..8c748a8214 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -273,6 +273,9 @@ function record( }, onIframeLoad: (iframe, childSn) => { iframeManager.attachIframe(iframe, childSn); + shadowDomManager.observeAttachShadow( + (iframe as Node) as HTMLIFrameElement, + ); }, keepIframeSrcFn, }); diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index cb4de6944d..1e4a8fbe07 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -226,6 +226,7 @@ export default class MutationBuffer { } public reset() { + this.shadowDomManager.reset(); this.canvasManager.reset(); } @@ -262,10 +263,16 @@ export default class MutationBuffer { const shadowHost: Element | null = n.getRootNode ? (n.getRootNode() as ShadowRoot)?.host : null; + // If n is in a nested shadow dom. + let rootShadowHost = shadowHost; + while ((rootShadowHost?.getRootNode?.() as ShadowRoot | undefined)?.host) + rootShadowHost = + (rootShadowHost?.getRootNode?.() as ShadowRoot | undefined)?.host || + null; // ensure shadowHost is a Node, or doc.contains will throw an error const notInDoc = !this.doc.contains(n) && - (!(shadowHost instanceof Node) || !this.doc.contains(shadowHost)); + (rootShadowHost === null || !this.doc.contains(rootShadowHost)); if (!n.parentNode || notInDoc) { return; } @@ -301,6 +308,9 @@ export default class MutationBuffer { }, onIframeLoad: (iframe, childSn) => { this.iframeManager.attachIframe(iframe, childSn); + this.shadowDomManager.observeAttachShadow( + (iframe as Node) as HTMLIFrameElement, + ); }, }); if (sn) { diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 86ea003911..e0ad26f33c 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -6,6 +6,7 @@ import { SamplingStrategy, } from '../types'; import { initMutationObserver, initScrollObserver } from './observer'; +import { patch } from '../utils'; type BypassOptions = Omit< MutationBufferParam, @@ -19,6 +20,7 @@ export class ShadowDomManager { private scrollCb: scrollCallback; private bypassOptions: BypassOptions; private mirror: Mirror; + private restorePatches: (() => void)[] = []; constructor(options: { mutationCb: mutationCallBack; @@ -30,6 +32,19 @@ export class ShadowDomManager { this.scrollCb = options.scrollCb; this.bypassOptions = options.bypassOptions; this.mirror = options.mirror; + + // Patch 'attachShadow' to observe newly added shadow doms. + const manager = this; + this.restorePatches.push( + patch(HTMLElement.prototype, 'attachShadow', function (original) { + return function () { + const shadowRoot = original.apply(this, arguments); + if (this.shadowRoot) + manager.addShadowRoot(this.shadowRoot, this.ownerDocument); + return shadowRoot; + }; + }), + ); } public addShadowRoot(shadowRoot: ShadowRoot, doc: Document) { @@ -52,4 +67,36 @@ export class ShadowDomManager { mirror: this.mirror, }); } + + /** + * Monkey patch 'attachShadow' of an IFrameElement to observe newly added shadow doms. + */ + public observeAttachShadow(iframeElement: HTMLIFrameElement) { + if (iframeElement.contentWindow) { + const manager = this; + this.restorePatches.push( + patch( + (iframeElement.contentWindow as Window & { + HTMLElement: { prototype: HTMLElement }; + }).HTMLElement.prototype, + 'attachShadow', + function (original) { + return function () { + const shadowRoot = original.apply(this, arguments); + if (this.shadowRoot) + manager.addShadowRoot( + this.shadowRoot, + iframeElement.contentDocument as Document, + ); + return shadowRoot; + }; + }, + ), + ); + } + } + + public reset() { + this.restorePatches.forEach((restorePatch) => restorePatch()); + } } diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 66e727b134..794a61b34c 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1421,8 +1421,12 @@ export class Replayer { parent = virtualParent; } - if (mutation.node.isShadow && hasShadowRoot(parent)) { - parent = parent.shadowRoot; + if (mutation.node.isShadow) { + // If the parent is attached a shadow dom after it's created, it won't have a shadow root. + if (!hasShadowRoot(parent)) { + ((parent as Node) as HTMLElement).attachShadow({ mode: 'open' }); + parent = ((parent as Node) as HTMLElement).shadowRoot!; + } else parent = parent.shadowRoot; } let previous: Node | null = null; diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 6d853e216d..06290e6068 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -9235,6 +9235,353 @@ exports[`record integration tests should record input userTriggered values if us ]" `; +exports[`record integration tests should record nested iframes and shadow doms 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\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Frame 2\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 13 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n frame 2\\\\n \\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 17 + } + ], + \\"id\\": 16 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 18 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 20 + } + ], + \\"id\\": 19 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\\\n\\", + \\"id\\": 21 + } + ], + \\"id\\": 14 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 14, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"five\\" + }, + \\"childNodes\\": [], + \\"id\\": 22 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 22, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 23, + \\"id\\": 25 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 23, + \\"id\\": 26 + } + ], + \\"rootId\\": 23, + \\"id\\": 24 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 23 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 26, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 23, + \\"id\\": 27, + \\"isShadow\\": true + } + }, + { + \\"parentId\\": 27, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 23, + \\"id\\": 28 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 28, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 29, + \\"id\\": 31 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 29, + \\"id\\": 32 + } + ], + \\"rootId\\": 29, + \\"id\\": 30 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 29 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 32, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"span\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 29, + \\"id\\": 33, + \\"isShadow\\": true + } + } + ] + } + } +]" +`; + exports[`record integration tests should record shadow DOM 1`] = ` "[ { @@ -9637,6 +9984,38 @@ exports[`record integration tests should record shadow DOM 1`] = ` } ] } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 44, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"span\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 47, + \\"isShadow\\": true + } + }, + { + \\"parentId\\": 47, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"nested shadow dom\\", + \\"id\\": 48 + } + } + ] + } } ]" `; diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 88dfbf5ef2..6999e3caee 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -530,6 +530,56 @@ describe('record integration tests', function (this: ISuite) { .then(() => { (shadowRoot.lastChild!.childNodes[0] as HTMLElement).innerText = '123'; + const nestedShadowElement = shadowRoot.lastChild! + .childNodes[0] as HTMLElement; + nestedShadowElement.attachShadow({ + mode: 'open', + }); + nestedShadowElement.shadowRoot!.appendChild( + document.createElement('span'), + ); + (nestedShadowElement.shadowRoot!.lastChild as HTMLElement).innerText = + 'nested shadow dom'; + }); + }); + await page.waitForTimeout(50); + + const snapshots = await page.evaluate('window.snapshots'); + assertSnapshot(snapshots); + }); + + it('should record nested iframes and shadow doms', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, 'frame2.html')); + + await page.evaluate(() => { + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + let iframe: HTMLIFrameElement; + sleep(10) + .then(() => { + // get contentDocument of iframe five + const contentDocument1 = document.querySelector('iframe')! + .contentDocument!; + // create shadow dom #1 + contentDocument1.body.attachShadow({ mode: 'open' }); + contentDocument1.body.shadowRoot!.appendChild( + document.createElement('div'), + ); + const div = contentDocument1.body.shadowRoot!.childNodes[0]; + iframe = contentDocument1.createElement('iframe'); + // append an iframe to shadow dom #1 + div.appendChild(iframe); + return sleep(10); + }) + .then(() => { + const contentDocument2 = iframe.contentDocument!; + // create shadow dom #2 in the iframe + contentDocument2.body.attachShadow({ mode: 'open' }); + contentDocument2.body.shadowRoot!.appendChild( + document.createElement('span'), + ); }); }); await page.waitForTimeout(50);