diff --git a/.changeset/single-style-capture.md b/.changeset/single-style-capture.md
new file mode 100644
index 0000000000..96f81ed621
--- /dev/null
+++ b/.changeset/single-style-capture.md
@@ -0,0 +1,6 @@
+---
+"rrweb-snapshot": patch
+"rrweb": patch
+---
+
+Edge case: Provide support for mutations on a ';
+ 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 =
+ '';
+ 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(``).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,
+ );
+ });
+});
diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts
index 490b515f5b..14a255bf6d 100644
--- a/packages/rrweb-snapshot/test/rebuild.test.ts
+++ b/packages/rrweb-snapshot/test/rebuild.test.ts
@@ -10,7 +10,7 @@ import {
createCache,
} from '../src/rebuild';
import { NodeType } from '../src/types';
-import { createMirror, Mirror } from '../src/utils';
+import { createMirror, Mirror, normalizeCssString } from '../src/utils';
const expect = _expect as unknown as {
(actual: T): {
@@ -20,7 +20,7 @@ const expect = _expect as unknown as {
expect.extend({
toMatchCss: function (received: string, expected: string) {
- const pass = normCss(received) === normCss(expected);
+ const pass = normalizeCssString(received) === normalizeCssString(expected);
const message: () => string = () =>
pass
? ''
@@ -32,10 +32,6 @@ expect.extend({
},
});
-function normCss(cssText: string): string {
- return cssText.replace(/[\s;]/g, '');
-}
-
function getDuration(hrtime: [number, number]) {
const [seconds, nanoseconds] = hrtime;
return seconds * 1000 + nanoseconds / 1000000;
diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts
index c3ff607fd6..5778eb0aff 100644
--- a/packages/rrweb-snapshot/test/snapshot.test.ts
+++ b/packages/rrweb-snapshot/test/snapshot.test.ts
@@ -162,22 +162,27 @@ describe('style elements', () => {
it('should serialize all rules of stylesheet when the sheet has a single child node', () => {
const styleEl = render(``);
styleEl.sheet?.insertRule('section { color: blue; }');
- expect(serializeNode(styleEl.childNodes[0])).toMatchObject({
- isStyle: true,
+ expect(serializeNode(styleEl)).toMatchObject({
rootId: undefined,
- textContent: 'section {color: blue;}body {color: red;}',
- type: 3,
+ attributes: {
+ _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(``);
+ styleEl.sheet?.insertRule('section.lost { color: unseeable; }'); // browser throws this away after append
styleEl.append(document.createTextNode('section { color: blue; }'));
- expect(serializeNode(styleEl.childNodes[1])).toMatchObject({
- isStyle: true,
+ styleEl.sheet?.insertRule('section.working { color: pink; }');
+ expect(serializeNode(styleEl)).toMatchObject({
rootId: undefined,
- textContent: 'section { color: blue; }',
- type: 3,
+ attributes: {
+ _cssText:
+ 'section.working {color: pink;}body {color: red;}/* rr_split */section {color: blue;}',
+ },
+ type: 2,
});
});
});
diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json
index d8ff22b763..2623a517ff 100644
--- a/packages/rrweb/package.json
+++ b/packages/rrweb/package.json
@@ -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",
diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts
index 3ab7f19170..3feae589f6 100644
--- a/packages/rrweb/src/record/mutation.ts
+++ b/packages/rrweb/src/record/mutation.ts
@@ -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
+
+
+
+
+
+
+
+
+