Skip to content

Commit a7c33f2

Browse files
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 <[email protected]> Co-authored-by: Justin Halsall <[email protected]>
1 parent e607e83 commit a7c33f2

File tree

5 files changed

+133
-57
lines changed

5 files changed

+133
-57
lines changed

.changeset/rotten-spies-enjoy.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'rrweb-snapshot': patch
3+
'rrweb': patch
4+
---
5+
6+
Ensure :hover works on replayer, even if a rule is behind a media query
7+
Respect the intent behind max-device-width and min-device-width media queries so that their effects are apparent in the replayer context

packages/rrweb-snapshot/src/css.ts

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export interface Node {
5656
};
5757
}
5858

59+
export interface NodeWithRules extends Node {
60+
/** Array of nodes with the types rule, comment and any of the at-rule types. */
61+
rules: Array<Rule | Comment | AtRule>;
62+
}
63+
5964
export interface Rule extends Node {
6065
/** The list of selectors of the rule, split on commas. Each selector is trimmed from whitespace and comments. */
6166
selectors?: string[];
@@ -98,13 +103,11 @@ export interface CustomMedia extends Node {
98103
/**
99104
* The @document at-rule.
100105
*/
101-
export interface Document extends Node {
106+
export interface Document extends NodeWithRules {
102107
/** The part following @document. */
103108
document?: string;
104109
/** The vendor prefix in @document, or undefined if there is none. */
105110
vendor?: string;
106-
/** Array of nodes with the types rule, comment and any of the at-rule types. */
107-
rules?: Array<Rule | Comment | AtRule>;
108111
}
109112

110113
/**
@@ -118,10 +121,7 @@ export interface FontFace extends Node {
118121
/**
119122
* The @host at-rule.
120123
*/
121-
export interface Host extends Node {
122-
/** Array of nodes with the types rule, comment and any of the at-rule types. */
123-
rules?: Array<Rule | Comment | AtRule>;
124-
}
124+
export type Host = NodeWithRules;
125125

126126
/**
127127
* The @import at-rule.
@@ -153,11 +153,9 @@ export interface KeyFrame extends Node {
153153
/**
154154
* The @media at-rule.
155155
*/
156-
export interface Media extends Node {
156+
export interface Media extends NodeWithRules {
157157
/** The part following @media. */
158158
media?: string;
159-
/** Array of nodes with the types rule, comment and any of the at-rule types. */
160-
rules?: Array<Rule | Comment | AtRule>;
161159
}
162160

163161
/**
@@ -181,11 +179,9 @@ export interface Page extends Node {
181179
/**
182180
* The @supports at-rule.
183181
*/
184-
export interface Supports extends Node {
182+
export interface Supports extends NodeWithRules {
185183
/** The part following @supports. */
186184
supports?: string;
187-
/** Array of nodes with the types rule, comment and any of the at-rule types. */
188-
rules?: Array<Rule | Comment | AtRule>;
189185
}
190186

191187
/** All at-rules. */
@@ -205,10 +201,8 @@ export type AtRule =
205201
/**
206202
* A collection of rules
207203
*/
208-
export interface StyleRules {
204+
export interface StyleRules extends NodeWithRules {
209205
source?: string;
210-
/** Array of nodes with the types rule, comment and any of the at-rule types. */
211-
rules: Array<Rule | Comment | AtRule>;
212206
/** Array of Errors. Errors collected during parsing when option silent is true. */
213207
parsingErrors?: ParserError[];
214208
}
@@ -224,7 +218,7 @@ export interface Stylesheet extends Node {
224218
// https://github.com/visionmedia/css-parse/pull/49#issuecomment-30088027
225219
const commentre = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g;
226220

227-
export function parse(css: string, options: ParserOptions = {}) {
221+
export function parse(css: string, options: ParserOptions = {}): Stylesheet {
228222
/**
229223
* Positional.
230224
*/
@@ -882,7 +876,7 @@ function trim(str: string) {
882876
* Adds non-enumerable parent node reference to each node.
883877
*/
884878

885-
function addParent(obj: Stylesheet, parent?: Stylesheet) {
879+
function addParent(obj: Stylesheet, parent?: Stylesheet): Stylesheet {
886880
const isNode = obj && typeof obj.type === 'string';
887881
const childParent = isNode ? obj : parent;
888882

packages/rrweb-snapshot/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import snapshot, {
1111
} from './snapshot';
1212
import rebuild, {
1313
buildNodeWithSN,
14-
addHoverClass,
14+
adaptCssForReplay,
1515
createCache,
1616
} from './rebuild';
1717
export * from './types';
@@ -22,7 +22,7 @@ export {
2222
serializeNodeWithId,
2323
rebuild,
2424
buildNodeWithSN,
25-
addHoverClass,
25+
adaptCssForReplay,
2626
createCache,
2727
transformAttribute,
2828
ignoreAttribute,

packages/rrweb-snapshot/src/rebuild.ts

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parse } from './css';
1+
import { Rule, Media, NodeWithRules, parse } from './css';
22
import {
33
serializedNodeWithId,
44
NodeType,
@@ -62,9 +62,11 @@ function escapeRegExp(str: string) {
6262
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
6363
}
6464

65+
const MEDIA_SELECTOR = /(max|min)-device-(width|height)/;
66+
const MEDIA_SELECTOR_GLOBAL = new RegExp(MEDIA_SELECTOR.source, 'g');
6567
const HOVER_SELECTOR = /([^\\]):hover/;
6668
const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR.source, 'g');
67-
export function addHoverClass(cssText: string, cache: BuildCache): string {
69+
export function adaptCssForReplay(cssText: string, cache: BuildCache): string {
6870
const cachedStyle = cache?.stylesWithHoverClass.get(cssText);
6971
if (cachedStyle) return cachedStyle;
7072

@@ -77,35 +79,61 @@ export function addHoverClass(cssText: string, cache: BuildCache): string {
7779
}
7880

7981
const selectors: string[] = [];
80-
ast.stylesheet.rules.forEach((rule) => {
81-
if ('selectors' in rule) {
82-
(rule.selectors || []).forEach((selector: string) => {
82+
const medias: string[] = [];
83+
function getSelectors(rule: Rule | Media | NodeWithRules) {
84+
if ('selectors' in rule && rule.selectors) {
85+
rule.selectors.forEach((selector: string) => {
8386
if (HOVER_SELECTOR.test(selector)) {
8487
selectors.push(selector);
8588
}
8689
});
8790
}
88-
});
89-
90-
if (selectors.length === 0) {
91-
return cssText;
91+
if ('media' in rule && rule.media && MEDIA_SELECTOR.test(rule.media)) {
92+
medias.push(rule.media);
93+
}
94+
if ('rules' in rule && rule.rules) {
95+
rule.rules.forEach(getSelectors);
96+
}
9297
}
98+
getSelectors(ast.stylesheet);
9399

94-
const selectorMatcher = new RegExp(
95-
selectors
96-
.filter((selector, index) => selectors.indexOf(selector) === index)
97-
.sort((a, b) => b.length - a.length)
98-
.map((selector) => {
99-
return escapeRegExp(selector);
100-
})
101-
.join('|'),
102-
'g',
103-
);
104-
105-
const result = cssText.replace(selectorMatcher, (selector) => {
106-
const newSelector = selector.replace(HOVER_SELECTOR_GLOBAL, '$1.\\:hover');
107-
return `${selector}, ${newSelector}`;
108-
});
100+
let result = cssText;
101+
if (selectors.length > 0) {
102+
const selectorMatcher = new RegExp(
103+
selectors
104+
.filter((selector, index) => selectors.indexOf(selector) === index)
105+
.sort((a, b) => b.length - a.length)
106+
.map((selector) => {
107+
return escapeRegExp(selector);
108+
})
109+
.join('|'),
110+
'g',
111+
);
112+
result = result.replace(selectorMatcher, (selector) => {
113+
const newSelector = selector.replace(
114+
HOVER_SELECTOR_GLOBAL,
115+
'$1.\\:hover',
116+
);
117+
return `${selector}, ${newSelector}`;
118+
});
119+
}
120+
if (medias.length > 0) {
121+
const mediaMatcher = new RegExp(
122+
medias
123+
.filter((media, index) => medias.indexOf(media) === index)
124+
.sort((a, b) => b.length - a.length)
125+
.map((media) => {
126+
return escapeRegExp(media);
127+
})
128+
.join('|'),
129+
'g',
130+
);
131+
result = result.replace(mediaMatcher, (media) => {
132+
// not attempting to maintain min-device-width along with min-width
133+
// (it's non standard)
134+
return media.replace(MEDIA_SELECTOR_GLOBAL, '$1-$2');
135+
});
136+
}
109137
cache?.stylesWithHoverClass.set(cssText, result);
110138
return result;
111139
}
@@ -196,7 +224,7 @@ function buildNode(
196224
const isTextarea = tagName === 'textarea' && name === 'value';
197225
const isRemoteOrDynamicCss = tagName === 'style' && name === '_cssText';
198226
if (isRemoteOrDynamicCss && hackCss && typeof value === 'string') {
199-
value = addHoverClass(value, cache);
227+
value = adaptCssForReplay(value, cache);
200228
}
201229
if ((isTextarea || isRemoteOrDynamicCss) && typeof value === 'string') {
202230
node.appendChild(doc.createTextNode(value));
@@ -341,7 +369,7 @@ function buildNode(
341369
case NodeType.Text:
342370
return doc.createTextNode(
343371
n.isStyle && hackCss
344-
? addHoverClass(n.textContent, cache)
372+
? adaptCssForReplay(n.textContent, cache)
345373
: n.textContent,
346374
);
347375
case NodeType.CDATA:

packages/rrweb-snapshot/test/rebuild.test.ts

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
*/
44
import * as fs from 'fs';
55
import * as path from 'path';
6-
import { addHoverClass, buildNodeWithSN, createCache } from '../src/rebuild';
6+
import {
7+
adaptCssForReplay,
8+
buildNodeWithSN,
9+
createCache,
10+
} from '../src/rebuild';
711
import { NodeType } from '../src/types';
812
import { createMirror, Mirror } from '../src/utils';
913

@@ -81,47 +85,90 @@ describe('rebuild', function () {
8185
describe('add hover class to hover selector related rules', function () {
8286
it('will do nothing to css text without :hover', () => {
8387
const cssText = 'body { color: white }';
84-
expect(addHoverClass(cssText, cache)).toEqual(cssText);
88+
expect(adaptCssForReplay(cssText, cache)).toEqual(cssText);
8589
});
8690

8791
it('can add hover class to css text', () => {
8892
const cssText = '.a:hover { color: white }';
89-
expect(addHoverClass(cssText, cache)).toEqual(
93+
expect(adaptCssForReplay(cssText, cache)).toEqual(
9094
'.a:hover, .a.\\:hover { color: white }',
9195
);
9296
});
9397

98+
it('can correctly add hover when in middle of selector', () => {
99+
const cssText = 'ul li a:hover img { color: white }';
100+
expect(adaptCssForReplay(cssText, cache)).toEqual(
101+
'ul li a:hover img, ul li a.\\:hover img { color: white }',
102+
);
103+
});
104+
105+
it('can correctly add hover on multiline selector', () => {
106+
const cssText = `ul li.specified a:hover img,
107+
ul li.multiline
108+
b:hover
109+
img,
110+
ul li.specified c:hover img {
111+
color: white
112+
}`;
113+
expect(adaptCssForReplay(cssText, cache)).toEqual(
114+
`ul li.specified a:hover img, ul li.specified a.\\:hover img,
115+
ul li.multiline
116+
b:hover
117+
img, ul li.multiline
118+
b.\\:hover
119+
img,
120+
ul li.specified c:hover img, ul li.specified c.\\:hover img {
121+
color: white
122+
}`,
123+
);
124+
});
125+
126+
it('can add hover class within media query', () => {
127+
const cssText = '@media screen { .m:hover { color: white } }';
128+
expect(adaptCssForReplay(cssText, cache)).toEqual(
129+
'@media screen { .m:hover, .m.\\:hover { color: white } }',
130+
);
131+
});
132+
94133
it('can add hover class when there is multi selector', () => {
95134
const cssText = '.a, .b:hover, .c { color: white }';
96-
expect(addHoverClass(cssText, cache)).toEqual(
135+
expect(adaptCssForReplay(cssText, cache)).toEqual(
97136
'.a, .b:hover, .b.\\:hover, .c { color: white }',
98137
);
99138
});
100139

101140
it('can add hover class when there is a multi selector with the same prefix', () => {
102141
const cssText = '.a:hover, .a:hover::after { color: white }';
103-
expect(addHoverClass(cssText, cache)).toEqual(
142+
expect(adaptCssForReplay(cssText, cache)).toEqual(
104143
'.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }',
105144
);
106145
});
107146

108147
it('can add hover class when :hover is not the end of selector', () => {
109148
const cssText = 'div:hover::after { color: white }';
110-
expect(addHoverClass(cssText, cache)).toEqual(
149+
expect(adaptCssForReplay(cssText, cache)).toEqual(
111150
'div:hover::after, div.\\:hover::after { color: white }',
112151
);
113152
});
114153

115154
it('can add hover class when the selector has multi :hover', () => {
116155
const cssText = 'a:hover b:hover { color: white }';
117-
expect(addHoverClass(cssText, cache)).toEqual(
156+
expect(adaptCssForReplay(cssText, cache)).toEqual(
118157
'a:hover b:hover, a.\\:hover b.\\:hover { color: white }',
119158
);
120159
});
121160

122161
it('will ignore :hover in css value', () => {
123162
const cssText = '.a::after { content: ":hover" }';
124-
expect(addHoverClass(cssText, cache)).toEqual(cssText);
163+
expect(adaptCssForReplay(cssText, cache)).toEqual(cssText);
164+
});
165+
166+
it('can adapt media rules to replay context', () => {
167+
const cssText =
168+
'@media only screen and (min-device-width : 1200px) { .a { width: 10px; }}';
169+
expect(adaptCssForReplay(cssText, cache)).toEqual(
170+
'@media only screen and (min-width : 1200px) { .a { width: 10px; }}',
171+
);
125172
});
126173

127174
// this benchmark is unreliable when run in parallel with other tests
@@ -131,7 +178,7 @@ describe('rebuild', function () {
131178
'utf8',
132179
);
133180
const start = process.hrtime();
134-
addHoverClass(cssText, cache);
181+
adaptCssForReplay(cssText, cache);
135182
const end = process.hrtime(start);
136183
const duration = getDuration(end);
137184
expect(duration).toBeLessThan(100);
@@ -146,11 +193,11 @@ describe('rebuild', function () {
146193
);
147194

148195
const start = process.hrtime();
149-
addHoverClass(cssText, cache);
196+
adaptCssForReplay(cssText, cache);
150197
const end = process.hrtime(start);
151198

152199
const cachedStart = process.hrtime();
153-
addHoverClass(cssText, cache);
200+
adaptCssForReplay(cssText, cache);
154201
const cachedEnd = process.hrtime(cachedStart);
155202

156203
expect(getDuration(cachedEnd) * factor).toBeLessThan(getDuration(end));

0 commit comments

Comments
 (0)