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 #43

Merged
merged 1 commit into from
Jul 9, 2021
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@highlight-run/rrweb",
"version": "0.12.1",
"version": "0.12.2",
"description": "record and replay the web",
"scripts": {
"test": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register test/**/*.test.ts",
Expand Down
157 changes: 98 additions & 59 deletions src/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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 = 2 * 1000;
Expand Down Expand Up @@ -112,6 +119,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();
/** The first time the player is playing. */
Expand Down Expand Up @@ -155,6 +164,7 @@ 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();

Expand All @@ -169,14 +179,15 @@ export class Replayer {
parent.__sn.tagName === 'textarea' &&
frag.textContent
) {
((parent as unknown) as HTMLTextAreaElement).value = frag.textContent;
(parent as unknown as HTMLTextAreaElement).value = frag.textContent;
}
parent.appendChild(frag);
// restore state of elements after they are mounted
this.restoreState(parent);
}
this.fragmentParentMap.clear();
this.elementStateMap.clear();
this.virtualStyleRulesMap.clear();

for (const d of scrollMap.values()) {
this.applyScroll(d);
Expand Down Expand Up @@ -386,9 +397,10 @@ export class Replayer {

public getMetaData(): playerMetaData {
const firstEvent = this.service.state.context.events[0];
const lastEvent = this.service.state.context.events[
this.service.state.context.events.length - 1
];
const lastEvent =
this.service.state.context.events[
this.service.state.context.events.length - 1
];
return {
startTime: firstEvent.timestamp,
endTime: lastEvent.timestamp,
Expand Down Expand Up @@ -838,13 +850,13 @@ export class Replayer {
const { triggerFocus } = this.config;
switch (d.type) {
case MouseInteractions.Blur:
if ('blur' in ((target as Node) as HTMLElement)) {
((target as Node) as HTMLElement).blur();
if ('blur' in (target as Node as HTMLElement)) {
(target as Node as HTMLElement).blur();
}
break;
case MouseInteractions.Focus:
if (triggerFocus && ((target as Node) as HTMLElement).focus) {
((target as Node) as HTMLElement).focus({
if (triggerFocus && (target as Node as HTMLElement).focus) {
(target as Node as HTMLElement).focus({
preventScroll: true,
});
}
Expand Down Expand Up @@ -914,7 +926,7 @@ export class Replayer {
if (!target) {
return this.debugNodeNotFound(d, d.id);
}
const mediaEl = (target as Node) as HTMLMediaElement;
const mediaEl = target as Node as HTMLMediaElement;
try {
if (d.type === MediaInteractions.Pause) {
mediaEl.pause();
Expand Down Expand Up @@ -943,67 +955,77 @@ export class Replayer {
return this.debugNodeNotFound(d, d.id);
}

const styleEl = (target as Node) as HTMLStyleElement;
const parent = (target.parentNode as unknown) as INode;
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 All @@ -1015,7 +1037,7 @@ export class Replayer {
return this.debugNodeNotFound(d, d.id);
}
try {
const ctx = ((target as unknown) as HTMLCanvasElement).getContext(
const ctx = (target as unknown as HTMLCanvasElement).getContext(
'2d',
)!;
if (d.setter) {
Expand Down Expand Up @@ -1148,7 +1170,8 @@ export class Replayer {
}

if (useVirtualParent && parentInDocument) {
const virtualParent = (document.createDocumentFragment() as unknown) as INode;
const virtualParent =
document.createDocumentFragment() as unknown as INode;
mirror.map[mutation.parentId] = virtualParent;
this.fragmentParentMap.set(virtualParent, parent);

Expand Down Expand Up @@ -1272,9 +1295,9 @@ export class Replayer {
const value = mutation.attributes[attributeName];
try {
if (value !== null) {
((target as Node) as Element).setAttribute(attributeName, value);
(target as Node as Element).setAttribute(attributeName, value);
} else {
((target as Node) as Element).removeAttribute(attributeName);
(target as Node as Element).removeAttribute(attributeName);
}
} catch (error) {
if (this.config.showWarning) {
Expand Down Expand Up @@ -1302,8 +1325,8 @@ export class Replayer {
});
} else {
try {
((target as Node) as Element).scrollTop = d.y;
((target as Node) as Element).scrollLeft = d.x;
(target as Node as Element).scrollTop = d.y;
(target as Node as Element).scrollLeft = d.x;
} catch (error) {
/**
* Seldomly we may found scroll target was removed before
Expand All @@ -1319,8 +1342,8 @@ export class Replayer {
return this.debugNodeNotFound(d, d.id);
}
try {
((target as Node) as HTMLInputElement).checked = d.isChecked;
((target as Node) as HTMLInputElement).value = d.text;
(target as Node as HTMLInputElement).checked = d.isChecked;
(target as Node as HTMLInputElement).value = d.text;
} catch (error) {
// for safe
}
Expand Down Expand Up @@ -1406,7 +1429,7 @@ export class Replayer {
if (!target) {
return this.debugNodeNotFound(d, id);
}
this.hoverElements((target as Node) as Element);
this.hoverElements(target as Node as Element);
}

private drawMouseTail(position: { x: number; y: number }) {
Expand Down Expand Up @@ -1489,16 +1512,21 @@ export class Replayer {
private storeState(parent: INode) {
if (parent) {
if (parent.nodeType === parent.ELEMENT_NODE) {
const parentElement = (parent as unknown) as HTMLElement;
const parentElement = parent as unknown as HTMLElement;
if (parentElement.scrollLeft || parentElement.scrollTop) {
// store scroll position state
this.elementStateMap.set(parent, {
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);
this.storeState(child as unknown as INode);
}
}
}
Expand All @@ -1510,7 +1538,7 @@ export class Replayer {
*/
private restoreState(parent: INode) {
if (parent.nodeType === parent.ELEMENT_NODE) {
const parentElement = (parent as unknown) as HTMLElement;
const parentElement = parent as unknown as HTMLElement;
if (this.elementStateMap.has(parent)) {
const storedState = this.elementStateMap.get(parent)!;
// restore scroll position
Expand All @@ -1522,11 +1550,22 @@ export class Replayer {
}
const children = parentElement.children;
for (const child of Array.from(children)) {
this.restoreState((child as unknown) as INode);
this.restoreState(child as unknown as INode);
}
}
}

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
Loading