Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hack-css-pseudo-classes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"rrweb-snapshot": patch
---

Add a `hackCssPseudoClasses` option to `rebuild` so snapshots can mirror `:active`, `:focus`, `:focus-visible` and `:focus-within` (in addition to `:hover`) as escaped classes for replay. Defaults to `[':hover']`, so existing behavior is unchanged.
41 changes: 36 additions & 5 deletions packages/rrweb-snapshot/src/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,28 @@ const mediaSelectorPlugin: AcceptedPlugin = {
},
};

/**
* User-action pseudo-classes that `pseudoClassPlugin` can mirror to an escaped
* class for replay.
*
* Each regex ends with a `(?![-\w])` boundary so a pseudo-class only matches as
* a whole token since otherwise `:focus` would match `:focus-visible` / `:focus-within`.
*/
const PSEUDO_CLASS_MIRRORS = {
':hover': /:hover(?![-\w])/g,
':active': /:active(?![-\w])/g,
':focus': /:focus(?![-\w])/g,
':focus-visible': /:focus-visible(?![-\w])/g,
':focus-within': /:focus-within(?![-\w])/g,
} as const;

export type HackCssPseudoClass = keyof typeof PSEUDO_CLASS_MIRRORS;

// Simplified from https://github.com/giuseppeg/postcss-pseudo-classes/blob/master/index.js
const pseudoClassPlugin: AcceptedPlugin = {
postcssPlugin: 'postcss-hover-classes',
const pseudoClassPlugin = (
pseudoClasses: HackCssPseudoClass[],
): AcceptedPlugin => ({
postcssPlugin: 'postcss-pseudo-classes',
prepare: function () {
const fixed: Rule[] = [];
return {
Expand All @@ -29,13 +48,25 @@ const pseudoClassPlugin: AcceptedPlugin = {
}
fixed.push(rule);
rule.selectors.forEach(function (selector) {
if (selector.includes(':hover')) {
rule.selector += ',\n' + selector.replace(/:hover/g, '.\\:hover');
for (const pseudoClass of pseudoClasses) {
const regex = PSEUDO_CLASS_MIRRORS[pseudoClass];
// cheap happy-path filter: skip the regex work when the
// pseudo-class isn't present at all.
if (!regex || !selector.includes(pseudoClass)) {
continue;
}
// compare against the result so a boundary miss (e.g. `:focus`
// inside `:focus-visible`, guarded by `(?![-\w])`) doesn't append a
// duplicate selector.
const mirrored = selector.replace(regex, '.\\' + pseudoClass);
if (mirrored !== selector) {
rule.selector += ',\n' + mirrored;
}
}
});
},
};
},
};
});

