Skip to content

[Feature Request]: Ability to reproduce more user-action pseudo-classes (:active, :focus, :focus-visible, etc.) in replay #1896

Description

@hi-ogawa

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:

  • :hover is reproduced with a class hack: pseudoClassPlugin rewrites foo:hoverfoo: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'

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

Additional Information

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions