From 247736a4c8a926fe40e3c76e1f79224aa9bb296e Mon Sep 17 00:00:00 2001 From: Meg Boehlert Date: Thu, 30 Jan 2025 22:43:25 -0500 Subject: [PATCH 1/2] preserve adopted styles from nodes being removed when virtual dom is in use --- .changeset/rare-snakes-pretend.md | 6 + packages/rrweb/src/replay/index.ts | 32 +++- .../rrweb/test/events/adopted-style-sheet.ts | 172 ++++++++++++++++++ packages/rrweb/test/replayer.test.ts | 25 ++- packages/types/src/index.ts | 10 +- 5 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 .changeset/rare-snakes-pretend.md diff --git a/.changeset/rare-snakes-pretend.md b/.changeset/rare-snakes-pretend.md new file mode 100644 index 0000000000..233837e978 --- /dev/null +++ b/.changeset/rare-snakes-pretend.md @@ -0,0 +1,6 @@ +--- +"rrweb": minor +"@rrweb/types": minor +--- + +Added a styleMap to store and retrieve adopted stylesheets not initially in the styleMirror when using the virtual DOM. When using the virtual DOM, adopted styles are applied after mutations, so any adopted styles from nodes - including styles from their child nodes - removed will not be applied to nodes that share the same styleId. \ No newline at end of file diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index fa3fcd2be6..6792f8f3db 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -70,6 +70,7 @@ import type { styleSheetRuleData, styleDeclarationData, adoptedStyleSheetData, + styleParam } from '@rrweb/types'; import { polyfill, @@ -152,6 +153,9 @@ export class Replayer { // Used to track StyleSheetObjects adopted on multiple document hosts. private styleMirror: StyleSheetMirror = new StyleSheetMirror(); + // Used to store adopted styles that get skipped over when using virtual dom and skipping in the timeline. + private adoptedStyleMap: Map = new Map(); + // Used to track video & audio elements, and keep them in sync with general playback. private mediaManager: MediaManager; @@ -337,6 +341,7 @@ export class Replayer { this.firstFullSnapshot = null; this.mirror.reset(); this.styleMirror.reset(); + this.adoptedStyleMap = new Map(); this.mediaManager.reset(); }); @@ -557,6 +562,7 @@ export class Replayer { this.mirror.reset(); this.styleMirror.reset(); this.mediaManager.reset(); + this.adoptedStyleMap = new Map(); this.config.root.removeChild(this.wrapper); this.emitter.emit(ReplayerEvents.Destroy); } @@ -699,6 +705,7 @@ export class Replayer { } this.mediaManager.reset(); this.styleMirror.reset(); + this.adoptedStyleMap = new Map(); this.rebuildFullSnapshot(event, isSync); this.iframe.contentWindow?.scrollTo(event.data.initialOffset); }; @@ -2070,7 +2077,30 @@ export class Replayer { private applyAdoptedStyleSheet(data: adoptedStyleSheetData) { const targetHost = this.mirror.getNode(data.id); - if (!targetHost) return; + if (!targetHost) { + // if node was removed before styles were applied, we want to store the styles for future nodes + data.styles?.forEach((style) => { + const key = style.styleId; + if (this.styleMirror.getStyle(key) === null) this.adoptedStyleMap.set(key, style); + }); + + return; + } + if (!data.styles || data.styles?.length < data.styleIds?.length) { + const styles: styleParam[] = [...data.styles || []]; + data.styleIds?.forEach((styleId) => { + // styles either already exist in style mirror or in the data + if (this.styleMirror.getStyle(styleId) !== null) return; + if (styles.find((style) => style.styleId === styleId)) return; + + // backup styles from removed nodes where original styles were not applied + const style: styleParam | undefined = this.adoptedStyleMap.get(styleId); + if (style) styles.push(style); + this.adoptedStyleMap.delete(styleId); + }); + + if (styles.length > 0) data.styles = [...styles]; + } // Create StyleSheet objects which will be adopted after. data.styles?.forEach((style) => { let newStyleSheet: CSSStyleSheet | null = null; diff --git a/packages/rrweb/test/events/adopted-style-sheet.ts b/packages/rrweb/test/events/adopted-style-sheet.ts index 70ddc4305a..5271f1fc5e 100644 --- a/packages/rrweb/test/events/adopted-style-sheet.ts +++ b/packages/rrweb/test/events/adopted-style-sheet.ts @@ -350,6 +350,178 @@ const events: eventWithTime[] = [ }, timestamp: now + 550, }, + // Create shadow host #3 + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 7, + nextId: null, + node: { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [], + id: 36, + }, + }, + { + parentId: 36, + nextId: null, + node: { + type: 2, + tagName: 'div', + id: 37, + childNodes: [], + isShadowHost: true, + attributes: { + id: 'shadow-host3' + } + }, + } + ], + }, + timestamp: now + 600, + }, + // Create a new shadow dom with button + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 37, + nextId: null, + node: { + type: 2, + tagName: 'button', + attributes: { + class: 'blue-btn', + }, + childNodes: [], + id: 38, + isShadow: true, + }, + }, + ], + }, + timestamp: now + 650, + }, + // Adopt the stylesheet #5 on the shadow dom + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.AdoptedStyleSheet, + id: 37, + styleIds: [5], + styles: [ + { + styleId: 5, + rules: [ + { rule: '.blue-btn { color: blue; }' } + ] + } + ] + }, + timestamp: now + 700, + }, + // Remove the parent of shadow host #3 and add a new shadow host #4 + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + // remove parent of shadow host #3 which contained the element with styles for style id 5 + removes: [ + { + parentId: 7, + id: 36, + } + ], + adds: [ + { + parentId: 7, + nextId: null, + node: { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [], + id: 40, + }, + }, + { + parentId: 40, + nextId: null, + node: { + type: 2, + tagName: 'div', + id: 41, + childNodes: [], + isShadowHost: true, + attributes: { + id: 'shadow-host4' + } + }, + } + ], + texts: [], + attributes: [], + }, + timestamp: now + 750, + }, + // Create a new shadow dom with button + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 41, + nextId: null, + node: { + type: 2, + tagName: 'button', + attributes: { + class: 'blue-btn', + }, + childNodes: [], + id: 42, + isShadow: true, + }, + }, + ], + }, + timestamp: now + 800, + }, + // Adopt stlyesheet #5 and #6 on the shadow dom + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.AdoptedStyleSheet, + id: 41, + styleIds: [5, 6], + styles: [ + { + styleId: 6, + rules: [ + { rule: '.blue-btn { border: 1px solid green }' } + ] + } + ] + }, + timestamp: now + 850, + }, ]; export default events; diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index 6067330266..bdee1ffbbf 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -827,7 +827,7 @@ describe('replayer', function () { var replayer = new Replayer(events,{showDebug:true}); replayer.play(); `); - await page.waitForTimeout(600); + await page.waitForTimeout(900); const iframe = await page.$('iframe'); const contentDocument = await iframe!.contentFrame()!; const colorRGBMap = { @@ -881,13 +881,34 @@ describe('replayer', function () { ).color, ), ).toEqual(colorRGBMap.green); + + // check the adopted stylesheet #5 is applied on the shadow dom #4's root + expect(await contentDocument!.evaluate(() => + window.getComputedStyle( + document + .querySelector('#shadow-host4')! + .shadowRoot!.querySelector('button')!, + ).color, + ), + ).toEqual(colorRGBMap.blue); + + // check the adopted stylesheet #6 is applied on the shadow dom #4's root + expect(await contentDocument!.evaluate(() => + window.getComputedStyle( + document + .querySelector('#shadow-host4')! + .shadowRoot!.querySelector('button')!, + ).border, + ), + ).toEqual(`1px solid ${colorRGBMap.green}`); + }; await checkCorrectness(); // To test the correctness of replaying adopted stylesheet events in the fast-forward mode. await page.evaluate('replayer.play(0);'); await waitForRAF(page); - await page.evaluate('replayer.pause(600);'); + await page.evaluate('replayer.pause(900);'); await checkCorrectness(); }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5403f0392c..98113ab478 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -459,14 +459,16 @@ export type styleSheetRuleParam = { export type styleSheetRuleCallback = (s: styleSheetRuleParam) => void; +export type styleParam = { + styleId: number; + rules: styleSheetAddRule[]; +}; + export type adoptedStyleSheetParam = { // id indicates the node id of document or shadow DOMs' host element. id: number; // New CSSStyleSheets which have never appeared before. - styles?: { - styleId: number; - rules: styleSheetAddRule[]; - }[]; + styles?: styleParam[]; // StyleSheet ids to be adopted. styleIds: number[]; }; From 9fb6f4830a2c171b99ba702ba46d947926e0ffd1 Mon Sep 17 00:00:00 2001 From: megboehlert Date: Mon, 3 Feb 2025 15:27:23 +0000 Subject: [PATCH 2/2] Apply formatting changes --- .changeset/rare-snakes-pretend.md | 2 +- packages/rrweb/src/replay/index.ts | 7 ++-- .../rrweb/test/events/adopted-style-sheet.ts | 30 ++++++++--------- packages/rrweb/test/replayer.test.ts | 33 ++++++++++--------- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.changeset/rare-snakes-pretend.md b/.changeset/rare-snakes-pretend.md index 233837e978..682b6855ef 100644 --- a/.changeset/rare-snakes-pretend.md +++ b/.changeset/rare-snakes-pretend.md @@ -3,4 +3,4 @@ "@rrweb/types": minor --- -Added a styleMap to store and retrieve adopted stylesheets not initially in the styleMirror when using the virtual DOM. When using the virtual DOM, adopted styles are applied after mutations, so any adopted styles from nodes - including styles from their child nodes - removed will not be applied to nodes that share the same styleId. \ No newline at end of file +Added a styleMap to store and retrieve adopted stylesheets not initially in the styleMirror when using the virtual DOM. When using the virtual DOM, adopted styles are applied after mutations, so any adopted styles from nodes - including styles from their child nodes - removed will not be applied to nodes that share the same styleId. diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 6792f8f3db..6126225d88 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -70,7 +70,7 @@ import type { styleSheetRuleData, styleDeclarationData, adoptedStyleSheetData, - styleParam + styleParam, } from '@rrweb/types'; import { polyfill, @@ -2081,13 +2081,14 @@ export class Replayer { // if node was removed before styles were applied, we want to store the styles for future nodes data.styles?.forEach((style) => { const key = style.styleId; - if (this.styleMirror.getStyle(key) === null) this.adoptedStyleMap.set(key, style); + if (this.styleMirror.getStyle(key) === null) + this.adoptedStyleMap.set(key, style); }); return; } if (!data.styles || data.styles?.length < data.styleIds?.length) { - const styles: styleParam[] = [...data.styles || []]; + const styles: styleParam[] = [...(data.styles || [])]; data.styleIds?.forEach((styleId) => { // styles either already exist in style mirror or in the data if (this.styleMirror.getStyle(styleId) !== null) return; diff --git a/packages/rrweb/test/events/adopted-style-sheet.ts b/packages/rrweb/test/events/adopted-style-sheet.ts index 5271f1fc5e..f571020a5e 100644 --- a/packages/rrweb/test/events/adopted-style-sheet.ts +++ b/packages/rrweb/test/events/adopted-style-sheet.ts @@ -380,10 +380,10 @@ const events: eventWithTime[] = [ childNodes: [], isShadowHost: true, attributes: { - id: 'shadow-host3' - } + id: 'shadow-host3', + }, }, - } + }, ], }, timestamp: now + 600, @@ -425,11 +425,9 @@ const events: eventWithTime[] = [ styles: [ { styleId: 5, - rules: [ - { rule: '.blue-btn { color: blue; }' } - ] - } - ] + rules: [{ rule: '.blue-btn { color: blue; }' }], + }, + ], }, timestamp: now + 700, }, @@ -443,7 +441,7 @@ const events: eventWithTime[] = [ { parentId: 7, id: 36, - } + }, ], adds: [ { @@ -467,10 +465,10 @@ const events: eventWithTime[] = [ childNodes: [], isShadowHost: true, attributes: { - id: 'shadow-host4' - } + id: 'shadow-host4', + }, }, - } + }, ], texts: [], attributes: [], @@ -514,11 +512,9 @@ const events: eventWithTime[] = [ styles: [ { styleId: 6, - rules: [ - { rule: '.blue-btn { border: 1px solid green }' } - ] - } - ] + rules: [{ rule: '.blue-btn { border: 1px solid green }' }], + }, + ], }, timestamp: now + 850, }, diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index bdee1ffbbf..d611b5a5ad 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -883,25 +883,28 @@ describe('replayer', function () { ).toEqual(colorRGBMap.green); // check the adopted stylesheet #5 is applied on the shadow dom #4's root - expect(await contentDocument!.evaluate(() => - window.getComputedStyle( - document - .querySelector('#shadow-host4')! - .shadowRoot!.querySelector('button')!, - ).color, + expect( + await contentDocument!.evaluate( + () => + window.getComputedStyle( + document + .querySelector('#shadow-host4')! + .shadowRoot!.querySelector('button')!, + ).color, ), ).toEqual(colorRGBMap.blue); // check the adopted stylesheet #6 is applied on the shadow dom #4's root - expect(await contentDocument!.evaluate(() => - window.getComputedStyle( - document - .querySelector('#shadow-host4')! - .shadowRoot!.querySelector('button')!, - ).border, - ), - ).toEqual(`1px solid ${colorRGBMap.green}`); - + expect( + await contentDocument!.evaluate( + () => + window.getComputedStyle( + document + .querySelector('#shadow-host4')! + .shadowRoot!.querySelector('button')!, + ).border, + ), + ).toEqual(`1px solid ${colorRGBMap.green}`); }; await checkCorrectness();