Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/olive-donkeys-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"rrweb-snapshot": patch
---

Fix `<style>` rules being dropped on replay when a mutation inserts a text node between two `_cssText` splits. The split points recorded by `markCssSplits` regularly land in the middle of a rule, which produces invalid css once a sibling is inserted between the parts; `applyCssSplits` now moves each split point forward to the end of the rule it lands in.
13 changes: 11 additions & 2 deletions packages/plugins/rrweb-plugin-network-record/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["src"],
"exclude": ["vite.config.ts", "vitest.config.ts", "test"],
"include": [
"src"
],
"exclude": [
"vite.config.ts",
"vitest.config.ts",
"test"
],
"compilerOptions": {
"rootDir": "src",
"tsBuildInfoFile": "./tsconfig.tsbuildinfo"
Expand All @@ -12,6 +18,9 @@
},
{
"path": "../../utils"
},
{
"path": "../../rrweb"
}
]
}
14 changes: 11 additions & 3 deletions packages/plugins/rrweb-plugin-network-replay/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["src"],
"exclude": ["vite.config.ts", "test"],
"include": [
"src"
],
"exclude": [
"vite.config.ts",
"test"
],
"compilerOptions": {
"rootDir": "src",
"tsBuildInfoFile": "./tsconfig.tsbuildinfo"
},
"references": [
{
"path": "../rrweb-plugin-network-record"
},
{
"path": "../../types"
},
{
"path": "../rrweb-plugin-network-record"
"path": "../../rrweb"
}
]
}
3 changes: 3 additions & 0 deletions packages/rrweb-snapshot/src/rebuild-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ export {
Mirror,
isNodeMetaEqual,
extractFileExtension,
cssRuleBoundaries,
nextCssRuleBoundary,
snapCssSplitsToRuleBoundaries,
} from './utils';
19 changes: 18 additions & 1 deletion packages/rrweb-snapshot/src/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
Mirror,
isNodeMetaEqual,
extractFileExtension,
cssRuleBoundaries,
nextCssRuleBoundary,
snapCssSplitsToRuleBoundaries,
} from './rebuild-utils';
import postcss from 'postcss';

