Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions src/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ export class Replayer {
private treeIndex!: TreeIndex;
private fragmentParentMap!: Map<INode, INode>;
private elementStateMap!: Map<INode, ElementState>;
// Hold the list of CSSRules during in-memory state restoration
private virtualStyleRulesMap!: Map<INode, CSSRule[]>;

private imageMap: Map<eventWithTime, HTMLImageElement> = new Map();

Expand Down Expand Up @@ -160,6 +162,8 @@ export class Replayer {
this.treeIndex = new TreeIndex();
this.fragmentParentMap = new Map<INode, INode>();
this.elementStateMap = new Map<INode, ElementState>();
this.virtualStyleRulesMap = new Map<INode, CSSRule[]>();

this.emitter.on(ReplayerEvents.Flush, () => {
const { scrollMap, inputMap } = this.treeIndex.flush();

Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -1559,6 +1596,30 @@ export class Replayer {
}
}

private restoreNodeSheet(node: INode) {
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);
}
}
}

private warnNodeNotFound(d: incrementalData, id: number) {
this.warn(`Node with id '${id}' not found in`, d);
}
Expand Down
13 changes: 13 additions & 0 deletions test/events/style-sheet-rule-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
38 changes: 38 additions & 0 deletions test/replayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment on lines +151 to +158
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice if we could simplify this a little

Suggested change
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")
const rules = [...replayer.iframe.contentDocument.styleSheets].map((sheet)=> sheet.rules);
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,
Expand All @@ -170,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")
Comment on lines +198 to +205
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same goes for here

Suggested change
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")
const rules = [...replayer.iframe.contentDocument.styleSheets].map((sheet)=> sheet.rules);
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;
Expand Down