diff --git a/packages/core/ui/core/properties/index.ts b/packages/core/ui/core/properties/index.ts index 57c95e5593..971136c630 100644 --- a/packages/core/ui/core/properties/index.ts +++ b/packages/core/ui/core/properties/index.ts @@ -14,6 +14,8 @@ export { unsetValue } from './property-shared'; const cssPropertyNames: string[] = []; const cssShorthandConverters = new Map [any, any][]>(); const cssShorthandLonghands = new Map(); +// Bumped per shorthand registration, so rules parsed before it can still expand it. +let cssShorthandsVersion = 0; const HAS_OWN = Object.prototype.hasOwnProperty; const symbolPropertyMap = {}; const cssSymbolPropertyMap = {}; @@ -155,6 +157,16 @@ export function _pendingCssShorthandSubstitution(cssName: string, value: string) return declarations; } +/** Changes whenever a shorthand is registered. */ +export function _getCssShorthandsVersion(): number { + return cssShorthandsVersion; +} + +/** Whether `cssName` is a registered shorthand. */ +export function _isCssShorthand(cssName: string): boolean { + return cssShorthandConverters.has(cssName); +} + /** * 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 @@ -1390,6 +1402,7 @@ export class ShorthandProperty implements ShorthandProperty< this.cssName = `css:${options.cssName}`; this.cssLocalName = `${options.cssName}`; cssShorthandConverters.set(options.cssName, options.converter as (value: string) => [any, any][]); + cssShorthandsVersion++; const converter = options.converter; diff --git a/packages/core/ui/styling/css-selector.spec.ts b/packages/core/ui/styling/css-selector.spec.ts index 63826b9c35..02a720db00 100644 --- a/packages/core/ui/styling/css-selector.spec.ts +++ b/packages/core/ui/styling/css-selector.spec.ts @@ -2,6 +2,7 @@ import { parse } from '../../css/reworkcss.js'; import { Screen } from '../../platform'; import { createSelector, RuleSet, StyleSheetSelectorScope, SelectorTier, fromAstNode, Node, Changes, matchSelectorCandidates } from './css-selector'; import { _populateRules } from './style-scope'; +import { CssProperty, ShorthandProperty } from '../core/properties'; describe('css-selector', () => { it('button[attr]', () => { @@ -447,6 +448,62 @@ describe('css-selector', () => { expect(new Set(rule.declarations.map((d) => d.value)).size).toBe(1); }); + describe('shorthands registered after parsing', () => { + // Plugins usually load after the app CSS has been parsed. + function registerLateShorthand(prefix: string) { + const start = new CssProperty({ name: `${prefix}Start`, cssName: `${prefix}-start` }); + const end = new CssProperty({ name: `${prefix}End`, cssName: `${prefix}-end` }); + new ShorthandProperty({ + name: `${prefix}Sides`, + cssName: `${prefix}-sides`, + getter: () => '', + converter: (value) => { + const [a, b = a] = String(value).split(' '); + return [ + [start, a], + [end, b], + ]; + }, + }); + } + + it('expands them in place on first use, once', () => { + const rule = createOne(`button { color: red; lateone-sides: 1 2; lateone-end: 5; }`); + expect(rule.declarations.map((d) => d.property)).toEqual(['color', 'lateone-sides', 'lateone-end']); + + registerLateShorthand('lateone'); + rule.expandLateShorthands(); + expect(rule.declarations).toEqual([ + { property: 'color', value: 'red' }, + { property: 'lateone-start', value: '1' }, + { property: 'lateone-end', value: '2' }, + { property: 'lateone-end', value: '5' }, + ]); + + const expanded = rule.declarations; + rule.expandLateShorthands(); + expect(rule.declarations).toBe(expanded); + }); + + it('keeps a variable value pending per longhand', () => { + const rule = createOne(`button { latetwo-sides: var(--s); }`); + registerLateShorthand('latetwo'); + rule.expandLateShorthands(); + + expect(rule.declarations.map((d) => d.property)).toEqual(['latetwo-start', 'latetwo-end']); + expect(rule.declarations.map((d) => String(d.value))).toEqual(['var(--s)', 'var(--s)']); + }); + + it('leaves rules without them untouched', () => { + const rule = createOne(`button { margin: 4; color: red; }`); + const declarations = rule.declarations; + registerLateShorthand('latethree'); + rule.expandLateShorthands(); + + expect(rule.declarations).toBe(declarations); + }); + }); + it('strips the unsupported !important flag while parsing', () => { const rule = createOne(`button { color: red !important; }`); expect(rule.declarations).toEqual([{ property: 'color', value: 'red' }]); diff --git a/packages/core/ui/styling/css-selector.ts b/packages/core/ui/styling/css-selector.ts index d09a3b3add..e2b5dbb9c8 100644 --- a/packages/core/ui/styling/css-selector.ts +++ b/packages/core/ui/styling/css-selector.ts @@ -1,6 +1,6 @@ import { parse as convertToCSSWhatSelector, Selector as CSSWhatSelector, DataType as CSSWhatDataType } from 'css-what'; import '../../globals'; -import { _expandCssShorthand, _pendingCssShorthandSubstitution, isCssVariable } from '../core/properties'; +import { _expandCssShorthand, _getCssShorthandsVersion, _isCssShorthand, _pendingCssShorthandSubstitution, isCssVariable } from '../core/properties'; import { isNullOrUndefined } from '../../utils/types'; import { cleanupImportantFlags } from './css-utils'; @@ -919,11 +919,42 @@ export class RuleSet { public tag?: string | number; public scopedTag?: string; + private shorthandsVersion = _getCssShorthandsVersion(); + constructor(selectors: SelectorCore[], declarations: Declaration[]) { this.selectors = selectors; this.declarations = declarations; this.selectors.forEach((sel) => (sel.ruleset = this)); } + + /** Expands, once, shorthands registered after this rule was parsed (e.g. by a plugin). */ + public expandLateShorthands(): void { + const version = _getCssShorthandsVersion(); + if (this.shorthandsVersion === version) { + return; + } + this.shorthandsVersion = version; + + const declarations = this.declarations; + let expanded: Declaration[]; + for (let i = 0, length = declarations.length; i < length; i++) { + const declaration = declarations[i]; + if (!expanded && typeof declaration.value === 'string' && _isCssShorthand(declaration.property)) { + expanded = declarations.slice(0, i); + } + if (!expanded) { + continue; + } + if (typeof declaration.value === 'string') { + pushDeclaration(expanded, declaration.property, declaration.value); + } else { + expanded.push(declaration); + } + } + if (expanded) { + this.declarations = expanded; + } + } public toString(): string { let desc = `${this.selectors.join(', ')} {${this.declarations.map((d, i) => `${i === 0 ? ' ' : ''}${d.property}: ${d.value}`).join('; ')} }`; if (this.mediaQueryString) { @@ -954,8 +985,10 @@ export function fromAstNode(astRule: ReworkCSS.Rule): RuleSet { function appendDeclaration(declarations: Declaration[], decl: ReworkCSS.Declaration): void { const property = isCssVariable(decl.property) ? decl.property : decl.property.toLowerCase(); - const value = cleanupImportantFlags(decl.value, decl.property); + pushDeclaration(declarations, property, cleanupImportantFlags(decl.value, decl.property)); +} +function pushDeclaration(declarations: Declaration[], property: string, value: string): void { // The cascade is defined on longhands: a shorthand declares each of its // longhands in its place, so it is expanded here to keep source order meaningful. const expanded = _expandCssShorthand(property, value); diff --git a/packages/core/ui/styling/style-scope.ts b/packages/core/ui/styling/style-scope.ts index a852366b1c..1fbbfd8b83 100644 --- a/packages/core/ui/styling/style-scope.ts +++ b/packages/core/ui/styling/style-scope.ts @@ -863,7 +863,14 @@ export class CssState { } const newPropertyValues = new view.style.PropertyBag(); - matchingSelectors.forEach((selector) => selector.ruleset.declarations.forEach((declaration) => (newPropertyValues[declaration.property] = declaration.value))); + for (let i = 0, length = matchingSelectors.length; i < length; i++) { + const ruleset = matchingSelectors[i].ruleset; + ruleset.expandLateShorthands(); + const declarations = ruleset.declarations; + for (let j = 0, count = declarations.length; j < count; j++) { + newPropertyValues[declarations[j].property] = declarations[j].value; + } + } const oldProperties = this._appliedPropertyValues; // A local write is the only thing that can drop a value the cascade already