Expand Down Expand Up @@ -206,17 +209,24 @@ export function applyCssSplits(
childTextNodes.push(scn);
}
}
const cssTextSplits = cssText.split('/* rr_split */');
let cssTextSplits = cssText.split('/* rr_split */');
while (
cssTextSplits.length > 1 &&
cssTextSplits.length > childTextNodes.length
) {
// unexpected: remerge the last two so that we don't discard any css
cssTextSplits.splice(-2, 2, cssTextSplits.slice(-2).join(''));
}
// the split points recorded by `markCssSplits` regularly land in the middle
// of a rule; move them to the end of that rule so that a sibling inserted
// between two of these text nodes by a later mutation can't cut a rule in
// half (see `snapCssSplitsToRuleBoundaries`)
cssTextSplits = snapCssSplitsToRuleBoundaries(cssTextSplits);
let adaptedCss = '';
let adaptedBoundaries: number[] = [];
if (hackCss) {
adaptedCss = adaptCssForReplay(cssTextSplits.join(''), cache);
adaptedBoundaries = cssRuleBoundaries(adaptedCss);
}
let startIndex = 0;
for (let i = 0; i < childTextNodes.length; i++) {
Expand Down Expand Up @@ -248,6 +258,13 @@ export function applyCssSplits(
// something went wrong, put a similar sized chunk in the right place
endIndex += cssTextSplits[i].length;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming all the bad cases are produced in here ("something went wrong") so possibly we could confine the nextCssRuleBoundary call to only in here.
I'd also be interested in the input data that caused the above search to go wrong.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Above comment was written before I realized we are in rebuild here not snapshot ...

}
// `adaptCssForReplay` rewrites selectors, so the split point has to be
// re-aligned with the rewritten css as well
endIndex = nextCssRuleBoundary(
adaptedBoundaries,
endIndex,
adaptedCss.length,
);
childTextNode.textContent = adaptedCss.substring(startIndex, endIndex);
startIndex = endIndex;
} else {
Expand Down
78 changes: 78 additions & 0 deletions packages/rrweb-snapshot/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,3 +608,81 @@ export function markCssSplits(
): string {
return splitCssText(cssText, style).join('/* rr_split */');
}

/**
* Offsets just past the end of each top-level css rule (i.e. just after a `}`
* which closes back to depth zero). Braces inside strings and comments are
* not counted.
*/
export function cssRuleBoundaries(cssText: string): number[] {
const boundaries: number[] = [];
let depth = 0;
let quote: string | null = null;
for (let i = 0; i < cssText.length; i++) {
const char = cssText[i];
if (quote !== null) {
if (char === '\\') i++;
else if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
} else if (char === '/' && cssText[i + 1] === '*') {
const end = cssText.indexOf('*/', i + 2);
i = end === -1 ? cssText.length : end + 1;
} else if (char === '{') {
depth++;
} else if (char === '}') {
depth = Math.max(0, depth - 1);
if (depth === 0) boundaries.push(i + 1);
}
}
return boundaries;
}

/**
* The first rule boundary at or after `index`, or the end of the string.
*/
export function nextCssRuleBoundary(
boundaries: number[],
index: number,
end: number,
): number {
return boundaries.find((boundary) => boundary >= index) ?? end;
}

/**
* Move each split point forward to the end of the rule it lands in.
*
* `splitCssText` locates the split points by searching for each text node's
* content inside the browser-serialized stylesheet, so a split point regularly
* lands in the middle of a rule (e.g. after `color: rgba(0, 0, 0, 0.84`).
* That is harmless while the parts stay adjacent, but keeping the parts
* separate is only worthwhile because later mutations can insert siblings
* between them — and a rule cut in half around an inserted sibling is invalid
* css. The browser then goes looking for the closing brace and drops every
* rule until it recovers.
*
* The exact position of a split point is a guess to begin with, so nothing is
* lost by moving it to the next rule boundary.
*/
export function snapCssSplitsToRuleBoundaries(splits: string[]): string[] {
if (splits.length < 2) return splits;
const cssText = splits.join('');
const boundaries = cssRuleBoundaries(cssText);
const snapped: string[] = [];
let from = 0;
let at = 0;
for (let i = 0; i < splits.length - 1; i++) {
at += splits[i].length;
const boundary = nextCssRuleBoundary(
boundaries,
Math.max(at, from),
cssText.length,
);
snapped.push(cssText.substring(from, boundary));
from = boundary;
}
snapped.push(cssText.substring(from));
return snapped;
}
119 changes: 115 additions & 4 deletions packages/rrweb-snapshot/test/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { describe, it, beforeEach, expect } from 'vitest';
import { mediaSelectorPlugin, pseudoClassPlugin } from '../src/css';
import postcss, { type AcceptedPlugin } from 'postcss';
import { JSDOM } from 'jsdom';
import { splitCssText, stringifyStylesheet } from './../src/utils';
import {
snapCssSplitsToRuleBoundaries,
splitCssText,
stringifyStylesheet,
} from './../src/utils';
import { applyCssSplits } from './../src/rebuild';
import * as fs from 'fs';
import * as path from 'path';
Expand Down Expand Up @@ -408,13 +412,74 @@ describe('applyCssSplits css rejoiner', function () {
'/* rr_split */',
);
applyCssSplits(sn3, markedCssText, true, mockLastUnusedArg);
// the split points move to the end of the rule they landed in, so each
// text node holds whole rules rather than half of one
expect((sn3.childNodes[0] as textNode).textContent).toEqual(
badStartThird.replace('.a:hover', '.a:hover,\n.a.\\:hover'),
'.a:hover,\n.a.\\:hover { background-color: red; }',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so IIRC the idea with this test was to test actual weird text nodes that don't land in logical css split points ... i.e. the original text nodes were injected arbitrarily ... it's a contrived example designed to show that we don't throw an error trying to parse any of the parts as valid CSS, so I'd reject this test modification, but rather ask you to find the root cause of what the styled-components was actually injecting (was there ever invalid css produced at record time ... if not we should not produce invalid css in our splitting ... that's the challenge)

);
expect((sn3.childNodes[1] as textNode).textContent).toEqual(
badMidThird.replace('input:hover', 'input:hover,\ninput.\\:hover'),
' input:hover,\ninput.\\:hover {border: 1px solid purple; }',
);
expect((sn3.childNodes[2] as textNode).textContent).toEqual('');
expect(
(sn3.childNodes[0] as textNode).textContent +
(sn3.childNodes[1] as textNode).textContent +
(sn3.childNodes[2] as textNode).textContent,
).toEqual(
[badStartThird, badMidThird, badEndThird]
.join('')
.replace('.a:hover', '.a:hover,\n.a.\\:hover')
.replace('input:hover', 'input:hover,\ninput.\\:hover'),
);
});

it('moves a split point which lands inside a rule to the end of that rule', () => {
const markedCssText = [
'.a { color: red; }.b { col',
'or: green; }.c { color: blue; }',
].join('/* rr_split */');
applyCssSplits(sn, markedCssText, false, mockLastUnusedArg);
expect((sn.childNodes[0] as textNode).textContent).toEqual(
'.a { color: red; }.b { color: green; }',
);
expect((sn.childNodes[1] as textNode).textContent).toEqual(
'.c { color: blue; }',
);
});

it('survives a sibling text node being inserted between the splits', () => {
// a later mutation can insert a text node between these two, which is the
// whole reason the split is preserved; the css has to stay valid when it
// does
const markedCssText = [
'.a { color: red; }.b { col',
'or: green; }.c { color: blue; }',
].join('/* rr_split */');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is starting with a broken split if I understand it correctly — is this a simulation of the 'bad/inexact' splitting?

applyCssSplits(sn, markedCssText, false, mockLastUnusedArg);
const inserted = '.inserted { color: pink; }';
expect(
(sn.childNodes[0] as textNode).textContent +
inserted +
(sn.childNodes[1] as textNode).textContent,
).toEqual(
'.a { color: red; }.b { color: green; }' +
inserted +
'.c { color: blue; }',
);
});

it('does not mistake braces inside strings for the end of a rule', () => {
const markedCssText = [
'.a::after { content: "}',
'"; }.b { color: red; }',
].join('/* rr_split */');
applyCssSplits(sn, markedCssText, false, mockLastUnusedArg);
expect((sn.childNodes[0] as textNode).textContent).toEqual(
'.a::after { content: "}"; }',
);
expect((sn.childNodes[1] as textNode).textContent).toEqual(
'.b { color: red; }',
);
expect((sn3.childNodes[2] as textNode).textContent).toEqual(badEndThird);
});

it('maintains entire css text when there are too few child nodes', () => {
Expand All @@ -434,3 +499,49 @@ describe('applyCssSplits css rejoiner', function () {
);
});
});

