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:
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user