From 6b2bb700f2bc13edde4f206232e1c5c080d31452 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:21:52 +0900 Subject: [PATCH 1/4] feat(rrweb-snapshot): opt into more user-action pseudo-classes when rebuilding `pseudoClassPlugin` only mirrored `:hover` to an escaped class. Add a `hackCssPseudoClasses` rebuild option (default `[':hover']`, unchanged) so consumers that rebuild snapshots without the event replayer can also mirror `:active`, `:focus`, `:focus-visible` and `:focus-within` for replay. The selected set is threaded through `rebuild` -> `adaptCssForReplay` and folded into its cache key. Each pseudo-class regex ends with a `(?![-\w])` boundary so `:focus` is not rewritten inside `:focus-visible` / `:focus-within`. Co-authored-by: OpenCode (claude-opus-4-8) --- packages/rrweb-snapshot/src/css.ts | 41 ++++++- packages/rrweb-snapshot/src/rebuild.ts | 58 ++++++++-- packages/rrweb-snapshot/test/css.test.ts | 12 +- .../rrweb-snapshot/test/integration.test.ts | 106 ++++++++++++++++++ 4 files changed, 194 insertions(+), 23 deletions(-) diff --git a/packages/rrweb-snapshot/src/css.ts b/packages/rrweb-snapshot/src/css.ts index ec85468af6..edd863c6ac 100644 --- a/packages/rrweb-snapshot/src/css.ts +++ b/packages/rrweb-snapshot/src/css.ts @@ -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 { @@ -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 }; diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index a6ef85f01b..f997259af1 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -1,4 +1,8 @@ -import { mediaSelectorPlugin, pseudoClassPlugin } from './css'; +import { + mediaSelectorPlugin, + pseudoClassPlugin, + type HackCssPseudoClass, +} from './css'; import { type serializedNodeWithId, type serializedElementNodeWithId, @@ -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 = new Map(); return { stylesWithHoverClass, @@ -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[]; /** Called after a rebuilt node is appended to its parent. */ afterAppend?: (n: Node, id: number) => unknown; /** Per-rebuild cache for derived snapshot data. */ @@ -199,6 +219,7 @@ export function applyCssSplits( cssText: string, hackCss: boolean, cache: BuildCache, + hackCssPseudoClasses?: HackCssPseudoClass[], ): void { const childTextNodes = []; for (const scn of n.childNodes) { @@ -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++) { @@ -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); } /** element or dynamic +
hover
+
active
+
focus
+
focus-visible
+
focus-within
+ `, + { waitUntil: 'load' }, + ); + + // Reproduce the static-snapshot consumer flow: snapshot, rebuild with the + // opt-in set, then add the mirror class ourselves (as a viewer would) and + // assert the rebuilt element actually paints the pseudo-class style. + const result = (await page.evaluate(`${code} + const snap = rrwebSnapshot.snapshot(document); + const { iframe } = rrwebSnapshot.rebuildIntoSandboxedIframe(snap, { + root: document.body, + hackCssPseudoClasses: [ + ':hover', + ':active', + ':focus', + ':focus-visible', + ':focus-within', + ], + }); + const doc = iframe.contentDocument; + // [element to read, class to toggle, element to toggle it on] + const cases = [ + ['.h', ':hover', '.h'], + ['.a', ':active', '.a'], + ['.f', ':focus', '.f'], + ['.fv', ':focus-visible', '.fv'], + ['.fw', ':focus-within', '.wrap'], + ]; + const out = {}; + for (const [readSel, klass, toggleSel] of cases) { + const readEl = doc.querySelector(readSel); + const before = getComputedStyle(readEl).backgroundColor; + doc.querySelector(toggleSel).classList.add(klass); + const after = getComputedStyle(readEl).backgroundColor; + out[readSel] = { before, after }; + } + JSON.stringify(out); + `)) as string; + + expect(JSON.parse(result)).toEqual({ + '.h': { before: 'rgb(255, 200, 200)', after: 'rgb(11, 11, 11)' }, + '.a': { before: 'rgb(255, 200, 200)', after: 'rgb(22, 22, 22)' }, + '.f': { before: 'rgb(255, 200, 200)', after: 'rgb(33, 33, 33)' }, + '.fv': { before: 'rgb(255, 200, 200)', after: 'rgb(44, 44, 44)' }, + '.fw': { before: 'rgb(255, 200, 200)', after: 'rgb(55, 55, 55)' }, + }); + }); + + it('hackCssPseudoClasses defaults to hover-only', async () => { + const page: puppeteer.Page = await browser.newPage(); + page.on('console', (msg) => console.log(msg.text())); + await page.goto(`${serverURL}/html`); + await page.setContent( + ` +
hover
+
focus
+ `, + { waitUntil: 'load' }, + ); + + // No hackCssPseudoClasses -> default [':hover']: hover still paints, but + // :focus is not mirrored, so adding the class has no effect. + const result = (await page.evaluate(`${code} + const snap = rrwebSnapshot.snapshot(document); + const { iframe } = rrwebSnapshot.rebuildIntoSandboxedIframe(snap, { + root: document.body, + }); + const doc = iframe.contentDocument; + const hoverEl = doc.querySelector('.h'); + const focusEl = doc.querySelector('.f'); + hoverEl.classList.add(':hover'); + focusEl.classList.add(':focus'); + JSON.stringify({ + hover: getComputedStyle(hoverEl).backgroundColor, + focus: getComputedStyle(focusEl).backgroundColor, + }); + `)) as string; + + expect(JSON.parse(result)).toEqual({ + hover: 'rgb(11, 11, 11)', // hover mirror is on by default + focus: 'rgb(255, 200, 200)', // :focus not mirrored -> stays off + }); + }); }); describe('iframe integration tests', function (this: ISuite) { From 9088e22a4e2a97c14f8df38e5c0f236a2d5b0ec7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:23:25 +0900 Subject: [PATCH 2/4] docs(changeset): rrweb-snapshot hackCssPseudoClasses option Co-authored-by: OpenCode (claude-opus-4-8) --- .changeset/hack-css-pseudo-classes.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hack-css-pseudo-classes.md diff --git a/.changeset/hack-css-pseudo-classes.md b/.changeset/hack-css-pseudo-classes.md new file mode 100644 index 0000000000..725b391d2d --- /dev/null +++ b/.changeset/hack-css-pseudo-classes.md @@ -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. From fa0a314a841e389ccb10105af9e6a54847cb077d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:10:23 +0900 Subject: [PATCH 3/4] test(rrweb-snapshot): cover the `:focus` boundary in pseudoClassPlugin Backs the `(?![-\w])` boundary: `:focus` must not be rewritten inside `:focus-visible` / `:focus-within`. Co-authored-by: OpenCode (claude-opus-4-8) --- packages/rrweb-snapshot/test/css.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index c584e3a6ec..a4e0dd2b63 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -56,6 +56,17 @@ describe('css parser', () => { 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("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;}'; From f7431c48c28834557ef49b2c8029de94abef6860 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:11:54 +0900 Subject: [PATCH 4/4] test(rrweb-snapshot): cover mirroring of each supported pseudo-class Co-authored-by: OpenCode (claude-opus-4-8) --- packages/rrweb-snapshot/test/css.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index a4e0dd2b63..c3c0dfd083 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -67,6 +67,21 @@ describe('css parser', () => { ).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;}';