describe('snapCssSplitsToRuleBoundaries', function () {
it('leaves splits which already sit on a rule boundary alone', () => {
const splits = ['.a { color: red; }', '.b { color: green; }'];
expect(snapCssSplitsToRuleBoundaries(splits)).toEqual(splits);
});

it('moves a split point forward to the end of the rule', () => {
expect(
snapCssSplitsToRuleBoundaries([
'.a { col',
'or: red; }.b { color: green; }',
]),
).toEqual(['.a { color: red; }', '.b { color: green; }']);
});

it('ignores braces inside strings and comments', () => {
expect(
snapCssSplitsToRuleBoundaries([
'.a { content: "}"; /* } */ ',
'}.b { color: red; }',
]),
).toEqual(['.a { content: "}"; /* } */ }', '.b { color: red; }']);
});

it('keeps nested at-rules together', () => {
expect(
snapCssSplitsToRuleBoundaries([
'@media print { .a { color',
': red; } }.b { color: green; }',
]),
).toEqual(['@media print { .a { color: red; } }', '.b { color: green; }']);
});

it('empties trailing splits when everything snapped into an earlier one', () => {
expect(
snapCssSplitsToRuleBoundaries(['.a { col', 'or', ': red; }']),
).toEqual(['.a { color: red; }', '', '']);
});

it('is a no-op for a single split', () => {
expect(snapCssSplitsToRuleBoundaries(['.a { color'])).toEqual([
'.a { color',
]);
});
});
Loading