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:
@@ -27,6 +27,7 @@ import {
|
||||
toLowerCase,
|
||||
extractFileExtension,
|
||||
absolutifyURLs,
|
||||
markCssSplits,
|
||||
} from './utils';
|
||||
import dom from '@rrweb/utils';
|
||||
|
||||
@@ -403,6 +404,7 @@ function serializeNode(
|
||||
* `newlyAddedElement: true` skips scrollTop and scrollLeft check
|
||||
*/
|
||||
newlyAddedElement?: boolean;
|
||||
cssCaptured?: boolean;
|
||||
},
|
||||
): serializedNode | false {
|
||||
const {
|
||||
@@ -420,6 +422,7 @@ function serializeNode(
|
||||
recordCanvas,
|
||||
keepIframeSrcFn,
|
||||
newlyAddedElement = false,
|
||||
cssCaptured = false,
|
||||
} = options;
|
||||
// Only record root id when document object is not the base document
|
||||
const rootId = getRootId(doc, mirror);
|
||||
@@ -466,6 +469,7 @@ function serializeNode(
|
||||
needsMask,
|
||||
maskTextFn,
|
||||
rootId,
|
||||
cssCaptured,
|
||||
});
|
||||
case n.CDATA_SECTION_NODE:
|
||||
return {
|
||||
@@ -497,48 +501,38 @@ function serializeTextNode(
|
||||
needsMask: boolean;
|
||||
maskTextFn: MaskTextFn | undefined;
|
||||
rootId: number | undefined;
|
||||
cssCaptured?: boolean;
|
||||
},
|
||||
): 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.
|
||||
// So just let it be undefined which is ok in this use case.
|
||||
const parent = dom.parentNode(n);
|
||||
const parentTagName = parent && (parent as HTMLElement).tagName;
|
||||
let text = dom.textContent(n);
|
||||
let textContent: string | null = '';
|
||||
const isStyle = parentTagName === 'STYLE' ? 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) {
|
||||
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) {
|
||||
text = maskTextFn
|
||||
? maskTextFn(text, dom.parentElement(n))
|
||||
: text.replace(/[\S]/g, '*');
|
||||
if (!isStyle && !isScript && textContent && needsMask) {
|
||||
textContent = maskTextFn
|
||||
? maskTextFn(textContent, dom.parentElement(n))
|
||||
: textContent.replace(/[\S]/g, '*');
|
||||
}
|
||||
|
||||
return {
|
||||
type: NodeType.Text,
|
||||
textContent: text || '',
|
||||
isStyle,
|
||||
textContent: textContent || '',
|
||||
rootId,
|
||||
};
|
||||
}
|
||||
@@ -608,17 +602,14 @@ function serializeElementNode(
|
||||
attributes._cssText = cssText;
|
||||
}
|
||||
}
|
||||
// dynamic stylesheet
|
||||
if (
|
||||
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(
|
||||
if (tagName === 'style' && (n as HTMLStyleElement).sheet) {
|
||||
let cssText = stringifyStylesheet(
|
||||
(n as HTMLStyleElement).sheet as CSSStyleSheet,
|
||||
);
|
||||
if (cssText) {
|
||||
if (n.childNodes.length > 1) {
|
||||
cssText = markCssSplits(cssText, n as HTMLStyleElement);
|
||||
}
|
||||
attributes._cssText = cssText;
|
||||
}
|
||||
}
|
||||
@@ -937,6 +928,7 @@ export function serializeNodeWithId(
|
||||
node: serializedElementNodeWithId,
|
||||
) => unknown;
|
||||
stylesheetLoadTimeout?: number;
|
||||
cssCaptured?: boolean;
|
||||
},
|
||||
): serializedNodeWithId | null {
|
||||
const {
|
||||
@@ -962,6 +954,7 @@ export function serializeNodeWithId(
|
||||
stylesheetLoadTimeout = 5000,
|
||||
keepIframeSrcFn = () => false,
|
||||
newlyAddedElement = false,
|
||||
cssCaptured = false,
|
||||
} = options;
|
||||
let { needsMask } = options;
|
||||
let { preserveWhiteSpace = true } = options;
|
||||
@@ -992,6 +985,7 @@ export function serializeNodeWithId(
|
||||
recordCanvas,
|
||||
keepIframeSrcFn,
|
||||
newlyAddedElement,
|
||||
cssCaptured,
|
||||
});
|
||||
if (!_serializedNode) {
|
||||
// TODO: dev only
|
||||
@@ -1007,7 +1001,6 @@ export function serializeNodeWithId(
|
||||
slimDOMExcluded(_serializedNode, slimDOMOptions) ||
|
||||
(!preserveWhiteSpace &&
|
||||
_serializedNode.type === NodeType.Text &&
|
||||
!_serializedNode.isStyle &&
|
||||
!_serializedNode.textContent.replace(/^\s+|\s+$/gm, '').length)
|
||||
) {
|
||||
id = IGNORED_NODE;
|
||||
@@ -1072,6 +1065,7 @@ export function serializeNodeWithId(
|
||||
onStylesheetLoad,
|
||||
stylesheetLoadTimeout,
|
||||
keepIframeSrcFn,
|
||||
cssCaptured: false,
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -1081,6 +1075,13 @@ export function serializeNodeWithId(
|
||||
) {
|
||||
// value parameter in DOM reflects the correct value, so ignore childNode
|
||||
} 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))) {
|
||||
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
|
||||
if (serializedChildNode) {
|
||||
|
||||
Reference in New Issue
Block a user