Fix some css issues with :hover and rewrite max-device-width (#1431)

* We weren't recursing into media queries (or @supports etc.) to rewrite hover pseudoclasses

* The early return meant that the stylesWithHoverClass cache wasn't being populated if there were no hover selectors on the stylesheet

 - not committing the test, but modifying the existing 'add a hover class to a previously processed css string' as follows shows the problem:

--- a/packages/rrweb-snapshot/test/rebuild.test.ts
+++ b/packages/rrweb-snapshot/test/rebuild.test.ts
@@ -151,6 +185,7 @@ describe('rebuild', function () {
         path.resolve(__dirname, './css/benchmark.css'),
         'utf8',
       );
+      cssText = cssText.replace(/:hover/g, '');

       const start = process.hrtime();
       addHoverClass(cssText, cache);

* Replace `min-device-width` and similar with `min-width` as the former looks out at the browser viewport whereas we need it to look at the replayer iframe viewport

* Add some tests to show how the hover replacement works against selector lists. I believe these were failing in a previous version of rrweb as I had some local patches that no longer seem to be needed to handle these cases

* Update name of function to reflect that 'addHoverClass' does more than just :hover. I believe this function is only exported for the purposes of use in the tests

* Apply formatting changes

* Create rotten-spies-enjoy.md

* Apply formatting changes

* Add correct typing on `getSelectors`

* Refactor CSS interfaces to include optional rules

* Change `rules` to be non optional

---------

Co-authored-by: eoghanmurray <eoghanmurray@users.noreply.github.com>
Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
This commit is contained in:
Eoghan Murray
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 3da2652950
commit 585c5c7ac3
5 changed files with 133 additions and 57 deletions

View File

@@ -1,4 +1,4 @@
import { parse } from './css';
import { Rule, Media, NodeWithRules, parse } from './css';
import {
serializedNodeWithId,
NodeType,
@@ -62,9 +62,11 @@ function escapeRegExp(str: string) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
const MEDIA_SELECTOR = /(max|min)-device-(width|height)/;
const MEDIA_SELECTOR_GLOBAL = new RegExp(MEDIA_SELECTOR.source, 'g');
const HOVER_SELECTOR = /([^\\]):hover/;
const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR.source, 'g');
export function addHoverClass(cssText: string, cache: BuildCache): string {
export function adaptCssForReplay(cssText: string, cache: BuildCache): string {
const cachedStyle = cache?.stylesWithHoverClass.get(cssText);
if (cachedStyle) return cachedStyle;
@@ -77,35 +79,61 @@ export function addHoverClass(cssText: string, cache: BuildCache): string {
}
const selectors: string[] = [];
ast.stylesheet.rules.forEach((rule) => {
if ('selectors' in rule) {
(rule.selectors || []).forEach((selector: string) => {
const medias: string[] = [];
function getSelectors(rule: Rule | Media | NodeWithRules) {
if ('selectors' in rule && rule.selectors) {
rule.selectors.forEach((selector: string) => {
if (HOVER_SELECTOR.test(selector)) {
selectors.push(selector);
}
});
}
});
if (selectors.length === 0) {
return cssText;
if ('media' in rule && rule.media && MEDIA_SELECTOR.test(rule.media)) {
medias.push(rule.media);
}
if ('rules' in rule && rule.rules) {
rule.rules.forEach(getSelectors);
}
}
getSelectors(ast.stylesheet);
const selectorMatcher = new RegExp(
selectors
.filter((selector, index) => selectors.indexOf(selector) === index)
.sort((a, b) => b.length - a.length)
.map((selector) => {
return escapeRegExp(selector);
})
.join('|'),
'g',
);
const result = cssText.replace(selectorMatcher, (selector) => {
const newSelector = selector.replace(HOVER_SELECTOR_GLOBAL, '$1.\\:hover');
return `${selector}, ${newSelector}`;
});
let result = cssText;
if (selectors.length > 0) {
const selectorMatcher = new RegExp(
selectors
.filter((selector, index) => selectors.indexOf(selector) === index)
.sort((a, b) => b.length - a.length)
.map((selector) => {
return escapeRegExp(selector);
})
.join('|'),
'g',
);
result = result.replace(selectorMatcher, (selector) => {
const newSelector = selector.replace(
HOVER_SELECTOR_GLOBAL,
'$1.\\:hover',
);
return `${selector}, ${newSelector}`;
});
}
if (medias.length > 0) {
const mediaMatcher = new RegExp(
medias
.filter((media, index) => medias.indexOf(media) === index)
.sort((a, b) => b.length - a.length)
.map((media) => {
return escapeRegExp(media);
})
.join('|'),
'g',
);
result = result.replace(mediaMatcher, (media) => {
// not attempting to maintain min-device-width along with min-width
// (it's non standard)
return media.replace(MEDIA_SELECTOR_GLOBAL, '$1-$2');
});
}
cache?.stylesWithHoverClass.set(cssText, result);
return result;
}
@@ -196,7 +224,7 @@ function buildNode(
const isTextarea = tagName === 'textarea' && name === 'value';
const isRemoteOrDynamicCss = tagName === 'style' && name === '_cssText';
if (isRemoteOrDynamicCss && hackCss && typeof value === 'string') {
value = addHoverClass(value, cache);
value = adaptCssForReplay(value, cache);
}
if ((isTextarea || isRemoteOrDynamicCss) && typeof value === 'string') {
node.appendChild(doc.createTextNode(value));
@@ -341,7 +369,7 @@ function buildNode(
case NodeType.Text:
return doc.createTextNode(
n.isStyle && hackCss
? addHoverClass(n.textContent, cache)
? adaptCssForReplay(n.textContent, cache)
: n.textContent,
);
case NodeType.CDATA: