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

@@ -5,7 +5,8 @@
"scripts": {
"prepare": "npm run prepack",
"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",
"test:headless": "cross-env PUPPETEER_HEADLESS=true yarn build-and-test",
"test:headful": "cross-env PUPPETEER_HEADLESS=false yarn build-and-test",

View File

@@ -287,12 +287,26 @@ export default class MutationBuffer {
};
const pushAdd = (n: Node) => {
const parent = dom.parentNode(n);
if (!parent || !inDom(n) || (parent as Element).tagName === 'TEXTAREA') {
if (!parent || !inDom(n)) {
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)
? this.mirror.getId(getShadowHost(n))
: this.mirror.getId(parent);
const nextId = getNextId(n);
if (parentId === -1 || nextId === -1) {
return addList.addNode(n);
@@ -335,6 +349,7 @@ export default class MutationBuffer {
onStylesheetLoad: (link, childSn) => {
this.stylesheetManager.attachLinkElement(link, childSn);
},
cssCaptured,
});
if (sn) {
adds.push({

View File

@@ -1,5 +1,6 @@
import {
rebuild,
adaptCssForReplay,
buildNodeWithSN,
NodeType,
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; }',
);
}
if (!injectStylesRules.length) {
return;
}
if (this.usingVirtualDom) {
const styleEl = this.virtualDom.createElement('style');
this.virtualDom.mirror.add(
@@ -1743,7 +1747,14 @@ export class Replayer {
}
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

File diff suppressed because it is too large Load Diff

View File

@@ -1386,18 +1386,18 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
{
\\"type\\": 2,
\\"tagName\\": \\"style\\",
\\"attributes\\": {},
\\"attributes\\": {
\\"_cssText\\": \\"div { color: red; }/* rr_split */section { color: blue; }\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"div { color: red; }\\",
\\"isStyle\\": true,
\\"textContent\\": \\"\\",
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"section { color: blue; }\\",
\\"isStyle\\": true,
\\"textContent\\": \\"\\",
\\"id\\": 7
}
],
@@ -1460,7 +1460,6 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"h1 { color: pink; }\\",
\\"isStyle\\": true,
\\"id\\": 12
}
},
@@ -1470,7 +1469,6 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"span { color: orange; }\\",
\\"isStyle\\": true,
\\"id\\": 13
}
}

View 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>

View File

@@ -101,7 +101,7 @@ describe('record integration tests', function (this: ISuite) {
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();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'empty.html'));
@@ -112,20 +112,29 @@ describe('record integration tests', function (this: ISuite) {
const ta = document.createElement('textarea');
ta.innerText = 'pre value';
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(() => {
const t = document.querySelector('textarea') as HTMLTextAreaElement;
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(() => {
const t = document.querySelector('textarea') as HTMLTextAreaElement;
(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.waitForTimeout(5);
await waitForRAF(page);
await page.evaluate(() => {
const t = document.querySelector('textarea') as HTMLTextAreaElement;
// 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
// 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
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);
let ts = replayer.iframe.contentDocument.querySelector('textarea');
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;
`);
expect(replayTextareaValues).toEqual([
'Mutation:pre value',
'ta2:',
'Mutation:ok',
'ta2:added',
'Mutation:ok3',
'User:1ok3',
'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 () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
@@ -1238,12 +1378,8 @@ describe('record integration tests', function (this: ISuite) {
});
/**
* https://github.com/rrweb-io/rrweb/pull/1417
* This test is to make sure that this problem doesn't regress
* 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.
* the regression part of the following is now handled by replayer.test.ts::'can deal with duplicate/conflicting values on style elements'
* so this test could be dropped if we add more robust mixing of `insertRule` into 'can record and replay style mutations'
*/
it('should record style mutations and replay them correctly', async () => {
const page: puppeteer.Page = await browser.newPage();
@@ -1336,4 +1472,77 @@ describe('record integration tests', function (this: ISuite) {
expect(changedColors).toEqual([NewColor, NewColor]);
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();
});
});

View File

@@ -250,18 +250,18 @@ export function stringifySnapshots(snapshots: eventWithTime[]): string {
function stripBlobURLsFromAttributes(node: {
attributes: {
src?: string;
[key: string]: any;
};
}) {
if (
'src' in node.attributes &&
node.attributes.src &&
typeof node.attributes.src === 'string' &&
node.attributes.src.startsWith('blob:')
) {
node.attributes.src = node.attributes.src
.replace(/[\w-]+$/, '...')
.replace(/:[0-9]+\//, ':xxxx/');
for (const attr in node.attributes) {
if (
typeof node.attributes[attr] === 'string' &&
node.attributes[attr].startsWith('blob:')
) {
node.attributes[attr] = node.attributes[attr]
.replace(/[\w-]+$/, '...')
.replace(/:[0-9]+\//, ':xxxx/');
}
}
}