* inline stylesheets when loaded * set empty link elements to loaded by default * Clean up stylesheet manager * Remove attribute mutation code * Update packages/rrweb/test/record.test.ts * Update packages/rrweb/test/record.test.ts * Update packages/rrweb/test/record.test.ts * Update packages/rrweb/scripts/repl.js * Update packages/rrweb/test/record.test.ts * Update packages/rrweb/src/record/index.ts * Add todo * Move require out of time sensitive assert * Add waitForRAF, its more reliable than waitForTimeout * Remove flaky tests * Add recording stylesheets in iframes * Remove variability from flaky test * Make test more robust * Fix naming * Add test cases for inlineImages * Add test cases for inlineImages * Record iframe mutations cross page * Test: should record images inside iframe with blob url after iframe was reloaded * Handle negative ids in rrdom correctly When iframes get inserted they create untracked elements, both on the dom and rrdom side. Because they are untracked they generate negative numbers when fetching the id from mirror. This creates a problem when comparing and fetching ids across mirrors. This commit tries to get away from using negative ids as much as possible in rrdom's comparisons * Update packages/rrdom/src/diff.ts Co-authored-by: Yun Feng <yun.feng@anu.edu.au> * Start unserialized nodes at -2 This way we don't accidentally think of them as mirror misses * Set unserialized id starting number at -2 * Remove duplication Co-authored-by: Yun Feng <yun.feng@anu.edu.au>
547 lines
17 KiB
TypeScript
547 lines
17 KiB
TypeScript
import { NodeType as RRNodeType, Mirror as NodeMirror } from 'rrweb-snapshot';
|
|
import type {
|
|
canvasMutationData,
|
|
canvasEventWithTime,
|
|
inputData,
|
|
scrollData,
|
|
} from 'rrweb/src/types';
|
|
import type {
|
|
IRRCDATASection,
|
|
IRRComment,
|
|
IRRDocument,
|
|
IRRDocumentType,
|
|
IRRElement,
|
|
IRRNode,
|
|
IRRText,
|
|
} from './document';
|
|
import type {
|
|
RRCanvasElement,
|
|
RRElement,
|
|
RRIFrameElement,
|
|
RRMediaElement,
|
|
RRStyleElement,
|
|
RRDocument,
|
|
Mirror,
|
|
} from '.';
|
|
|
|
const NAMESPACES: Record<string, string> = {
|
|
svg: 'http://www.w3.org/2000/svg',
|
|
'xlink:href': 'http://www.w3.org/1999/xlink',
|
|
xmlns: 'http://www.w3.org/2000/xmlns/',
|
|
};
|
|
|
|
// camel case svg element tag names
|
|
const SVGTagMap: Record<string, string> = {
|
|
altglyph: 'altGlyph',
|
|
altglyphdef: 'altGlyphDef',
|
|
altglyphitem: 'altGlyphItem',
|
|
animatecolor: 'animateColor',
|
|
animatemotion: 'animateMotion',
|
|
animatetransform: 'animateTransform',
|
|
clippath: 'clipPath',
|
|
feblend: 'feBlend',
|
|
fecolormatrix: 'feColorMatrix',
|
|
fecomponenttransfer: 'feComponentTransfer',
|
|
fecomposite: 'feComposite',
|
|
feconvolvematrix: 'feConvolveMatrix',
|
|
fediffuselighting: 'feDiffuseLighting',
|
|
fedisplacementmap: 'feDisplacementMap',
|
|
fedistantlight: 'feDistantLight',
|
|
fedropshadow: 'feDropShadow',
|
|
feflood: 'feFlood',
|
|
fefunca: 'feFuncA',
|
|
fefuncb: 'feFuncB',
|
|
fefuncg: 'feFuncG',
|
|
fefuncr: 'feFuncR',
|
|
fegaussianblur: 'feGaussianBlur',
|
|
feimage: 'feImage',
|
|
femerge: 'feMerge',
|
|
femergenode: 'feMergeNode',
|
|
femorphology: 'feMorphology',
|
|
feoffset: 'feOffset',
|
|
fepointlight: 'fePointLight',
|
|
fespecularlighting: 'feSpecularLighting',
|
|
fespotlight: 'feSpotLight',
|
|
fetile: 'feTile',
|
|
feturbulence: 'feTurbulence',
|
|
foreignobject: 'foreignObject',
|
|
glyphref: 'glyphRef',
|
|
lineargradient: 'linearGradient',
|
|
radialgradient: 'radialGradient',
|
|
};
|
|
|
|
export type ReplayerHandler = {
|
|
mirror: NodeMirror;
|
|
applyCanvas: (
|
|
canvasEvent: canvasEventWithTime,
|
|
canvasMutationData: canvasMutationData,
|
|
target: HTMLCanvasElement,
|
|
) => void;
|
|
applyInput: (data: inputData) => void;
|
|
applyScroll: (data: scrollData, isSync: boolean) => void;
|
|
};
|
|
|
|
export function diff(
|
|
oldTree: Node,
|
|
newTree: IRRNode,
|
|
replayer: ReplayerHandler,
|
|
rrnodeMirror?: Mirror,
|
|
) {
|
|
const oldChildren = oldTree.childNodes;
|
|
const newChildren = newTree.childNodes;
|
|
rrnodeMirror =
|
|
rrnodeMirror ||
|
|
(newTree as RRDocument).mirror ||
|
|
(newTree.ownerDocument as RRDocument).mirror;
|
|
|
|
if (oldChildren.length > 0 || newChildren.length > 0) {
|
|
diffChildren(
|
|
Array.from(oldChildren),
|
|
newChildren,
|
|
oldTree,
|
|
replayer,
|
|
rrnodeMirror,
|
|
);
|
|
}
|
|
|
|
let inputDataToApply = null,
|
|
scrollDataToApply = null;
|
|
switch (newTree.RRNodeType) {
|
|
case RRNodeType.Document: {
|
|
const newRRDocument = newTree as IRRDocument;
|
|
scrollDataToApply = (newRRDocument as RRDocument).scrollData;
|
|
break;
|
|
}
|
|
case RRNodeType.Element: {
|
|
const oldElement = oldTree as HTMLElement;
|
|
const newRRElement = newTree as IRRElement;
|
|
diffProps(oldElement, newRRElement, rrnodeMirror);
|
|
scrollDataToApply = (newRRElement as RRElement).scrollData;
|
|
inputDataToApply = (newRRElement as RRElement).inputData;
|
|
switch (newRRElement.tagName) {
|
|
case 'AUDIO':
|
|
case 'VIDEO': {
|
|
const oldMediaElement = oldTree as HTMLMediaElement;
|
|
const newMediaRRElement = newRRElement as RRMediaElement;
|
|
if (newMediaRRElement.paused !== undefined)
|
|
newMediaRRElement.paused
|
|
? void oldMediaElement.pause()
|
|
: void oldMediaElement.play();
|
|
if (newMediaRRElement.muted !== undefined)
|
|
oldMediaElement.muted = newMediaRRElement.muted;
|
|
if (newMediaRRElement.volume !== undefined)
|
|
oldMediaElement.volume = newMediaRRElement.volume;
|
|
if (newMediaRRElement.currentTime !== undefined)
|
|
oldMediaElement.currentTime = newMediaRRElement.currentTime;
|
|
break;
|
|
}
|
|
case 'CANVAS':
|
|
{
|
|
const rrCanvasElement = newTree as RRCanvasElement;
|
|
// This canvas element is created with initial data in an iframe element. https://github.com/rrweb-io/rrweb/pull/944
|
|
if (rrCanvasElement.rr_dataURL !== null) {
|
|
const image = document.createElement('img');
|
|
image.onload = () => {
|
|
const ctx = (oldElement as HTMLCanvasElement).getContext('2d');
|
|
if (ctx) {
|
|
ctx.drawImage(image, 0, 0, image.width, image.height);
|
|
}
|
|
};
|
|
image.src = rrCanvasElement.rr_dataURL;
|
|
}
|
|
rrCanvasElement.canvasMutations.forEach((canvasMutation) =>
|
|
replayer.applyCanvas(
|
|
canvasMutation.event,
|
|
canvasMutation.mutation,
|
|
oldTree as HTMLCanvasElement,
|
|
),
|
|
);
|
|
}
|
|
break;
|
|
case 'STYLE':
|
|
applyVirtualStyleRulesToNode(
|
|
oldElement as HTMLStyleElement,
|
|
(newTree as RRStyleElement).rules,
|
|
);
|
|
break;
|
|
}
|
|
if (newRRElement.shadowRoot) {
|
|
if (!oldElement.shadowRoot) oldElement.attachShadow({ mode: 'open' });
|
|
const oldChildren = oldElement.shadowRoot!.childNodes;
|
|
const newChildren = newRRElement.shadowRoot.childNodes;
|
|
if (oldChildren.length > 0 || newChildren.length > 0)
|
|
diffChildren(
|
|
Array.from(oldChildren),
|
|
newChildren,
|
|
oldElement.shadowRoot!,
|
|
replayer,
|
|
rrnodeMirror,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case RRNodeType.Text:
|
|
case RRNodeType.Comment:
|
|
case RRNodeType.CDATA:
|
|
if (
|
|
oldTree.textContent !==
|
|
(newTree as IRRText | IRRComment | IRRCDATASection).data
|
|
)
|
|
oldTree.textContent = (newTree as
|
|
| IRRText
|
|
| IRRComment
|
|
| IRRCDATASection).data;
|
|
break;
|
|
default:
|
|
}
|
|
|
|
scrollDataToApply && replayer.applyScroll(scrollDataToApply, true);
|
|
/**
|
|
* Input data need to get applied after all children of this node are updated.
|
|
* Otherwise when we set a value for a select element whose options are empty, the value won't actually update.
|
|
*/
|
|
inputDataToApply && replayer.applyInput(inputDataToApply);
|
|
|
|
// IFrame element doesn't have child nodes.
|
|
if (newTree.nodeName === 'IFRAME') {
|
|
const oldContentDocument = (oldTree as HTMLIFrameElement).contentDocument;
|
|
const newIFrameElement = newTree as RRIFrameElement;
|
|
// If the iframe is cross-origin, the contentDocument will be null.
|
|
if (oldContentDocument) {
|
|
const sn = rrnodeMirror.getMeta(newIFrameElement.contentDocument);
|
|
if (sn) {
|
|
replayer.mirror.add(oldContentDocument, { ...sn });
|
|
}
|
|
diff(
|
|
oldContentDocument,
|
|
newIFrameElement.contentDocument,
|
|
replayer,
|
|
rrnodeMirror,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function diffProps(
|
|
oldTree: HTMLElement,
|
|
newTree: IRRElement,
|
|
rrnodeMirror: Mirror,
|
|
) {
|
|
const oldAttributes = oldTree.attributes;
|
|
const newAttributes = newTree.attributes;
|
|
|
|
for (const name in newAttributes) {
|
|
const newValue = newAttributes[name];
|
|
const sn = rrnodeMirror.getMeta(newTree);
|
|
if (sn && 'isSVG' in sn && sn.isSVG && NAMESPACES[name])
|
|
oldTree.setAttributeNS(NAMESPACES[name], name, newValue);
|
|
else if (newTree.tagName === 'CANVAS' && name === 'rr_dataURL') {
|
|
const image = document.createElement('img');
|
|
image.src = newValue;
|
|
image.onload = () => {
|
|
const ctx = (oldTree as HTMLCanvasElement).getContext('2d');
|
|
if (ctx) {
|
|
ctx.drawImage(image, 0, 0, image.width, image.height);
|
|
}
|
|
};
|
|
} else oldTree.setAttribute(name, newValue);
|
|
}
|
|
|
|
for (const { name } of Array.from(oldAttributes))
|
|
if (!(name in newAttributes)) oldTree.removeAttribute(name);
|
|
|
|
newTree.scrollLeft && (oldTree.scrollLeft = newTree.scrollLeft);
|
|
newTree.scrollTop && (oldTree.scrollTop = newTree.scrollTop);
|
|
}
|
|
|
|
function diffChildren(
|
|
oldChildren: (Node | undefined)[],
|
|
newChildren: IRRNode[],
|
|
parentNode: Node,
|
|
replayer: ReplayerHandler,
|
|
rrnodeMirror: Mirror,
|
|
) {
|
|
let oldStartIndex = 0,
|
|
oldEndIndex = oldChildren.length - 1,
|
|
newStartIndex = 0,
|
|
newEndIndex = newChildren.length - 1;
|
|
let oldStartNode = oldChildren[oldStartIndex],
|
|
oldEndNode = oldChildren[oldEndIndex],
|
|
newStartNode = newChildren[newStartIndex],
|
|
newEndNode = newChildren[newEndIndex];
|
|
let oldIdToIndex: Record<number, number> | undefined = undefined,
|
|
indexInOld;
|
|
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
|
|
const oldStartId = replayer.mirror.getId(oldStartNode);
|
|
const oldEndId = replayer.mirror.getId(oldEndNode);
|
|
const newStartId = rrnodeMirror.getId(newStartNode);
|
|
const newEndId = rrnodeMirror.getId(newEndNode);
|
|
|
|
// rrdom contains elements with negative ids, we don't want to accidentally match those to a mirror mismatch (-1) id.
|
|
// Negative oldStartId happen when nodes are not in the mirror, but are in the DOM.
|
|
// eg.iframes come with a document, html, head and body nodes.
|
|
// thats why below we always check if an id is negative.
|
|
|
|
if (oldStartNode === undefined) {
|
|
oldStartNode = oldChildren[++oldStartIndex];
|
|
} else if (oldEndNode === undefined) {
|
|
oldEndNode = oldChildren[--oldEndIndex];
|
|
} else if (
|
|
oldStartId !== -1 &&
|
|
// same first element?
|
|
oldStartId === newStartId
|
|
) {
|
|
diff(oldStartNode, newStartNode, replayer, rrnodeMirror);
|
|
oldStartNode = oldChildren[++oldStartIndex];
|
|
newStartNode = newChildren[++newStartIndex];
|
|
} else if (
|
|
oldEndId !== -1 &&
|
|
// same last element?
|
|
oldEndId === newEndId
|
|
) {
|
|
diff(oldEndNode, newEndNode, replayer, rrnodeMirror);
|
|
oldEndNode = oldChildren[--oldEndIndex];
|
|
newEndNode = newChildren[--newEndIndex];
|
|
} else if (
|
|
oldStartId !== -1 &&
|
|
// is the first old element the same as the last new element?
|
|
oldStartId === newEndId
|
|
) {
|
|
parentNode.insertBefore(oldStartNode, oldEndNode.nextSibling);
|
|
diff(oldStartNode, newEndNode, replayer, rrnodeMirror);
|
|
oldStartNode = oldChildren[++oldStartIndex];
|
|
newEndNode = newChildren[--newEndIndex];
|
|
} else if (
|
|
oldEndId !== -1 &&
|
|
// is the last old element the same as the first new element?
|
|
oldEndId === newStartId
|
|
) {
|
|
parentNode.insertBefore(oldEndNode, oldStartNode);
|
|
diff(oldEndNode, newStartNode, replayer, rrnodeMirror);
|
|
oldEndNode = oldChildren[--oldEndIndex];
|
|
newStartNode = newChildren[++newStartIndex];
|
|
} else {
|
|
// none of the elements matched
|
|
|
|
if (!oldIdToIndex) {
|
|
oldIdToIndex = {};
|
|
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
|
|
const oldChild = oldChildren[i];
|
|
if (oldChild && replayer.mirror.hasNode(oldChild))
|
|
oldIdToIndex[replayer.mirror.getId(oldChild)] = i;
|
|
}
|
|
}
|
|
indexInOld = oldIdToIndex[rrnodeMirror.getId(newStartNode)];
|
|
if (indexInOld) {
|
|
const nodeToMove = oldChildren[indexInOld]!;
|
|
parentNode.insertBefore(nodeToMove, oldStartNode);
|
|
diff(nodeToMove, newStartNode, replayer, rrnodeMirror);
|
|
oldChildren[indexInOld] = undefined;
|
|
} else {
|
|
const newNode = createOrGetNode(
|
|
newStartNode,
|
|
replayer.mirror,
|
|
rrnodeMirror,
|
|
);
|
|
|
|
/**
|
|
* A mounted iframe element has an automatically created HTML element.
|
|
* We should delete it before insert a serialized one. Otherwise, an error 'Only one element on document allowed' will be thrown.
|
|
*/
|
|
if (
|
|
parentNode.nodeName === '#document' &&
|
|
replayer.mirror.getMeta(newNode)?.type === RRNodeType.Element &&
|
|
(parentNode as Document).documentElement
|
|
) {
|
|
parentNode.removeChild((parentNode as Document).documentElement);
|
|
oldChildren[oldStartIndex] = undefined;
|
|
oldStartNode = undefined;
|
|
}
|
|
parentNode.insertBefore(newNode, oldStartNode || null);
|
|
diff(newNode, newStartNode, replayer, rrnodeMirror);
|
|
}
|
|
newStartNode = newChildren[++newStartIndex];
|
|
}
|
|
}
|
|
if (oldStartIndex > oldEndIndex) {
|
|
const referenceRRNode = newChildren[newEndIndex + 1];
|
|
let referenceNode = null;
|
|
if (referenceRRNode)
|
|
parentNode.childNodes.forEach((child) => {
|
|
if (
|
|
replayer.mirror.getId(child) === rrnodeMirror.getId(referenceRRNode)
|
|
)
|
|
referenceNode = child;
|
|
});
|
|
for (; newStartIndex <= newEndIndex; ++newStartIndex) {
|
|
const newNode = createOrGetNode(
|
|
newChildren[newStartIndex],
|
|
replayer.mirror,
|
|
rrnodeMirror,
|
|
);
|
|
parentNode.insertBefore(newNode, referenceNode);
|
|
diff(newNode, newChildren[newStartIndex], replayer, rrnodeMirror);
|
|
}
|
|
} else if (newStartIndex > newEndIndex) {
|
|
for (; oldStartIndex <= oldEndIndex; oldStartIndex++) {
|
|
const node = oldChildren[oldStartIndex];
|
|
if (node) {
|
|
parentNode.removeChild(node);
|
|
replayer.mirror.removeNodeFromMap(node);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createOrGetNode(
|
|
rrNode: IRRNode,
|
|
domMirror: NodeMirror,
|
|
rrnodeMirror: Mirror,
|
|
): Node {
|
|
const nodeId = rrnodeMirror.getId(rrNode);
|
|
const sn = rrnodeMirror.getMeta(rrNode);
|
|
let node: Node | null = null;
|
|
// negative ids shouldn't be compared accross mirrors
|
|
if (nodeId > -1) node = domMirror.getNode(nodeId);
|
|
if (node !== null) return node;
|
|
switch (rrNode.RRNodeType) {
|
|
case RRNodeType.Document:
|
|
node = new Document();
|
|
break;
|
|
case RRNodeType.DocumentType:
|
|
node = document.implementation.createDocumentType(
|
|
(rrNode as IRRDocumentType).name,
|
|
(rrNode as IRRDocumentType).publicId,
|
|
(rrNode as IRRDocumentType).systemId,
|
|
);
|
|
break;
|
|
case RRNodeType.Element: {
|
|
let tagName = (rrNode as IRRElement).tagName.toLowerCase();
|
|
tagName = SVGTagMap[tagName] || tagName;
|
|
if (sn && 'isSVG' in sn && sn?.isSVG) {
|
|
node = document.createElementNS(NAMESPACES['svg'], tagName);
|
|
} else node = document.createElement((rrNode as IRRElement).tagName);
|
|
break;
|
|
}
|
|
case RRNodeType.Text:
|
|
node = document.createTextNode((rrNode as IRRText).data);
|
|
break;
|
|
case RRNodeType.Comment:
|
|
node = document.createComment((rrNode as IRRComment).data);
|
|
break;
|
|
case RRNodeType.CDATA:
|
|
node = document.createCDATASection((rrNode as IRRCDATASection).data);
|
|
break;
|
|
}
|
|
|
|
if (sn) domMirror.add(node, { ...sn });
|
|
return node;
|
|
}
|
|
|
|
export function getNestedRule(
|
|
rules: CSSRuleList,
|
|
position: number[],
|
|
): CSSGroupingRule {
|
|
const rule = rules[position[0]] as CSSGroupingRule;
|
|
if (position.length === 1) {
|
|
return rule;
|
|
} else {
|
|
return getNestedRule(
|
|
(rule.cssRules[position[1]] as CSSGroupingRule).cssRules,
|
|
position.slice(2),
|
|
);
|
|
}
|
|
}
|
|
|
|
export enum StyleRuleType {
|
|
Insert,
|
|
Remove,
|
|
Snapshot,
|
|
SetProperty,
|
|
RemoveProperty,
|
|
}
|
|
type InsertRule = {
|
|
cssText: string;
|
|
type: StyleRuleType.Insert;
|
|
index?: number | number[];
|
|
};
|
|
type RemoveRule = {
|
|
type: StyleRuleType.Remove;
|
|
index: number | number[];
|
|
};
|
|
type SetPropertyRule = {
|
|
type: StyleRuleType.SetProperty;
|
|
index: number[];
|
|
property: string;
|
|
value: string | null;
|
|
priority: string | undefined;
|
|
};
|
|
type RemovePropertyRule = {
|
|
type: StyleRuleType.RemoveProperty;
|
|
index: number[];
|
|
property: string;
|
|
};
|
|
|
|
export type VirtualStyleRules = Array<
|
|
InsertRule | RemoveRule | SetPropertyRule | RemovePropertyRule
|
|
>;
|
|
|
|
export function getPositionsAndIndex(nestedIndex: number[]) {
|
|
const positions = [...nestedIndex];
|
|
const index = positions.pop();
|
|
return { positions, index };
|
|
}
|
|
|
|
export function applyVirtualStyleRulesToNode(
|
|
styleNode: HTMLStyleElement,
|
|
virtualStyleRules: VirtualStyleRules,
|
|
) {
|
|
const sheet = styleNode.sheet!;
|
|
|
|
virtualStyleRules.forEach((rule) => {
|
|
if (rule.type === StyleRuleType.Insert) {
|
|
try {
|
|
if (Array.isArray(rule.index)) {
|
|
const { positions, index } = getPositionsAndIndex(rule.index);
|
|
const nestedRule = getNestedRule(sheet.cssRules, positions);
|
|
nestedRule.insertRule(rule.cssText, index);
|
|
} else {
|
|
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 {
|
|
if (Array.isArray(rule.index)) {
|
|
const { positions, index } = getPositionsAndIndex(rule.index);
|
|
const nestedRule = getNestedRule(sheet.cssRules, positions);
|
|
nestedRule.deleteRule(index || 0);
|
|
} else {
|
|
sheet.deleteRule(rule.index);
|
|
}
|
|
} catch (e) {
|
|
/**
|
|
* accessing styleSheet rules may cause SecurityError
|
|
* for specific access control settings
|
|
*/
|
|
}
|
|
} else if (rule.type === StyleRuleType.SetProperty) {
|
|
const nativeRule = (getNestedRule(
|
|
sheet.cssRules,
|
|
rule.index,
|
|
) as unknown) as CSSStyleRule;
|
|
nativeRule.style.setProperty(rule.property, rule.value, rule.priority);
|
|
} else if (rule.type === StyleRuleType.RemoveProperty) {
|
|
const nativeRule = (getNestedRule(
|
|
sheet.cssRules,
|
|
rule.index,
|
|
) as unknown) as CSSStyleRule;
|
|
nativeRule.style.removeProperty(rule.property);
|
|
}
|
|
});
|
|
}
|