Skip to content

Commit

Permalink
Buffer modifications to virtual stylesheets (#618)
Browse files Browse the repository at this point in the history
* Fix sheet insertion

Restore skip duration

Use virtualStyleRulesMap to re-populate stylesheet on Flush event

Clear virtualStyleRulesMap after flush applied

* Support rule deletion in virtual processing

* Simply restoreNodeSheet with early aborts

* Encountered a bug where firstFullSnapshot was played twice because timer was immediately started and reached the snapshot before the setTimeout returned

* Ignoring a FullSnapshot needs to be a one-time only thing, as otherwise we'll ignore it after scrubbing (restarting play head at a particular time). This is a problem if mutations have altered the player state, and we try to replay those mutations, so we e.g. try to remove an element that has already been removed because we haven't reset the FullSnapshot state

* Some `npm run typings` related fixups

* add basic html snapshot functionality

* move restoreNodeSheet to it's own module

* Refactor virtual style rules to buffer changes.
Only applies changes on flush.

`virtualStyleRulesMap` now works with strings instead of CSSRules.
CSSRules can only be via made `.insertRule` on CSSStyleSheet in most browsers.
And `new CSSStyleSheet()` only works in Chrome currently.

* remove unused code

* move VirtualStyleRules from CSSRule to string in tests

* correct paths for tests

* naming

* create and restore style snapshots for virtual nodes

* update replayer snapshot

* move storeCSSRules to virtual-styles.ts

* try/catch access to .sheet in case of access errors

* clean up tests

Co-authored-by: Vladimir Milenko <vladimir.milenko@uber.com>
Co-authored-by: Eoghan Murray <eoghan@getthere.ie>
  • Loading branch information
3 people authored Jul 8, 2021
1 parent 7a0e04c commit 39c8ba1
Show file tree
Hide file tree
Showing 9 changed files with 1,851 additions and 69 deletions.
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"scripts": {
"prepare": "npm run prepack",
"prepack": "npm run bundle",
"test": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register test/**/*.test.ts",
"test": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register -r ignore-styles -r jsdom-global/register test/**.test.ts",
"test:headless": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true PUPPETEER_HEADLESS=true mocha -r ts-node/register -r ignore-styles -r jsdom-global/register test/**.test.ts",
"test:watch": "PUPPETEER_HEADLESS=true npm run test -- --watch --watch-extensions js,ts",
"repl": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true ts-node scripts/repl.ts",
"bundle:browser": "cross-env BROWSER_ONLY=true rollup --config",
Expand Down Expand Up @@ -39,14 +40,20 @@
"devDependencies": {
"@types/chai": "^4.1.6",
"@types/inquirer": "0.0.43",
"@types/jsdom": "^16.2.12",
"@types/mocha": "^5.2.5",
"@types/node": "^10.11.7",
"@types/puppeteer": "^5.4.3",
"chai": "^4.2.0",
"cross-env": "^5.2.0",
"fast-mhtml": "^1.1.9",
"ignore-styles": "^5.0.1",
"inquirer": "^6.2.1",
"jest-snapshot": "^23.6.0",
"jsdom": "^16.6.0",
"jsdom-global": "^3.0.2",
"mocha": "^5.2.0",
"node-libtidy": "^0.4.0",
"prettier": "2.2.1",
"puppeteer": "^9.1.1",
"rollup": "^2.3.3",
Expand Down
113 changes: 77 additions & 36 deletions src/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ import {
} from '../utils';
import getInjectStyleRules from './styles/inject-style';
import './styles/style.css';
import {
applyVirtualStyleRulesToNode,
storeCSSRules,
StyleRuleType,
VirtualStyleRules,
VirtualStyleRulesMap,
} from './virtual-styles';

const SKIP_TIME_THRESHOLD = 10 * 1000;
const SKIP_TIME_INTERVAL = 5 * 1000;
Expand Down Expand Up @@ -85,6 +92,8 @@ export class Replayer {
private treeIndex!: TreeIndex;
private fragmentParentMap!: Map<INode, INode>;
private elementStateMap!: Map<INode, ElementState>;
// Hold the list of CSSRules for in-memory state restoration
private virtualStyleRulesMap!: VirtualStyleRulesMap;

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

Expand Down Expand Up @@ -129,14 +138,21 @@ export class Replayer {
this.treeIndex = new TreeIndex();
this.fragmentParentMap = new Map<INode, INode>();
this.elementStateMap = new Map<INode, ElementState>();
this.virtualStyleRulesMap = new Map();

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

this.fragmentParentMap.forEach((parent, frag) =>
this.restoreRealParent(frag, parent),
);
for (const node of this.virtualStyleRulesMap.keys()) {
// restore css rules of style 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 @@ -909,64 +925,73 @@ export class Replayer {
const styleEl = (target as Node) as HTMLStyleElement;
const parent = (target.parentNode as unknown) as INode;
const usingVirtualParent = this.fragmentParentMap.has(parent);
let placeholderNode;

if (usingVirtualParent) {
/**
* Always use existing DOM node, when it's there.
* In in-memory replay, there is virtual node, but it's `sheet` is inaccessible.
* Hence, we buffer all style changes in virtualStyleRulesMap.
*/
const styleSheet = usingVirtualParent ? null : styleEl.sheet;
let rules: VirtualStyleRules;

if (!styleSheet) {
/**
* styleEl.sheet is only accessible if the styleEl is part of the
* dom. This doesn't work on DocumentFragments so we have to re-add
* it to the dom temporarily.
* dom. This doesn't work on DocumentFragments so we have to add the
* style mutations to the virtualStyleRulesMap.
*/
const domParent = this.fragmentParentMap.get(
(target.parentNode as unknown) as INode,
);
placeholderNode = document.createTextNode('');
parent.replaceChild(placeholderNode, target);
domParent!.appendChild(target);
}

const styleSheet: CSSStyleSheet = styleEl.sheet!;
if (this.virtualStyleRulesMap.has(target)) {
rules = this.virtualStyleRulesMap.get(target) as VirtualStyleRules;
} else {
rules = [];
this.virtualStyleRulesMap.set(target, rules);
}
}

if (d.adds) {
d.adds.forEach(({ rule, index }) => {
try {
const _index =
index === undefined
? undefined
: Math.min(index, styleSheet.rules.length);
if (styleSheet) {
try {
styleSheet.insertRule(rule, _index);
const _index =
index === undefined
? undefined
: Math.min(index, styleSheet.cssRules.length);
try {
styleSheet.insertRule(rule, _index);
} catch (e) {
/**
* sometimes we may capture rules with browser prefix
* insert rule with prefixs in other browsers may cause Error
*/
}
} catch (e) {
/**
* sometimes we may capture rules with browser prefix
* insert rule with prefixs in other browsers may cause Error
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
} else {
rules?.push({ cssText: rule, index, type: StyleRuleType.Insert });
}
});
}

if (d.removes) {
d.removes.forEach(({ index }) => {
try {
styleSheet.deleteRule(index);
} catch (e) {
/**
* same as insertRule
*/
if (usingVirtualParent) {
rules?.push({ index, type: StyleRuleType.Remove });
} else {
try {
styleSheet?.deleteRule(index);
} catch (e) {
/**
* same as insertRule
*/
}
}
});
}

if (usingVirtualParent && placeholderNode) {
parent.replaceChild(target, placeholderNode);
}

break;
}
case IncrementalSource.CanvasMutation: {
Expand Down Expand Up @@ -1496,6 +1521,11 @@ export class Replayer {
scroll: [parentElement.scrollLeft, parentElement.scrollTop],
});
}
if (parentElement.tagName === 'STYLE')
storeCSSRules(
parentElement as HTMLStyleElement,
this.virtualStyleRulesMap,
);
const children = parentElement.children;
for (const child of Array.from(children)) {
this.storeState((child as unknown) as INode);
Expand Down Expand Up @@ -1527,6 +1557,17 @@ 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;

applyVirtualStyleRulesToNode(storedRules, styleNode);
}

private warnNodeNotFound(d: incrementalData, id: number) {
this.warn(`Node with id '${id}' not found in`, d);
}
Expand Down
119 changes: 119 additions & 0 deletions src/replay/virtual-styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { INode } from 'rrweb-snapshot';

export enum StyleRuleType {
Insert,
Remove,
Snapshot,
}

type InsertRule = {
cssText: string;
type: StyleRuleType.Insert;
index?: number;
};
type RemoveRule = {
type: StyleRuleType.Remove;
index: number;
};
type SnapshotRule = {
type: StyleRuleType.Snapshot;
cssTexts: string[];
};

export type VirtualStyleRules = Array<InsertRule | RemoveRule | SnapshotRule>;
export type VirtualStyleRulesMap = Map<INode, VirtualStyleRules>;

export function applyVirtualStyleRulesToNode(
storedRules: VirtualStyleRules,
styleNode: HTMLStyleElement,
) {
storedRules.forEach((rule) => {
if (rule.type === StyleRuleType.Insert) {
try {
styleNode.sheet?.insertRule(rule.cssText, rule.index);
} catch (e) {
/**
* sometimes we may capture rules with browser prefix
* insert rule with prefixs in other browsers may cause Error
*/
}
} else if (rule.type === StyleRuleType.Remove) {
try {
styleNode.sheet?.deleteRule(rule.index);
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
} else if (rule.type === StyleRuleType.Snapshot) {
restoreSnapshotOfStyleRulesToNode(rule.cssTexts, styleNode);
}
});
}

function restoreSnapshotOfStyleRulesToNode(
cssTexts: string[],
styleNode: HTMLStyleElement,
) {
try {
const existingRules = Array.from(styleNode.sheet?.cssRules || []).map(
(rule) => rule.cssText,
);
const existingRulesReversed = Object.entries(existingRules).reverse();
let lastMatch = existingRules.length;
existingRulesReversed.forEach(([index, rule]) => {
const indexOf = cssTexts.indexOf(rule);
if (indexOf === -1 || indexOf > lastMatch) {
try {
styleNode.sheet?.deleteRule(Number(index));
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
}
lastMatch = indexOf;
});
cssTexts.forEach((cssText, index) => {
try {
if (styleNode.sheet?.cssRules[index]?.cssText !== cssText) {
styleNode.sheet?.insertRule(cssText, index);
}
} catch (e) {
/**
* sometimes we may capture rules with browser prefix
* insert rule with prefixs in other browsers may cause Error
*/
}
});
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
}

export function storeCSSRules(
parentElement: HTMLStyleElement,
virtualStyleRulesMap: VirtualStyleRulesMap,
) {
try {
const cssTexts = Array.from(
(parentElement as HTMLStyleElement).sheet?.cssRules || [],
).map((rule) => rule.cssText);
virtualStyleRulesMap.set((parentElement as unknown) as INode, [
{
type: StyleRuleType.Snapshot,
cssTexts,
},
]);
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
}
Loading

0 comments on commit 39c8ba1

Please sign in to comment.