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:
Eoghan Murray
2026-04-01 12:00:00 +08:00
committed by GitHub
parent f8b3f4f8a2
commit 67657a8710
19 changed files with 1595 additions and 387 deletions

View File

@@ -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) {