Skip to content

Commit d44b323

Browse files
committed
fix(core): css var() fallbacks, initial, and order-independent custom properties
- var() finds its matching closing parenthesis, so fallbacks such as rgb(...) are no longer truncated. - The fallback is everything after the first comma, so comma lists (e.g. gradient stops) survive. var(--missing, a, b) now resolves to "a, b" as the spec requires, instead of "a". - A custom property set to `initial` uses the var() fallback. - Custom properties are settled before the properties that use them, and re-evaluated when one refers to another declared after it.
1 parent 8cbcbfd commit d44b323

5 files changed

Lines changed: 165 additions & 14 deletions

File tree

‎apps/automated/src/ui/styling/style-tests.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1996,7 +1996,7 @@ export function test_css_variable_fallback() {
19961996
},
19971997
{
19981998
className: 'undefined-css-variable-with-multiple-fallbacks',
1999-
expectedColor: limeColor,
1999+
expectedColor: undefined,
20002000
},
20012001
{
20022002
className: 'undefined-css-variable-with-missing-fallback-value',
@@ -2037,7 +2037,7 @@ export function test_css_variable_fallback() {
20372037
20382038
.undefined-css-variable-with-multiple-fallbacks {
20392039
--my-fallback-var: lime;
2040-
color: var(--undefined-var, var(--my-fallback-var), yellow); /* resolved as color: lime; */
2040+
color: var(--undefined-var, var(--my-fallback-var), yellow); /* resolved as color: unset; */
20412041
}
20422042
20432043
.undefined-css-variable-with-missing-fallback-value {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { _evaluateCssVariableExpression } from '.';
4+
5+
function evaluate(value: string, variables: Record<string, string> = {}) {
6+
const view = { style: { getCssVariable: (name: string) => (name in variables ? variables[name] : null) } };
7+
return _evaluateCssVariableExpression(view as any, 'color', value);
8+
}
9+
10+
describe('_evaluateCssVariableExpression', () => {
11+
it('substitutes defined variables and falls back for undefined ones', () => {
12+
expect(evaluate('var(--a)', { '--a': 'blue' })).toBe('blue');
13+
expect(evaluate('var(--a, red)', { '--a': 'blue' })).toBe('blue');
14+
expect(evaluate('var(--missing, red)')).toBe('red');
15+
expect(evaluate('var(--missing)')).toBe('unset');
16+
});
17+
18+
it('resolves nested fallbacks', () => {
19+
expect(evaluate('var(--missing, var(--b))', { '--b': 'lime' })).toBe('lime');
20+
expect(evaluate('var(--missing, var(--missing-too, yellow))')).toBe('yellow');
21+
expect(evaluate('var(--missing, var(--missing-too))')).toBe('unset');
22+
});
23+
24+
it('keeps parentheses inside the fallback', () => {
25+
expect(evaluate('var(--missing, rgb(0, 0, 255))')).toBe('rgb(0, 0, 255)');
26+
expect(evaluate('linear-gradient(var(--missing, rgb(255, 0, 0) 0%, rgb(0, 0, 255) 100%))')).toBe('linear-gradient(rgb(255, 0, 0) 0%, rgb(0, 0, 255) 100%)');
27+
});
28+
29+
it('keeps every comma-separated part of the fallback', () => {
30+
expect(evaluate('var(--missing, red 0%, blue 100%)')).toBe('red 0%, blue 100%');
31+
});
32+
33+
it('uses the fallback for a variable set to initial', () => {
34+
expect(evaluate('var(--a, red)', { '--a': 'initial' })).toBe('red');
35+
expect(evaluate('var(--a)', { '--a': 'initial' })).toBe('unset');
36+
});
37+
38+
it('resolves a gradient built from variables with fallbacks', () => {
39+
const variables = {
40+
'--via-stops': 'initial',
41+
'--position': 'to right in oklab',
42+
'--from': 'rgb(98, 96, 255)',
43+
'--from-position': '0%',
44+
'--to': 'rgb(246, 51, 154)',
45+
'--to-position': '100%',
46+
};
47+
expect(evaluate('linear-gradient(var(--via-stops, var(--position), var(--from) var(--from-position), var(--to) var(--to-position)))', variables)).toBe(
48+
'linear-gradient(to right in oklab, rgb(98, 96, 255) 0%, rgb(246, 51, 154) 100%)',
49+
);
50+
});
51+
52+
it('leaves values without variables alone', () => {
53+
expect(evaluate('rgb(0, 0, 0)')).toBe('rgb(0, 0, 0)');
54+
});
55+
});

‎packages/core/ui/core/properties/index.ts‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -219,20 +219,22 @@ export function _evaluateCssVariableExpression(view: ViewBase, cssName: string,
219219
continue;
220220
}
221221

222-
const endIdx = output.indexOf(')', idx);
222+
const endIdx = findClosingParenthesis(output, idx + 3);
223223
if (endIdx === -1) {
224224
continue;
225225
}
226226

227-
const matched = output
228-
.substring(idx + 4, endIdx)
229-
.split(',')
230-
.map((v) => v.trim())
231-
.filter((v) => !!v);
232-
const cssVariableName = matched.shift();
227+
// The fallback is everything after the first comma, commas included.
228+
const body = output.substring(idx + 4, endIdx);
229+
const commaIdx = body.indexOf(',');
230+
const cssVariableName = (commaIdx === -1 ? body : body.substring(0, commaIdx)).trim();
231+
const fallback = commaIdx === -1 ? '' : body.substring(commaIdx + 1).trim();
233232
let cssVariableValue = view.style.getCssVariable(cssVariableName);
234-
if (cssVariableValue === null && matched.length) {
235-
cssVariableValue = _evaluateCssVariableExpression(view, cssName, matched.join(', ')).split(',')[0];
233+
if (cssVariableValue?.trim() === 'initial') {
234+
cssVariableValue = null;
235+
}
236+
if (cssVariableValue === null && fallback) {
237+
cssVariableValue = _evaluateCssVariableExpression(view, cssName, fallback);
236238
}
237239

238240
if (!cssVariableValue) {
@@ -245,6 +247,20 @@ export function _evaluateCssVariableExpression(view: ViewBase, cssName: string,
245247
return output;
246248
}
247249

250+
function findClosingParenthesis(value: string, openIdx: number): number {
251+
let depth = 0;
252+
for (let i = openIdx; i < value.length; i++) {
253+
const ch = value[i];
254+
if (ch === '(') {
255+
depth++;
256+
} else if (ch === ')' && --depth === 0) {
257+
return i;
258+
}
259+
}
260+
261+
return -1;
262+
}
263+
248264
export function _evaluateCssCalcExpression(value: string) {
249265
if (typeof value !== 'string') {
250266
return value;

‎packages/core/ui/styling/style-scope.spec.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,33 @@ describe('CssState.setPropertyValues', () => {
112112
expect(view.style.color.toString()).toBe('#FF0000');
113113
});
114114

115+
it('resolves custom properties that refer to ones declared after them', () => {
116+
const { view } = styled('label { --a: var(--b, blue); --b: var(--c); --c: red; color: var(--a); }');
117+
view._cssState.onLoaded();
118+
119+
expect(view.style.color.toString()).toBe('#FF0000');
120+
});
121+
122+
it('evaluates properties after the custom properties they use, whatever the order', () => {
123+
const { view } = styled('.a { color: var(--brand); } .b { --brand: var(--base); --base: red; }', 'a b');
124+
view._cssState.onLoaded();
125+
126+
expect(view.style.color.toString()).toBe('#FF0000');
127+
});
128+
129+
it('prefers a later-defined variable over the fallback that referenced it', () => {
130+
const css = `
131+
.l { --via-stops: initial; }
132+
.from { --from: red; --stops: var(--via-stops, var(--from)); }
133+
.via { --via: lime; --via-stops: var(--from), var(--via); --stops: var(--via-stops); }
134+
.to { --stops: var(--via-stops, var(--from)); color: var(--stops); }
135+
`;
136+
const { view } = styled(css, 'l from via to');
137+
view._cssState.onLoaded();
138+
139+
expect(view.style.getCssVariable('--stops')).toBe('red, lime');
140+
});
141+
115142
it('does not re-apply an unchanged css expression', () => {
116143
const { view } = styled('label { --brand: red; color: var(--brand); }');
117144
view._cssState.onLoaded();

‎packages/core/ui/styling/style-scope.ts‎

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,57 @@ function evaluateCssExpressions(view: ViewBase, property: string, value: string)
117117
return value;
118118
}
119119

120+
// Custom properties may refer to ones declared after them, so re-evaluate until they settle.
121+
function settleCssVariables(view: ViewBase, cssExpsProperties: Record<string, string>) {
122+
let names: string[];
123+
for (const property in cssExpsProperties) {
124+
if (isCssVariable(property)) {
125+
if (!names) {
126+
names = [];
127+
}
128+
names.push(property);
129+
view.style.setScopedCssVariable(property, evaluateCssExpressions(view, property, cssExpsProperties[property]));
130+
}
131+
}
132+
if (!names || names.length < 2 || !hasForwardCssVariableReference(names, cssExpsProperties)) {
133+
return;
134+
}
135+
136+
for (let pass = 1; pass < names.length; pass++) {
137+
let changed = false;
138+
for (const name of names) {
139+
const value = evaluateCssExpressions(view, name, cssExpsProperties[name]);
140+
if (value !== view.style.getCssVariable(name)) {
141+
view.style.setScopedCssVariable(name, value);
142+
changed = true;
143+
}
144+
}
145+
if (!changed) {
146+
return;
147+
}
148+
}
149+
}
150+
151+
function hasForwardCssVariableReference(names: string[], values: Record<string, string>): boolean {
152+
for (let i = 0; i < names.length; i++) {
153+
const value = values[names[i]];
154+
for (let j = i + 1; j < names.length; j++) {
155+
const ref = `var(${names[j]}`;
156+
let at = value.indexOf(ref);
157+
while (at !== -1) {
158+
// `var(--a` must not match `var(--ab`.
159+
const next = value[at + ref.length];
160+
if (next === ')' || next === ',' || next === ' ' || next === undefined) {
161+
return true;
162+
}
163+
at = value.indexOf(ref, at + 1);
164+
}
165+
}
166+
}
167+
168+
return false;
169+
}
170+
120171
/**
121172
* Only marks the merged list dirty - it is rebuilt on next read, since frameworks
122173
* register stylesheets one call at a time.
@@ -934,21 +985,23 @@ export class CssState {
934985
valuesToApply[property] = value;
935986
}
936987
//we need to parse CSS vars first before evaluating css expressions
988+
if (cssExpsProperties) {
989+
settleCssVariables(view, cssExpsProperties);
990+
}
937991
for (const property in cssExpsProperties) {
938992
const hadOldValue = property in oldProperties;
939993
const oldValue = hadOldValue ? oldProperties[property] : undefined;
940994
if (hadOldValue) {
941995
delete oldProperties[property];
942996
}
943997

944-
const value = evaluateCssExpressions(view, property, cssExpsProperties[property]);
945-
946998
if (isCssVariable(property)) {
947-
view.style.setScopedCssVariable(property, value);
948999
delete newPropertyValues[property];
9491000
continue;
9501001
}
9511002

1003+
const value = evaluateCssExpressions(view, property, cssExpsProperties[property]);
1004+
9521005
if (value === unsetValue) {
9531006
delete newPropertyValues[property];
9541007
} else {

0 commit comments

Comments
 (0)