Single style capture (#1437)
Support a contrived/rare case where a <style> element has multiple text node children (this is usually only possible to recreate via javascript append) ... this PR fixes cases where there are subsequent text mutations to these nodes; previously these would have been lost * In this scenario, a new CSS comment may now be inserted into the captured `_cssText` for a <style> element to show where it should be broken up into text elements upon replay: `/* rr_split */` * The new 'can record and replay style mutations' test is the principal way to the problematic scenarios, and is a detailed 'catch-all' test with many checks to cover most of the ways things can fail * There are new tests for splitting/rebuilding the css using the rr_split marker * The prior 'dynamic stylesheet' route is now the main route for serializing a stylesheet; dynamic stylesheet were missed out in #1533 but that case is now covered with this PR This PR was originally extracted from #1475 so the initial motivation was to change the approach on stringifying <style> elements to do so in a single place. This is also the motivating factor for always serializing <style> elements via the `_cssText` attribute rather than in it's childNodes; in #1475 we will be delaying populating `_cssText` for performance and instead recorrding them as assets. Thanks for the detailed review to Justin Halsall <Juice10@users.noreply.github.com> & Yun Feng <https://github.com/YunFeng0817>
This commit is contained in:
6
.changeset/single-style-capture.md
Normal file
6
.changeset/single-style-capture.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"rrweb-snapshot": patch
|
||||||
|
"rrweb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Edge case: Provide support for mutations on a <style> element which (unusually) has multiple text nodes
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"eslint-plugin-compat": "^5.0.0",
|
"eslint-plugin-compat": "^5.0.0",
|
||||||
"eslint-plugin-jest": "^27.6.0",
|
"eslint-plugin-jest": "^27.6.0",
|
||||||
"eslint-plugin-tsdoc": "^0.2.17",
|
"eslint-plugin-tsdoc": "^0.2.17",
|
||||||
|
"happy-dom": "^14.12.0",
|
||||||
"markdownlint": "^0.25.1",
|
"markdownlint": "^0.25.1",
|
||||||
"markdownlint-cli": "^0.31.1",
|
"markdownlint-cli": "^0.31.1",
|
||||||
"prettier": "2.8.4",
|
"prettier": "2.8.4",
|
||||||
|
|||||||
@@ -46,7 +46,6 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^5.23.0",
|
"@typescript-eslint/eslint-plugin": "^5.23.0",
|
||||||
"@typescript-eslint/parser": "^5.23.0",
|
"@typescript-eslint/parser": "^5.23.0",
|
||||||
"eslint": "^8.15.0",
|
"eslint": "^8.15.0",
|
||||||
"happy-dom": "^14.12.0",
|
|
||||||
"puppeteer": "^17.1.3",
|
"puppeteer": "^17.1.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.3.1",
|
"vite": "^5.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { mediaSelectorPlugin, pseudoClassPlugin } from './css';
|
import { mediaSelectorPlugin, pseudoClassPlugin } from './css';
|
||||||
import {
|
import {
|
||||||
type serializedNodeWithId,
|
type serializedNodeWithId,
|
||||||
|
type serializedElementNodeWithId,
|
||||||
|
type serializedTextNodeWithId,
|
||||||
NodeType,
|
NodeType,
|
||||||
type tagMap,
|
type tagMap,
|
||||||
type elementNode,
|
type elementNode,
|
||||||
@@ -78,6 +80,77 @@ export function createCache(): BuildCache {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* undo splitCssText/markCssSplits
|
||||||
|
* (would move to utils.ts but uses `adaptCssForReplay`)
|
||||||
|
*/
|
||||||
|
export function applyCssSplits(
|
||||||
|
n: serializedElementNodeWithId,
|
||||||
|
cssText: string,
|
||||||
|
hackCss: boolean,
|
||||||
|
cache: BuildCache,
|
||||||
|
): void {
|
||||||
|
const childTextNodes: serializedTextNodeWithId[] = [];
|
||||||
|
for (const scn of n.childNodes) {
|
||||||
|
if (scn.type === NodeType.Text) {
|
||||||
|
childTextNodes.push(scn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cssTextSplits = cssText.split('/* rr_split */');
|
||||||
|
while (
|
||||||
|
cssTextSplits.length > 1 &&
|
||||||
|
cssTextSplits.length > childTextNodes.length
|
||||||
|
) {
|
||||||
|
// unexpected: remerge the last two so that we don't discard any css
|
||||||
|
cssTextSplits.splice(-2, 2, cssTextSplits.slice(-2).join(''));
|
||||||
|
}
|
||||||
|
for (let i = 0; i < childTextNodes.length; i++) {
|
||||||
|
const childTextNode = childTextNodes[i];
|
||||||
|
const cssTextSection = cssTextSplits[i];
|
||||||
|
if (childTextNode && cssTextSection) {
|
||||||
|
// id will be assigned when these child nodes are
|
||||||
|
// iterated over in buildNodeWithSN
|
||||||
|
childTextNode.textContent = hackCss
|
||||||
|
? adaptCssForReplay(cssTextSection, cache)
|
||||||
|
: cssTextSection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normally a <style> element has a single textNode containing the rules.
|
||||||
|
* During serialization, we bypass this (`styleEl.sheet`) to get the rules the
|
||||||
|
* browser sees and serialize this to a special _cssText attribute, blanking
|
||||||
|
* out any text nodes. This function reverses that and also handles cases where
|
||||||
|
* there were no textNode children present (dynamic css/or a <link> element) as
|
||||||
|
* well as multiple textNodes, which need to be repopulated (based on presence of
|
||||||
|
* a special `rr_split` marker in case they are modified by subsequent mutations.
|
||||||
|
*/
|
||||||
|
export function buildStyleNode(
|
||||||
|
n: serializedElementNodeWithId,
|
||||||
|
styleEl: HTMLStyleElement, // when inlined, a <link type="stylesheet"> also gets rebuilt as a <style>
|
||||||
|
cssText: string,
|
||||||
|
options: {
|
||||||
|
doc: Document;
|
||||||
|
hackCss: boolean;
|
||||||
|
cache: BuildCache;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { doc, hackCss, cache } = options;
|
||||||
|
if (n.childNodes.length) {
|
||||||
|
applyCssSplits(n, cssText, hackCss, cache);
|
||||||
|
} else {
|
||||||
|
if (hackCss) {
|
||||||
|
cssText = adaptCssForReplay(cssText, cache);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
<link> element or dynamic <style> are serialized without any child nodes
|
||||||
|
we create the text node without an ID or presence in mirror as it can't
|
||||||
|
*/
|
||||||
|
styleEl.appendChild(doc.createTextNode(cssText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildNode(
|
function buildNode(
|
||||||
n: serializedNodeWithId,
|
n: serializedNodeWithId,
|
||||||
options: {
|
options: {
|
||||||
@@ -154,14 +227,13 @@ function buildNode(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTextarea = tagName === 'textarea' && name === 'value';
|
if (typeof value !== 'string') {
|
||||||
const isRemoteOrDynamicCss = tagName === 'style' && name === '_cssText';
|
// pass
|
||||||
if (isRemoteOrDynamicCss && hackCss && typeof value === 'string') {
|
} else if (tagName === 'style' && name === '_cssText') {
|
||||||
value = adaptCssForReplay(value, cache);
|
buildStyleNode(n, node as HTMLStyleElement, value, options);
|
||||||
}
|
continue; // no need to set _cssText as attribute
|
||||||
if ((isTextarea || isRemoteOrDynamicCss) && typeof value === 'string') {
|
} else if (tagName === 'textarea' && name === 'value') {
|
||||||
// https://github.com/rrweb-io/rrweb/issues/112
|
// create without an ID or presence in mirror
|
||||||
// https://github.com/rrweb-io/rrweb/pull/1351
|
|
||||||
node.appendChild(doc.createTextNode(value));
|
node.appendChild(doc.createTextNode(value));
|
||||||
n.childNodes = []; // value overrides childNodes
|
n.childNodes = []; // value overrides childNodes
|
||||||
continue;
|
continue;
|
||||||
@@ -317,11 +389,11 @@ function buildNode(
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
case NodeType.Text:
|
case NodeType.Text:
|
||||||
return doc.createTextNode(
|
if (n.isStyle && hackCss) {
|
||||||
n.isStyle && hackCss
|
// support legacy style
|
||||||
? adaptCssForReplay(n.textContent, cache)
|
return doc.createTextNode(adaptCssForReplay(n.textContent, cache));
|
||||||
: n.textContent,
|
}
|
||||||
);
|
return doc.createTextNode(n.textContent);
|
||||||
case NodeType.CDATA:
|
case NodeType.CDATA:
|
||||||
return doc.createCDATASection(n.textContent);
|
return doc.createCDATASection(n.textContent);
|
||||||
case NodeType.Comment:
|
case NodeType.Comment:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
toLowerCase,
|
toLowerCase,
|
||||||
extractFileExtension,
|
extractFileExtension,
|
||||||
absolutifyURLs,
|
absolutifyURLs,
|
||||||
|
markCssSplits,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import dom from '@rrweb/utils';
|
import dom from '@rrweb/utils';
|
||||||
|
|
||||||
@@ -403,6 +404,7 @@ function serializeNode(
|
|||||||
* `newlyAddedElement: true` skips scrollTop and scrollLeft check
|
* `newlyAddedElement: true` skips scrollTop and scrollLeft check
|
||||||
*/
|
*/
|
||||||
newlyAddedElement?: boolean;
|
newlyAddedElement?: boolean;
|
||||||
|
cssCaptured?: boolean;
|
||||||
},
|
},
|
||||||
): serializedNode | false {
|
): serializedNode | false {
|
||||||
const {
|
const {
|
||||||
@@ -420,6 +422,7 @@ function serializeNode(
|
|||||||
recordCanvas,
|
recordCanvas,
|
||||||
keepIframeSrcFn,
|
keepIframeSrcFn,
|
||||||
newlyAddedElement = false,
|
newlyAddedElement = false,
|
||||||
|
cssCaptured = false,
|
||||||
} = options;
|
} = options;
|
||||||
// Only record root id when document object is not the base document
|
// Only record root id when document object is not the base document
|
||||||
const rootId = getRootId(doc, mirror);
|
const rootId = getRootId(doc, mirror);
|
||||||
@@ -466,6 +469,7 @@ function serializeNode(
|
|||||||
needsMask,
|
needsMask,
|
||||||
maskTextFn,
|
maskTextFn,
|
||||||
rootId,
|
rootId,
|
||||||
|
cssCaptured,
|
||||||
});
|
});
|
||||||
case n.CDATA_SECTION_NODE:
|
case n.CDATA_SECTION_NODE:
|
||||||
return {
|
return {
|
||||||
@@ -497,48 +501,38 @@ function serializeTextNode(
|
|||||||
needsMask: boolean;
|
needsMask: boolean;
|
||||||
maskTextFn: MaskTextFn | undefined;
|
maskTextFn: MaskTextFn | undefined;
|
||||||
rootId: number | undefined;
|
rootId: number | undefined;
|
||||||
|
cssCaptured?: boolean;
|
||||||
},
|
},
|
||||||
): serializedNode {
|
): serializedNode {
|
||||||
const { needsMask, maskTextFn, rootId } = options;
|
const { needsMask, maskTextFn, rootId, cssCaptured } = options;
|
||||||
// The parent node may not be a html element which has a tagName attribute.
|
// The parent node may not be a html element which has a tagName attribute.
|
||||||
// So just let it be undefined which is ok in this use case.
|
// So just let it be undefined which is ok in this use case.
|
||||||
const parent = dom.parentNode(n);
|
const parent = dom.parentNode(n);
|
||||||
const parentTagName = parent && (parent as HTMLElement).tagName;
|
const parentTagName = parent && (parent as HTMLElement).tagName;
|
||||||
let text = dom.textContent(n);
|
let textContent: string | null = '';
|
||||||
const isStyle = parentTagName === 'STYLE' ? true : undefined;
|
const isStyle = parentTagName === 'STYLE' ? true : undefined;
|
||||||
const isScript = parentTagName === 'SCRIPT' ? true : undefined;
|
const isScript = parentTagName === 'SCRIPT' ? true : undefined;
|
||||||
if (isStyle && text) {
|
|
||||||
try {
|
|
||||||
// try to read style sheet
|
|
||||||
if (n.nextSibling || n.previousSibling) {
|
|
||||||
// This is not the only child of the stylesheet.
|
|
||||||
// We can't read all of the sheet's .cssRules and expect them
|
|
||||||
// to _only_ include the current rule(s) added by the text node.
|
|
||||||
// So we'll be conservative and keep textContent as-is.
|
|
||||||
} else if ((parent as HTMLStyleElement).sheet?.cssRules) {
|
|
||||||
text = stringifyStylesheet((parent as HTMLStyleElement).sheet!);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
`Cannot get CSS styles from text's parentNode. Error: ${err as string}`,
|
|
||||||
n,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
text = absolutifyURLs(text, getHref(options.doc));
|
|
||||||
}
|
|
||||||
if (isScript) {
|
if (isScript) {
|
||||||
text = 'SCRIPT_PLACEHOLDER';
|
textContent = 'SCRIPT_PLACEHOLDER';
|
||||||
|
} else if (!cssCaptured) {
|
||||||
|
textContent = dom.textContent(n);
|
||||||
|
if (isStyle && textContent) {
|
||||||
|
// mutation only: we don't need to use stringifyStylesheet
|
||||||
|
// as a <style> text node mutation obliterates any previous
|
||||||
|
// programmatic rule manipulation (.insertRule etc.)
|
||||||
|
// so the current textContent represents the most up to date state
|
||||||
|
textContent = absolutifyURLs(textContent, getHref(options.doc));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!isStyle && !isScript && text && needsMask) {
|
if (!isStyle && !isScript && textContent && needsMask) {
|
||||||
text = maskTextFn
|
textContent = maskTextFn
|
||||||
? maskTextFn(text, dom.parentElement(n))
|
? maskTextFn(textContent, dom.parentElement(n))
|
||||||
: text.replace(/[\S]/g, '*');
|
: textContent.replace(/[\S]/g, '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: NodeType.Text,
|
type: NodeType.Text,
|
||||||
textContent: text || '',
|
textContent: textContent || '',
|
||||||
isStyle,
|
|
||||||
rootId,
|
rootId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -608,17 +602,14 @@ function serializeElementNode(
|
|||||||
attributes._cssText = cssText;
|
attributes._cssText = cssText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// dynamic stylesheet
|
if (tagName === 'style' && (n as HTMLStyleElement).sheet) {
|
||||||
if (
|
let cssText = stringifyStylesheet(
|
||||||
tagName === 'style' &&
|
|
||||||
(n as HTMLStyleElement).sheet &&
|
|
||||||
// TODO: Currently we only try to get dynamic stylesheet when it is an empty style element
|
|
||||||
!(n.innerText || dom.textContent(n) || '').trim().length
|
|
||||||
) {
|
|
||||||
const cssText = stringifyStylesheet(
|
|
||||||
(n as HTMLStyleElement).sheet as CSSStyleSheet,
|
(n as HTMLStyleElement).sheet as CSSStyleSheet,
|
||||||
);
|
);
|
||||||
if (cssText) {
|
if (cssText) {
|
||||||
|
if (n.childNodes.length > 1) {
|
||||||
|
cssText = markCssSplits(cssText, n as HTMLStyleElement);
|
||||||
|
}
|
||||||
attributes._cssText = cssText;
|
attributes._cssText = cssText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -937,6 +928,7 @@ export function serializeNodeWithId(
|
|||||||
node: serializedElementNodeWithId,
|
node: serializedElementNodeWithId,
|
||||||
) => unknown;
|
) => unknown;
|
||||||
stylesheetLoadTimeout?: number;
|
stylesheetLoadTimeout?: number;
|
||||||
|
cssCaptured?: boolean;
|
||||||
},
|
},
|
||||||
): serializedNodeWithId | null {
|
): serializedNodeWithId | null {
|
||||||
const {
|
const {
|
||||||
@@ -962,6 +954,7 @@ export function serializeNodeWithId(
|
|||||||
stylesheetLoadTimeout = 5000,
|
stylesheetLoadTimeout = 5000,
|
||||||
keepIframeSrcFn = () => false,
|
keepIframeSrcFn = () => false,
|
||||||
newlyAddedElement = false,
|
newlyAddedElement = false,
|
||||||
|
cssCaptured = false,
|
||||||
} = options;
|
} = options;
|
||||||
let { needsMask } = options;
|
let { needsMask } = options;
|
||||||
let { preserveWhiteSpace = true } = options;
|
let { preserveWhiteSpace = true } = options;
|
||||||
@@ -992,6 +985,7 @@ export function serializeNodeWithId(
|
|||||||
recordCanvas,
|
recordCanvas,
|
||||||
keepIframeSrcFn,
|
keepIframeSrcFn,
|
||||||
newlyAddedElement,
|
newlyAddedElement,
|
||||||
|
cssCaptured,
|
||||||
});
|
});
|
||||||
if (!_serializedNode) {
|
if (!_serializedNode) {
|
||||||
// TODO: dev only
|
// TODO: dev only
|
||||||
@@ -1007,7 +1001,6 @@ export function serializeNodeWithId(
|
|||||||
slimDOMExcluded(_serializedNode, slimDOMOptions) ||
|
slimDOMExcluded(_serializedNode, slimDOMOptions) ||
|
||||||
(!preserveWhiteSpace &&
|
(!preserveWhiteSpace &&
|
||||||
_serializedNode.type === NodeType.Text &&
|
_serializedNode.type === NodeType.Text &&
|
||||||
!_serializedNode.isStyle &&
|
|
||||||
!_serializedNode.textContent.replace(/^\s+|\s+$/gm, '').length)
|
!_serializedNode.textContent.replace(/^\s+|\s+$/gm, '').length)
|
||||||
) {
|
) {
|
||||||
id = IGNORED_NODE;
|
id = IGNORED_NODE;
|
||||||
@@ -1072,6 +1065,7 @@ export function serializeNodeWithId(
|
|||||||
onStylesheetLoad,
|
onStylesheetLoad,
|
||||||
stylesheetLoadTimeout,
|
stylesheetLoadTimeout,
|
||||||
keepIframeSrcFn,
|
keepIframeSrcFn,
|
||||||
|
cssCaptured: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1081,6 +1075,13 @@ export function serializeNodeWithId(
|
|||||||
) {
|
) {
|
||||||
// value parameter in DOM reflects the correct value, so ignore childNode
|
// value parameter in DOM reflects the correct value, so ignore childNode
|
||||||
} else {
|
} else {
|
||||||
|
if (
|
||||||
|
serializedNode.type === NodeType.Element &&
|
||||||
|
(serializedNode as elementNode).attributes._cssText !== undefined &&
|
||||||
|
typeof serializedNode.attributes._cssText === 'string'
|
||||||
|
) {
|
||||||
|
bypassOptions.cssCaptured = true;
|
||||||
|
}
|
||||||
for (const childN of Array.from(dom.childNodes(n))) {
|
for (const childN of Array.from(dom.childNodes(n))) {
|
||||||
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
|
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
|
||||||
if (serializedChildNode) {
|
if (serializedChildNode) {
|
||||||
|
|||||||
@@ -20,9 +20,18 @@ export type documentTypeNode = {
|
|||||||
systemId: string;
|
systemId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type attributes = {
|
type cssTextKeyAttr = {
|
||||||
[key: string]: string | number | true | null;
|
_cssText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type attributes = cssTextKeyAttr & {
|
||||||
|
[key: string]:
|
||||||
|
| string
|
||||||
|
| number // properties e.g. rr_scrollLeft or rr_mediaCurrentTime
|
||||||
|
| true // e.g. checked on <input type="radio">
|
||||||
|
| null; // an indication that an attribute was removed (during a mutation)
|
||||||
|
};
|
||||||
|
|
||||||
export type legacyAttributes = {
|
export type legacyAttributes = {
|
||||||
/**
|
/**
|
||||||
* @deprecated old bug in rrweb was causing these to always be set
|
* @deprecated old bug in rrweb was causing these to always be set
|
||||||
@@ -45,6 +54,10 @@ export type elementNode = {
|
|||||||
export type textNode = {
|
export type textNode = {
|
||||||
type: NodeType.Text;
|
type: NodeType.Text;
|
||||||
textContent: string;
|
textContent: string;
|
||||||
|
/**
|
||||||
|
* @deprecated styles are now always snapshotted against parent <style> element
|
||||||
|
* style mutations can still happen via an added textNode, but they don't need this attribute for correct replay
|
||||||
|
*/
|
||||||
isStyle?: true;
|
isStyle?: true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,6 +91,11 @@ export type serializedElementNodeWithId = Extract<
|
|||||||
Record<'type', NodeType.Element>
|
Record<'type', NodeType.Element>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type serializedTextNodeWithId = Extract<
|
||||||
|
serializedNodeWithId,
|
||||||
|
Record<'type', NodeType.Text>
|
||||||
|
>;
|
||||||
|
|
||||||
export type tagMap = {
|
export type tagMap = {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,14 +99,28 @@ export function escapeImportStatement(rule: CSSImportRule): string {
|
|||||||
return statement.join(' ') + ';';
|
return statement.join(' ') + ';';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* serialize the css rules from the .sheet property
|
||||||
|
* for <link rel="stylesheet"> elements, this is the only way of getting the rules without a FETCH
|
||||||
|
* for <style> elements, this is less preferable to looking at childNodes[0].textContent
|
||||||
|
* (which will include vendor prefixed rules which may not be used or visible to the recorded browser,
|
||||||
|
* but which might be needed by the replayer browser)
|
||||||
|
* however, at snapshot time, we don't know whether the style element has suffered
|
||||||
|
* any programmatic manipulation prior to the snapshot, in which case the .sheet would be more up to date
|
||||||
|
*/
|
||||||
export function stringifyStylesheet(s: CSSStyleSheet): string | null {
|
export function stringifyStylesheet(s: CSSStyleSheet): string | null {
|
||||||
try {
|
try {
|
||||||
const rules = s.rules || s.cssRules;
|
const rules = s.rules || s.cssRules;
|
||||||
if (!rules) {
|
if (!rules) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
let sheetHref = s.href;
|
||||||
|
if (!sheetHref && s.ownerNode && s.ownerNode.ownerDocument) {
|
||||||
|
// an inline <style> element
|
||||||
|
sheetHref = s.ownerNode.ownerDocument.location.href;
|
||||||
|
}
|
||||||
const stringifiedRules = Array.from(rules, (rule: CSSRule) =>
|
const stringifiedRules = Array.from(rules, (rule: CSSRule) =>
|
||||||
stringifyRule(rule, s.href),
|
stringifyRule(rule, sheetHref),
|
||||||
).join('');
|
).join('');
|
||||||
return fixBrowserCompatibilityIssuesInCSS(stringifiedRules);
|
return fixBrowserCompatibilityIssuesInCSS(stringifiedRules);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -428,3 +442,62 @@ export function absolutifyURLs(cssText: string | null, href: string): string {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intention is to normalize by remove spaces, semicolons and CSS comments
|
||||||
|
* so that we can compare css as authored vs. output of stringifyStylesheet
|
||||||
|
*/
|
||||||
|
export function normalizeCssString(cssText: string): string {
|
||||||
|
return cssText.replace(/(\/\*[^*]*\*\/)|[\s;]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the output of stringifyStylesheet to individual text nodes of a <style> element
|
||||||
|
* performance is not considered as this is anticipated to be very much an edge case
|
||||||
|
* (javascript is needed to add extra text nodes to a <style>)
|
||||||
|
*/
|
||||||
|
export function splitCssText(
|
||||||
|
cssText: string,
|
||||||
|
style: HTMLStyleElement,
|
||||||
|
): string[] {
|
||||||
|
const childNodes = Array.from(style.childNodes);
|
||||||
|
const splits: string[] = [];
|
||||||
|
if (childNodes.length > 1 && cssText && typeof cssText === 'string') {
|
||||||
|
const cssTextNorm = normalizeCssString(cssText);
|
||||||
|
for (let i = 1; i < childNodes.length; i++) {
|
||||||
|
if (
|
||||||
|
childNodes[i].textContent &&
|
||||||
|
typeof childNodes[i].textContent === 'string'
|
||||||
|
) {
|
||||||
|
const textContentNorm = normalizeCssString(childNodes[i].textContent!);
|
||||||
|
for (let j = 3; j < textContentNorm.length; j++) {
|
||||||
|
// find a substring that appears only once
|
||||||
|
const bit = textContentNorm.substring(0, j);
|
||||||
|
if (cssTextNorm.split(bit).length === 2) {
|
||||||
|
const splitNorm = cssTextNorm.indexOf(bit);
|
||||||
|
// find the split point in the original text
|
||||||
|
for (let k = splitNorm; k < cssText.length; k++) {
|
||||||
|
if (
|
||||||
|
normalizeCssString(cssText.substring(0, k)).length === splitNorm
|
||||||
|
) {
|
||||||
|
splits.push(cssText.substring(0, k));
|
||||||
|
cssText = cssText.substring(k);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
splits.push(cssText); // either the full thing if no splits were found, or the last split
|
||||||
|
return splits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markCssSplits(
|
||||||
|
cssText: string,
|
||||||
|
style: HTMLStyleElement,
|
||||||
|
): string {
|
||||||
|
return splitCssText(cssText, style).join('/* rr_split */');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1094,12 +1094,13 @@ exports[`shadow DOM integration tests > snapshot shadow DOM 1`] = `
|
|||||||
{
|
{
|
||||||
\\"type\\": 2,
|
\\"type\\": 2,
|
||||||
\\"tagName\\": \\"style\\",
|
\\"tagName\\": \\"style\\",
|
||||||
\\"attributes\\": {},
|
\\"attributes\\": {
|
||||||
|
\\"_cssText\\": \\":host { display: inline-block; width: 650px; font-family: \\\\\\"Roboto Slab\\\\\\"; contain: content; }:host([background]) { background: var(--background-color, #9E9E9E); border-radius: 10px; padding: 10px; }#panels { box-shadow: rgba(0, 0, 0, 0.3) 0px 2px 2px; background: white; border-radius: 3px; padding: 16px; height: 250px; overflow: auto; }#tabs { display: inline-flex; user-select: none; }#tabs slot { display: inline-flex; }#tabs ::slotted(*) { font: 400 16px / 22px Roboto; padding: 16px 8px; margin: 0px; text-align: center; width: 100px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; cursor: pointer; border-top-left-radius: 3px; border-top-right-radius: 3px; background: linear-gradient(rgb(250, 250, 250), rgb(238, 238, 238)); border: none; }#tabs ::slotted([aria-selected=\\\\\\"true\\\\\\"]) { font-weight: 600; background: white; box-shadow: none; }#tabs ::slotted(:focus) { z-index: 1; }#panels ::slotted([aria-hidden=\\\\\\"true\\\\\\"]) { display: none; }\\"
|
||||||
|
},
|
||||||
\\"childNodes\\": [
|
\\"childNodes\\": [
|
||||||
{
|
{
|
||||||
\\"type\\": 3,
|
\\"type\\": 3,
|
||||||
\\"textContent\\": \\":host { display: inline-block; width: 650px; font-family: \\\\\\"Roboto Slab\\\\\\"; contain: content; }:host([background]) { background: var(--background-color, #9E9E9E); border-radius: 10px; padding: 10px; }#panels { box-shadow: rgba(0, 0, 0, 0.3) 0px 2px 2px; background: white; border-radius: 3px; padding: 16px; height: 250px; overflow: auto; }#tabs { display: inline-flex; user-select: none; }#tabs slot { display: inline-flex; }#tabs ::slotted(*) { font: 400 16px / 22px Roboto; padding: 16px 8px; margin: 0px; text-align: center; width: 100px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; cursor: pointer; border-top-left-radius: 3px; border-top-right-radius: 3px; background: linear-gradient(rgb(250, 250, 250), rgb(238, 238, 238)); border: none; }#tabs ::slotted([aria-selected=\\\\\\"true\\\\\\"]) { font-weight: 600; background: white; box-shadow: none; }#tabs ::slotted(:focus) { z-index: 1; }#panels ::slotted([aria-hidden=\\\\\\"true\\\\\\"]) { display: none; }\\",
|
\\"textContent\\": \\"\\",
|
||||||
\\"isStyle\\": true,
|
|
||||||
\\"id\\": 38
|
\\"id\\": 38
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { describe, it, beforeEach, expect } from 'vitest';
|
||||||
import { mediaSelectorPlugin, pseudoClassPlugin } from '../src/css';
|
import { mediaSelectorPlugin, pseudoClassPlugin } from '../src/css';
|
||||||
import postcss, { AcceptedPlugin } from 'postcss';
|
import postcss, { type AcceptedPlugin } from 'postcss';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { splitCssText, stringifyStylesheet } from './../src/utils';
|
||||||
|
import { applyCssSplits } from './../src/rebuild';
|
||||||
|
import {
|
||||||
|
NodeType,
|
||||||
|
type serializedElementNodeWithId,
|
||||||
|
type BuildCache,
|
||||||
|
type textNode,
|
||||||
|
} from '../src/types';
|
||||||
|
import { Window } from 'happy-dom';
|
||||||
|
|
||||||
describe('css parser', () => {
|
describe('css parser', () => {
|
||||||
function parse(plugin: AcceptedPlugin, input: string): string {
|
function parse(plugin: AcceptedPlugin, input: string): string {
|
||||||
@@ -73,3 +86,157 @@ li[attr="has,comma"] a.\\:hover {background: red;}`,
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('css splitter', () => {
|
||||||
|
it('finds css textElement splits correctly', () => {
|
||||||
|
const window = new Window({ url: 'https://localhost:8080' });
|
||||||
|
const document = window.document;
|
||||||
|
document.head.innerHTML = '<style>.a{background-color:red;}</style>';
|
||||||
|
const style = document.querySelector('style');
|
||||||
|
if (style) {
|
||||||
|
// as authored, e.g. no spaces
|
||||||
|
style.append('.a{background-color:black;}');
|
||||||
|
|
||||||
|
// how it is currently stringified (spaces present)
|
||||||
|
const expected = [
|
||||||
|
'.a { background-color: red; }',
|
||||||
|
'.a { background-color: black; }',
|
||||||
|
];
|
||||||
|
const browserSheet = expected.join('');
|
||||||
|
expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet);
|
||||||
|
|
||||||
|
expect(splitCssText(browserSheet, style)).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds css textElement splits correctly when comments are present', () => {
|
||||||
|
const window = new Window({ url: 'https://localhost:8080' });
|
||||||
|
const document = window.document;
|
||||||
|
// as authored, with comment, missing semicolons
|
||||||
|
document.head.innerHTML =
|
||||||
|
'<style>.a{color:red}.b{color:blue} /* author comment */</style>';
|
||||||
|
const style = document.querySelector('style');
|
||||||
|
if (style) {
|
||||||
|
style.append('/* author comment */.a{color:red}.b{color:green}');
|
||||||
|
|
||||||
|
// how it is currently stringified (spaces present)
|
||||||
|
const expected = [
|
||||||
|
'.a { color: red; } .b { color: blue; }',
|
||||||
|
'.a { color: red; } .b { color: green; }',
|
||||||
|
];
|
||||||
|
const browserSheet = expected.join('');
|
||||||
|
expect(splitCssText(browserSheet, style)).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds css textElement splits correctly when vendor prefixed rules have been removed', () => {
|
||||||
|
const style = JSDOM.fragment(`<style></style>`).querySelector('style');
|
||||||
|
if (style) {
|
||||||
|
// as authored, with newlines
|
||||||
|
style.appendChild(
|
||||||
|
JSDOM.fragment(`.x {
|
||||||
|
-webkit-transition: all 4s ease;
|
||||||
|
content: 'try to keep a newline';
|
||||||
|
transition: all 4s ease;
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
// TODO: splitCssText can't handle it yet if both start with .x
|
||||||
|
style.appendChild(
|
||||||
|
JSDOM.fragment(`.y {
|
||||||
|
-moz-transition: all 5s ease;
|
||||||
|
transition: all 5s ease;
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
// browser .rules would usually omit the vendored versions and modifies the transition value
|
||||||
|
const expected = [
|
||||||
|
'.x { content: "try to keep a newline"; background: red; transition: 4s; }',
|
||||||
|
'.y { transition: 5s; }',
|
||||||
|
];
|
||||||
|
const browserSheet = expected.join('');
|
||||||
|
|
||||||
|
// can't do this as JSDOM doesn't have style.sheet
|
||||||
|
// also happy-dom doesn't strip out vendor-prefixed rules like a real browser does
|
||||||
|
//expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet);
|
||||||
|
|
||||||
|
expect(splitCssText(browserSheet, style)).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyCssSplits css rejoiner', function () {
|
||||||
|
const mockLastUnusedArg = null as unknown as BuildCache;
|
||||||
|
const halfCssText = '.a { background-color: red; }';
|
||||||
|
const otherHalfCssText = halfCssText.replace('.a', '.x');
|
||||||
|
const markedCssText = [halfCssText, otherHalfCssText].join('/* rr_split */');
|
||||||
|
let sn: serializedElementNodeWithId;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sn = {
|
||||||
|
type: NodeType.Element,
|
||||||
|
tagName: 'style',
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: NodeType.Text,
|
||||||
|
textContent: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: NodeType.Text,
|
||||||
|
textContent: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as serializedElementNodeWithId;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies css splits correctly', () => {
|
||||||
|
// happy path
|
||||||
|
applyCssSplits(sn, markedCssText, false, mockLastUnusedArg);
|
||||||
|
expect((sn.childNodes[0] as textNode).textContent).toEqual(halfCssText);
|
||||||
|
expect((sn.childNodes[1] as textNode).textContent).toEqual(
|
||||||
|
otherHalfCssText,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies css splits correctly even when there are too many child nodes', () => {
|
||||||
|
let sn3 = {
|
||||||
|
type: NodeType.Element,
|
||||||
|
tagName: 'style',
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: NodeType.Text,
|
||||||
|
textContent: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: NodeType.Text,
|
||||||
|
textContent: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: NodeType.Text,
|
||||||
|
textContent: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as serializedElementNodeWithId;
|
||||||
|
applyCssSplits(sn3, markedCssText, false, mockLastUnusedArg);
|
||||||
|
expect((sn3.childNodes[0] as textNode).textContent).toEqual(halfCssText);
|
||||||
|
expect((sn3.childNodes[1] as textNode).textContent).toEqual(
|
||||||
|
otherHalfCssText,
|
||||||
|
);
|
||||||
|
expect((sn3.childNodes[2] as textNode).textContent).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains entire css text when there are too few child nodes', () => {
|
||||||
|
let sn1 = {
|
||||||
|
type: NodeType.Element,
|
||||||
|
tagName: 'style',
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: NodeType.Text,
|
||||||
|
textContent: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as serializedElementNodeWithId;
|
||||||
|
applyCssSplits(sn1, markedCssText, false, mockLastUnusedArg);
|
||||||
|
expect((sn1.childNodes[0] as textNode).textContent).toEqual(
|
||||||
|
halfCssText + otherHalfCssText,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
createCache,
|
createCache,
|
||||||
} from '../src/rebuild';
|
} from '../src/rebuild';
|
||||||
import { NodeType } from '../src/types';
|
import { NodeType } from '../src/types';
|
||||||
import { createMirror, Mirror } from '../src/utils';
|
import { createMirror, Mirror, normalizeCssString } from '../src/utils';
|
||||||
|
|
||||||
const expect = _expect as unknown as {
|
const expect = _expect as unknown as {
|
||||||
<T = unknown>(actual: T): {
|
<T = unknown>(actual: T): {
|
||||||
@@ -20,7 +20,7 @@ const expect = _expect as unknown as {
|
|||||||
|
|
||||||
expect.extend({
|
expect.extend({
|
||||||
toMatchCss: function (received: string, expected: string) {
|
toMatchCss: function (received: string, expected: string) {
|
||||||
const pass = normCss(received) === normCss(expected);
|
const pass = normalizeCssString(received) === normalizeCssString(expected);
|
||||||
const message: () => string = () =>
|
const message: () => string = () =>
|
||||||
pass
|
pass
|
||||||
? ''
|
? ''
|
||||||
@@ -32,10 +32,6 @@ expect.extend({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function normCss(cssText: string): string {
|
|
||||||
return cssText.replace(/[\s;]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDuration(hrtime: [number, number]) {
|
function getDuration(hrtime: [number, number]) {
|
||||||
const [seconds, nanoseconds] = hrtime;
|
const [seconds, nanoseconds] = hrtime;
|
||||||
return seconds * 1000 + nanoseconds / 1000000;
|
return seconds * 1000 + nanoseconds / 1000000;
|
||||||
|
|||||||
@@ -162,22 +162,27 @@ describe('style elements', () => {
|
|||||||
it('should serialize all rules of stylesheet when the sheet has a single child node', () => {
|
it('should serialize all rules of stylesheet when the sheet has a single child node', () => {
|
||||||
const styleEl = render(`<style>body { color: red; }</style>`);
|
const styleEl = render(`<style>body { color: red; }</style>`);
|
||||||
styleEl.sheet?.insertRule('section { color: blue; }');
|
styleEl.sheet?.insertRule('section { color: blue; }');
|
||||||
expect(serializeNode(styleEl.childNodes[0])).toMatchObject({
|
expect(serializeNode(styleEl)).toMatchObject({
|
||||||
isStyle: true,
|
|
||||||
rootId: undefined,
|
rootId: undefined,
|
||||||
textContent: 'section {color: blue;}body {color: red;}',
|
attributes: {
|
||||||
type: 3,
|
_cssText: 'section {color: blue;}body {color: red;}',
|
||||||
|
},
|
||||||
|
type: 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should serialize individual text nodes on stylesheets with multiple child nodes', () => {
|
it('should serialize all rules on stylesheets with mix of insertion type', () => {
|
||||||
const styleEl = render(`<style>body { color: red; }</style>`);
|
const styleEl = render(`<style>body { color: red; }</style>`);
|
||||||
|
styleEl.sheet?.insertRule('section.lost { color: unseeable; }'); // browser throws this away after append
|
||||||
styleEl.append(document.createTextNode('section { color: blue; }'));
|
styleEl.append(document.createTextNode('section { color: blue; }'));
|
||||||
expect(serializeNode(styleEl.childNodes[1])).toMatchObject({
|
styleEl.sheet?.insertRule('section.working { color: pink; }');
|
||||||
isStyle: true,
|
expect(serializeNode(styleEl)).toMatchObject({
|
||||||
rootId: undefined,
|
rootId: undefined,
|
||||||
textContent: 'section { color: blue; }',
|
attributes: {
|
||||||
type: 3,
|
_cssText:
|
||||||
|
'section.working {color: pink;}body {color: red;}/* rr_split */section {color: blue;}',
|
||||||
|
},
|
||||||
|
type: 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "npm run prepack",
|
"prepare": "npm run prepack",
|
||||||
"prepack": "npm run build",
|
"prepack": "npm run build",
|
||||||
"retest": "vitest run --exclude test/benchmark",
|
"retest": "cross-env PUPPETEER_HEADLESS=true yarn retest:headful",
|
||||||
|
"retest:headful": "vitest run --exclude test/benchmark",
|
||||||
"build-and-test": "yarn build && yarn retest",
|
"build-and-test": "yarn build && yarn retest",
|
||||||
"test:headless": "cross-env PUPPETEER_HEADLESS=true yarn build-and-test",
|
"test:headless": "cross-env PUPPETEER_HEADLESS=true yarn build-and-test",
|
||||||
"test:headful": "cross-env PUPPETEER_HEADLESS=false yarn build-and-test",
|
"test:headful": "cross-env PUPPETEER_HEADLESS=false yarn build-and-test",
|
||||||
|
|||||||
@@ -287,12 +287,26 @@ export default class MutationBuffer {
|
|||||||
};
|
};
|
||||||
const pushAdd = (n: Node) => {
|
const pushAdd = (n: Node) => {
|
||||||
const parent = dom.parentNode(n);
|
const parent = dom.parentNode(n);
|
||||||
if (!parent || !inDom(n) || (parent as Element).tagName === 'TEXTAREA') {
|
if (!parent || !inDom(n)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let cssCaptured = false;
|
||||||
|
if (n.nodeType === Node.TEXT_NODE) {
|
||||||
|
const parentTag = (parent as Element).tagName;
|
||||||
|
if (parentTag === 'TEXTAREA') {
|
||||||
|
// genTextAreaValueMutation already called via parent
|
||||||
|
return;
|
||||||
|
} else if (parentTag === 'STYLE' && this.addedSet.has(parent)) {
|
||||||
|
// css content will be recorded via parent's _cssText attribute when
|
||||||
|
// mutation adds entire <style> element
|
||||||
|
cssCaptured = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const parentId = isShadowRoot(parent)
|
const parentId = isShadowRoot(parent)
|
||||||
? this.mirror.getId(getShadowHost(n))
|
? this.mirror.getId(getShadowHost(n))
|
||||||
: this.mirror.getId(parent);
|
: this.mirror.getId(parent);
|
||||||
|
|
||||||
const nextId = getNextId(n);
|
const nextId = getNextId(n);
|
||||||
if (parentId === -1 || nextId === -1) {
|
if (parentId === -1 || nextId === -1) {
|
||||||
return addList.addNode(n);
|
return addList.addNode(n);
|
||||||
@@ -335,6 +349,7 @@ export default class MutationBuffer {
|
|||||||
onStylesheetLoad: (link, childSn) => {
|
onStylesheetLoad: (link, childSn) => {
|
||||||
this.stylesheetManager.attachLinkElement(link, childSn);
|
this.stylesheetManager.attachLinkElement(link, childSn);
|
||||||
},
|
},
|
||||||
|
cssCaptured,
|
||||||
});
|
});
|
||||||
if (sn) {
|
if (sn) {
|
||||||
adds.push({
|
adds.push({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
rebuild,
|
rebuild,
|
||||||
|
adaptCssForReplay,
|
||||||
buildNodeWithSN,
|
buildNodeWithSN,
|
||||||
NodeType,
|
NodeType,
|
||||||
type BuildCache,
|
type BuildCache,
|
||||||
@@ -881,6 +882,9 @@ export class Replayer {
|
|||||||
'html.rrweb-paused *, html.rrweb-paused *:before, html.rrweb-paused *:after { animation-play-state: paused !important; }',
|
'html.rrweb-paused *, html.rrweb-paused *:before, html.rrweb-paused *:after { animation-play-state: paused !important; }',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!injectStylesRules.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.usingVirtualDom) {
|
if (this.usingVirtualDom) {
|
||||||
const styleEl = this.virtualDom.createElement('style');
|
const styleEl = this.virtualDom.createElement('style');
|
||||||
this.virtualDom.mirror.add(
|
this.virtualDom.mirror.add(
|
||||||
@@ -1743,7 +1747,14 @@ export class Replayer {
|
|||||||
}
|
}
|
||||||
return this.warnNodeNotFound(d, mutation.id);
|
return this.warnNodeNotFound(d, mutation.id);
|
||||||
}
|
}
|
||||||
target.textContent = mutation.value;
|
|
||||||
|
const parentEl = target.parentElement as Element | RRElement;
|
||||||
|
if (mutation.value && parentEl && parentEl.tagName === 'STYLE') {
|
||||||
|
// assumes hackCss: true (which isn't currently configurable from rrweb)
|
||||||
|
target.textContent = adaptCssForReplay(mutation.value, this.cache);
|
||||||
|
} else {
|
||||||
|
target.textContent = mutation.value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* https://github.com/rrweb-io/rrweb/pull/865
|
* https://github.com/rrweb-io/rrweb/pull/865
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1386,18 +1386,18 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
|
|||||||
{
|
{
|
||||||
\\"type\\": 2,
|
\\"type\\": 2,
|
||||||
\\"tagName\\": \\"style\\",
|
\\"tagName\\": \\"style\\",
|
||||||
\\"attributes\\": {},
|
\\"attributes\\": {
|
||||||
|
\\"_cssText\\": \\"div { color: red; }/* rr_split */section { color: blue; }\\"
|
||||||
|
},
|
||||||
\\"childNodes\\": [
|
\\"childNodes\\": [
|
||||||
{
|
{
|
||||||
\\"type\\": 3,
|
\\"type\\": 3,
|
||||||
\\"textContent\\": \\"div { color: red; }\\",
|
\\"textContent\\": \\"\\",
|
||||||
\\"isStyle\\": true,
|
|
||||||
\\"id\\": 6
|
\\"id\\": 6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
\\"type\\": 3,
|
\\"type\\": 3,
|
||||||
\\"textContent\\": \\"section { color: blue; }\\",
|
\\"textContent\\": \\"\\",
|
||||||
\\"isStyle\\": true,
|
|
||||||
\\"id\\": 7
|
\\"id\\": 7
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1460,7 +1460,6 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
|
|||||||
\\"node\\": {
|
\\"node\\": {
|
||||||
\\"type\\": 3,
|
\\"type\\": 3,
|
||||||
\\"textContent\\": \\"h1 { color: pink; }\\",
|
\\"textContent\\": \\"h1 { color: pink; }\\",
|
||||||
\\"isStyle\\": true,
|
|
||||||
\\"id\\": 12
|
\\"id\\": 12
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1470,7 +1469,6 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
|
|||||||
\\"node\\": {
|
\\"node\\": {
|
||||||
\\"type\\": 3,
|
\\"type\\": 3,
|
||||||
\\"textContent\\": \\"span { color: orange; }\\",
|
\\"textContent\\": \\"span { color: orange; }\\",
|
||||||
\\"isStyle\\": true,
|
|
||||||
\\"id\\": 13
|
\\"id\\": 13
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
packages/rrweb/test/html/style.html
Normal file
31
packages/rrweb/test/html/style.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>style</title>
|
||||||
|
<style id="dual-textContent">
|
||||||
|
body { background-color: black; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
// not the same from the POV of the DOM of just sticking this text in above
|
||||||
|
document.querySelector('style').append(
|
||||||
|
document.createTextNode('body { color: orange !important; }')
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<style id="single-textContent">
|
||||||
|
a:hover { outline: 1px solid red; }
|
||||||
|
</style>
|
||||||
|
<style id="empty"></style>
|
||||||
|
<script>
|
||||||
|
// this simulates how <link> is stringified
|
||||||
|
let empty = document.getElementById('empty');
|
||||||
|
empty.sheet.insertRule('a:hover { outline: 1px solid blue; }');
|
||||||
|
</script>
|
||||||
|
<style id="hover-mutation">
|
||||||
|
/* replaceme */
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -101,7 +101,7 @@ describe('record integration tests', function (this: ISuite) {
|
|||||||
await assertSnapshot(snapshots);
|
await assertSnapshot(snapshots);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can record textarea mutations correctly', async () => {
|
it('can record and replay textarea mutations correctly', async () => {
|
||||||
const page: puppeteer.Page = await browser.newPage();
|
const page: puppeteer.Page = await browser.newPage();
|
||||||
await page.goto('about:blank');
|
await page.goto('about:blank');
|
||||||
await page.setContent(getHtml.call(this, 'empty.html'));
|
await page.setContent(getHtml.call(this, 'empty.html'));
|
||||||
@@ -112,20 +112,29 @@ describe('record integration tests', function (this: ISuite) {
|
|||||||
const ta = document.createElement('textarea');
|
const ta = document.createElement('textarea');
|
||||||
ta.innerText = 'pre value';
|
ta.innerText = 'pre value';
|
||||||
document.body.append(ta);
|
document.body.append(ta);
|
||||||
|
|
||||||
|
const ta2 = document.createElement('textarea');
|
||||||
|
ta2.id = 'ta2';
|
||||||
|
document.body.append(ta2);
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(5);
|
await waitForRAF(page);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const t = document.querySelector('textarea') as HTMLTextAreaElement;
|
const t = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
t.innerText = 'ok'; // this mutation should be recorded
|
t.innerText = 'ok'; // this mutation should be recorded
|
||||||
|
|
||||||
|
const ta2t = document.createTextNode('added');
|
||||||
|
document.getElementById('ta2').append(ta2t);
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(5);
|
await waitForRAF(page);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const t = document.querySelector('textarea') as HTMLTextAreaElement;
|
const t = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
(t.childNodes[0] as Text).appendData('3'); // this mutation is also valid
|
(t.childNodes[0] as Text).appendData('3'); // this mutation is also valid
|
||||||
|
|
||||||
|
document.getElementById('ta2').remove(); // done with this
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(5);
|
await waitForRAF(page);
|
||||||
await page.type('textarea', '1'); // types (inserts) at index 0, in front of existing text
|
await page.type('textarea', '1'); // types (inserts) at index 0, in front of existing text
|
||||||
await page.waitForTimeout(5);
|
await waitForRAF(page);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const t = document.querySelector('textarea') as HTMLTextAreaElement;
|
const t = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
// user has typed so childNode content should now be ignored
|
// user has typed so childNode content should now be ignored
|
||||||
@@ -136,7 +145,7 @@ describe('record integration tests', function (this: ISuite) {
|
|||||||
// there is nothing explicit in rrweb which enforces this, but this test may protect against
|
// there is nothing explicit in rrweb which enforces this, but this test may protect against
|
||||||
// a future change where a mutation on a textarea incorrectly updates the .value
|
// a future change where a mutation on a textarea incorrectly updates the .value
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(5);
|
await waitForRAF(page);
|
||||||
await page.type('textarea', '2'); // cursor is at index 1
|
await page.type('textarea', '2'); // cursor is at index 1
|
||||||
|
|
||||||
const snapshots = (await page.evaluate(
|
const snapshots = (await page.evaluate(
|
||||||
@@ -153,12 +162,18 @@ describe('record integration tests', function (this: ISuite) {
|
|||||||
replayer.pause((e.timestamp - window.snapshots[0].timestamp)+1);
|
replayer.pause((e.timestamp - window.snapshots[0].timestamp)+1);
|
||||||
let ts = replayer.iframe.contentDocument.querySelector('textarea');
|
let ts = replayer.iframe.contentDocument.querySelector('textarea');
|
||||||
vals.push((e.data.source === 0 ? 'Mutation' : 'User') + ':' + ts.value);
|
vals.push((e.data.source === 0 ? 'Mutation' : 'User') + ':' + ts.value);
|
||||||
|
let ts2 = replayer.iframe.contentDocument.getElementById('ta2');
|
||||||
|
if (ts2) {
|
||||||
|
vals.push('ta2:' + ts2.value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
vals;
|
vals;
|
||||||
`);
|
`);
|
||||||
expect(replayTextareaValues).toEqual([
|
expect(replayTextareaValues).toEqual([
|
||||||
'Mutation:pre value',
|
'Mutation:pre value',
|
||||||
|
'ta2:',
|
||||||
'Mutation:ok',
|
'Mutation:ok',
|
||||||
|
'ta2:added',
|
||||||
'Mutation:ok3',
|
'Mutation:ok3',
|
||||||
'User:1ok3',
|
'User:1ok3',
|
||||||
'Mutation:1ok3', // if this gets set to 'ignore', it's an error, as the 'user' has modified the textarea
|
'Mutation:1ok3', // if this gets set to 'ignore', it's an error, as the 'user' has modified the textarea
|
||||||
@@ -166,6 +181,131 @@ describe('record integration tests', function (this: ISuite) {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can record and replay style mutations', async () => {
|
||||||
|
// This test shows that the `isStyle` attribute on textContent is not needed in a mutation
|
||||||
|
// TODO: we could get a lot more elaborate here with mixed textContent and insertRule mutations
|
||||||
|
const page: puppeteer.Page = await browser.newPage();
|
||||||
|
await page.goto(`${serverURL}/html`);
|
||||||
|
await page.setContent(getHtml.call(this, 'style.html'));
|
||||||
|
|
||||||
|
await waitForRAF(page); // ensure mutations aren't included in fullsnapshot
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
let styleEl = document.querySelector('style#dual-textContent');
|
||||||
|
if (styleEl) {
|
||||||
|
styleEl.append(
|
||||||
|
document.createTextNode('body { background-color: darkgreen; }'),
|
||||||
|
);
|
||||||
|
styleEl.append(
|
||||||
|
document.createTextNode(
|
||||||
|
'.absolutify { background-image: url("./rel"); }',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await waitForRAF(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
let styleEl = document.querySelector('style#dual-textContent');
|
||||||
|
if (styleEl) {
|
||||||
|
styleEl.childNodes.forEach((cn) => {
|
||||||
|
if (cn.textContent) {
|
||||||
|
cn.textContent = cn.textContent.replace('darkgreen', 'purple');
|
||||||
|
cn.textContent = cn.textContent.replace(
|
||||||
|
'orange !important',
|
||||||
|
'yellow',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await waitForRAF(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
let styleEl = document.querySelector('style#dual-textContent');
|
||||||
|
if (styleEl) {
|
||||||
|
styleEl.childNodes.forEach((cn) => {
|
||||||
|
if (cn.textContent) {
|
||||||
|
cn.textContent = cn.textContent.replace(
|
||||||
|
'black',
|
||||||
|
'black !important',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let hoverMutationStyleEl = document.querySelector('style#hover-mutation');
|
||||||
|
if (hoverMutationStyleEl) {
|
||||||
|
hoverMutationStyleEl.childNodes.forEach((cn) => {
|
||||||
|
if (cn.textContent) {
|
||||||
|
cn.textContent = 'a:hover { outline: cyan solid 1px; }';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let st = document.createElement('style');
|
||||||
|
st.id = 'goldilocks';
|
||||||
|
st.innerText = 'body { color: brown }';
|
||||||
|
document.body.append(st);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForRAF(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
let styleEl = document.querySelector('style#goldilocks');
|
||||||
|
if (styleEl) {
|
||||||
|
styleEl.childNodes.forEach((cn) => {
|
||||||
|
if (cn.textContent) {
|
||||||
|
cn.textContent = cn.textContent.replace('brown', 'gold');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshots = (await page.evaluate(
|
||||||
|
'window.snapshots',
|
||||||
|
)) as eventWithTime[];
|
||||||
|
|
||||||
|
// following ensures that the ./rel url has been absolutized (in a mutation)
|
||||||
|
await assertSnapshot(snapshots);
|
||||||
|
|
||||||
|
// check after each mutation and text input
|
||||||
|
const replayStyleValues = await page.evaluate(`
|
||||||
|
const { Replayer } = rrweb;
|
||||||
|
const replayer = new Replayer(window.snapshots);
|
||||||
|
const vals = [];
|
||||||
|
window.snapshots.filter((e)=>e.data.attributes || e.data.source === 5).forEach((e)=>{
|
||||||
|
replayer.pause((e.timestamp - window.snapshots[0].timestamp)+1);
|
||||||
|
let bodyStyle = getComputedStyle(replayer.iframe.contentDocument.querySelector('body'))
|
||||||
|
vals.push({
|
||||||
|
'background-color': bodyStyle['background-color'],
|
||||||
|
'color': bodyStyle['color'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vals.push(replayer.iframe.contentDocument.getElementById('single-textContent').innerText);
|
||||||
|
vals.push(replayer.iframe.contentDocument.getElementById('empty').innerText);
|
||||||
|
vals.push(replayer.iframe.contentDocument.getElementById('hover-mutation').innerText);
|
||||||
|
vals;
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(replayStyleValues).toEqual([
|
||||||
|
{
|
||||||
|
'background-color': 'rgb(0, 100, 0)', // darkgreen
|
||||||
|
color: 'rgb(255, 165, 0)', // orange (from style.html)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'background-color': 'rgb(128, 0, 128)', // purple
|
||||||
|
color: 'rgb(255, 255, 0)', // yellow
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'background-color': 'rgb(0, 0, 0)', // black !important
|
||||||
|
color: 'rgb(165, 42, 42)', // brown
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'background-color': 'rgb(0, 0, 0)',
|
||||||
|
color: 'rgb(255, 215, 0)', // gold
|
||||||
|
},
|
||||||
|
'a:hover,\na.\\:hover { outline: red solid 1px; }', // has run adaptCssForReplay
|
||||||
|
'a:hover,\na.\\:hover { outline: blue solid 1px; }', // has run adaptCssForReplay
|
||||||
|
'a:hover,\na.\\:hover { outline: cyan solid 1px; }', // has run adaptCssForReplay after text mutation
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('can record childList mutations', async () => {
|
it('can record childList mutations', async () => {
|
||||||
const page: puppeteer.Page = await browser.newPage();
|
const page: puppeteer.Page = await browser.newPage();
|
||||||
await page.goto('about:blank');
|
await page.goto('about:blank');
|
||||||
@@ -1238,12 +1378,8 @@ describe('record integration tests', function (this: ISuite) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* https://github.com/rrweb-io/rrweb/pull/1417
|
* the regression part of the following is now handled by replayer.test.ts::'can deal with duplicate/conflicting values on style elements'
|
||||||
* This test is to make sure that this problem doesn't regress
|
* so this test could be dropped if we add more robust mixing of `insertRule` into 'can record and replay style mutations'
|
||||||
* Test case description:
|
|
||||||
* 1. Record two style elements. One is recorded as a full snapshot and the other is recorded as an incremental snapshot.
|
|
||||||
* 2. Change the color of both style elements to yellow as incremental style mutation.
|
|
||||||
* 3. Replay the recorded events and check if the style mutation is applied correctly.
|
|
||||||
*/
|
*/
|
||||||
it('should record style mutations and replay them correctly', async () => {
|
it('should record style mutations and replay them correctly', async () => {
|
||||||
const page: puppeteer.Page = await browser.newPage();
|
const page: puppeteer.Page = await browser.newPage();
|
||||||
@@ -1336,4 +1472,77 @@ describe('record integration tests', function (this: ISuite) {
|
|||||||
expect(changedColors).toEqual([NewColor, NewColor]);
|
expect(changedColors).toEqual([NewColor, NewColor]);
|
||||||
await page.close();
|
await page.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should record style mutations with multiple child nodes and replay them correctly', async () => {
|
||||||
|
// ensure that presence of multiple text nodes doesn't interfere with programmatic insertRule operations
|
||||||
|
|
||||||
|
const page: puppeteer.Page = await browser.newPage();
|
||||||
|
const Color = 'rgb(255, 0, 0)'; // red color
|
||||||
|
|
||||||
|
await page.setContent(
|
||||||
|
`
|
||||||
|
<!DOCTYPE html><html lang="en">
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
/* hello */
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="one"></div>
|
||||||
|
<div id="two"></div>
|
||||||
|
<script>
|
||||||
|
document.querySelector("style").append(document.createTextNode("/* world */"));
|
||||||
|
document.querySelector("style").sheet.insertRule('#one { color: ${Color}; }', 0);
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
// Start rrweb recording
|
||||||
|
await page.evaluate(
|
||||||
|
(code, recordSnippet) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.textContent = `${code};${recordSnippet}`;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
},
|
||||||
|
code,
|
||||||
|
generateRecordSnippet({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.evaluate(async (Color) => {
|
||||||
|
// Create a new style element with the same content as the existing style element and apply it to the #two div element
|
||||||
|
const incrementalStyle = document.createElement(
|
||||||
|
'style',
|
||||||
|
) as HTMLStyleElement;
|
||||||
|
incrementalStyle.append(document.createTextNode('/* hello */'));
|
||||||
|
incrementalStyle.append(document.createTextNode('/* world */'));
|
||||||
|
document.head.appendChild(incrementalStyle);
|
||||||
|
incrementalStyle.sheet!.insertRule(`#two { color: ${Color}; }`, 0);
|
||||||
|
}, Color);
|
||||||
|
|
||||||
|
const snapshots = (await page.evaluate(
|
||||||
|
'window.snapshots',
|
||||||
|
)) as eventWithTime[];
|
||||||
|
await assertSnapshot(snapshots);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay the recorded events and check if the style mutation is applied correctly
|
||||||
|
*/
|
||||||
|
const changedColors = await page.evaluate(`
|
||||||
|
const { Replayer } = rrweb;
|
||||||
|
const replayer = new Replayer(window.snapshots);
|
||||||
|
replayer.pause(1000);
|
||||||
|
|
||||||
|
// Get the color of the element after applying the style mutation event
|
||||||
|
[
|
||||||
|
window.getComputedStyle(
|
||||||
|
replayer.iframe.contentDocument.querySelector('#one'),
|
||||||
|
).color,
|
||||||
|
window.getComputedStyle(
|
||||||
|
replayer.iframe.contentDocument.querySelector('#two'),
|
||||||
|
).color,
|
||||||
|
];
|
||||||
|
`);
|
||||||
|
expect(changedColors).toEqual([Color, Color]);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -250,18 +250,18 @@ export function stringifySnapshots(snapshots: eventWithTime[]): string {
|
|||||||
|
|
||||||
function stripBlobURLsFromAttributes(node: {
|
function stripBlobURLsFromAttributes(node: {
|
||||||
attributes: {
|
attributes: {
|
||||||
src?: string;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
if (
|
for (const attr in node.attributes) {
|
||||||
'src' in node.attributes &&
|
if (
|
||||||
node.attributes.src &&
|
typeof node.attributes[attr] === 'string' &&
|
||||||
typeof node.attributes.src === 'string' &&
|
node.attributes[attr].startsWith('blob:')
|
||||||
node.attributes.src.startsWith('blob:')
|
) {
|
||||||
) {
|
node.attributes[attr] = node.attributes[attr]
|
||||||
node.attributes.src = node.attributes.src
|
.replace(/[\w-]+$/, '...')
|
||||||
.replace(/[\w-]+$/, '...')
|
.replace(/:[0-9]+\//, ':xxxx/');
|
||||||
.replace(/:[0-9]+\//, ':xxxx/');
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user