From 25ed6b55a1a2e8cc1d347a7848ff09ee8c7491cb Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Sat, 26 Jul 2025 21:32:12 +0300 Subject: [PATCH 1/7] feat(core): Enhanced CSS variables resolution --- apps/automated/src/ui/styling/style-tests.ts | 27 +++++- packages/core/ui/core/properties/index.ts | 45 +++++----- packages/core/ui/styling/style-scope.ts | 90 ++++++++++++-------- 3 files changed, 101 insertions(+), 61 deletions(-) diff --git a/apps/automated/src/ui/styling/style-tests.ts b/apps/automated/src/ui/styling/style-tests.ts index c1f9eccc10..2d7bc6453e 100644 --- a/apps/automated/src/ui/styling/style-tests.ts +++ b/apps/automated/src/ui/styling/style-tests.ts @@ -1927,6 +1927,27 @@ export function test_css_variables() { TKUnit.assertEqual((label.backgroundColor).hex, redColor, 'Label - background-color is red'); } +export function test_undefined_css_variable_invalidates_entire_expression() { + const page = helper.getClearCurrentPage(); + + const cssVarName = `--my-background-color-${Date.now()}`; + + const stack = new StackLayout(); + stack.css = ` + Label.lab1 { + box-shadow: 10 10 5 12 var(${cssVarName}); + color: black; + }`; + + const label = new Label(); + page.content = stack; + stack.addChild(label); + + label.className = 'lab1'; + + TKUnit.assertEqual(label.style.boxShadow, undefined, 'the css variable is undefined'); +} + export function test_css_calc_and_variables() { const page = helper.getClearCurrentPage(); @@ -1969,6 +1990,7 @@ export function test_css_calc_and_variables() { export function test_css_variable_fallback() { const redColor = '#FF0000'; + const greeColor = '#008000'; const blueColor = '#0000FF'; const limeColor = new Color('lime').hex; const yellowColor = new Color('yellow').hex; @@ -1996,7 +2018,7 @@ export function test_css_variable_fallback() { }, { className: 'undefined-css-variable-with-multiple-fallbacks', - expectedColor: limeColor, + expectedColor: greeColor, }, { className: 'undefined-css-variable-with-missing-fallback-value', @@ -2036,8 +2058,7 @@ export function test_css_variable_fallback() { } .undefined-css-variable-with-multiple-fallbacks { - --my-fallback-var: lime; - color: var(--undefined-var, var(--my-fallback-var), yellow); /* resolved as color: lime; */ + color: var(--undefined-var, var(--my-fallback-var), green); /* resolved as color: green; */ } .undefined-css-variable-with-missing-fallback-value { diff --git a/packages/core/ui/core/properties/index.ts b/packages/core/ui/core/properties/index.ts index 6ed0776772..bc193b8365 100644 --- a/packages/core/ui/core/properties/index.ts +++ b/packages/core/ui/core/properties/index.ts @@ -48,6 +48,7 @@ export interface CssAnimationPropertyOptions { const cssPropertyNames: string[] = []; const symbolPropertyMap = {}; const cssSymbolPropertyMap = {}; +const cssErrorVarPlaceHolder = '&error_var'; const inheritableProperties = new Array>(); const inheritableCssProperties = new Array>(); @@ -107,20 +108,12 @@ export function isCssWideKeyword(value: any): value is CoreTypes.CSSWideKeywords return value === 'initial' || value === 'inherit' || isCssUnsetValue(value); } -export function _evaluateCssVariableExpression(view: ViewBase, cssName: string, value: string): string { - if (typeof value !== 'string') { - return value; - } - - if (!isCssVariableExpression(value)) { - // Value is not using css-variable(s) - return value; - } - +export function _evaluateCssVariableExpression(view: ViewBase, value: string, onCssVarExpressionParse?: (cssVarName: string) => void): string { let output = value.trim(); - // Evaluate every (and nested) css-variables in the value. + // Evaluate every (and nested) css-variables in the value let lastValue: string; + while (lastValue !== output) { lastValue = output; @@ -140,31 +133,35 @@ export function _evaluateCssVariableExpression(view: ViewBase, cssName: string, .map((v) => v.trim()) .filter((v) => !!v); const cssVariableName = matched.shift(); + + // Execute the callback early to allow operations like preloading missing variables + if (onCssVarExpressionParse) { + onCssVarExpressionParse(cssVariableName); + } + let cssVariableValue = view.style.getCssVariable(cssVariableName); - if (cssVariableValue === null && matched.length) { - cssVariableValue = _evaluateCssVariableExpression(view, cssName, matched.join(', ')).split(',')[0]; + if (cssVariableValue == null && matched.length) { + for (const cssVal of matched) { + if (cssVal && !cssVal.includes(cssErrorVarPlaceHolder)) { + cssVariableValue = cssVal; + break; + } + } } if (!cssVariableValue) { - cssVariableValue = 'unset'; + cssVariableValue = cssErrorVarPlaceHolder; } output = `${output.substring(0, idx)}${cssVariableValue}${output.substring(endIdx + 1)}`; } - return output; + // If at least one variable failed to resolve, discard the whole expression + return output.includes(cssErrorVarPlaceHolder) ? undefined : output; } export function _evaluateCssCalcExpression(value: string) { - if (typeof value !== 'string') { - return value; - } - - if (isCssCalcExpression(value)) { - return require('@csstools/css-calc').calc(_replaceKeywordsWithValues(_replaceDip(value))); - } else { - return value; - } + return require('@csstools/css-calc').calc(_replaceKeywordsWithValues(_replaceDip(value))); } function _replaceDip(value: string) { diff --git a/packages/core/ui/styling/style-scope.ts b/packages/core/ui/styling/style-scope.ts index 08eaecd397..2e902ece15 100644 --- a/packages/core/ui/styling/style-scope.ts +++ b/packages/core/ui/styling/style-scope.ts @@ -63,22 +63,29 @@ const kebabCasePattern = /-([a-z])/g; const pattern = /('|")(.*?)\1/; /** - * Evaluate css-variable and css-calc expressions + * Evaluate css-variable and css-calc expressions. */ -function evaluateCssExpressions(view: ViewBase, property: string, value: string) { - const newValue = _evaluateCssVariableExpression(view, property, value); - if (newValue === 'unset') { - return unsetValue; +function evaluateCssExpressions(view: ViewBase, property: string, value: string, onCssVarExpressionParse?: (cssVarName: string) => void) { + if (typeof value !== 'string') { + return value; } - value = newValue; + if (isCssVariableExpression(value)) { + const newValue = _evaluateCssVariableExpression(view, value, onCssVarExpressionParse); + if (newValue === undefined) { + return unsetValue; + } - try { - value = _evaluateCssCalcExpression(value); - } catch (e) { - Trace.write(`Failed to evaluate css-calc for property [${property}] for expression [${value}] to ${view}. ${e.stack}`, Trace.categories.Error, Trace.messageType.error); + value = newValue; + } - return unsetValue; + if (isCssCalcExpression(value)) { + try { + value = _evaluateCssCalcExpression(value); + } catch (e) { + Trace.write(`Failed to evaluate css-calc for property [${property}] for expression [${value}] to ${view}. ${e.stack}`, Trace.categories.Error, Trace.messageType.error); + return unsetValue; + } } return value; @@ -710,48 +717,63 @@ export class CssState { const valuesToApply = {}; const cssExpsProperties = {}; const replacementFunc = (g) => g[1].toUpperCase(); + const onCssVarExpressionParse = (cssVarName: string) => { + // If variable name is still in the property bag, parse its value and apply it to the view + if (cssVarName in cssExpsProperties) { + cssExpsPropsCallback(cssVarName); + } + }; + // This callback is also used for handling nested css variable expressions + const cssExpsPropsCallback = (property: string) => { + let value = cssExpsProperties[property]; - for (const property in newPropertyValues) { - const value = cleanupImportantFlags(newPropertyValues[property], property); + // Remove the property first to avoid recalculating it in a later step using lazy loading + delete cssExpsProperties[property]; - const isCssExp = isCssVariableExpression(value) || isCssCalcExpression(value); + value = evaluateCssExpressions(view, property, value, onCssVarExpressionParse); - if (isCssExp) { - // we handle css exp separately because css vars must be evaluated first - cssExpsProperties[property] = value; - continue; - } - delete oldProperties[property]; - if (property in oldProperties && oldProperties[property] === value) { - // Skip unchanged values - continue; + if (value === unsetValue) { + delete newPropertyValues[property]; } + if (isCssVariable(property)) { view.style.setScopedCssVariable(property, value); delete newPropertyValues[property]; - continue; } + valuesToApply[property] = value; - } - //we need to parse CSS vars first before evaluating css expressions - for (const property in cssExpsProperties) { + }; + + for (const property in newPropertyValues) { + const value = cleanupImportantFlags(newPropertyValues[property], property); + const isCssExp = isCssVariableExpression(value) || isCssCalcExpression(value); + + // We can skip the unset part of old values since they will be overwritten by new values delete oldProperties[property]; - const value = evaluateCssExpressions(view, property, cssExpsProperties[property]); - if (property in oldProperties && oldProperties[property] === value) { - // Skip unchanged values + + if (isCssExp) { + // We handle css exp separately because css vars must be evaluated first + cssExpsProperties[property] = value; continue; } - if (value === unsetValue) { - delete newPropertyValues[property]; - } + if (isCssVariable(property)) { view.style.setScopedCssVariable(property, value); delete newPropertyValues[property]; + continue; } - valuesToApply[property] = value; } + const cssExpsPropKeys = Object.keys(cssExpsProperties); + + // We need to parse CSS vars first before evaluating css expressions + for (const property of cssExpsPropKeys) { + if (property in cssExpsProperties) { + cssExpsPropsCallback(property); + } + } + // Unset removed values for (const property in oldProperties) { if (property in view.style) { From 80e28cfbb879842bf10b7e42208ae11b9e74d33c Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Sat, 26 Jul 2025 21:54:29 +0300 Subject: [PATCH 2/7] chore: Added missing automated test --- apps/automated/src/ui/styling/style-tests.ts | 34 ++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/automated/src/ui/styling/style-tests.ts b/apps/automated/src/ui/styling/style-tests.ts index 2d7bc6453e..b3326b5b23 100644 --- a/apps/automated/src/ui/styling/style-tests.ts +++ b/apps/automated/src/ui/styling/style-tests.ts @@ -1948,6 +1948,36 @@ export function test_undefined_css_variable_invalidates_entire_expression() { TKUnit.assertEqual(label.style.boxShadow, undefined, 'the css variable is undefined'); } +export function test_css_variable_that_resolves_to_another_css_variable_order_desc() { + const page = helper.getClearCurrentPage(); + + const cssVarName = `--my-var1-${Date.now()}`; + const cssVarName2 = `--my-var2-${Date.now()}`; + const cssVarName3 = `--my-var3-${Date.now()}`; + const greenColor = '#008000'; + + const stack = new StackLayout(); + stack.css = ` + StackLayout.var { + background-color: var(${cssVarName3}); + } + StackLayout.var { + ${cssVarName3}: var(${cssVarName2}); + } + StackLayout.var { + ${cssVarName2}: var(${cssVarName}); + } + StackLayout.var { + ${cssVarName}: ${greenColor}; + } + `; + + stack.className = 'var'; + page.content = stack; + + TKUnit.assertEqual(stack.style.backgroundColor.hex, greenColor, 'Failed to resolve css variable of css variable'); +} + export function test_css_calc_and_variables() { const page = helper.getClearCurrentPage(); @@ -1990,7 +2020,7 @@ export function test_css_calc_and_variables() { export function test_css_variable_fallback() { const redColor = '#FF0000'; - const greeColor = '#008000'; + const greenColor = '#008000'; const blueColor = '#0000FF'; const limeColor = new Color('lime').hex; const yellowColor = new Color('yellow').hex; @@ -2018,7 +2048,7 @@ export function test_css_variable_fallback() { }, { className: 'undefined-css-variable-with-multiple-fallbacks', - expectedColor: greeColor, + expectedColor: greenColor, }, { className: 'undefined-css-variable-with-missing-fallback-value', From 030e4adfa998aa13981e6681f85314a83cb7904d Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Sat, 26 Jul 2025 21:58:33 +0300 Subject: [PATCH 3/7] chore: null check --- apps/automated/src/ui/styling/style-tests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/automated/src/ui/styling/style-tests.ts b/apps/automated/src/ui/styling/style-tests.ts index b3326b5b23..dfa4394610 100644 --- a/apps/automated/src/ui/styling/style-tests.ts +++ b/apps/automated/src/ui/styling/style-tests.ts @@ -1975,7 +1975,7 @@ export function test_css_variable_that_resolves_to_another_css_variable_order_de stack.className = 'var'; page.content = stack; - TKUnit.assertEqual(stack.style.backgroundColor.hex, greenColor, 'Failed to resolve css variable of css variable'); + TKUnit.assertEqual(stack.style.backgroundColor?.hex, greenColor, 'Failed to resolve css variable of css variable'); } export function test_css_calc_and_variables() { From 95b223375a0ad5ac77e7d30ee88ea30232450750 Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Thu, 7 Aug 2025 19:45:59 +0300 Subject: [PATCH 4/7] ref: Added new css var function parser --- packages/core/css/css-var-func-parser.spec.ts | 100 ++++++++++++ packages/core/css/css-var-func-parser.ts | 142 ++++++++++++++++++ packages/core/ui/core/properties/index.ts | 53 +------ packages/core/ui/styling/style-scope.ts | 26 ++-- 4 files changed, 260 insertions(+), 61 deletions(-) create mode 100644 packages/core/css/css-var-func-parser.spec.ts create mode 100644 packages/core/css/css-var-func-parser.ts diff --git a/packages/core/css/css-var-func-parser.spec.ts b/packages/core/css/css-var-func-parser.spec.ts new file mode 100644 index 0000000000..f9c00f95dc --- /dev/null +++ b/packages/core/css/css-var-func-parser.spec.ts @@ -0,0 +1,100 @@ +import { parseCssVariableExpression } from './css-var-func-parser'; + +describe('css-var-func-parser', () => { + it('basic css variable resolution', () => { + const cssVal = 'red'; + const value = parseCssVariableExpression('var(--bg-color)', (cssVarName) => cssVal); + + expect(value).toBe('red'); + }); + + it('css expression with list of values', () => { + const cssVal = 'red'; + const value = parseCssVariableExpression('25 solid var(--bg-color)', (cssVarName) => cssVal); + + expect(value).toBe('25 solid red'); + }); + + it('basic css variable resolution failure', () => { + const value = parseCssVariableExpression('var(--bg-color)', null); + + expect(value).toBe('unset'); + }); + + it('css expression with list of values failure', () => { + expect(() => parseCssVariableExpression('25 solid var(--bg-color)', null)).toThrowError('var(--bg-color)'); + }); + + it('css variable with basic fallback', () => { + const value = parseCssVariableExpression('var(--bg-color, yellow)', null); + + expect(value).toBe('yellow'); + }); + + it('css variable with css variable fallback', () => { + const fallbackVal = 'blue'; + const value = parseCssVariableExpression('var(--bg-color, var(--test))', (cssVarName) => { + if (cssVarName === '--test') { + return fallbackVal; + } + }); + + expect(value).toBe(fallbackVal); + }); + + it('css variable with multiple fallbacks', () => { + const value = parseCssVariableExpression('var(--bg-color, var(--test), var(--test2), purple)', null); + + expect(value).toBe('purple'); + }); + + it('css variable with deeply nested css variable fallback', () => { + const fallbackVal = 'black'; + const value = parseCssVariableExpression('var(--bg-color, var(--test, var(--test2, var(--test3, red))))', (cssVarName) => { + if (cssVarName === '--test3') { + return fallbackVal; + } + }); + + expect(value).toBe(fallbackVal); + }); + + it('css expression with list of comma-separated values', () => { + const expression = 'var(--shadow-color) var(--shadow-offset-x) var(--shadow-offset-y), var(--shadow-inset-color) var(--shadow-inset-x) var(--shadow-inset-y)'; + const value = parseCssVariableExpression(expression, (cssVarName) => { + switch (cssVarName) { + case '--shadow-color': + return 'green'; + case '--shadow-offset-x': + return '5'; + case '--shadow-offset-y': + return '5'; + case '--shadow-inset-color': + return 'yellow'; + case '--shadow-inset-x': + return '15'; + case '--shadow-inset-y': + return '15'; + } + }); + + expect(value).toBe('green 5 5, yellow 15 15'); + }); + + it('css expression with list of comma-separated values failure', () => { + const expression = 'var(--shadow-color) var(--shadow-offset-x) var(--shadow-offset-y), var(--shadow-inset-color) var(--shadow-inset-x) var(--shadow-inset-y)'; + + expect(() => + parseCssVariableExpression(expression, (cssVarName) => { + switch (cssVarName) { + case '--shadow-color': + return 'green'; + case '--shadow-offset-x': + return '5'; + case '--shadow-offset-y': + return '5'; + } + }), + ).toThrowError('var(--shadow-inset-color)'); + }); +}); diff --git a/packages/core/css/css-var-func-parser.ts b/packages/core/css/css-var-func-parser.ts new file mode 100644 index 0000000000..df6e4490cd --- /dev/null +++ b/packages/core/css/css-var-func-parser.ts @@ -0,0 +1,142 @@ +import { tokenize, TokenType } from '@csstools/css-tokenizer'; +import { ComponentValue, FunctionNode, isCommentNode, isFunctionNode, isTokenNode, parseCommaSeparatedListOfComponentValues, parseListOfComponentValues, stringify, walk } from '@csstools/css-parser-algorithms'; + +const functionName: string = 'var'; + +export function parseCssVariableExpression(value: string, replaceWithCallback: (cssVarName: string) => string): string { + const componentValueSet = parseCommaSeparatedListOfComponentValues( + tokenize({ + css: value, + }), + ); + + for (const componentValues of componentValueSet) { + let unresolvedNode: ComponentValue = null; + + walk(componentValues, (entry, index) => { + const componentValue = entry.node; + + if (isFunctionNode(componentValue) && componentValue.getName() === functionName) { + const newNode = _parseCssVarFunctionArguments(componentValue, replaceWithCallback); + const parentNodes = entry.parent.value; + + if (!newNode || !_replaceNode(index, newNode, parentNodes)) { + unresolvedNode = componentValue; + return false; + } + } + }); + + if (unresolvedNode) { + const isSingleNode = componentValueSet.length === 1 && componentValueSet[0].length === 1 && componentValueSet[0][0] === unresolvedNode; + if (!isSingleNode) { + throw new Error(`Failed to resolve CSS variable '${unresolvedNode}' for expression: '${value}'`); + } + + return 'unset'; + } + } + + return stringify(componentValueSet).trim(); +} + +/** + * Parses css variable functions and their fallback values. + * + * @param node + * @param replaceWithCallback + * @returns + */ +function _parseCssVarFunctionArguments(node: FunctionNode, replaceWithCallback: (cssVarName: string) => string): ComponentValue | ComponentValue[] { + let cssVarName: string = null; + let finalValue: ComponentValue | ComponentValue[] = null; + let currentFallbackValues: ComponentValue[]; + + const fallbackValueSet: ComponentValue[][] = []; + + node.forEach((entry) => { + const childNode = entry.node; + + if (isCommentNode(childNode)) { + return; + } + + if (isTokenNode(childNode)) { + const tokens = childNode.value; + + if (tokens[0] === TokenType.Ident) { + if (tokens[1].startsWith('--')) { + if (fallbackValueSet.length) { + throw new Error('Invalid css variable function fallback value: ' + childNode); + } else { + // This is the first parsable parameter + cssVarName = tokens[1]; + } + return; + } + } + + // Track the fallback comma-separated values + if (tokens[0] === TokenType.Comma) { + currentFallbackValues = []; + fallbackValueSet.push(currentFallbackValues); + return; + } + } + + if (currentFallbackValues) { + currentFallbackValues.push(childNode); + } + }); + + // Resolve the css variable here and use a fallback value if it fails + const cssVarValue = replaceWithCallback?.(cssVarName); + + if (cssVarValue) { + finalValue = parseListOfComponentValues( + tokenize({ + css: cssVarValue, + }), + ); + } else { + // Switch to the first available fallback value if any + for (const componentValues of fallbackValueSet) { + let isValidFallback: boolean = true; + + walk(componentValues, (entry, index) => { + const componentValue = entry.node; + + if (isFunctionNode(componentValue) && componentValue.getName() === functionName) { + const nodeResult = _parseCssVarFunctionArguments(componentValue, replaceWithCallback); + const parentNodes = entry.parent.value; + + if (!nodeResult || !_replaceNode(index, nodeResult, parentNodes)) { + isValidFallback = false; + return false; // Invalid fallback + } + } + }); + + if (isValidFallback) { + finalValue = componentValues; + break; + } + } + } + + return finalValue; +} + +function _replaceNode(index: string | number, newNode: ComponentValue | ComponentValue[], nodes: ComponentValue[]): boolean { + if (typeof index !== 'number') { + return false; + } + + if (Array.isArray(newNode)) { + nodes.splice(index, 1, ...newNode); + } else { + nodes.splice(index, 1, newNode); + } + + return true; +} diff --git a/packages/core/ui/core/properties/index.ts b/packages/core/ui/core/properties/index.ts index bc193b8365..d73e641de1 100644 --- a/packages/core/ui/core/properties/index.ts +++ b/packages/core/ui/core/properties/index.ts @@ -6,6 +6,7 @@ import { Style } from '../../styling/style'; import { profile } from '../../../profiling'; import { CoreTypes } from '../../enums'; +import { parseCssVariableExpression } from '../../../css/css-var-func-parser'; /** * Value specifying that Property should be set to its initial value. @@ -108,56 +109,8 @@ export function isCssWideKeyword(value: any): value is CoreTypes.CSSWideKeywords return value === 'initial' || value === 'inherit' || isCssUnsetValue(value); } -export function _evaluateCssVariableExpression(view: ViewBase, value: string, onCssVarExpressionParse?: (cssVarName: string) => void): string { - let output = value.trim(); - - // Evaluate every (and nested) css-variables in the value - let lastValue: string; - - while (lastValue !== output) { - lastValue = output; - - const idx = output.lastIndexOf('var('); - if (idx === -1) { - continue; - } - - const endIdx = output.indexOf(')', idx); - if (endIdx === -1) { - continue; - } - - const matched = output - .substring(idx + 4, endIdx) - .split(',') - .map((v) => v.trim()) - .filter((v) => !!v); - const cssVariableName = matched.shift(); - - // Execute the callback early to allow operations like preloading missing variables - if (onCssVarExpressionParse) { - onCssVarExpressionParse(cssVariableName); - } - - let cssVariableValue = view.style.getCssVariable(cssVariableName); - if (cssVariableValue == null && matched.length) { - for (const cssVal of matched) { - if (cssVal && !cssVal.includes(cssErrorVarPlaceHolder)) { - cssVariableValue = cssVal; - break; - } - } - } - - if (!cssVariableValue) { - cssVariableValue = cssErrorVarPlaceHolder; - } - - output = `${output.substring(0, idx)}${cssVariableValue}${output.substring(endIdx + 1)}`; - } - - // If at least one variable failed to resolve, discard the whole expression - return output.includes(cssErrorVarPlaceHolder) ? undefined : output; +export function _evaluateCssVariableExpression(value: string, cssVarResolveCallback?: (cssVarName: string) => string): string { + return parseCssVariableExpression(value, cssVarResolveCallback); } export function _evaluateCssCalcExpression(value: string) { diff --git a/packages/core/ui/styling/style-scope.ts b/packages/core/ui/styling/style-scope.ts index 2e902ece15..20935e340e 100644 --- a/packages/core/ui/styling/style-scope.ts +++ b/packages/core/ui/styling/style-scope.ts @@ -65,25 +65,29 @@ const pattern = /('|")(.*?)\1/; /** * Evaluate css-variable and css-calc expressions. */ -function evaluateCssExpressions(view: ViewBase, property: string, value: string, onCssVarExpressionParse?: (cssVarName: string) => void) { +function evaluateCssExpressions(view: ViewBase, property: string, value: string, cssVarResolveCallback: (cssVarName: string) => string) { if (typeof value !== 'string') { return value; } if (isCssVariableExpression(value)) { - const newValue = _evaluateCssVariableExpression(view, value, onCssVarExpressionParse); - if (newValue === undefined) { + try { + value = _evaluateCssVariableExpression(value, cssVarResolveCallback); + } catch (e) { + if (e instanceof Error) { + Trace.write(`Failed to evaluate css-var for property [${property}] for expression [${value}] to ${view}. ${e.message}`, Trace.categories.Style, Trace.messageType.warn); + } return unsetValue; } - - value = newValue; } if (isCssCalcExpression(value)) { try { value = _evaluateCssCalcExpression(value); } catch (e) { - Trace.write(`Failed to evaluate css-calc for property [${property}] for expression [${value}] to ${view}. ${e.stack}`, Trace.categories.Error, Trace.messageType.error); + if (e instanceof Error) { + Trace.write(`Failed to evaluate css-calc for property [${property}] for expression [${value}] to ${view}. ${e.message}`, Trace.categories.Style, Trace.messageType.error); + } return unsetValue; } } @@ -717,11 +721,12 @@ export class CssState { const valuesToApply = {}; const cssExpsProperties = {}; const replacementFunc = (g) => g[1].toUpperCase(); - const onCssVarExpressionParse = (cssVarName: string) => { + const cssVarResolveCallback = (cssVarName: string) => { // If variable name is still in the property bag, parse its value and apply it to the view if (cssVarName in cssExpsProperties) { cssExpsPropsCallback(cssVarName); } + return view.style.getCssVariable(cssVarName); }; // This callback is also used for handling nested css variable expressions const cssExpsPropsCallback = (property: string) => { @@ -730,7 +735,7 @@ export class CssState { // Remove the property first to avoid recalculating it in a later step using lazy loading delete cssExpsProperties[property]; - value = evaluateCssExpressions(view, property, value, onCssVarExpressionParse); + value = evaluateCssExpressions(view, property, value, cssVarResolveCallback); if (value === unsetValue) { delete newPropertyValues[property]; @@ -765,9 +770,8 @@ export class CssState { valuesToApply[property] = value; } + // Keep a copy of css expressions keys and iterate that while removing properties from the bag const cssExpsPropKeys = Object.keys(cssExpsProperties); - - // We need to parse CSS vars first before evaluating css expressions for (const property of cssExpsPropKeys) { if (property in cssExpsProperties) { cssExpsPropsCallback(property); @@ -1159,7 +1163,7 @@ export const applyInlineStyle = profile(function applyInlineStyle(view: ViewBase return; } - const value = evaluateCssExpressions(view, property, d.value); + const value = evaluateCssExpressions(view, property, d.value, (cssVarName) => view.style.getCssVariable(cssVarName)); if (property in view.style) { view.style[property] = value; } else { From 0fc2f4269334ae7b7fd65f69ff1a2414aaf82ba8 Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Thu, 7 Aug 2025 19:47:47 +0300 Subject: [PATCH 5/7] chore: Removed unused variable --- packages/core/ui/core/properties/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/ui/core/properties/index.ts b/packages/core/ui/core/properties/index.ts index d73e641de1..c568690086 100644 --- a/packages/core/ui/core/properties/index.ts +++ b/packages/core/ui/core/properties/index.ts @@ -49,7 +49,6 @@ export interface CssAnimationPropertyOptions { const cssPropertyNames: string[] = []; const symbolPropertyMap = {}; const cssSymbolPropertyMap = {}; -const cssErrorVarPlaceHolder = '&error_var'; const inheritableProperties = new Array>(); const inheritableCssProperties = new Array>(); From 4550befe8d3ccb6bcdd3b6aa85ceb073c394f6fc Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Thu, 7 Aug 2025 19:50:35 +0300 Subject: [PATCH 6/7] chore: Inline style improvement --- packages/core/ui/styling/style-scope.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/ui/styling/style-scope.ts b/packages/core/ui/styling/style-scope.ts index 20935e340e..b636f2c938 100644 --- a/packages/core/ui/styling/style-scope.ts +++ b/packages/core/ui/styling/style-scope.ts @@ -1141,6 +1141,7 @@ function resolveFilePathFromImport(importSource: string, fileName: string): stri export const applyInlineStyle = profile(function applyInlineStyle(view: ViewBase, styleStr: string) { const localStyle = `local { ${styleStr} }`; const inlineRuleSet = CSSSource.fromSource(localStyle).selectors; + const cssVarResolveCallback = (cssVarName: string) => view.style.getCssVariable(cssVarName); // Reset unscoped css-variables view.style.resetUnscopedCssVariables(); @@ -1163,7 +1164,7 @@ export const applyInlineStyle = profile(function applyInlineStyle(view: ViewBase return; } - const value = evaluateCssExpressions(view, property, d.value, (cssVarName) => view.style.getCssVariable(cssVarName)); + const value = evaluateCssExpressions(view, property, d.value, cssVarResolveCallback); if (property in view.style) { view.style[property] = value; } else { From 3990c2ce4fa61ef7ea8b02b6b7649de9011fc5d4 Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Thu, 7 Aug 2025 20:34:42 +0300 Subject: [PATCH 7/7] chore: Added automated test --- apps/automated/src/ui/styling/style-tests.ts | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/automated/src/ui/styling/style-tests.ts b/apps/automated/src/ui/styling/style-tests.ts index dfa4394610..0a42a0022e 100644 --- a/apps/automated/src/ui/styling/style-tests.ts +++ b/apps/automated/src/ui/styling/style-tests.ts @@ -1948,6 +1948,36 @@ export function test_undefined_css_variable_invalidates_entire_expression() { TKUnit.assertEqual(label.style.boxShadow, undefined, 'the css variable is undefined'); } +export function test_css_variable_with_another_css_variable_as_value() { + const page = helper.getClearCurrentPage(); + const redColor = '#FF0000'; + const cssVarName = `--my-background-color-${Date.now()}`; + const cssShadowVarName = `--my-shadow-color-${Date.now()}`; + + const stack = new StackLayout(); + stack.css = ` + StackLayout { + ${cssVarName}: ${redColor}; + } + + Label { + ${cssShadowVarName}: var(${cssVarName}); + } + + Label.lab1 { + box-shadow: 10 10 5 12 var(${cssShadowVarName}); + color: black; + }`; + + const label = new Label(); + page.content = stack; + stack.addChild(label); + + label.className = 'lab1'; + + TKUnit.assertEqual(label.style.boxShadow?.color?.hex, redColor, 'Failed to resolve css expression variable'); +} + export function test_css_variable_that_resolves_to_another_css_variable_order_desc() { const page = helper.getClearCurrentPage();