Skip to content
Merged
206 changes: 206 additions & 0 deletions packages/core/__tests__/benchmarks/css-state.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { bench, describe } from 'vitest';

import { StyleScope, addTaggedAdditionalCSS, removeTaggedAdditionalCSS } from '../../ui/styling/style-scope';
import { StackLayout } from '../../ui/layouts/stack-layout';
import { Label } from '../../ui/label';
import type { ViewBase } from '../../ui/core/view-base';

/**
* A stylesheet shaped like a themed app (the kind `@nativescript/theme` ships).
*/
function themeCss(): string {
const parts: string[] = ['* { font-family: sans-serif; }'];
const types = ['label', 'button', 'stacklayout', 'image', 'textfield'];

for (const type of types) {
parts.push(`${type} { color: #222222; font-size: 14; }`);
parts.push(`${type}.accent { color: #0088cc; }`);
parts.push(`${type}:highlighted { background-color: #eeeeee; }`);
}

for (let i = 0; i < 80; i++) {
parts.push(`.cls-${i} { margin: ${i % 20}; padding: ${i % 10}; }`);
parts.push(`.cls-${i} .child-${i} { color: #333333; }`);
}

return parts.join('\n');
}

/**
* Component-level CSS the way Angular's emulated encapsulation emits it:
* every rule carries a `[_ngcontent-cN]` attribute selector.
*/
function componentCss(componentIndex: number): string {
const attr = `_ngcontent-c${componentIndex}`;

return [`.title[${attr}] { color: #111111; font-size: 18; }`, `.body[${attr}] { color: #444444; font-size: 13; }`, `label[${attr}] { margin: 2; }`].join('\n');
}

const COMPONENT_COUNT = 30;

function buildTree(viewCount: number, ngComponentIndex?: number): { root: StackLayout; views: ViewBase[] } {
const root = new StackLayout();
const views: ViewBase[] = [];

let current: StackLayout = root;
for (let i = 0; i < viewCount; i++) {
if (i % 8 === 0) {
const layout = new StackLayout();
current.addChild(layout);
current = layout;
views.push(layout);
continue;
}

const view = new Label();
view.className = `cls-${i % 80} ${i % 5 === 0 ? 'accent' : 'title'}`;
current.addChild(view);
views.push(view);
}

if (ngComponentIndex !== undefined) {
// Angular's EmulatedRenderer sets a content attribute on every element it creates.
const attr = `_ngcontent-c${ngComponentIndex}`;
for (const view of views) {
view[attr] = '';
}
}

return { root, views };
}

function attachScope(root: StackLayout, views: ViewBase[], scope: StyleScope): void {
root._styleScope = scope;
for (const view of views) {
view._styleScope = scope;
}
}

function styleTree(views: ViewBase[]): void {
for (const view of views) {
// The load path: match selectors, subscribe for dynamic updates, apply values.
view._cssState.onLoaded();
}
}

function restyleTree(views: ViewBase[]): void {
for (const view of views) {
view._cssState.onChange();
view._cssState.onLoaded();
}
}

describe('css state - initial styling', () => {
const scope = new StyleScope();
scope.css = themeCss();

bench('style a 400 view tree (plain css)', () => {
const { root, views } = buildTree(400);
attachScope(root, views, scope);
styleTree(views);
});

const ngScope = new StyleScope();
ngScope.css = themeCss() + '\n' + Array.from({ length: COMPONENT_COUNT }, (_, i) => componentCss(i)).join('\n');

bench('style a 400 view tree (angular attribute-scoped css)', () => {
const { root, views } = buildTree(400, 3);
attachScope(root, views, ngScope);
styleTree(views);
});
});

describe('css state - dynamic updates', () => {
const scope = new StyleScope();
scope.css = themeCss();

const { root, views } = buildTree(200);
attachScope(root, views, scope);
styleTree(views);

bench('re-apply matched properties for 200 views (no actual change)', () => {
for (const view of views) {
(view._cssState as any).updateDynamicState();
}
});

const classTree = buildTree(200);
attachScope(classTree.root, classTree.views, scope);
for (const view of classTree.views) {
// The class-change path only runs for loaded views.
(view as any)._isLoaded = true;
}
styleTree(classTree.views);

bench('toggle a class on 200 loaded views', () => {
for (const view of classTree.views) {
view.className = `${view.className} accent`;
}
for (const view of classTree.views) {
view.className = view.className.replace(' accent', '');
}
});

bench('toggle a pseudo class on 200 views', () => {
for (const view of views) {
view.addPseudoClass('highlighted');
}
for (const view of views) {
view.deletePseudoClass('highlighted');
}
});
});

describe('css state - adding css at runtime (angular component styles)', () => {
bench('add 30 component stylesheets and restyle a 200 view tree', () => {
const scope = new StyleScope();
scope.css = themeCss();

const { root, views } = buildTree(200, 3);
attachScope(root, views, scope);
styleTree(views);

for (let i = 0; i < COMPONENT_COUNT; i++) {
addTaggedAdditionalCSS(componentCss(i), `bench-component-${i}`);
// Angular creates the component's views right after registering its styles,
// which forces the scope to rebuild and every view to re-match.
restyleTree(views);
}

for (let i = 0; i < COMPONENT_COUNT; i++) {
removeTaggedAdditionalCSS(`bench-component-${i}`);
}
});
});

describe('css state - angular component loading', () => {
// What actually happens when an Angular app navigates: each component registers
// its (attribute scoped) styles the first time it is used, then immediately
// creates and styles its own views.
bench('load 30 components (register styles + style 20 views each)', () => {
const scope = new StyleScope();
scope.css = themeCss();

for (let i = 0; i < COMPONENT_COUNT; i++) {
addTaggedAdditionalCSS(componentCss(i), `bench-ng-${i}`);

const { root, views } = buildTree(20, i);
attachScope(root, views, scope);
styleTree(views);
}

for (let i = 0; i < COMPONENT_COUNT; i++) {
removeTaggedAdditionalCSS(`bench-ng-${i}`);
}
});
});

describe('css state - selector scope construction', () => {
const css = themeCss();

bench('build a StyleScope from a themed stylesheet', () => {
const scope = new StyleScope();
scope.css = css;
scope.ensureSelectors();
});
});
124 changes: 112 additions & 12 deletions packages/core/ui/core/properties/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { calc } from '@csstools/css-calc';
export { unsetValue } from './property-shared';

const cssPropertyNames: string[] = [];
const cssShorthandConverters = new Map<string, (value: string) => [any, any][]>();
const cssShorthandLonghands = new Map<string, string[]>();
const HAS_OWN = Object.prototype.hasOwnProperty;
const symbolPropertyMap = {};
const cssSymbolPropertyMap = {};
Expand Down Expand Up @@ -56,6 +58,113 @@ export function _getStyleProperties(): CssProperty<any, any>[] {
return getPropertiesFromMap(cssSymbolPropertyMap) as CssProperty<any, any>[];
}

/**
* Placeholder cascaded for each longhand of a shorthand that cannot be split while
* parsing - a single `var()` may substitute several longhands at once, so the
* shorthand is only parsed once its expression is resolved per view.
* @see https://drafts.csswg.org/css-variables/#variables-in-shorthands
*/
export class CssPendingSubstitution {
constructor(
public readonly shorthand: string,
public readonly value: string,
) {}

toString(): string {
return this.value;
}
}

export function _isCssPendingSubstitution(value: unknown): value is CssPendingSubstitution {
return value instanceof CssPendingSubstitution;
}

