Skip to content

fix(rrweb-snapshot): align _cssText split points with css rule boundaries - #1920

Open
creasty wants to merge 1 commit into
rrweb-io:mainfrom
creasty:fix/css-splits-rule-boundaries
Open

fix(rrweb-snapshot): align _cssText split points with css rule boundaries#1920
creasty wants to merge 1 commit into
rrweb-io:mainfrom
creasty:fix/css-splits-rule-boundaries

Conversation

@creasty

@creasty creasty commented Sep 2, 2026

Copy link
Copy Markdown

applyCssSplits hands each /* rr_split */ part to one of the <style>'s text nodes. The whole reason those parts are kept separate is the one written in buildStyleNode's docstring — "in case they are modified by subsequent mutations". But the split points recorded by markCssSplits regularly land in the middle of a rule, and when a mutation then inserts a sibling text node between two parts, the result is invalid css.

The problem

splitCssText doesn't know where the rule boundaries are. It locates each split point by searching for a text node's content inside the browser-serialized stylesheet, normalising whitespace to compare, then converting the offset back. That lands close to the right place but not exactly on it — routinely a few characters short, in the middle of a declaration:

.a[aria-expanded="true"] { background-color: rgba(0, 0, 0, 0.05); color: rgba(0, 0, 0, 0.84

While the parts stay adjacent this is harmless: concatenating the text nodes gives back the original css, and that is what the browser parses. It stops being harmless as soon as something is inserted between them. A css-in-js library (styled-components here) adds rules at runtime by inserting a text node into the same <style>, positioned by nextId — right between two of the parts. What the browser gets is:

... color: rgba(0, 0, 0, 0.84.dqzkfX{cursor:pointer;-webkit-user-select:none; ...

The declaration never closes, so the parser goes looking for the closing brace and discards every rule until it recovers.

In a 43-second recording of our app (~200 text nodes in one <style>, 45 split points), 57 of 311 rules were dropped this way. On screen, two navigation buttons lost their styling entirely and fell back to the UA button appearance (border: outset 2px, background: buttonface) while their neighbours were fine — the difference being only whether their rule happened to sit after a splice point.

Reproducing it needs nothing exotic:

// a split point that landed inside `.b`
applyCssSplits(sn, '.a { color: red; }.b { col/* rr_split */or: green; }', false, cache);
// before: childNodes[0] = '.a { color: red; }.b { col'
//         childNodes[1] = 'or: green; }'
// a later mutation inserts a text node between the two and `.b` is cut in half
// after:  childNodes[0] = '.a { color: red; }.b { color: green; }'
//         childNodes[1] = ''

The fix

Move each split point forward to the end of the rule it lands in, before distributing the parts (snapCssSplitsToRuleBoundaries). Braces inside strings and comments aren't counted, and nested blocks (@media, @supports, @keyframes) close at their outermost brace.

The exact position of a split point is a guess to begin with, so nothing is lost by moving it — the concatenation is unchanged, only which text node holds which rule. And once every part is a whole number of rules, a sibling inserted between any two of them is harmless.

The same alignment is applied to endIndex on the hackCss path: adaptCssForReplay changes the length of the css, so the existing searchBit scan is used as before and then snapped to a rule boundary in the rewritten string. This also covers the case where the next part is empty after snapping and the scan has nothing to search for.

Why on the replay side

The split points are baked into _cssText at record time, so fixing splitCssText would only help recordings made after the change ships. Doing it in applyCssSplits fixes recordings that already exist, which matters given how long recorder rollout takes relative to a player deploy.

Behaviour change

One existing expectation changes: applies css splits correctly when split parts are invalid by themselves x3 asserted per-node contents that were half-rules. The parts are now whole rules; the test asserts both the new distribution and that the concatenation is unchanged. The other existing cases are unaffected (split points that already sit on a boundary don't move, and the remerge/too-few-nodes paths are untouched).

Tests

In applyCssSplits css rejoiner:

  • a split point which lands inside a rule is moved to the end of that rule
  • the css survives a sibling text node being inserted between the parts (the actual failure)
  • braces inside strings are not mistaken for the end of a rule

And a snapCssSplitsToRuleBoundaries describe covering: already-aligned splits are left alone, a split point moved forward, braces in strings and comments, nested at-rules kept together, trailing splits emptied when everything snapped into an earlier one, and the single-split no-op.

All ten fail on main and pass with the change. packages/rrweb-snapshot: vitest run 141 passed / 1 skipped, tsc --noEmit clean, eslint src clean. packages/rrweb is unchanged against main (the same 11 puppeteer benchmark/integration tests time out on both, in a container).

`applyCssSplits` hands each `/* rr_split */` part to one of the `<style>`'s
text nodes, so that a later mutation can modify an individual node. But the
split points recorded by `markCssSplits` regularly land in the middle of a
rule: `splitCssText` locates them by searching for a text node's content
inside the browser-serialized stylesheet (normalising whitespace to compare,
then converting the offset back), which lands close to the right place but
not exactly on it.

That is harmless while the parts stay adjacent — concatenating the text
nodes gives back the original css. It stops being harmless when a mutation
inserts a sibling text node between two parts, which is exactly what the
splits exist for: a css-in-js library adding a rule at runtime inserts a
text node into the same `<style>`, positioned by `nextId`. The browser then
sees

    ... color: rgba(0, 0, 0, 0.84.dqzkfX{cursor:pointer; ...

the declaration never closes, and every rule until the parser recovers is
discarded. In a 43-second recording of a styled-components app (~200 text
nodes in one `<style>`, 45 split points) 57 of 311 rules were dropped this
way, leaving two navigation buttons with the UA button appearance while
their neighbours rendered correctly.

Move each split point forward to the end of the rule it lands in before
distributing the parts. Braces inside strings and comments are not counted,
and nested blocks close at their outermost brace. The position of a split
point is a guess to begin with, so nothing is lost by moving it: the
concatenation is unchanged, only which text node holds which rule.

The same alignment is applied to `endIndex` on the `hackCss` path, since
`adaptCssForReplay` changes the length of the css — the existing `searchBit`
scan runs as before and its result is then snapped to a rule boundary in the
rewritten string.

Doing this on the replay side rather than in `splitCssText` also fixes
recordings that already exist, which matters given how long recorder rollout
takes relative to a player deploy.
@changeset-bot

changeset-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2ccb13e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 22 packages
Name Type
rrweb-snapshot Patch
@rrweb/all Patch
@rrweb/browser-client Patch
@rrweb/packer Patch
@rrweb/record Patch
@rrweb/replay Patch
rrdom-nodejs Patch
rrdom Patch
rrvideo Patch
rrweb-player Patch
rrweb Patch
@rrweb/types Patch
@rrweb/utils Patch
@rrweb/web-extension Patch
@rrweb/rrweb-plugin-canvas-webrtc-record Patch
@rrweb/rrweb-plugin-canvas-webrtc-replay Patch
@rrweb/rrweb-plugin-console-record Patch
@rrweb/rrweb-plugin-console-replay Patch
@rrweb/rrweb-plugin-network-record Patch
@rrweb/rrweb-plugin-network-replay Patch
@rrweb/rrweb-plugin-sequential-id-record Patch
@rrweb/rrweb-plugin-sequential-id-replay Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@eoghanmurray

Copy link
Copy Markdown
Collaborator

Thanks for your contribution! This is a gnarly bit of rrweb and I originally authored it thinking I was covering some very rare edge cases for correctness.

Reproducing it needs nothing exotic

So that's reproduces the fault, but I would like to see what the original text nodes looked like in a minimal example. I don't believe that styled-components would arbitrarily split inside a rule (and I don't think that's what your saying; but the tests start off with the bad output of the rrweb split-serialization, rather than starting off with good output and showing how we expect rrweb to serialize it).

I think maybe a new integration test showing browser behaviour might be more valuable to ensure we don't regress this longer term. I'll add a few more comments inline

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?

@@ -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 ...

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Changes

Size change: +28.08 kB (+0.27%) | Total size: 10453.89 kB

all - 1975.30 kB -> 1980.82 kB (+5.52 kB (+0.28%))
File Base PR Diff
all.cjs 567.78 kB 569.40 kB +1.62 kB (+0.29%)
all.js 567.45 kB 569.07 kB +1.62 kB (+0.29%)
all.umd.cjs 570.99 kB 572.64 kB +1.66 kB (+0.29%)
all.umd.min.cjs 269.09 kB 269.71 kB +636 B (+0.23%)
replay - 1407.10 kB -> 1412.63 kB (+5.52 kB (+0.39%))
File Base PR Diff
replay.cjs 403.84 kB 405.46 kB +1.62 kB (+0.40%)
replay.js 403.75 kB 405.37 kB +1.62 kB (+0.40%)
replay.umd.cjs 406.82 kB 408.47 kB +1.66 kB (+0.41%)
replay.umd.min.cjs 192.70 kB 193.32 kB +636 B (+0.32%)
rrweb - 1913.48 kB -> 1919.00 kB (+5.52 kB (+0.29%))
File Base PR Diff
rrweb.cjs 550.94 kB 552.56 kB +1.62 kB (+0.29%)
rrweb.js 550.64 kB 552.26 kB +1.62 kB (+0.29%)
rrweb.umd.cjs 552.16 kB 553.81 kB +1.66 kB (+0.30%)
rrweb.umd.min.cjs 259.74 kB 260.36 kB +636 B (+0.24%)
rrweb-player - 1643.88 kB -> 1649.40 kB (+5.52 kB (+0.34%))
File Base PR Diff
rrweb-player.cjs 473.37 kB 474.99 kB +1.62 kB (+0.34%)
rrweb-player.js 473.24 kB 474.87 kB +1.62 kB (+0.34%)
rrweb-player.umd.cjs 476.16 kB 477.82 kB +1.66 kB (+0.35%)
rrweb-player.umd.min.cjs 221.11 kB 221.73 kB +636 B (+0.28%)
rrweb-snapshot - 609.57 kB -> 615.55 kB (+5.99 kB (+0.98%))
File Base PR Diff
rrweb-snapshot.cjs 175.01 kB 176.79 kB +1.78 kB (+1.01%)
rrweb-snapshot.js 174.07 kB 175.76 kB +1.69 kB (+0.97%)
rrweb-snapshot.umd.cjs 177.48 kB 179.29 kB +1.80 kB (+1.02%)
rrweb-snapshot.umd.min.cjs 83.00 kB 83.72 kB +737 B (+0.87%)

// 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)

@eoghanmurray

Copy link
Copy Markdown
Collaborator

Why on the replay side

I get that you want to fix at replay for your existing recordings, but this needs to be fixed at record time, so I'd love to see an example of the 'off by x chars' splits being produced, as that is not the intention for it to be inexact.

@eoghanmurray eoghanmurray left a comment

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.

Request changes as I don't think moving boundaries at replay time is correct for all recorded material, and could introduce new errors.
I'd like to see a test page (even as simple as pointing me to a failing production site) so I can see where the algorithm is going wrong in the record time splitting. If that can be identified, along with the associated insertions which are causing the trouble, then we might be able to apply the replay time fix less invasively, e.g. by doing it the prior way first, catching an exception and only then trying to shift things around

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants