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 5f1101cc23..2c959e62ef 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -70,6 +70,7 @@ import type { styleDeclarationData, adoptedStyleSheetData, serializedElementNodeWithId, + styleParam } from '@rrweb/types'; import { polyfill, @@ -151,6 +152,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 styleMap: Map = new Map(); + // Used to track video & audio elements, and keep them in sync with general playback. private mediaManager: MediaManager; @@ -336,6 +340,7 @@ export class Replayer { this.firstFullSnapshot = null; this.mirror.reset(); this.styleMirror.reset(); + this.styleMap = new Map(); this.mediaManager.reset(); }); @@ -556,6 +561,7 @@ export class Replayer { this.mirror.reset(); this.styleMirror.reset(); this.mediaManager.reset(); + this.styleMap = new Map(); this.config.root.removeChild(this.wrapper); this.emitter.emit(ReplayerEvents.Destroy); } @@ -698,6 +704,7 @@ export class Replayer { } this.mediaManager.reset(); this.styleMirror.reset(); + this.styleMap = new Map(); this.rebuildFullSnapshot(event, isSync); this.iframe.contentWindow?.scrollTo(event.data.initialOffset); }; @@ -2066,7 +2073,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.styleMap.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.styleMap.get(styleId); + if (style) styles.push(style); + this.styleMap.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 c38ec356da..04f1567664 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -828,7 +828,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 = { @@ -882,13 +882,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 bba276e483..d074a09ce1 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -451,14 +451,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[]; };