From b3aff9027bff51120169b94b0da83e0c5b065c8b Mon Sep 17 00:00:00 2001 From: Vladimir Milenko Date: Sat, 3 Apr 2021 00:13:39 +0200 Subject: [PATCH 1/3] Fix sheet insertion Restore skip duration Use virtualStyleRulesMap to re-populate stylesheet on Flush event Clear virtualStyleRulesMap after flush applied --- src/replay/index.ts | 61 ++++++++++++++++++++++++++++++++++++++++--- test/replayer.test.ts | 13 +++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/replay/index.ts b/src/replay/index.ts index 255083b76c..63c48efab6 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -117,6 +117,8 @@ export class Replayer { private treeIndex!: TreeIndex; private fragmentParentMap!: Map; private elementStateMap!: Map; + // Hold the list of CSSRules during in-memory state restoration + private virtualStyleRulesMap!: Map; private imageMap: Map = new Map(); @@ -160,6 +162,8 @@ export class Replayer { this.treeIndex = new TreeIndex(); this.fragmentParentMap = new Map(); this.elementStateMap = new Map(); + this.virtualStyleRulesMap = new Map(); + this.emitter.on(ReplayerEvents.Flush, () => { const { scrollMap, inputMap } = this.treeIndex.flush(); @@ -180,8 +184,13 @@ export class Replayer { // restore state of elements after they are mounted this.restoreState(parent); } + for (const [node] of this.virtualStyleRulesMap.entries()) { + // restore css rules of elements after they are mounted + this.restoreNodeSheet(node); + } this.fragmentParentMap.clear(); this.elementStateMap.clear(); + this.virtualStyleRulesMap.clear(); for (const d of scrollMap.values()) { this.applyScroll(d); @@ -913,6 +922,17 @@ export class Replayer { const styleEl = (target as Node) as HTMLStyleElement; const parent = (target.parentNode as unknown) as INode; const usingVirtualParent = this.fragmentParentMap.has(parent); + + /** + * Always use existing DOM node, when it's there. + * In in-memory replay, there is virtual node, but it's `sheet` will be removed during replacement. + * Hence, we re-create it and re-populate it on each run to not miss pre-existing styles and previously inserted + */ + const styleSheet = usingVirtualParent + ? new CSSStyleSheet() + : styleEl.sheet + ? styleEl.sheet + : new CSSStyleSheet(); let placeholderNode; if (usingVirtualParent) { @@ -927,9 +947,21 @@ export class Replayer { placeholderNode = document.createTextNode(''); parent.replaceChild(placeholderNode, target); domParent!.appendChild(target); - } - const styleSheet: CSSStyleSheet = styleEl.sheet!; + if (!this.virtualStyleRulesMap.has(target)) { + this.virtualStyleRulesMap.set( + target, + Array.from(styleEl.sheet?.rules || []), + ); + } + + const existingRules = this.virtualStyleRulesMap.get(target); + if (existingRules) { + existingRules.forEach((rule, index) => { + styleSheet?.insertRule(rule.cssText, index); + }); + } + } if (d.adds) { d.adds.forEach(({ rule, index }) => { @@ -966,11 +998,16 @@ export class Replayer { } }); } - + if (usingVirtualParent) { + // Update rules list according to new styleSheet + this.virtualStyleRulesMap.set( + target, + Array.from(styleSheet?.rules || []), + ); + } if (usingVirtualParent && placeholderNode) { parent.replaceChild(target, placeholderNode); } - break; } case IncrementalSource.CanvasMutation: { @@ -1559,6 +1596,22 @@ export class Replayer { } } + private restoreNodeSheet(node: INode) { + if (node.nodeName === 'STYLE') { + const styleNode = (node as unknown) as HTMLStyleElement; + if (this.virtualStyleRulesMap.has(node)) { + const storedRules = this.virtualStyleRulesMap.get(node); + if (!storedRules) return; + storedRules.forEach((rule, index) => { + if (styleNode?.sheet?.rules[index]) { + styleNode.sheet?.deleteRule(index); + } + styleNode.sheet?.insertRule(rule.cssText, index); + }); + } + } + } + private warnNodeNotFound(d: incrementalData, id: number) { this.warn(`Node with id '${id}' not found in`, d); } diff --git a/test/replayer.test.ts b/test/replayer.test.ts index e61c3d3714..d1ce128b45 100644 --- a/test/replayer.test.ts +++ b/test/replayer.test.ts @@ -146,6 +146,19 @@ describe('replayer', function (this: ISuite) { replayer.play(1500); replayer['timer']['actions'].length; `); + + const result = await this.page.evaluate(` + const rules = Array.from(replayer.iframe.contentDocument.head.children) + .filter(x=>x.nodeName === 'STYLE') + .reduce((acc, node) => { + acc.push(...node.sheet.rules); + return acc; + }, []); + + rules.some(x=>x.selectorText === ".css-1fbxx79") + `); + + expect(result).to.equal(true); expect(actionLength).to.equal( styleSheetRuleEvents.filter( (e) => e.timestamp - styleSheetRuleEvents[0].timestamp >= 1500, From 48016b3c0ccaf63b73f2b22af8dd9a365c2407a1 Mon Sep 17 00:00:00 2001 From: Vladimir Milenko Date: Sun, 4 Apr 2021 01:05:08 +0200 Subject: [PATCH 2/3] Support rule deletion in virtual processing --- src/replay/index.ts | 5 +++++ test/events/style-sheet-rule-events.ts | 13 +++++++++++++ test/replayer.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/src/replay/index.ts b/src/replay/index.ts index 63c48efab6..d8724d8d5a 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -1608,6 +1608,11 @@ export class Replayer { } styleNode.sheet?.insertRule(rule.cssText, index); }); + if(styleNode.sheet && styleNode.sheet.rules.length > storedRules.length) { + for(let i = styleNode.sheet.rules.length-1; i < storedRules.length - 1; i--) { + styleNode.sheet.removeRule(i); + } + } } } } diff --git a/test/events/style-sheet-rule-events.ts b/test/events/style-sheet-rule-events.ts index 5740f50215..bc56eae457 100644 --- a/test/events/style-sheet-rule-events.ts +++ b/test/events/style-sheet-rule-events.ts @@ -141,6 +141,19 @@ const events: eventWithTime[] = [ type: EventType.IncrementalSnapshot, timestamp: now + 1000, }, + { + data: { + id: 105, + removes: [ + { + index: 2, + }, + ], + source: IncrementalSource.StyleSheetRule, + }, + type: EventType.IncrementalSnapshot, + timestamp: now + 2500, + }, ]; export default events; diff --git a/test/replayer.test.ts b/test/replayer.test.ts index d1ce128b45..fbd59ee1af 100644 --- a/test/replayer.test.ts +++ b/test/replayer.test.ts @@ -183,6 +183,31 @@ describe('replayer', function (this: ISuite) { ); }); + it('can fast forward past StyleSheetRule deletion on virtual elements', async () => { + await this.page.evaluate( + `events = ${JSON.stringify(styleSheetRuleEvents)}`, + ); + const actionLength = await this.page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events); + replayer.play(2500); + replayer['timer']['actions'].length; + `); + + const result = await this.page.evaluate(` + const rules = Array.from(replayer.iframe.contentDocument.head.children) + .filter(x=>x.nodeName === 'STYLE') + .reduce((acc, node) => { + acc.push(...node.sheet.rules); + return acc; + }, []); + + rules.some(x=>x.selectorText === ".css-1fbxx79") + `); + + expect(result).to.equal(false); + }); + it('can stream events in live mode', async () => { const status = await this.page.evaluate(` const { Replayer } = rrweb; From d68e395921f8654d40cdf783bd63bfcf40ccb271 Mon Sep 17 00:00:00 2001 From: Vladimir Milenko Date: Sun, 4 Apr 2021 22:17:23 +0200 Subject: [PATCH 3/3] Simply restoreNodeSheet with early aborts --- src/replay/index.ts | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/replay/index.ts b/src/replay/index.ts index d8724d8d5a..529e714973 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -1597,22 +1597,25 @@ export class Replayer { } private restoreNodeSheet(node: INode) { - if (node.nodeName === 'STYLE') { - const styleNode = (node as unknown) as HTMLStyleElement; - if (this.virtualStyleRulesMap.has(node)) { - const storedRules = this.virtualStyleRulesMap.get(node); - if (!storedRules) return; - storedRules.forEach((rule, index) => { - if (styleNode?.sheet?.rules[index]) { - styleNode.sheet?.deleteRule(index); - } - styleNode.sheet?.insertRule(rule.cssText, index); - }); - if(styleNode.sheet && styleNode.sheet.rules.length > storedRules.length) { - for(let i = styleNode.sheet.rules.length-1; i < storedRules.length - 1; i--) { - styleNode.sheet.removeRule(i); - } - } + const storedRules = this.virtualStyleRulesMap.get(node); + if (node.nodeName !== 'STYLE') return; + + if(!storedRules) return; + + const styleNode = (node as unknown) as HTMLStyleElement; + + storedRules.forEach((rule, index) => { + // Esnure consistency of rules list + if (styleNode?.sheet?.rules[index]) { + styleNode.sheet?.deleteRule(index); + } + styleNode.sheet?.insertRule(rule.cssText, index); + }); + // Avoid situation, when your Node has more styles, than it should + // Otherwise, inserting will be broken + if(styleNode.sheet && styleNode.sheet.rules.length > storedRules.length) { + for(let i = styleNode.sheet.rules.length-1; i < storedRules.length - 1; i--) { + styleNode.sheet.removeRule(i); } } }