You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I have searched the issue tracker for a feature request that matches the one I want to file, without success.
What package is this feature request for?
rrweb-snapshot
Problem Description
First off, thanks for maintaining the awesome package rrweb-snapshot! I'm integrating it for the upcoming Vitest browser-mode trace view, and it's become an important foundation for us.
While integrating it we hit a gap around user-action pseudo-classes, which we currently work around with a local patch. I'm filing this to see whether it's something you'd be open to supporting upstream.
The analysis below is partly AI-assisted. The higher-level record/replay parts in particular are not something I'm familiar with, since my focus is at the rrweb-snapshot API layer.
rrweb reproduces user-action pseudo-class styling inconsistently, and a couple of states aren't reproduced at all:
:hover is reproduced with a class hack: pseudoClassPlugin rewrites foo:hover → foo:hover, foo.\:hover, and the replayer toggles the :hover class as the synthetic cursor moves (hoverElements()). This works because :hover can't be triggered from JS.
:focus / :focus-within rely on real DOM focus: the replayer calls target.focus() / target.blur() on recorded Focus/Blur events, gated by triggerFocus. This is fragile, because it only paints while the replay iframe actually holds focus, and it moves real focus around (it can steal input focus, see Focus styles with triggerFocus: false #876).
:focus-visible is not faithfully reproduced. Programmatic .focus() generally does not match :focus-visible (it's a keyboard-vs-pointer modality heuristic), so focus-visible styling is lost on replay.
:active is not reproduced on the page at all. The only .active styling is on rrweb's own synthetic cursor for a click ripple (style.css), never the clicked element.
Net effect: replays visually diverge from the recording for common interactive states (pressed buttons, keyboard focus rings, etc.).
This especially affects downstream consumers of rrweb-snapshot that use snapshot + rebuild without the event replayer, like a static DOM trace viewer. We capture snapshots independently at specific moments during a test (for example on UI assertions or interaction calls), then rebuild them later in the viewer. Roughly how we integrate:
import{snapshot,rebuild,createMirror}from'rrweb-snapshot'constPSEUDO_CLASSES=[':hover',':active',':focus',':focus-visible',':focus-within']//// 📸 snapshot (capture side)//constmirror=createMirror()constserialized=snapshot(document,{ mirror })// track which nodes matched each pseudo-class, by mirror node idconstpseudoClassIds: Record<string,number[]>={}for(constklassofPSEUDO_CLASSES){pseudoClassIds[klass]=[...document.querySelectorAll(klass)].map(el=>mirror.getId(el))}//// 👷 rebuild (view side)//constviewMirror=createMirror()rebuild(serialized,{ doc,mirror: viewMirror})// re-apply the snapshot-time states as classesfor(const[klass,ids]ofObject.entries(pseudoClassIds)){for(constidofids){viewMirror.getNode(id)?.classList.add(klass)}}
For those classes to take effect, rebuild()'s CSS rewrite has to emit matching mirror selectors, so foo:focus { … } becomes foo:focus, foo.\:focus { … }. Today only :hover is rewritten, so .\:focus and friends never exist. Our local patch just extends the hover-only rewrite in pseudoClassPlugin:
We're not alone in wanting this. Chromatic's chromatic-e2e snapshot integration carries essentially the same patch in its fork (chromaui/rrweb#7: same css.ts, same :active / :focus / :focus-visible / :focus-within rewrites), so two independent snapshot integrations have converged on it.
Proposed Solution
Minimal ask: make the rrweb-snapshot pseudo-class set extensible
The :hover rewrite in pseudoClassPlugin is hardcoded. The smallest useful change is to let rebuild opt into rewriting more user-action pseudo-classes than just :hover (default unchanged: hover-only). Concretely, something like:
typeRebuildOptions={// ...existing optionshackCss?: boolean;/** * User-action pseudo-classes to mirror as escaped classes for replay, * e.g. `foo:focus` -> `foo:focus, foo.\:focus`. * Default: [':hover'] (current behavior). */// NOTE: free strings make the selector regex handling messy, so probably limit to a known enum:// hackCssPseudoClasses?: (':hover' | ':focus' | ':active' | ...)[]hackCssPseudoClasses?: string[];};
In the integration shown above, this replaces our local patch, so we'd just pass the set to rebuild.
This is a small, contained change. The plugin already derives from postcss-pseudo-classes, which handles many pseudo-classes, and was deliberately narrowed to hover-only (#1535), so re-widening behind an option is low-friction. It also keeps default output and existing replay behavior untouched, and the cache key in adaptCssForReplay would just need to include the selected set.
Further potential scope (rrweb replay — optional, for discussion)
If it's of interest, the replayer could also apply these mirror classes itself, improving replay fidelity directly instead of relying on real .focus(), by toggling .\:active between MouseDown/MouseUp and .\:focus / .\:focus-within on Focus/Blur. This would also let triggerFocus: false keep focus styles (potentially addresses #876).
Flagging this as a direction rather than a concrete proposal. On the data side:
:active, :focus, :focus-within — the recorded MouseInteraction payload already carries the target node id + timing, so these look doable replay-only, no recorder change.
:focus-visible — the exception. The stream records that focus happened, not whether:focus-visible matched (modality/element-type heuristic). Faithful support would likely need capturing that at record time (e.g. target.matches(':focus-visible') on focus).
Alternatives Considered
Keep patching rrweb-snapshot downstream (current workaround for the static-snapshot use case) — works but forks the plugin and drifts from upstream.
Real element.focus() only (current replayer behavior) — can't reproduce :focus-visible, needs the iframe focused, and steals focus.
Tangentially related: rrweb already reproduces other user-state pseudo-classes when the state is scriptable, e.g. :defined by defining the element (Feat: Add support for replaying :defined pseudo-class of custom elements #1155) and :focus via real .focus(). :hover and :active aren't scriptable, so they use the class-mirror hack instead, which is the bucket this request extends.
Preflight Checklist
What package is this feature request for?
rrweb-snapshot
Problem Description
First off, thanks for maintaining the awesome package
rrweb-snapshot! I'm integrating it for the upcoming Vitest browser-mode trace view, and it's become an important foundation for us.While integrating it we hit a gap around user-action pseudo-classes, which we currently work around with a local patch. I'm filing this to see whether it's something you'd be open to supporting upstream.
The analysis below is partly AI-assisted. The higher-level record/replay parts in particular are not something I'm familiar with, since my focus is at the
rrweb-snapshotAPI layer.rrweb reproduces user-action pseudo-class styling inconsistently, and a couple of states aren't reproduced at all:
:hoveris reproduced with a class hack:pseudoClassPluginrewritesfoo:hover→foo:hover, foo.\:hover, and the replayer toggles the:hoverclass as the synthetic cursor moves (hoverElements()). This works because:hovercan't be triggered from JS.:focus/:focus-withinrely on real DOM focus: the replayer callstarget.focus()/target.blur()on recorded Focus/Blur events, gated bytriggerFocus. This is fragile, because it only paints while the replay iframe actually holds focus, and it moves real focus around (it can steal input focus, see Focus styles withtriggerFocus: false#876).:focus-visibleis not faithfully reproduced. Programmatic.focus()generally does not match:focus-visible(it's a keyboard-vs-pointer modality heuristic), so focus-visible styling is lost on replay.:activeis not reproduced on the page at all. The only.activestyling is on rrweb's own synthetic cursor for a click ripple (style.css), never the clicked element.Net effect: replays visually diverge from the recording for common interactive states (pressed buttons, keyboard focus rings, etc.).
This especially affects downstream consumers of
rrweb-snapshotthat usesnapshot+rebuildwithout the event replayer, like a static DOM trace viewer. We capture snapshots independently at specific moments during a test (for example on UI assertions or interaction calls), then rebuild them later in the viewer. Roughly how we integrate:For those classes to take effect,
rebuild()'s CSS rewrite has to emit matching mirror selectors, sofoo:focus { … }becomesfoo:focus, foo.\:focus { … }. Today only:hoveris rewritten, so.\:focusand friends never exist. Our local patch just extends the hover-only rewrite inpseudoClassPlugin:rule.selectors.forEach(function (selector) { if (selector.includes(':hover')) { rule.selector += ',\n' + selector.replace(/:hover/g, '.\\:hover'); } + if (selector.includes(':active')) { + rule.selector += ',\n' + selector.replace(/:active/g, '.\\:active'); + } + if (selector.includes(':focus-visible')) { + rule.selector += ',\n' + selector.replace(/:focus-visible/g, '.\\:focus-visible'); + } + if (selector.includes(':focus-within')) { + rule.selector += ',\n' + selector.replace(/:focus-within/g, '.\\:focus-within'); + } + if (/:focus(?![-\w])/.test(selector)) { + rule.selector += ',\n' + selector.replace(/:focus(?![-\w])/g, '.\\:focus'); + } });We're not alone in wanting this. Chromatic's
chromatic-e2esnapshot integration carries essentially the same patch in its fork (chromaui/rrweb#7: samecss.ts, same:active/:focus/:focus-visible/:focus-withinrewrites), so two independent snapshot integrations have converged on it.Proposed Solution
Minimal ask: make the
rrweb-snapshotpseudo-class set extensibleThe
:hoverrewrite inpseudoClassPluginis hardcoded. The smallest useful change is to letrebuildopt into rewriting more user-action pseudo-classes than just:hover(default unchanged: hover-only). Concretely, something like:In the integration shown above, this replaces our local patch, so we'd just pass the set to
rebuild.This is a small, contained change. The plugin already derives from
postcss-pseudo-classes, which handles many pseudo-classes, and was deliberately narrowed to hover-only (#1535), so re-widening behind an option is low-friction. It also keeps default output and existing replay behavior untouched, and the cache key inadaptCssForReplaywould just need to include the selected set.Further potential scope (rrweb replay — optional, for discussion)
If it's of interest, the replayer could also apply these mirror classes itself, improving replay fidelity directly instead of relying on real
.focus(), by toggling.\:activebetween MouseDown/MouseUp and.\:focus/.\:focus-withinon Focus/Blur. This would also lettriggerFocus: falsekeep focus styles (potentially addresses #876).Flagging this as a direction rather than a concrete proposal. On the data side:
:active,:focus,:focus-within— the recordedMouseInteractionpayload already carries the target node id + timing, so these look doable replay-only, no recorder change.:focus-visible— the exception. The stream records that focus happened, not whether:focus-visiblematched (modality/element-type heuristic). Faithful support would likely need capturing that at record time (e.g.target.matches(':focus-visible')on focus).Alternatives Considered
rrweb-snapshotdownstream (current workaround for the static-snapshot use case) — works but forks the plugin and drifts from upstream.element.focus()only (current replayer behavior) — can't reproduce:focus-visible, needs the iframe focused, and steals focus.Additional Information
triggerFocus: false#876 asks for exactly the class-based focus idea.pseudoClassPluginperf on large stylesheets ([perf]addHoverClass()performs poorly on large stylesheets #1350) and invalid-CSS edge cases on complex:not(...)selectors ([Bug]: addHoverClass creating invalid css #1379, [Bug]: Empty css rules break replay with "CssSyntaxError CssSyntaxError: Unclosed string" error #1734, [Bug]: CSS splitting causes corrupted css that causes a processing error failing to replay #1692). Suggest keeping any extension opt-in and benchmarking.:definedby defining the element (Feat: Add support for replaying :defined pseudo-class of custom elements #1155) and:focusvia real.focus().:hoverand:activearen't scriptable, so they use the class-mirror hack instead, which is the bucket this request extends.