/**
* The longhands a shorthand expands into, probed from its converter on first use -
* the longhand properties a converter closes over are not initialized yet while
* the shorthand itself is being registered.
*/
function getCssShorthandLonghands(cssName: string): string[] | undefined {
const known = cssShorthandLonghands.get(cssName);
if (known) {
return known;
}

const converter = cssShorthandConverters.get(cssName);
if (!converter) {
return undefined;
}

try {
const probed = converter(unsetValue);
if (!probed?.length) {
return undefined;
}

const longhands: string[] = [];
for (let i = 0, length = probed.length; i < length; i++) {
longhands.push(probed[i][0].cssLocalName);
}

cssShorthandLonghands.set(cssName, longhands);

return longhands;
} catch (e) {
Trace.write(`Could not determine the longhands of shorthand [${cssName}]. ${e}`, Trace.categories.Style, Trace.messageType.warn);

return undefined;
}
}

/**
* One pending-substitution value per longhand of the shorthand, or `undefined`
* when the longhands cannot be determined.
*/
export function _pendingCssShorthandSubstitution(cssName: string, value: string): [string, CssPendingSubstitution][] | undefined {
const longhands = getCssShorthandLonghands(cssName);
if (!longhands) {
return undefined;
}

const pending = new CssPendingSubstitution(cssName, value);
const declarations: [string, CssPendingSubstitution][] = [];
for (let i = 0, length = longhands.length; i < length; i++) {
declarations.push([longhands[i], pending]);
}

return declarations;
}

/**
* Expand a shorthand declaration into its longhand declarations, or `undefined`
* when the property is not a shorthand, the value still has to be evaluated per
* view (`var()`/`calc()`), or it does not parse.
*/
export function _expandCssShorthand(cssName: string, value: string): [string, any][] | undefined {
const converter = cssShorthandConverters.get(cssName);
if (!converter) {
return undefined;
}

if (typeof value === 'string' && (isCssVariableExpression(value) || isCssCalcExpression(value))) {
return undefined;
}

try {
const converted = converter(value);
const expanded: [string, any][] = [];
for (let i = 0, length = converted.length; i < length; i++) {
expanded.push([converted[i][0].cssLocalName, converted[i][1]]);
}

return expanded;
} catch (e) {
Trace.write(`Failed to expand shorthand [${cssName}] with value [${value}]. ${e}`, Trace.categories.Style, Trace.messageType.warn);

return undefined;
}
}

export function isCssVariable(property: string) {
return CSS_VARIABLE_NAME_RE.test(property);
}
Expand Down Expand Up @@ -1227,7 +1336,6 @@ export class ShorthandProperty<T extends Style, P> implements ShorthandProperty<

protected readonly cssValueDescriptor: PropertyDescriptor;
protected readonly localValueDescriptor: PropertyDescriptor;
protected readonly propertyBagDescriptor: PropertyDescriptor;

public readonly sourceKey: symbol;

Expand All @@ -1239,6 +1347,7 @@ export class ShorthandProperty<T extends Style, P> implements ShorthandProperty<

this.cssName = `css:${options.cssName}`;
this.cssLocalName = `${options.cssName}`;
cssShorthandConverters.set(options.cssName, options.converter as (value: string) => [any, any][]);

const converter = options.converter;

Expand Down Expand Up @@ -1286,16 +1395,6 @@ export class ShorthandProperty<T extends Style, P> implements ShorthandProperty<
set: setLocalValue,
};

this.propertyBagDescriptor = {
enumerable: false,
configurable: true,
set(value: string) {
converter(value).forEach(([property, value]) => {
this[property.cssLocalName] = value;
});
},
};

cssSymbolPropertyMap[key] = this;
}

Expand All @@ -1311,7 +1410,8 @@ export class ShorthandProperty<T extends Style, P> implements ShorthandProperty<
Object.defineProperty(cls.prototype, this.cssLocalName, this.localValueDescriptor);
}

Object.defineProperty(cls.prototype.PropertyBag, this.cssLocalName, this.propertyBagDescriptor);
// Nothing is defined on `PropertyBag`: shorthands are expanded while parsing,
// and the var()/calc() ones that do reach the bag must stay unconverted.
}
}

Expand Down
Loading