Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
38 changes: 32 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,69 @@ 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 rewrite ':focus' inside ':focus-visible' / ':focus-within'", () => {
// backs the `(?![-\w])` boundary: without it, `:focus` would match inside
// these and emit a spurious `.\:focus-visible` mirror
expect(
parse(pseudoClassPlugin([':focus']), '.a:focus-visible { color: red }'),
).toEqual('.a:focus-visible { color: red }');
expect(
parse(pseudoClassPlugin([':focus']), '.a:focus-within { color: red }'),
).toEqual('.a:focus-within { color: red }');
});

it('mirrors each supported user-action pseudo-class', () => {
expect(parse(pseudoClassPlugin([':active']), '.a:active { color: red }')).toEqual(
'.a:active,\n.a.\\:active { color: red }',
);
expect(parse(pseudoClassPlugin([':focus']), '.a:focus { color: red }')).toEqual(
'.a:focus,\n.a.\\:focus { color: red }',
);
expect(
parse(pseudoClassPlugin([':focus-visible']), '.a:focus-visible { color: red }'),
).toEqual('.a:focus-visible,\n.a.\\:focus-visible { color: red }');
expect(
parse(pseudoClassPlugin([':focus-within']), '.a:focus-within { color: red }'),
).toEqual('.a:focus-within,\n.a.\\:focus-within { color: red }');
});

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