Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Buffer modifications to virtual stylesheets #618

Merged
merged 21 commits into from
Jul 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b3aff90
Fix sheet insertion
VladimirMilenko Apr 2, 2021
48016b3
Support rule deletion in virtual processing
VladimirMilenko Apr 3, 2021
d68e395
Simply restoreNodeSheet with early aborts
VladimirMilenko Apr 4, 2021
0bc5bc3
Encountered a bug where firstFullSnapshot was played twice because ti…
eoghanmurray Jun 30, 2021
ff65e4c
Ignoring a FullSnapshot needs to be a one-time only thing, as otherwi…
eoghanmurray Jun 30, 2021
803eb17
Some `npm run typings` related fixups
eoghanmurray Jul 1, 2021
aea6c9a
Merge remote-tracking branch 'VladimirMilenko/incremental-snapshot-cs…
Juice10 Jul 2, 2021
11d7572
add basic html snapshot functionality
Juice10 Jul 2, 2021
8e03559
Merge remote-tracking branch 'statcounter/fix-another-firstfullsnapsh…
Juice10 Jul 3, 2021
5f31a1d
move restoreNodeSheet to it's own module
Juice10 Jul 5, 2021
b6ce12a
Refactor virtual style rules to buffer changes.
Juice10 Jul 5, 2021
e8df6bf
remove unused code
Juice10 Jul 5, 2021
d2cbb2d
move VirtualStyleRules from CSSRule to string in tests
Juice10 Jul 5, 2021
af30029
correct paths for tests
Juice10 Jul 5, 2021
1b367b6
naming
Juice10 Jul 5, 2021
8ee22ee
create and restore style snapshots for virtual nodes
Juice10 Jul 6, 2021
a4dc780
update replayer snapshot
Juice10 Jul 6, 2021
ccc2f79
move storeCSSRules to virtual-styles.ts
Juice10 Jul 6, 2021
bd74dc8
Merge branch 'master' of git://github.com/rrweb-io/rrweb into css-rules
Juice10 Jul 6, 2021
501e376
try/catch access to .sheet in case of access errors
Juice10 Jul 6, 2021
13c4bf4
clean up tests
Juice10 Jul 6, 2021
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
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
118 changes: 78 additions & 40 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 @@ -128,14 +137,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 @@ -501,10 +517,7 @@ export class Replayer {

// events are kept sorted by timestamp, check if this is the last event
let last_index = this.service.state.context.events.length - 1;
if (
event ===
this.service.state.context.events[last_index]
) {
if (event === this.service.state.context.events[last_index]) {
const finish = () => {
if (last_index < this.service.state.context.events.length - 1) {
// more events have been added since the setTimeout
Expand Down Expand Up @@ -911,64 +924,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 @@ -1498,6 +1520,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 @@ -1529,6 +1556,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