export { mediaSelectorPlugin, pseudoClassPlugin };
58 changes: 46 additions & 12 deletions packages/rrweb-snapshot/src/rebuild.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { mediaSelectorPlugin, pseudoClassPlugin } from './css';
import {
mediaSelectorPlugin,
pseudoClassPlugin,
type HackCssPseudoClass,
} from './css';
import {
type serializedNodeWithId,
type serializedElementNodeWithId,
Expand Down Expand Up @@ -63,26 +67,36 @@ function getTagName(n: elementNode): string {
return tagName;
}

export function adaptCssForReplay(cssText: string, cache: BuildCache): string {
const cachedStyle = cache?.stylesWithHoverClass.get(cssText);
export function adaptCssForReplay(
cssText: string,
cache: BuildCache,
pseudoClasses: HackCssPseudoClass[] = [':hover'],
): string {
// The rewritten result depends on the selected pseudo-class set, so it has
// to be part of the cache key (\u0000 can't appear in css, so it's a safe
// separator).
const cacheKey = `${pseudoClasses.join(',')}\u0000${cssText}`;
const cachedStyle = cache?.stylesWithHoverClass.get(cacheKey);
if (cachedStyle) return cachedStyle;

let result = cssText;
try {
const ast: { css: string } = postcss([
mediaSelectorPlugin,
pseudoClassPlugin,
pseudoClassPlugin(pseudoClasses),
]).process(cssText);
result = ast.css;
} catch (error) {
console.warn('Failed to adapt css for replay', error);
}

cache?.stylesWithHoverClass.set(cssText, result);
cache?.stylesWithHoverClass.set(cacheKey, result);
return result;
}

export function createCache(): BuildCache {
// caches all pseudo-class rewrites, not only hover; the name is kept for
// back compat.
const stylesWithHoverClass: Map<string, string> = new Map();
return {
stylesWithHoverClass,
Expand Down Expand Up @@ -110,6 +124,12 @@ type RebuildOptions = {
onVisit?: (node: Node) => unknown;
/** Adapts CSS selectors for replay-specific hover behavior when enabled. */
hackCss?: boolean;
/**
* User-action pseudo-classes to mirror as escaped classes when `hackCss` is
* enabled, e.g. `foo:focus` becomes `foo:focus, foo.\:focus`.
* Defaults to `[':hover']`.
*/
hackCssPseudoClasses?: HackCssPseudoClass[];
Comment on lines +127 to +132

@hi-ogawa hi-ogawa Jul 2, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternative API may be to extend hackCss option like:

hackCss: boolean | { pseudoClasses: HackCssPseudoClass[] }

Please let me know if you have any API preference 🙏

/** Called after a rebuilt node is appended to its parent. */
afterAppend?: (n: Node, id: number) => unknown;
/** Per-rebuild cache for derived snapshot data. */
Expand Down Expand Up @@ -199,6 +219,7 @@ export function applyCssSplits(
cssText: string,
hackCss: boolean,
cache: BuildCache,
hackCssPseudoClasses?: HackCssPseudoClass[],
): void {
const childTextNodes = [];
for (const scn of n.childNodes) {
Expand All @@ -216,7 +237,11 @@ export function applyCssSplits(
}
let adaptedCss = '';
if (hackCss) {
adaptedCss = adaptCssForReplay(cssTextSplits.join(''), cache);
adaptedCss = adaptCssForReplay(
cssTextSplits.join(''),
cache,
hackCssPseudoClasses,
);
}
let startIndex = 0;
for (let i = 0; i < childTextNodes.length; i++) {
Expand Down Expand Up @@ -273,14 +298,15 @@ export function buildStyleNode(
doc: Document;
hackCss: boolean;
cache: BuildCache;
hackCssPseudoClasses?: HackCssPseudoClass[];
},
) {
const { doc, hackCss, cache } = options;
const { doc, hackCss, cache, hackCssPseudoClasses } = options;
if (n.childNodes.length) {
applyCssSplits(n, cssText, hackCss, cache);
applyCssSplits(n, cssText, hackCss, cache, hackCssPseudoClasses);
} else {
if (hackCss) {
cssText = adaptCssForReplay(cssText, cache);
cssText = adaptCssForReplay(cssText, cache, hackCssPseudoClasses);
}
/**
<link> element or dynamic <style> are serialized without any child nodes
Expand All @@ -296,9 +322,10 @@ function buildNode(
doc: Document;
hackCss: boolean;
cache: BuildCache;
hackCssPseudoClasses?: HackCssPseudoClass[];
},
): Node | null {
const { doc, hackCss, cache } = options;
const { doc, hackCss, cache, hackCssPseudoClasses } = options;
switch (n.type) {
case NodeType.Document:
return doc.implementation.createDocument(null, '', null);
Expand Down Expand Up @@ -529,7 +556,9 @@ function buildNode(
case NodeType.Text:
if (n.isStyle && hackCss) {
// support legacy style
return doc.createTextNode(adaptCssForReplay(n.textContent, cache));
return doc.createTextNode(
adaptCssForReplay(n.textContent, cache, hackCssPseudoClasses),
);
}
return doc.createTextNode(n.textContent);
case NodeType.CDATA:
Expand All @@ -548,6 +577,7 @@ export function buildNodeWithSN(
mirror: Mirror;
skipChild?: boolean;
hackCss: boolean;
hackCssPseudoClasses?: HackCssPseudoClass[];
/**
* This callback will be called for each of this nodes' `.childNodes` after they are appended to _this_ node.
* Caveat: This callback _doesn't_ get called when this node is appended to the DOM.
Expand All @@ -561,6 +591,7 @@ export function buildNodeWithSN(
mirror,
skipChild = false,
hackCss = true,
hackCssPseudoClasses,
afterAppend,
cache,
} = options;
Expand All @@ -577,7 +608,7 @@ export function buildNodeWithSN(
// For safety concern, check if the node in mirror is the same as the node we are trying to build
if (isNodeMetaEqual(meta, n)) return mirror.getNode(n.id);
}
let node = buildNode(n, { doc, hackCss, cache });
let node = buildNode(n, { doc, hackCss, cache, hackCssPseudoClasses });
if (!node) {
return null;
}
Expand Down Expand Up @@ -627,6 +658,7 @@ export function buildNodeWithSN(
mirror,
skipChild: false,
hackCss,
hackCssPseudoClasses,
afterAppend,
cache,
});
Expand Down Expand Up @@ -719,6 +751,7 @@ function rebuild(
doc,
onVisit,
hackCss = true,
hackCssPseudoClasses,
afterAppend,
cache,
mirror = new Mirror(),
Expand All @@ -728,6 +761,7 @@ function rebuild(
mirror,
skipChild: false,
hackCss,
hackCssPseudoClasses,
afterAppend,
cache,
});
Expand Down
12 changes: 6 additions & 6 deletions packages/rrweb-snapshot/test/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,43 +53,43 @@ describe('css parser', () => {
it('parses nested commas in selectors correctly', () => {
const cssText =
'body > ul :is(li:not(:first-of-type) a.current, li:not(:first-of-type).active a) {background: red;}';
expect(parse(pseudoClassPlugin, cssText)).toEqual(cssText);
expect(parse(pseudoClassPlugin([':hover']), cssText)).toEqual(cssText);
});

it("doesn't ignore :hover within :is brackets", () => {
const cssText =
'body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) {background: red;}';
expect(parse(pseudoClassPlugin, cssText))
expect(parse(pseudoClassPlugin([':hover']), cssText))
.toEqual(`body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a),
body > ul :is(li:not(:first-of-type) a.\\:hover, li:not(:first-of-type).active a) {background: red;}`);
});

it('should parse selector with comma nested inside ()', () => {
const cssText =
'[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }';
expect(parse(pseudoClassPlugin, cssText))
expect(parse(pseudoClassPlugin([':hover']), cssText))
.toEqual(`[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active),
[_nghost-ng-c4172599085]:not(.fit-content).aim-select.\\:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }`);
});

it('ignores ( in strings', () => {
const cssText =
'li[attr="weirdly("] a:hover, li[attr="weirdly)"] a {background-color: red;}';
expect(parse(pseudoClassPlugin, cssText))
expect(parse(pseudoClassPlugin([':hover']), cssText))
.toEqual(`li[attr="weirdly("] a:hover, li[attr="weirdly)"] a,
li[attr="weirdly("] a.\\:hover {background-color: red;}`);
});

it('ignores escaping in strings', () => {
const cssText = `li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a {background-color: red;}`;
expect(parse(pseudoClassPlugin, cssText))
expect(parse(pseudoClassPlugin([':hover']), cssText))
.toEqual(`li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a,
li[attr="weirder\\"("] a.\\:hover {background-color: red;}`);
});

it('ignores comma in string', () => {
const cssText = 'li[attr="has,comma"] a:hover {background: red;}';
expect(parse(pseudoClassPlugin, cssText)).toEqual(
expect(parse(pseudoClassPlugin([':hover']), cssText)).toEqual(
`li[attr="has,comma"] a:hover,
li[attr="has,comma"] a.\\:hover {background: red;}`,
);
Expand Down
Loading