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

@@ -3,7 +3,11 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import { addHoverClass, buildNodeWithSN, createCache } from '../src/rebuild';
import {
adaptCssForReplay,
buildNodeWithSN,
createCache,
} from '../src/rebuild';
import { NodeType } from '../src/types';
import { createMirror, Mirror } from '../src/utils';
@@ -81,47 +85,90 @@ describe('rebuild', function () {
describe('add hover class to hover selector related rules', function () {
it('will do nothing to css text without :hover', () => {
const cssText = 'body { color: white }';
expect(addHoverClass(cssText, cache)).toEqual(cssText);
expect(adaptCssForReplay(cssText, cache)).toEqual(cssText);
});
it('can add hover class to css text', () => {
const cssText = '.a:hover { color: white }';
expect(addHoverClass(cssText, cache)).toEqual(
expect(adaptCssForReplay(cssText, cache)).toEqual(
'.a:hover, .a.\\:hover { color: white }',
);
});
it('can correctly add hover when in middle of selector', () => {
const cssText = 'ul li a:hover img { color: white }';
expect(adaptCssForReplay(cssText, cache)).toEqual(
'ul li a:hover img, ul li a.\\:hover img { color: white }',
);
});
it('can correctly add hover on multiline selector', () => {
const cssText = `ul li.specified a:hover img,
ul li.multiline
b:hover
img,
ul li.specified c:hover img {
color: white
}`;
expect(adaptCssForReplay(cssText, cache)).toEqual(
`ul li.specified a:hover img, ul li.specified a.\\:hover img,
ul li.multiline
b:hover
img, ul li.multiline
b.\\:hover
img,
ul li.specified c:hover img, ul li.specified c.\\:hover img {
color: white
}`,
);
});
it('can add hover class within media query', () => {
const cssText = '@media screen { .m:hover { color: white } }';
expect(adaptCssForReplay(cssText, cache)).toEqual(
'@media screen { .m:hover, .m.\\:hover { color: white } }',
);
});
it('can add hover class when there is multi selector', () => {
const cssText = '.a, .b:hover, .c { color: white }';
expect(addHoverClass(cssText, cache)).toEqual(
expect(adaptCssForReplay(cssText, cache)).toEqual(
'.a, .b:hover, .b.\\:hover, .c { color: white }',
);
});
it('can add hover class when there is a multi selector with the same prefix', () => {
const cssText = '.a:hover, .a:hover::after { color: white }';
expect(addHoverClass(cssText, cache)).toEqual(
expect(adaptCssForReplay(cssText, cache)).toEqual(
'.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }',
);
});
it('can add hover class when :hover is not the end of selector', () => {
const cssText = 'div:hover::after { color: white }';
expect(addHoverClass(cssText, cache)).toEqual(
expect(adaptCssForReplay(cssText, cache)).toEqual(
'div:hover::after, div.\\:hover::after { color: white }',
);
});
it('can add hover class when the selector has multi :hover', () => {
const cssText = 'a:hover b:hover { color: white }';
expect(addHoverClass(cssText, cache)).toEqual(
expect(adaptCssForReplay(cssText, cache)).toEqual(
'a:hover b:hover, a.\\:hover b.\\:hover { color: white }',
);
});
it('will ignore :hover in css value', () => {
const cssText = '.a::after { content: ":hover" }';
expect(addHoverClass(cssText, cache)).toEqual(cssText);
expect(adaptCssForReplay(cssText, cache)).toEqual(cssText);
});
it('can adapt media rules to replay context', () => {
const cssText =
'@media only screen and (min-device-width : 1200px) { .a { width: 10px; }}';
expect(adaptCssForReplay(cssText, cache)).toEqual(
'@media only screen and (min-width : 1200px) { .a { width: 10px; }}',
);
});
// this benchmark is unreliable when run in parallel with other tests
@@ -131,7 +178,7 @@ describe('rebuild', function () {
'utf8',
);
const start = process.hrtime();
addHoverClass(cssText, cache);
adaptCssForReplay(cssText, cache);
const end = process.hrtime(start);
const duration = getDuration(end);
expect(duration).toBeLessThan(100);
@@ -146,11 +193,11 @@ describe('rebuild', function () {
);
const start = process.hrtime();
addHoverClass(cssText, cache);
adaptCssForReplay(cssText, cache);
const end = process.hrtime(start);
const cachedStart = process.hrtime();
addHoverClass(cssText, cache);
adaptCssForReplay(cssText, cache);
const cachedEnd = process.hrtime(cachedStart);
expect(getDuration(cachedEnd) * factor).toBeLessThan(getDuration(end));