From 105268f47248a23550d6f4825b36d64fab391e63 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] snapshot and rebuild shadow DOM https://github.com/rrweb-io/rrweb/issues/38 --- .gitignore | 1 + src/rebuild.ts | 26 +- src/snapshot.ts | 43 ++- src/types.ts | 2 + src/utils.ts | 5 + test/__snapshots__/integration.ts.snap | 400 +++++++++++++++++++++++++ test/html/shadow-dom.html | 209 +++++++++++++ test/integration.ts | 47 +++ 8 files changed, 717 insertions(+), 16 deletions(-) create mode 100644 src/utils.ts create mode 100644 test/html/shadow-dom.html diff --git a/.gitignore b/.gitignore index 2c597d03..3365d492 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ build dist es lib +temp diff --git a/src/rebuild.ts b/src/rebuild.ts index 8091be3b..9ab83156 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -7,6 +7,7 @@ import { idNodeMap, INode, } from './types'; +import { isElement } from './utils'; const tagMap: tagMap = { script: 'noscript', @@ -177,6 +178,25 @@ function buildNode( } } } + if (n.isShadowHost) { + /** + * Since node is newly rebuilt, it should be a normal element + * without shadowRoot. + * But if there are some weird situations that has defined + * custom element in the scope before we rebuild node, it may + * register the shadowRoot earlier. + * The logic in the 'else' block is just a try-my-best solution + * for the corner case, please let we know if it is wrong and + * we can remove it. + */ + if (!node.shadowRoot) { + node.attachShadow({ mode: 'open' }); + } else { + while (node.shadowRoot.firstChild) { + node.shadowRoot.removeChild(node.shadowRoot.firstChild); + } + } + } return node; case NodeType.Text: return doc.createTextNode( @@ -240,7 +260,11 @@ export function buildNodeWithSN( continue; } - node.appendChild(childNode); + if (childN.isShadow && isElement(node) && node.shadowRoot) { + node.shadowRoot.appendChild(childNode); + } else { + node.appendChild(childNode); + } if (afterAppend) { afterAppend(childNode); } diff --git a/src/snapshot.ts b/src/snapshot.ts index 09991b14..62832cc0 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -8,6 +8,7 @@ import { MaskInputOptions, SlimDOMOptions, } from './types'; +import { isElement } from './utils'; let _id = 1; const tagNameRegex = RegExp('[^a-z0-9-_]'); @@ -622,26 +623,38 @@ export function serializeNodeWithId( ) { preserveWhiteSpace = false; } + const bypassOptions = { + doc, + map, + blockClass, + blockSelector, + skipChild, + inlineStylesheet, + maskInputOptions, + slimDOMOptions, + recordCanvas, + preserveWhiteSpace, + onSerialize, + onIframeLoad, + iframeLoadTimeout, + }; for (const childN of Array.from(n.childNodes)) { - const serializedChildNode = serializeNodeWithId(childN, { - doc, - map, - blockClass, - blockSelector, - skipChild, - inlineStylesheet, - maskInputOptions, - slimDOMOptions, - recordCanvas, - preserveWhiteSpace, - onSerialize, - onIframeLoad, - iframeLoadTimeout, - }); + 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 ( diff --git a/src/types.ts b/src/types.ts index 3ae2d76f..0a9eb630 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,8 @@ export type serializedNode = ( | commentNode ) & { rootId?: number; + isShadowHost?: boolean; + isShadow?: boolean; }; export type serializedNodeWithId = serializedNode & { id: number }; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..de0e1e8c --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,5 @@ +import { INode } from './types'; + +export function isElement(n: Node | INode): n is Element { + return n.nodeType === n.ELEMENT_NODE; +} diff --git a/test/__snapshots__/integration.ts.snap b/test/__snapshots__/integration.ts.snap index 75734c14..e6577820 100644 --- a/test/__snapshots__/integration.ts.snap +++ b/test/__snapshots__/integration.ts.snap @@ -211,6 +211,25 @@ exports[`[html file]: picture.html 1`] = ` " `; +exports[`[html file]: shadow-dom.html 1`] = ` +" + + + shadow DOM + + + + + + +
content panel 1
+
content panel 2
+
content panel 3
+
+ + " +`; + exports[`[html file]: video.html 1`] = ` " @@ -409,3 +428,384 @@ exports[`iframe integration tests 1`] = ` \\"id\\": 1 }" `; + +exports[`shadown DOM integration tests 1`] = ` +"{ + \\"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\\": \\"shadow DOM\\", + \\"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 \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"fancy-tabs\\", + \\"attributes\\": { + \\"background\\": \\"\\", + \\"role\\": \\"tablist\\", + \\"selected\\": \\"1\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"button\\", + \\"attributes\\": { + \\"slot\\": \\"title\\", + \\"role\\": \\"tab\\", + \\"tabindex\\": \\"-1\\", + \\"aria-selected\\": \\"false\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Tab 1\\", + \\"id\\": 19 + } + ], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 20 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"button\\", + \\"attributes\\": { + \\"slot\\": \\"title\\", + \\"selected\\": \\"\\", + \\"role\\": \\"tab\\", + \\"tabindex\\": \\"0\\", + \\"aria-selected\\": \\"true\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Tab 2\\", + \\"id\\": 22 + } + ], + \\"id\\": 21 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 23 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"button\\", + \\"attributes\\": { + \\"slot\\": \\"title\\", + \\"role\\": \\"tab\\", + \\"tabindex\\": \\"-1\\", + \\"aria-selected\\": \\"false\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Tab 3\\", + \\"id\\": 25 + } + ], + \\"id\\": 24 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 26 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"section\\", + \\"attributes\\": { + \\"role\\": \\"tabpanel\\", + \\"tabindex\\": \\"0\\", + \\"aria-hidden\\": \\"true\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"content panel 1\\", + \\"id\\": 28 + } + ], + \\"id\\": 27 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 29 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"section\\", + \\"attributes\\": { + \\"role\\": \\"tabpanel\\", + \\"tabindex\\": \\"0\\", + \\"aria-hidden\\": \\"false\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"content panel 2\\", + \\"id\\": 31 + } + ], + \\"id\\": 30 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 32 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"section\\", + \\"attributes\\": { + \\"role\\": \\"tabpanel\\", + \\"tabindex\\": \\"0\\", + \\"aria-hidden\\": \\"true\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"content panel 3\\", + \\"id\\": 34 + } + ], + \\"id\\": 33 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 35 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 36, + \\"isShadow\\": true + }, + { + \\"type\\": 2, + \\"tagName\\": \\"style\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n :host {\\\\n display: inline-block;\\\\n width: 650px;\\\\n font-family: 'Roboto Slab';\\\\n contain: content;\\\\n }\\\\n :host([background]) {\\\\n background: var(--background-color, #9E9E9E);\\\\n border-radius: 10px;\\\\n padding: 10px;\\\\n }\\\\n #panels {\\\\n box-shadow: 0 2px 2px rgba(0, 0, 0, .3);\\\\n background: white;\\\\n border-radius: 3px;\\\\n padding: 16px;\\\\n height: 250px;\\\\n overflow: auto;\\\\n }\\\\n #tabs {\\\\n display: inline-flex;\\\\n -webkit-user-select: none;\\\\n user-select: none;\\\\n }\\\\n #tabs slot {\\\\n display: inline-flex; /* Safari bug. Treats as a parent */\\\\n }\\\\n /* Safari does not support #id prefixes on ::slotted\\\\n See https://bugs.webkit.org/show_bug.cgi?id=160538 */\\\\n #tabs ::slotted(*) {\\\\n font: 400 16px/22px 'Roboto';\\\\n padding: 16px 8px;\\\\n margin: 0;\\\\n text-align: center;\\\\n width: 100px;\\\\n text-overflow: ellipsis;\\\\n white-space: nowrap;\\\\n overflow: hidden;\\\\n cursor: pointer;\\\\n border-top-left-radius: 3px;\\\\n border-top-right-radius: 3px;\\\\n background: linear-gradient(#fafafa, #eee);\\\\n border: none; /* if the user users a + + +
content panel 1
+
content panel 2
+
content panel 3
+ + + + diff --git a/test/integration.ts b/test/integration.ts index 807639f0..751fbeed 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -166,3 +166,50 @@ describe('iframe integration tests', function (this: ISuite) { assert(result.pass, result.pass ? '' : result.report()); }).timeout(5000); }); + +describe('shadown DOM integration tests', function (this: ISuite) { + const shadowDomHtml = path.join(__dirname, 'html/shadow-dom.html'); + const raw = fs.readFileSync(shadowDomHtml, 'utf-8'); + + before(async () => { + this.server = await server(); + this.browser = await puppeteer.launch({ + // headless: false, + }); + + const bundle = await rollup.rollup({ + input: path.resolve(__dirname, '../src/index.ts'), + plugins: [typescript()], + }); + const { code } = await bundle.generate({ + name: 'rrweb', + format: 'iife', + }); + this.code = code; + }); + + after(async () => { + await this.browser.close(); + await this.server.close(); + }); + + it('snapshot shadow DOM', async () => { + const page: puppeteer.Page = await this.browser.newPage(); + // console for debug + // tslint:disable-next-line: no-console + page.on('console', (msg) => console.log(msg.text())); + await page.goto(`http://localhost:3030/html`); + await page.setContent(raw, { + waitUntil: 'load', + }); + const snapshotResult = JSON.stringify( + await page.evaluate(`${this.code}; + rrweb.snapshot(document)[0]; + `), + null, + 2, + ); + const result = matchSnapshot(snapshotResult, __filename, this.title); + assert(result.pass, result.pass ? '' : result.report()); + }).timeout(5000); +});