From fed5dda6cf43e1877ffe9eb733f8098cf1f5be6e Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Tue, 9 Sep 2025 15:16:37 +0100 Subject: [PATCH 1/2] Leverage monkeypatching on `addShadowRoot` to save a reference to the mode:closed shadow roots, and record them just like open ones --- .changeset/closed-shadow-root.md | 7 ++ .../rrweb/src/record/shadow-dom-manager.ts | 14 ++- ..._shadow_DOM_after_monkeypatching.snap.json | 115 ++++++++++++++++++ packages/rrweb/test/integration.test.ts | 24 ++++ packages/rrweb/test/utils.ts | 14 ++- packages/utils/src/index.ts | 3 + 6 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 .changeset/closed-shadow-root.md create mode 100644 packages/rrweb/test/__snapshots__/integration.test.ts.record_integration_tests.should_record_closed_shadow_DOM_after_monkeypatching.snap.json diff --git a/.changeset/closed-shadow-root.md b/.changeset/closed-shadow-root.md new file mode 100644 index 0000000000..c842a9f61d --- /dev/null +++ b/.changeset/closed-shadow-root.md @@ -0,0 +1,7 @@ +--- +"rrweb": patch +"rrweb-snapshot": patch +"utils": patch +--- + +Add recording of shadow DOM nodes which have been created with the { mode: 'closed' } flag diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 35affb729b..e0b45e7e1b 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -21,6 +21,10 @@ type BypassOptions = Omit< sampling: SamplingStrategy; }; +type ElementWithShadowRoot = Element & { + __rrClosedShadowRoot: ShadowRoot; +}; + export class ShadowDomManager { private shadowDoms = new WeakSet(); private mutationCb: mutationCallBack; @@ -133,9 +137,13 @@ export class ShadowDomManager { // For the shadow dom elements in the document, monitor their dom mutations. // For shadow dom elements that aren't in the document yet, // we start monitoring them once their shadow dom host is appended to the document. - const shadowRootEl = dom.shadowRoot(this); - if (shadowRootEl && inDom(this)) - manager.addShadowRoot(shadowRootEl, doc); + if (sRoot && inDom(this)) { + manager.addShadowRoot(sRoot, doc); + } + if (option.mode === 'closed') { + // FIXME: this exposes a closed root + (this as ElementWithShadowRoot).__rrClosedShadowRoot = sRoot; + } return sRoot; }; }, diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.record_integration_tests.should_record_closed_shadow_DOM_after_monkeypatching.snap.json b/packages/rrweb/test/__snapshots__/integration.test.ts.record_integration_tests.should_record_closed_shadow_DOM_after_monkeypatching.snap.json new file mode 100644 index 0000000000..385c777967 --- /dev/null +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.record_integration_tests.should_record_closed_shadow_DOM_after_monkeypatching.snap.json @@ -0,0 +1,115 @@ +[ + { + "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 + } + } + ] + } + } +] \ No newline at end of file diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 89534c99c7..7d8fc64e48 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -1047,6 +1047,30 @@ describe('record integration tests', function (this: ISuite) { await assertSnapshot(snapshots); }); + it('should record closed shadow DOM after monkeypatching', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + page.on('console', (msg) => console.log(msg.text())); + await page.setContent(getHtml.call(this, 'blank.html')); + await page.evaluate(() => { + return new Promise((resolve) => { + const el = document.createElement('div') as HTMLDivElement; + const shadow = el.attachShadow({ mode: 'closed' }); + shadow.appendChild(document.createElement('input')); + setTimeout(() => { + document.body.append(el); + resolve(null); + }, 10); + }); + }); + await waitForRAF(page); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + await assertSnapshot(snapshots, true); + }); + it('should record shadow DOM 3', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index d8463591b3..e015c2de78 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -301,6 +301,7 @@ function stringifyDomSnapshot(mhtml: string): string { export async function assertSnapshot( snapshotsOrPage: eventWithTime[] | puppeteer.Page, + useOwnFile: boolean | string = false, ) { let snapshots: eventWithTime[]; if (!Array.isArray(snapshotsOrPage)) { @@ -318,7 +319,18 @@ export async function assertSnapshot( } expect(snapshots).toBeDefined(); - expect(stringifySnapshots(snapshots)).toMatchSnapshot(); + if (useOwnFile) { + if (typeof useOwnFile !== 'string') { + // e.g. 'mutation.test.ts > mutation > add elements at once' + useOwnFile = expect.getState().currentTestName.split('/').pop(); + } + useOwnFile = useOwnFile.replace(/ > /g, '.').replace(/\s/g, '_'); + + const fname = `./__snapshots__/${useOwnFile}.snap.json`; + expect(stringifySnapshots(snapshots)).toMatchFileSnapshot(fname); + } else { + expect(stringifySnapshots(snapshots)).toMatchSnapshot(); + } } export function replaceLast(str: string, find: string, replace: string) { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 94a8dc8394..e86f7826ae 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -204,6 +204,9 @@ export function styleSheets(n: ShadowRoot): StyleSheetList { export function shadowRoot(n: Node): ShadowRoot | null { if (!n || !('shadowRoot' in n)) return null; + if ('__rrClosedShadowRoot' in n) { + return n.__rrClosedShadowRoot as ShadowRoot; + } return getUntaintedAccessor('Element', n as Element, 'shadowRoot'); } From 588917968436bd95011935c39480a0ed60be4e8e Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Wed, 12 Feb 2025 15:34:20 +0000 Subject: [PATCH 2/2] Put each snap file in it's own folder and shorten names --- packages/rrweb/test/utils.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index e015c2de78..403a2bbee0 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -320,13 +320,15 @@ export async function assertSnapshot( expect(snapshots).toBeDefined(); if (useOwnFile) { + // e.g. 'mutation.test.ts > mutation > add elements at once' + const long_fname = expect.getState().currentTestName.split('/').pop(); + const file = long_fname.split(' > ')[0].replace('.test.ts', ''); if (typeof useOwnFile !== 'string') { - // e.g. 'mutation.test.ts > mutation > add elements at once' - useOwnFile = expect.getState().currentTestName.split('/').pop(); + useOwnFile = long_fname.substring(long_fname.indexOf(' > ') + 3); } useOwnFile = useOwnFile.replace(/ > /g, '.').replace(/\s/g, '_'); - const fname = `./__snapshots__/${useOwnFile}.snap.json`; + const fname = `./__${file}.snapshots__/${useOwnFile}.json`; expect(stringifySnapshots(snapshots)).toMatchFileSnapshot(fname); } else { expect(stringifySnapshots(snapshots)).toMatchSnapshot();