add support for nested shadow dom (#834)

* fix: can't record shadow host and shadow dom in incremental mutations

* enable to record newly added shadow dom

* Revert "enable to record newly added shadow dom"

This reverts commit cf7c0ad551ac457f00e3f754702c1464314f6a86.

* Revert "fix: can't record shadow host and shadow dom in incremental mutations"

This reverts commit 8b25cc97f83cbc333702c0ba73684e54eeadaabe.

* fix: can't record shadow host and shadow dom in incremental mutations

* add support for nested shadow root and add integration test

* fix test error

* enable to record shadow-dom in iframes

* add an integration test case for nested iframes and shadow-doms

* use the patch function
This commit is contained in:
Yun Feng
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 39eed78dad
commit c67a48df70
7 changed files with 497 additions and 4 deletions

View File

@@ -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) {

View File

@@ -273,6 +273,9 @@ function record<T = eventWithTime>(
},
onIframeLoad: (iframe, childSn) => {
iframeManager.attachIframe(iframe, childSn);
shadowDomManager.observeAttachShadow(
(iframe as Node) as HTMLIFrameElement,
);
},
keepIframeSrcFn,
});

View File

@@ -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) {

View File

@@ -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());
}
}

View File

@@ -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;

View File

@@ -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
}
}
]
}
}
]"
`;

View File

@@ -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);