From bab5526dd5e9d42f464aa78af3cc0f0177bacfed Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] Keep blocked root elements as placeholders (#696) * Keep blocked root elements as placeholders `serializeNode` turns blocked elements into placeholder nodes so we need to make sure we don't remove these elements from the mutations when they get added. We do however need to keep removing any children of these blocked elements from getting added or mutated. * Update packages/rrweb/src/record/mutation.ts --- packages/rrweb/src/record/mutation.ts | 19 +- .../__snapshots__/integration.test.ts.snap | 206 ++++++++++++++++++ packages/rrweb/test/integration.test.ts | 24 +- 3 files changed, 236 insertions(+), 13 deletions(-) diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index fd4ac710..4ba50a54 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -262,9 +262,6 @@ export default class MutationBuffer { ns = ns && ns.nextSibling; nextId = ns && this.mirror.getId((ns as unknown) as INode); } - if (nextId === -1 && isBlocked(n.nextSibling, this.blockClass)) { - nextId = null; - } return nextId; }; const pushAdd = (n: Node) => { @@ -534,11 +531,7 @@ export default class MutationBuffer { const parentId = isShadowRoot(m.target) ? this.mirror.getId((m.target.host as unknown) as INode) : this.mirror.getId(m.target as INode); - if ( - isBlocked(n, this.blockClass) || - isBlocked(m.target, this.blockClass) || - isIgnored(n) - ) { + if (isBlocked(m.target, this.blockClass) || isIgnored(n)) { return; } // removed node has not been serialized yet, just remove it from the Set @@ -582,9 +575,7 @@ export default class MutationBuffer { }; private genAdds = (n: Node | INode, target?: Node | INode) => { - if (isBlocked(n, this.blockClass)) { - return; - } + // parent was blocked, so we can ignore this node if (target && isBlocked(target, this.blockClass)) { return; } @@ -604,7 +595,11 @@ export default class MutationBuffer { this.addedSet.add(n); this.droppedSet.delete(n); } - n.childNodes.forEach((childN) => this.genAdds(childN)); + + // if this node is blocked `serializeNode` will turn it into a placeholder element + // but we have to remove it's children otherwise they will be added as placeholders too + if (!isBlocked(n, this.blockClass)) + n.childNodes.forEach((childN) => this.genAdds(childN)); }; } diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index ca16fefb..d336859c 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -332,6 +332,212 @@ exports[`block 1`] = ` ]" `; +exports[`block 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\\": 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\\": \\"meta\\", + \\"attributes\\": { + \\"http-equiv\\": \\"X-UA-Compatible\\", + \\"content\\": \\"ie=edge\\" + }, + \\"childNodes\\": [], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 11 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Block record\\", + \\"id\\": 13 + } + ], + \\"id\\": 12 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 14 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"class\\": \\"rr-block\\", + \\"rr_width\\": \\"50px\\", + \\"rr_height\\": \\"50px\\" + }, + \\"childNodes\\": [], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 19 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 21 + } + ], + \\"id\\": 20 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 22 + } + ], + \\"id\\": 16 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 16, + \\"nextId\\": 18, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"button\\", + \\"attributes\\": { + \\"class\\": \\"rr-block\\", + \\"rr_width\\": \\"100px\\", + \\"rr_height\\": \\"100px\\" + }, + \\"childNodes\\": [], + \\"id\\": 23 + } + } + ] + } + } +]" +`; + exports[`canvas 1`] = ` "[ { diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 5949b753..723f8900 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -185,7 +185,9 @@ describe('record integration tests', function (this: ISuite) { // toggle the select box await page.click('.select2-container', { clickCount: 2, delay: 100 }); // test storage of !important style - await page.evaluate('document.getElementById("select2-drop").setAttribute("style", document.getElementById("select2-drop").style.cssText + "color:black !important")'); + await page.evaluate( + 'document.getElementById("select2-drop").setAttribute("style", document.getElementById("select2-drop").style.cssText + "color:black !important")', + ); const snapshots = await page.evaluate('window.snapshots'); assertSnapshot(snapshots, __filename, 'select2'); }); @@ -317,6 +319,26 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots, __filename, 'block'); }); + it('should not record blocked elements dynamically added', async () => { + const page: puppeteer.Page = await this.browser.newPage(); + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, 'block.html')); + + await page.evaluate(() => { + const el = document.createElement('button'); + el.className = 'rr-block'; + el.style.width = '100px'; + el.style.height = '100px'; + el.innerText = 'Should not be recorded'; + + const nextElement = document.querySelector('.rr-block')!; + nextElement.parentNode!.insertBefore(el, nextElement); + }); + + const snapshots = await page.evaluate('window.snapshots'); + assertSnapshot(snapshots, __filename, 'block 2'); + }); + it('should record DOM node movement 1', async () => { const page: puppeteer.Page = await this.browser.newPage(); await page.goto('about:blank');