Skip to content

Commit 0a521b1

Browse files
authored
fix: deduplicate tag-scoped media rules (#9301)
1 parent 4372a88 commit 0a521b1

File tree

5 files changed

+100
-39
lines changed

5 files changed

+100
-39
lines changed

packages/vaadin-themable-mixin/src/css-rules.js

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
// Based on https://github.com/jouni/j-elements/blob/main/test/old-components/Stylable.js
8+
const mediaRulesCache = new WeakMap();
89

910
/**
1011
* Check if the media query is a non-standard "tag scoped selector".
@@ -21,47 +22,85 @@ function isTagScopedMedia(media) {
2122
}
2223

2324
/**
24-
* Check if the media query string matches the given tag name.
25-
*
26-
* @param {string} media
27-
* @param {string} tagName
28-
* @return {boolean}
29-
*/
30-
function matchesTagScopedMedia(media, tagName) {
31-
return media === tagName;
32-
}
33-
34-
/**
35-
* Recursively processes a style sheet for matching "tag scoped" rules.
25+
* Recursively processes a style sheet for media rules that match
26+
* the specified predicate.
3627
*
3728
* @param {CSSStyleSheet} styleSheet
38-
* @param {string} tagName
29+
* @param {(rule: CSSRule) => boolean} predicate
30+
* @return {Array<CSSMediaRule | CSSImportRule>}
3931
*/
40-
function extractStyleSheetTagScopedCSSRules(styleSheet, tagName) {
41-
const matchingRules = [];
32+
function extractMediaRulesFromStyleSheet(styleSheet, predicate) {
33+
const result = [];
4234

4335
for (const rule of styleSheet.cssRules) {
4436
const ruleType = rule.constructor.name;
4537

4638
if (ruleType === 'CSSImportRule') {
47-
if (!isTagScopedMedia(rule.media.mediaText)) {
48-
matchingRules.push(...extractStyleSheetTagScopedCSSRules(rule.styleSheet, tagName));
49-
continue;
50-
}
51-
52-
if (matchesTagScopedMedia(rule.media.mediaText, tagName)) {
53-
matchingRules.push(...rule.styleSheet.cssRules);
39+
if (predicate(rule)) {
40+
result.push(rule);
41+
} else {
42+
result.push(...extractMediaRulesFromStyleSheet(rule.styleSheet, predicate));
5443
}
5544
}
5645

5746
if (ruleType === 'CSSMediaRule') {
58-
if (matchesTagScopedMedia(rule.media.mediaText, tagName)) {
59-
matchingRules.push(...rule.cssRules);
47+
if (predicate(rule)) {
48+
result.push(rule);
6049
}
6150
}
6251
}
6352

64-
return matchingRules;
53+
return result;
54+
}
55+
56+
/**
57+
* Deduplicates media rules by their CSS text, keeping the last occurrence.
58+
*
59+
* @param {Array<CSSMediaRule | CSSImportRule>} rules
60+
* @return {Array<CSSMediaRule | CSSImportRule>}
61+
*/
62+
function deduplicateMediaRules(rules) {
63+
const seen = new Set();
64+
return rules.reduceRight((deduped, rule) => {
65+
const key = rule.styleSheet?.cssText ?? rule.cssText;
66+
if (!seen.has(key)) {
67+
seen.add(key);
68+
deduped.unshift(rule);
69+
}
70+
return deduped;
71+
}, []);
72+
}
73+
74+
/**
75+
* Extracts all CSS rules from a style sheet that are contained in media queries
76+
* with a "tag scoped selector" matching the specified tag name.
77+
*
78+
* This function caches the results for each style sheet to avoid
79+
* reprocessing the same style sheet multiple times.
80+
*
81+
* @param {CSSStyleSheet} styleSheet
82+
* @param {string} tagName
83+
* @return {CSSRule[]}
84+
*/
85+
function extractTagScopedCSSRulesFromStyleSheet(styleSheet, tagName) {
86+
let mediaRules = mediaRulesCache.get(styleSheet);
87+
if (!mediaRules) {
88+
// Collect all media rules that look like "tag scoped selectors", e.g. "@media vaadin-text-field { ... }"
89+
mediaRules = extractMediaRulesFromStyleSheet(styleSheet, (rule) => isTagScopedMedia(rule.media.mediaText));
90+
91+
// Remove duplicate media rules which may result from multiple imports of the same stylesheet
92+
mediaRules = deduplicateMediaRules(mediaRules);
93+
94+
// Group rules by tag name specified in the media query
95+
mediaRules = Map.groupBy(mediaRules, (rule) => rule.media.mediaText);
96+
97+
// Save the processed media rules in the cache
98+
mediaRulesCache.set(styleSheet, mediaRules);
99+
}
100+
101+
return (mediaRules.get(tagName) ?? []).flatMap((mediaRule) =>
102+
Array.from(mediaRule.styleSheet?.cssRules ?? mediaRule.cssRules),
103+
);
65104
}
66105

67106
/**
@@ -81,10 +120,10 @@ function extractStyleSheetTagScopedCSSRules(styleSheet, tagName) {
81120
* @return {CSSRule[]}
82121
*/
83122
export function extractTagScopedCSSRules(root, tagName) {
84-
const styleSheets = new Set([...root.styleSheets]);
85-
const adoptedStyleSheets = new Set([...root.adoptedStyleSheets]);
123+
const styleSheets = new Set(root.styleSheets);
124+
const adoptedStyleSheets = new Set(root.adoptedStyleSheets);
86125

87126
return [...styleSheets.union(adoptedStyleSheets)].flatMap((styleSheet) => {
88-
return extractStyleSheetTagScopedCSSRules(styleSheet, tagName);
127+
return extractTagScopedCSSRulesFromStyleSheet(styleSheet, tagName);
89128
});
90129
}
File renamed without changes.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:host {
2+
color: red;
3+
}

packages/vaadin-themable-mixin/test/css-rules-extraction-test-shared.css

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
@import './css-rules-extraction-test-button.css' test-button;
1+
@import './css-rules-extraction-test-button-black.css' test-button;
2+
@import './css-rules-extraction-test-button-red.css' test-button;
3+
@import './css-rules-extraction-test-button-black.css' test-button;
24
@import './css-rules-extraction-test-text-field.css' test-text-field;
35

46
@media test-text-field {

packages/vaadin-themable-mixin/test/css-rules-extraction.test.js

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { extractTagScopedCSSRules } from '../src/css-rules.js';
55
const BASE_PATH = import.meta.url.split('/').slice(0, -1).join('/');
66

77
describe('CSS rules extraction', () => {
8-
it('should extract rules from tag-scoped @media', () => {
8+
it('should extract and deduplicate rules from tag-scoped @media', () => {
99
fixtureSync(`
1010
<style>
1111
@media test-button {
@@ -14,6 +14,18 @@ describe('CSS rules extraction', () => {
1414
}
1515
}
1616
17+
@media test-button {
18+
:host {
19+
color: red;
20+
}
21+
}
22+
23+
@media test-button {
24+
:host {
25+
color: black;
26+
}
27+
}
28+
1729
@media test-text-field {
1830
#label {
1931
color: black;
@@ -35,8 +47,9 @@ describe('CSS rules extraction', () => {
3547

3648
{
3749
const rules = extractTagScopedCSSRules(document, 'test-button');
38-
expect(rules).to.have.lengthOf(1);
39-
expect(rules[0].cssText).to.equal(':host { color: black; }');
50+
expect(rules).to.have.lengthOf(2);
51+
expect(rules[0].cssText).to.equal(':host { color: red; }');
52+
expect(rules[1].cssText).to.equal(':host { color: black; }');
4053
}
4154
{
4255
const rules = extractTagScopedCSSRules(document, 'test-text-field');
@@ -47,19 +60,22 @@ describe('CSS rules extraction', () => {
4760
}
4861
});
4962

50-
it('should extract rules from tag-scoped @import', async () => {
63+
it('should extract and deduplicate rules from tag-scoped @import', async () => {
5164
const style = fixtureSync(`
5265
<style>
53-
@import '${BASE_PATH}/css-rules-extraction-test-button.css' test-button;
66+
@import '${BASE_PATH}/css-rules-extraction-test-button-black.css' test-button;
67+
@import '${BASE_PATH}/css-rules-extraction-test-button-red.css' test-button;
68+
@import '${BASE_PATH}/css-rules-extraction-test-button-black.css' test-button;
5469
@import '${BASE_PATH}/css-rules-extraction-test-text-field.css' test-text-field;
5570
</style>
5671
`);
5772
await oneEvent(style, 'load');
5873

5974
{
6075
const rules = extractTagScopedCSSRules(document, 'test-button');
61-
expect(rules).to.have.lengthOf(1);
62-
expect(rules[0].cssText).to.equal(':host { color: black; }');
76+
expect(rules).to.have.lengthOf(2);
77+
expect(rules[0].cssText).to.equal(':host { color: red; }');
78+
expect(rules[1].cssText).to.equal(':host { color: black; }');
6379
}
6480
{
6581
const rules = extractTagScopedCSSRules(document, 'test-text-field');
@@ -69,7 +85,7 @@ describe('CSS rules extraction', () => {
6985
}
7086
});
7187

72-
it('should extract rules from tag-scoped @media and @import inside an imported stylesheet', async () => {
88+
it('should extract and deduplicate rules from tag-scoped @media and @import inside an imported stylesheet', async () => {
7389
const style = fixtureSync(`
7490
<style>
7591
@import '${BASE_PATH}/css-rules-extraction-test-shared.css';
@@ -79,8 +95,9 @@ describe('CSS rules extraction', () => {
7995

8096
{
8197
const rules = extractTagScopedCSSRules(document, 'test-button');
82-
expect(rules).to.have.lengthOf(1);
83-
expect(rules[0].cssText).to.equal(':host { color: black; }');
98+
expect(rules).to.have.lengthOf(2);
99+
expect(rules[0].cssText).to.equal(':host { color: red; }');
100+
expect(rules[1].cssText).to.equal(':host { color: black; }');
84101
}
85102
{
86103
const rules = extractTagScopedCSSRules(document, 'test-text-field');

0 commit comments

Comments
 (0)