Fix: shadow dom bugs (#1049)

* Add test cases for bugs

* Fix shadow dom recording

When moving an element containing shadow dom
When adding an element to shadow dom before its attached to the dom

* Apply formatting changes

* Refactor in dom checking code

* Nodes don't get processed in more than one mutation buffer

* Constrain node mutations to one mutation buffer per request animation frame

* Make tests less flaky under heavy load

* Apply suggestions from code review

* Update packages/rrweb-snapshot/test/rebuild.test.ts

* Remove unused nodeSet

Co-authored-by: Yun Feng <yun.feng0817@gmail.com>
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent e91433bb66
commit 236d7a3f3c
11 changed files with 831 additions and 29 deletions

View File

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

View File

@@ -0,0 +1,3 @@
<html>
<body></body>
</html>

View File

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