[Feature Request]: Ability to reproduce more user-action pseudo-classes (:active, :focus, :focus-visible, etc.) in replay
Preflight Checklist
- 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:
: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 #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-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'
const PSEUDO_CLASSES = [':hover', ':active', ':focus', ':focus-visible', ':focus-within']
//
// snapshot (capture side)
//
const mirror = createMirror()
const serialized = snapshot(document, { mirror })
// track which nodes matched each pseudo-class, by mirror node id
const pseudoClassIds: Record<string, number[]> = {}
for (const klass of PSEUDO_CLASSES) {
pseudoClassIds[klass] = [...document.querySelectorAll(klass)].map(el => mirror.getId(el))
}
//
// rebuild (view side)
//
const viewMirror = createMirror()
rebuild(serialized, { doc, mirror: viewMirror })
// re-apply the snapshot-time states as classes
for (const [klass, ids] of Object.entries(pseudoClassIds)) {
for (const id of ids) {
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:
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-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:
type RebuildOptions = {
// ...existing options
hackCss?: 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.
rebuild(serialized, { doc, mirror: viewMirror, hackCssPseudoClasses: PSEUDO_CLASSES })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 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
- Keep patching
rrweb-snapshotdownstream (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.
Additional Information
- Prior demand: #876 asks for exactly the class-based focus idea.
- Caveats to respect if the rewrite set grows:
pseudoClassPluginperf on large stylesheets (#1350) and invalid-CSS edge cases on complex:not(...)selectors (#1379, #1734, #1692). Suggest keeping any extension opt-in and benchmarking. - Tangentially related: rrweb already reproduces other user-state pseudo-classes when the state is scriptable, e.g.
:definedby defining the element (#1155) and:focusvia real.focus().:hoverand:activearen't scriptable, so they use the class-mirror hack instead, which is the bucket this request extends.
Source: rrweb-io/rrweb