Skip to content

feat(rrweb): add adaptCssInTextMutations player config option - #1917

Open
creasty wants to merge 1 commit into
rrweb-io:mainfrom
creasty:feat/adapt-css-in-text-mutations
Open

feat(rrweb): add adaptCssInTextMutations player config option#1917
creasty wants to merge 1 commit into
rrweb-io:mainfrom
creasty:feat/adapt-css-in-text-mutations

Conversation

@creasty

@creasty creasty commented Aug 27, 2026

Copy link
Copy Markdown

Problem

When the replayer applies a text mutation to a child of a <style> element, it
rewrites the value for replay (:hover.\:hover, max-device-width
max-width):

https://github.com/rrweb-io/rrweb/blob/main/packages/rrweb/src/replay/index.ts#L1761-L1766

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;
}

That is correct, and it fixed a real gap — before #1431 / #1437 the rewrite was
only applied to nodes added via adds, so a stylesheet updated through text
mutations lost its :hover rules on replay.

The cost, though, is a postcss parse of the whole value, once per mutation.
That is fine when each mutation replaces a small stylesheet. It is not fine when
a consumer replays a stylesheet that is built up over many text mutations,
because each mutation then carries the accumulated CSS and the total work is
quadratic in the number of mutations.

We hit this in a session-replay tool for an app whose CSS-in-JS runtime appends
one rule at a time. To keep seeking fast we pre-process the recording so that the
rules for one <style> are folded into a single text node, and the additions
after the first become text mutations carrying the CSS accumulated so far. That
turns ~1250 stylesheet child insertions (each of which makes the browser reparse
the whole sheet) into one, which is a large win — but the replayer then parses
the accumulated CSS ~1250 times.

Measured in Chromium on a synthetic recording with 1251 rules in one <style>
(124 KB of CSS in total, 14k DOM nodes, 175 s), median of 3:

first sync apply seek backwards
folded, rewrite in the replayer 9005 ms 38–69 ms
not folded 1312 ms 297–1158 ms
floor: same node count, no CSS involved 90 ms 10–70 ms

The 9 s is entirely postcss. The per-Replayer cache in adaptCssForReplay
makes later seeks cheap, but the first pass over a stretch of unvisited events
blocks the main thread for ~9 s.

Rewriting per rule and concatenating produces the same result and is O(n) — the
two plugins in adaptCssForReplay work per rule, so processing each rule and
joining is equivalent to processing the join. There is currently no way for a
consumer to say "I already did this", and the cache is private to Replayer, so
it cannot be pre-populated either.

Change

Adds adaptCssInTextMutations to playerConfig, defaulting to true — existing
behaviour is unchanged, and nothing needs to be updated by existing consumers.
Setting it to false means "the text mutation values I supply are already
replay-ready".

Only text mutations are covered. Nodes added via adds are always rewritten,
because their values come straight from the recording.

With the option set to false and the rewrite done per rule on our side, the
same measurement lands at 63 ms for the first sync apply and 10–55 ms for
backward seeks — i.e. at the floor above — and the CSS that ends up in the DOM is
byte-for-byte identical to what the replayer produces on its own (129,217 bytes,
250 :hover, 125 .\:hover).

Tests

Two tests in packages/rrweb/test/replayer.test.ts over a new fixture
(test/events/style-text-mutation-hover.ts) — a <style> whose text node is
replaced by a mutation carrying a :hover rule:

  • default config → the value is rewritten
  • adaptCssInTextMutations: false → it is passed through unchanged

Both pass. Note that 6 snapshot tests in replayer.test.ts fail in my
environment both with and without this change (they look Chromium-version
dependent), so those failures are unrelated.

A changeset is included (minor for rrweb and @rrweb/replay).

The replayer rewrites CSS for replay (`:hover` -> `.\:hover`,
`max-device-width` -> `max-width`) when applying a text mutation to a child of
a `<style>` element. The rewrite is a postcss parse of the whole value, so a
consumer that replays a stylesheet built up over many text mutations pays it
once per mutation on the accumulated CSS, which is quadratic in the number of
mutations.

This adds `adaptCssInTextMutations` (default `true`, so existing behaviour is
unchanged) so such a consumer can pass already-adapted values and skip the
repeated parse. Nodes added via `adds` are unaffected and always rewritten,
because their values come straight from the recording.
@changeset-bot

changeset-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1f3284

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

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

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

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.

1 participant