Skip to content

Commit f5ab957

Browse files
authored
Improved conditional CSS rendering (#154)
1 parent fd9749b commit f5ab957

File tree

7 files changed

+795
-241
lines changed

7 files changed

+795
-241
lines changed

.changeset/dirty-fireants-leave.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@vanilla-extract/css': patch
3+
---
4+
5+
Improved conditional CSS rendering
6+
7+
Previously all conditional CSS (@media and @supports) in a `.css.ts` file would merge together. This meant each unique query (e.g. `@media screen and (min-width: 700px)`) would only be rendered once. This output is ideal for file size but can lead to the conditions being rendered in the wrong order. The new strategy will still merge conditions together but only if it is considered safe to do so.
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
interface Rule {
2+
selector: string;
3+
rule: any;
4+
}
5+
6+
type Condition = {
7+
query: string;
8+
rules: Array<Rule>;
9+
children: ConditionalRuleset;
10+
};
11+
12+
export class ConditionalRuleset {
13+
ruleset: Array<Condition>;
14+
/**
15+
* Stores information about where conditions must in relation to other conditions
16+
*
17+
* e.g. mobile -> tablet, desktop
18+
*/
19+
precedenceLookup: Map<string, Set<String>>;
20+
21+
constructor() {
22+
this.ruleset = [];
23+
this.precedenceLookup = new Map();
24+
}
25+
26+
findOrCreateCondition(conditionQuery: string) {
27+
let targetCondition = this.ruleset.find(
28+
(cond) => cond.query === conditionQuery,
29+
);
30+
31+
if (!targetCondition) {
32+
// No target condition so create one
33+
targetCondition = {
34+
query: conditionQuery,
35+
rules: [],
36+
children: new ConditionalRuleset(),
37+
};
38+
this.ruleset.push(targetCondition);
39+
}
40+
41+
return targetCondition;
42+
}
43+
44+
getConditionalRulesetByPath(conditionPath: Array<string>) {
45+
let currRuleset: ConditionalRuleset = this;
46+
47+
for (const query of conditionPath) {
48+
const condition = currRuleset.findOrCreateCondition(query);
49+
50+
currRuleset = condition.children;
51+
}
52+
53+
return currRuleset;
54+
}
55+
56+
addRule(rule: Rule, conditionQuery: string, conditionPath: Array<string>) {
57+
const ruleset = this.getConditionalRulesetByPath(conditionPath);
58+
const targetCondition = ruleset.findOrCreateCondition(conditionQuery);
59+
60+
if (!targetCondition) {
61+
throw new Error('Failed to add conditional rule');
62+
}
63+
64+
targetCondition.rules.push(rule);
65+
}
66+
67+
addConditionPrecedence(
68+
conditionPath: Array<string>,
69+
conditionOrder: Array<string>,
70+
) {
71+
const ruleset = this.getConditionalRulesetByPath(conditionPath);
72+
73+
for (let i = 0; i < conditionOrder.length; i++) {
74+
const condition = conditionOrder[i];
75+
76+
const conditionPrecedence =
77+
ruleset.precedenceLookup.get(condition) ?? new Set();
78+
79+
for (const lowerPrecedenceCondition of conditionOrder.slice(i + 1)) {
80+
conditionPrecedence.add(lowerPrecedenceCondition);
81+
}
82+
83+
ruleset.precedenceLookup.set(condition, conditionPrecedence);
84+
}
85+
}
86+
87+
isCompatible(incomingRuleset: ConditionalRuleset) {
88+
for (const [
89+
condition,
90+
orderPrecedence,
91+
] of this.precedenceLookup.entries()) {
92+
for (const lowerPrecedenceCondition of orderPrecedence) {
93+
if (
94+
incomingRuleset.precedenceLookup
95+
.get(lowerPrecedenceCondition as string)
96+
?.has(condition)
97+
) {
98+
return false;
99+
}
100+
}
101+
}
102+
103+
// Check that children are compatible
104+
for (const { query, children } of incomingRuleset.ruleset) {
105+
const matchingCondition = this.ruleset.find(
106+
(cond) => cond.query === query,
107+
);
108+
109+
if (
110+
matchingCondition &&
111+
!matchingCondition.children.isCompatible(children)
112+
) {
113+
return false;
114+
}
115+
}
116+
117+
return true;
118+
}
119+
120+
merge(incomingRuleset: ConditionalRuleset) {
121+
// Merge rulesets into one array
122+
for (const { query, rules, children } of incomingRuleset.ruleset) {
123+
const matchingCondition = this.ruleset.find(
124+
(cond) => cond.query === query,
125+
);
126+
127+
if (matchingCondition) {
128+
matchingCondition.rules.push(...rules);
129+
130+
matchingCondition.children.merge(children);
131+
} else {
132+
this.ruleset.push({ query, rules, children });
133+
}
134+
}
135+
136+
// Merge order precendeces
137+
for (const [
138+
condition,
139+
incomingOrderPrecedence,
140+
] of incomingRuleset.precedenceLookup.entries()) {
141+
const orderPrecendence =
142+
this.precedenceLookup.get(condition) ?? new Set();
143+
144+
this.precedenceLookup.set(
145+
condition,
146+
new Set([...orderPrecendence, ...incomingOrderPrecedence]),
147+
);
148+
}
149+
}
150+
151+
/**
152+
* Merge another ConditionalRuleset into this one if they are compatible
153+
*
154+
* @returns true if successful, false if the ruleset is incompatible
155+
*/
156+
mergeIfCompatible(incomingRuleset: ConditionalRuleset) {
157+
if (!this.isCompatible(incomingRuleset)) {
158+
return false;
159+
}
160+
161+
this.merge(incomingRuleset);
162+
163+
return true;
164+
}
165+
166+
sort() {
167+
this.ruleset.sort((a, b) => {
168+
const aWeights = this.precedenceLookup.get(a.query);
169+
170+
if (aWeights?.has(b.query)) {
171+
// A is higher precedence
172+
return -1;
173+
}
174+
175+
const bWeights = this.precedenceLookup.get(b.query);
176+
177+
if (bWeights?.has(a.query)) {
178+
// B is higher precedence
179+
return 1;
180+
}
181+
182+
return 0;
183+
});
184+
}
185+
186+
renderToObj() {
187+
// Sort rulesets according to required rule order
188+
this.sort();
189+
190+
const target: any = {};
191+
192+
for (const { query, rules, children } of this.ruleset) {
193+
target[query] = {};
194+
195+
for (const rule of rules) {
196+
target[query][rule.selector] = rule.rule;
197+
}
198+
199+
Object.assign(target[query], children.renderToObj());
200+
}
201+
202+
return target;
203+
}
204+
}

packages/css/src/simplePsuedos.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
const simplePseudoMap = {
2+
':-moz-any-link': true,
3+
':-moz-full-screen': true,
4+
':-moz-placeholder': true,
5+
':-moz-read-only': true,
6+
':-moz-read-write': true,
7+
':-ms-fullscreen': true,
8+
':-ms-input-placeholder': true,
9+
':-webkit-any-link': true,
10+
':-webkit-full-screen': true,
11+
'::-moz-placeholder': true,
12+
'::-moz-progress-bar': true,
13+
'::-moz-range-progress': true,
14+
'::-moz-range-thumb': true,
15+
'::-moz-range-track': true,
16+
'::-moz-selection': true,
17+
'::-ms-backdrop': true,
18+
'::-ms-browse': true,
19+
'::-ms-check': true,
20+
'::-ms-clear': true,
21+
'::-ms-fill': true,
22+
'::-ms-fill-lower': true,
23+
'::-ms-fill-upper': true,
24+
'::-ms-reveal': true,
25+
'::-ms-thumb': true,
26+
'::-ms-ticks-after': true,
27+
'::-ms-ticks-before': true,
28+
'::-ms-tooltip': true,
29+
'::-ms-track': true,
30+
'::-ms-value': true,
31+
'::-webkit-backdrop': true,
32+
'::-webkit-input-placeholder': true,
33+
'::-webkit-progress-bar': true,
34+
'::-webkit-progress-inner-value': true,
35+
'::-webkit-progress-value': true,
36+
'::-webkit-resizer': true,
37+
'::-webkit-scrollbar-button': true,
38+
'::-webkit-scrollbar-corner': true,
39+
'::-webkit-scrollbar-thumb': true,
40+
'::-webkit-scrollbar-track-piece': true,
41+
'::-webkit-scrollbar-track': true,
42+
'::-webkit-scrollbar': true,
43+
'::-webkit-slider-runnable-track': true,
44+
'::-webkit-slider-thumb': true,
45+
'::after': true,
46+
'::backdrop': true,
47+
'::before': true,
48+
'::cue': true,
49+
'::first-letter': true,
50+
'::first-line': true,
51+
'::grammar-error': true,
52+
'::placeholder': true,
53+
'::selection': true,
54+
'::spelling-error': true,
55+
':active': true,
56+
':after': true,
57+
':any-link': true,
58+
':before': true,
59+
':blank': true,
60+
':checked': true,
61+
':default': true,
62+
':defined': true,
63+
':disabled': true,
64+
':empty': true,
65+
':enabled': true,
66+
':first': true,
67+
':first-child': true,
68+
':first-letter': true,
69+
':first-line': true,
70+
':first-of-type': true,
71+
':focus': true,
72+
':focus-visible': true,
73+
':focus-within': true,
74+
':fullscreen': true,
75+
':hover': true,
76+
':in-range': true,
77+
':indeterminate': true,
78+
':invalid': true,
79+
':last-child': true,
80+
':last-of-type': true,
81+
':left': true,
82+
':link': true,
83+
':only-child': true,
84+
':only-of-type': true,
85+
':optional': true,
86+
':out-of-range': true,
87+
':placeholder-shown': true,
88+
':read-only': true,
89+
':read-write': true,
90+
':required': true,
91+
':right': true,
92+
':root': true,
93+
':scope': true,
94+
':target': true,
95+
':valid': true,
96+
':visited': true,
97+
} as const;
98+
99+
export type SimplePseudos = keyof typeof simplePseudoMap;
100+
101+
export const simplePseudos = Object.keys(
102+
simplePseudoMap,
103+
) as Array<SimplePseudos>;
104+
105+
export const simplePseudoLookup = simplePseudoMap as Record<string, boolean>;

0 commit comments

Comments
 (0)