From 8b25cc97f83cbc333702c0ba73684e54eeadaabe Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Mon, 7 Feb 2022 20:56:46 +1100 Subject: [PATCH 01/10] fix: can't record shadow host and shadow dom in incremental mutations --- packages/rrweb-snapshot/src/snapshot.ts | 63 +++++++++++++------------ 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 0f8de04a84..c32f2bede0 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -854,6 +854,27 @@ export function serializeNodeWithId( // this property was not needed in replay side delete serializedNode.needBlock; } + const bypassOptions = { + doc, + map, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + skipChild, + inlineStylesheet, + maskInputOptions, + maskTextFn, + maskInputFn, + slimDOMOptions, + inlineImages, + recordCanvas, + preserveWhiteSpace, + onSerialize, + onIframeLoad, + iframeLoadTimeout, + keepIframeSrcFn, + }; if ( (serializedNode.type === NodeType.Document || serializedNode.type === NodeType.Element) && @@ -867,42 +888,24 @@ export function serializeNodeWithId( ) { preserveWhiteSpace = false; } - const bypassOptions = { - doc, - map, - blockClass, - blockSelector, - maskTextClass, - maskTextSelector, - skipChild, - inlineStylesheet, - maskInputOptions, - maskTextFn, - maskInputFn, - slimDOMOptions, - inlineImages, - recordCanvas, - preserveWhiteSpace, - onSerialize, - onIframeLoad, - iframeLoadTimeout, - keepIframeSrcFn, - }; for (const childN of Array.from(n.childNodes)) { const serializedChildNode = serializeNodeWithId(childN, bypassOptions); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); } } - - if (isElement(n) && n.shadowRoot) { - serializedNode.isShadowHost = true; - for (const childN of Array.from(n.shadowRoot.childNodes)) { - const serializedChildNode = serializeNodeWithId(childN, bypassOptions); - if (serializedChildNode) { - serializedChildNode.isShadow = true; - serializedNode.childNodes.push(serializedChildNode); - } + } + if ( + serializedNode.type === NodeType.Element && + isElement(n) && + n.shadowRoot + ) { + serializedNode.isShadowHost = true; + for (const childN of Array.from(n.shadowRoot.childNodes)) { + const serializedChildNode = serializeNodeWithId(childN, bypassOptions); + if (serializedChildNode) { + serializedChildNode.isShadow = true; + serializedNode.childNodes.push(serializedChildNode); } } } From cf7c0ad551ac457f00e3f754702c1464314f6a86 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Tue, 8 Feb 2022 01:12:59 +1100 Subject: [PATCH 02/10] enable to record newly added shadow dom --- packages/rrweb-snapshot/src/rebuild.ts | 7 +++++-- packages/rrweb-snapshot/src/snapshot.ts | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 847cdd0698..394c3a7a40 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -215,7 +215,10 @@ function buildNode( n.attributes.rr_dataURL ) { // backup original img srcset - node.setAttribute('rrweb-original-srcset', n.attributes.srcset as string); + node.setAttribute( + 'rrweb-original-srcset', + n.attributes.srcset as string, + ); } else { node.setAttribute(name, value); } @@ -367,7 +370,7 @@ export function buildNodeWithSN( if ( (n.type === NodeType.Document || n.type === NodeType.Element) && - !skipChild + (!skipChild || n.isShadowHost) ) { for (const childN of n.childNodes) { const childNode = buildNodeWithSN(childN, { diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index c32f2bede0..c08a601b2d 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -902,7 +902,12 @@ export function serializeNodeWithId( ) { serializedNode.isShadowHost = true; for (const childN of Array.from(n.shadowRoot.childNodes)) { - const serializedChildNode = serializeNodeWithId(childN, bypassOptions); + const serializedChildNode = serializeNodeWithId( + childN, + Object.assign(bypassOptions, { + skipChild: false, + }), + ); if (serializedChildNode) { serializedChildNode.isShadow = true; serializedNode.childNodes.push(serializedChildNode); From 54dfb06c9da707eb90b856e9bb6a4010ce2e5603 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Tue, 8 Feb 2022 20:01:15 +1100 Subject: [PATCH 03/10] Revert "enable to record newly added shadow dom" This reverts commit cf7c0ad551ac457f00e3f754702c1464314f6a86. --- packages/rrweb-snapshot/src/rebuild.ts | 7 ++----- packages/rrweb-snapshot/src/snapshot.ts | 7 +------ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 394c3a7a40..847cdd0698 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -215,10 +215,7 @@ function buildNode( n.attributes.rr_dataURL ) { // backup original img srcset - node.setAttribute( - 'rrweb-original-srcset', - n.attributes.srcset as string, - ); + node.setAttribute('rrweb-original-srcset', n.attributes.srcset as string); } else { node.setAttribute(name, value); } @@ -370,7 +367,7 @@ export function buildNodeWithSN( if ( (n.type === NodeType.Document || n.type === NodeType.Element) && - (!skipChild || n.isShadowHost) + !skipChild ) { for (const childN of n.childNodes) { const childNode = buildNodeWithSN(childN, { diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index c08a601b2d..c32f2bede0 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -902,12 +902,7 @@ export function serializeNodeWithId( ) { serializedNode.isShadowHost = true; for (const childN of Array.from(n.shadowRoot.childNodes)) { - const serializedChildNode = serializeNodeWithId( - childN, - Object.assign(bypassOptions, { - skipChild: false, - }), - ); + const serializedChildNode = serializeNodeWithId(childN, bypassOptions); if (serializedChildNode) { serializedChildNode.isShadow = true; serializedNode.childNodes.push(serializedChildNode); From 5a647b3ea17fbdd1086c623592812ef4e6e8459d Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Tue, 8 Feb 2022 20:01:26 +1100 Subject: [PATCH 04/10] Revert "fix: can't record shadow host and shadow dom in incremental mutations" This reverts commit 8b25cc97f83cbc333702c0ba73684e54eeadaabe. --- packages/rrweb-snapshot/src/snapshot.ts | 63 ++++++++++++------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index c32f2bede0..0f8de04a84 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -854,27 +854,6 @@ export function serializeNodeWithId( // this property was not needed in replay side delete serializedNode.needBlock; } - const bypassOptions = { - doc, - map, - blockClass, - blockSelector, - maskTextClass, - maskTextSelector, - skipChild, - inlineStylesheet, - maskInputOptions, - maskTextFn, - maskInputFn, - slimDOMOptions, - inlineImages, - recordCanvas, - preserveWhiteSpace, - onSerialize, - onIframeLoad, - iframeLoadTimeout, - keepIframeSrcFn, - }; if ( (serializedNode.type === NodeType.Document || serializedNode.type === NodeType.Element) && @@ -888,24 +867,42 @@ export function serializeNodeWithId( ) { preserveWhiteSpace = false; } + const bypassOptions = { + doc, + map, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + skipChild, + inlineStylesheet, + maskInputOptions, + maskTextFn, + maskInputFn, + slimDOMOptions, + inlineImages, + recordCanvas, + preserveWhiteSpace, + onSerialize, + onIframeLoad, + iframeLoadTimeout, + keepIframeSrcFn, + }; for (const childN of Array.from(n.childNodes)) { const serializedChildNode = serializeNodeWithId(childN, bypassOptions); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); } } - } - if ( - serializedNode.type === NodeType.Element && - isElement(n) && - n.shadowRoot - ) { - serializedNode.isShadowHost = true; - for (const childN of Array.from(n.shadowRoot.childNodes)) { - const serializedChildNode = serializeNodeWithId(childN, bypassOptions); - if (serializedChildNode) { - serializedChildNode.isShadow = true; - serializedNode.childNodes.push(serializedChildNode); + + if (isElement(n) && n.shadowRoot) { + serializedNode.isShadowHost = true; + for (const childN of Array.from(n.shadowRoot.childNodes)) { + const serializedChildNode = serializeNodeWithId(childN, bypassOptions); + if (serializedChildNode) { + serializedChildNode.isShadow = true; + serializedNode.childNodes.push(serializedChildNode); + } } } } From 0f5f35a46e5f3800338926358398af16e8cbfc5c Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Tue, 8 Feb 2022 20:13:06 +1100 Subject: [PATCH 05/10] fix: can't record shadow host and shadow dom in incremental mutations --- packages/rrweb-snapshot/src/snapshot.ts | 2 +- packages/rrweb/src/record/mutation.ts | 1 + packages/rrweb/src/record/shadow-dom-manager.ts | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 0f8de04a84..ccd13036e7 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -853,6 +853,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 || @@ -896,7 +897,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/mutation.ts b/packages/rrweb/src/record/mutation.ts index 205a9220d5..e37361edfc 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -225,6 +225,7 @@ export default class MutationBuffer { } public reset() { + this.shadowDomManager.reset(); this.canvasManager.reset(); } diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 86ea003911..32079d9d37 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -19,6 +19,7 @@ export class ShadowDomManager { private scrollCb: scrollCallback; private bypassOptions: BypassOptions; private mirror: Mirror; + private originalAttachShadow: (init: ShadowRootInit) => ShadowRoot; constructor(options: { mutationCb: mutationCallBack; @@ -30,6 +31,16 @@ export class ShadowDomManager { this.scrollCb = options.scrollCb; this.bypassOptions = options.bypassOptions; this.mirror = options.mirror; + + // Monkey patch 'attachShadow' to observe newly added shadow doms. + this.originalAttachShadow = HTMLElement.prototype.attachShadow; + const attachShadow = this.originalAttachShadow; + const manager = this; + HTMLElement.prototype.attachShadow = function () { + const shadowRoot = attachShadow.apply(this, arguments); + manager.addShadowRoot(this.shadowRoot, document); + return shadowRoot; + }; } public addShadowRoot(shadowRoot: ShadowRoot, doc: Document) { @@ -52,4 +63,8 @@ export class ShadowDomManager { mirror: this.mirror, }); } + + public reset() { + HTMLElement.prototype.attachShadow = this.originalAttachShadow; + } } From 41ef65e996b9d6dee6f638f1aaedba9a3f3b32b9 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Wed, 16 Feb 2022 17:50:04 +1100 Subject: [PATCH 06/10] add support for nested shadow root and add integration test --- packages/rrweb/src/record/mutation.ts | 7 +++- .../rrweb/src/record/shadow-dom-manager.ts | 2 +- packages/rrweb/src/replay/index.ts | 8 +++-- .../__snapshots__/integration.test.ts.snap | 32 +++++++++++++++++++ packages/rrweb/test/integration.test.ts | 10 ++++++ 5 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index e37361edfc..d462785920 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -262,10 +262,15 @@ 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).host) + rootShadowHost = (rootShadowHost?.getRootNode() as ShadowRoot).host; // 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 instanceof Node) || + !this.doc.contains(rootShadowHost)); if (!n.parentNode || notInDoc) { return; } diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 32079d9d37..edcff53e82 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -38,7 +38,7 @@ export class ShadowDomManager { const manager = this; HTMLElement.prototype.attachShadow = function () { const shadowRoot = attachShadow.apply(this, arguments); - manager.addShadowRoot(this.shadowRoot, document); + if (this.shadowRoot) manager.addShadowRoot(this.shadowRoot, document); return shadowRoot; }; } 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 9de563069a..3a190f6dd3 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -9588,6 +9588,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..89d10057ed 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -530,6 +530,16 @@ 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); From 310b1d1982a26baf532dbe011d1dbd3a7fca1924 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Wed, 16 Feb 2022 20:39:52 +1100 Subject: [PATCH 07/10] fix test error --- packages/rrweb/src/record/mutation.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index d462785920..062cbfcdc9 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -264,8 +264,10 @@ export default class MutationBuffer { : null; // If n is in a nested shadow dom. let rootShadowHost = shadowHost; - while ((rootShadowHost?.getRootNode?.() as ShadowRoot).host) - rootShadowHost = (rootShadowHost?.getRootNode() as ShadowRoot).host; + 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) && From 7d9ea45a27db715a8c7449149e0296959ad615bc Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Thu, 10 Mar 2022 02:26:38 +1100 Subject: [PATCH 08/10] enable to record shadow-dom in iframes --- packages/rrweb/src/record/index.ts | 3 ++ packages/rrweb/src/record/mutation.ts | 6 ++- .../rrweb/src/record/shadow-dom-manager.ts | 40 ++++++++++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) 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 d50010cfbd..1e4a8fbe07 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -272,8 +272,7 @@ export default class MutationBuffer { // ensure shadowHost is a Node, or doc.contains will throw an error const notInDoc = !this.doc.contains(n) && - (!(rootShadowHost instanceof Node) || - !this.doc.contains(rootShadowHost)); + (rootShadowHost === null || !this.doc.contains(rootShadowHost)); if (!n.parentNode || notInDoc) { return; } @@ -309,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 edcff53e82..519c174406 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -14,11 +14,19 @@ type BypassOptions = Omit< sampling: SamplingStrategy; }; +type WindowWithHTMLElement = Window & { + HTMLElement: { prototype: HTMLElement; new (): HTMLElement }; +}; + export class ShadowDomManager { private mutationCb: mutationCallBack; private scrollCb: scrollCallback; private bypassOptions: BypassOptions; private mirror: Mirror; + private observedIFrames: { + iframe: HTMLIFrameElement; + originalAttachShadow: (init: ShadowRootInit) => ShadowRoot; + }[] = []; private originalAttachShadow: (init: ShadowRootInit) => ShadowRoot; constructor(options: { @@ -38,7 +46,8 @@ export class ShadowDomManager { const manager = this; HTMLElement.prototype.attachShadow = function () { const shadowRoot = attachShadow.apply(this, arguments); - if (this.shadowRoot) manager.addShadowRoot(this.shadowRoot, document); + if (this.shadowRoot) + manager.addShadowRoot(this.shadowRoot, this.ownerDocument); return shadowRoot; }; } @@ -64,7 +73,36 @@ export class ShadowDomManager { }); } + /** + * Monkey patch 'attachShadow' of an IFrameElement to observe newly added shadow doms. + */ + public observeAttachShadow(iframeElement: HTMLIFrameElement) { + if (iframeElement.contentWindow) { + const originalAttachShadow = (iframeElement.contentWindow as WindowWithHTMLElement) + .HTMLElement.prototype.attachShadow; + const manager = this; + (iframeElement.contentWindow as WindowWithHTMLElement).HTMLElement.prototype.attachShadow = function () { + const shadowRoot = originalAttachShadow.apply(this, arguments); + if (this.shadowRoot) + manager.addShadowRoot( + this.shadowRoot, + iframeElement.contentDocument as Document, + ); + return shadowRoot; + }; + this.observedIFrames.push({ + iframe: iframeElement, + originalAttachShadow, + }); + } + } + public reset() { HTMLElement.prototype.attachShadow = this.originalAttachShadow; + this.observedIFrames.forEach( + ({ iframe, originalAttachShadow }) => + iframe.contentWindow && + ((iframe.contentWindow as WindowWithHTMLElement).HTMLElement.prototype.attachShadow = originalAttachShadow), + ); } } From 9a1b8bafee24bc1107122203d3e37f01a508a530 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Thu, 10 Mar 2022 03:37:14 +1100 Subject: [PATCH 09/10] add an integration test case for nested iframes and shadow-doms --- .../__snapshots__/integration.test.ts.snap | 347 ++++++++++++++++++ packages/rrweb/test/integration.test.ts | 40 ++ 2 files changed, 387 insertions(+) diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 3c4cb04138..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`] = ` "[ { diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 89d10057ed..6999e3caee 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -548,6 +548,46 @@ describe('record integration tests', function (this: ISuite) { 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); + + const snapshots = await page.evaluate('window.snapshots'); + assertSnapshot(snapshots); + }); + it('should mask texts', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); From 0b1947bbbe05cffa04fee553cfb555edf25997c7 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Sat, 12 Mar 2022 01:22:37 +1100 Subject: [PATCH 10/10] use the patch function --- .../rrweb/src/record/shadow-dom-manager.ts | 72 +++++++++---------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 519c174406..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, @@ -14,20 +15,12 @@ type BypassOptions = Omit< sampling: SamplingStrategy; }; -type WindowWithHTMLElement = Window & { - HTMLElement: { prototype: HTMLElement; new (): HTMLElement }; -}; - export class ShadowDomManager { private mutationCb: mutationCallBack; private scrollCb: scrollCallback; private bypassOptions: BypassOptions; private mirror: Mirror; - private observedIFrames: { - iframe: HTMLIFrameElement; - originalAttachShadow: (init: ShadowRootInit) => ShadowRoot; - }[] = []; - private originalAttachShadow: (init: ShadowRootInit) => ShadowRoot; + private restorePatches: (() => void)[] = []; constructor(options: { mutationCb: mutationCallBack; @@ -40,16 +33,18 @@ export class ShadowDomManager { this.bypassOptions = options.bypassOptions; this.mirror = options.mirror; - // Monkey patch 'attachShadow' to observe newly added shadow doms. - this.originalAttachShadow = HTMLElement.prototype.attachShadow; - const attachShadow = this.originalAttachShadow; + // Patch 'attachShadow' to observe newly added shadow doms. const manager = this; - HTMLElement.prototype.attachShadow = function () { - const shadowRoot = attachShadow.apply(this, arguments); - if (this.shadowRoot) - manager.addShadowRoot(this.shadowRoot, this.ownerDocument); - return shadowRoot; - }; + 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) { @@ -78,31 +73,30 @@ export class ShadowDomManager { */ public observeAttachShadow(iframeElement: HTMLIFrameElement) { if (iframeElement.contentWindow) { - const originalAttachShadow = (iframeElement.contentWindow as WindowWithHTMLElement) - .HTMLElement.prototype.attachShadow; const manager = this; - (iframeElement.contentWindow as WindowWithHTMLElement).HTMLElement.prototype.attachShadow = function () { - const shadowRoot = originalAttachShadow.apply(this, arguments); - if (this.shadowRoot) - manager.addShadowRoot( - this.shadowRoot, - iframeElement.contentDocument as Document, - ); - return shadowRoot; - }; - this.observedIFrames.push({ - iframe: iframeElement, - originalAttachShadow, - }); + 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() { - HTMLElement.prototype.attachShadow = this.originalAttachShadow; - this.observedIFrames.forEach( - ({ iframe, originalAttachShadow }) => - iframe.contentWindow && - ((iframe.contentWindow as WindowWithHTMLElement).HTMLElement.prototype.attachShadow = originalAttachShadow), - ); + this.restorePatches.forEach((restorePatch) => restorePatch()); } }