Unify remote element style declarations (#23263)

Front components run in a Web Worker with a fake DOM. Until now the
worker had a hand-rolled `style` object for remote elements and the host
had its own separate CSS-string parser: two implementations of the same
parsing that kept drifting apart (several review rounds fixed edge cases
in one copy but not the other).

What changed:
- One shared `createStyleProxy` now backs `element.style` in the worker,
and one shared `parseCssDeclarations` feeds both the worker proxy and
the host's `parseCssString`. Most of the diff is existing logic split
out of `installStylePropertyOnRemoteElements` into small single-purpose
utils (`splitCssDeclarations`, `stripImportantPriorityFromCssValue`,
`normalizeCssPropertyName`, `formatCssValue`, ...), not new behavior.
- `!important` is stripped from values instead of tracked. Nothing ever
read priorities back, and the host applies styles through React inline
styles, which cannot express `!important`. Rendering note: `color: red
!important` used to reach React as an invalid value (property silently
not applied); it now applies, without the priority.
- Style writes flush to the host synchronously, exactly as on main.
- The parser handles quotes, escapes and parentheses; CSS comments
inside hand-written `cssText` are not supported.

This shared proxy is also the base for the worker `getComputedStyle`
stub in the geometry PR. Second of three PRs splitting the geometry
mirror work.
This commit is contained in:
Raphaël Bosi
2026-07-28 14:59:33 +02:00
committed by GitHub
parent 64001591f2
commit b8e2a6e910
27 changed files with 853 additions and 218 deletions
@@ -0,0 +1,60 @@
const UNITLESS_CSS_PROPERTY_BASE_NAMES = [
'animationIterationCount',
'aspectRatio',
'borderImageOutset',
'borderImageSlice',
'borderImageWidth',
'boxFlex',
'boxFlexGroup',
'boxOrdinalGroup',
'columnCount',
'columns',
'flex',
'flexGrow',
'flexPositive',
'flexShrink',
'flexNegative',
'flexOrder',
'gridArea',
'gridRow',
'gridRowEnd',
'gridRowSpan',
'gridRowStart',
'gridColumn',
'gridColumnEnd',
'gridColumnSpan',
'gridColumnStart',
'fontWeight',
'lineClamp',
'lineHeight',
'opacity',
'order',
'orphans',
'scale',
'tabSize',
'widows',
'zIndex',
'zoom',
'fillOpacity',
'floodOpacity',
'stopOpacity',
'strokeDasharray',
'strokeDashoffset',
'strokeMiterlimit',
'strokeOpacity',
'strokeWidth',
];
const UNITLESS_CSS_PROPERTY_VENDOR_PREFIXES = ['Webkit', 'Moz', 'ms', 'O'];
const withVendorPrefixedAliases = (propertyName: string): string[] => [
propertyName,
...UNITLESS_CSS_PROPERTY_VENDOR_PREFIXES.map(
(vendorPrefix) =>
`${vendorPrefix}${propertyName[0].toUpperCase()}${propertyName.slice(1)}`,
),
];
export const UNITLESS_CSS_PROPERTY_NAMES = new Set(
UNITLESS_CSS_PROPERTY_BASE_NAMES.flatMap(withVendorPrefixedAliases),
);
@@ -1,9 +1,9 @@
import { parseCssString } from '../parseCssString';
describe('parseCssString', () => {
it('should return the input unchanged when it is not a non-empty string', () => {
it('should return undefined when the input is not a non-empty string', () => {
expect(parseCssString(undefined)).toBeUndefined();
expect(parseCssString('')).toBe('');
expect(parseCssString('')).toBeUndefined();
});
it('should convert kebab-case properties to camelCase', () => {
@@ -23,13 +23,27 @@ describe('parseCssString', () => {
});
});
it('should skip declarations without a colon', () => {
expect(parseCssString('color: red; invalid')).toEqual({ color: 'red' });
it('should keep semicolons inside url() values', () => {
expect(
parseCssString(
'background-image: url(data:image/png;base64,abc); color: red',
),
).toEqual({
backgroundImage: 'url(data:image/png;base64,abc)',
color: 'red',
});
});
it('should only split on the first colon so values may contain colons', () => {
expect(parseCssString('background: url(http://example.com)')).toEqual({
background: 'url(http://example.com)',
it('should strip an important priority from values', () => {
expect(parseCssString('color: red !important; width: 10px')).toEqual({
color: 'red',
width: '10px',
});
});
it('should keep an earlier important declaration over a later normal duplicate', () => {
expect(parseCssString('color: red !important; color: blue')).toEqual({
color: 'red',
});
});
});
@@ -1,35 +1,28 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type CSSProperties } from 'react';
import { kebabToCamelCase } from 'twenty-shared/utils';
import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName';
import { parseCssDeclarations } from '@/utils/parseCssDeclarations';
export const parseCssString = (
styleString: string | undefined,
): CSSProperties | undefined => {
if (!isNonEmptyString(styleString)) {
return styleString as CSSProperties | undefined;
return undefined;
}
const style: Record<string, string> = {};
const declarations = styleString.split(';').filter(Boolean);
const reactStyleProperties: Record<string, string> = {};
for (const declaration of declarations) {
const colonIndex = declaration.indexOf(':');
if (colonIndex === -1) {
continue;
}
for (const { cssPropertyName, cssValue } of parseCssDeclarations(
styleString,
)) {
const reactStylePropertyName = isCssCustomPropertyName(cssPropertyName)
? cssPropertyName
: kebabToCamelCase(cssPropertyName);
const property = declaration.slice(0, colonIndex).trim();
const value = declaration.slice(colonIndex + 1).trim();
const isCssCustomProperty = property.startsWith('--');
const key = isCssCustomProperty
? property
: property.replace(/-([a-z])/g, (_, letter: string) =>
letter.toUpperCase(),
);
style[key] = value;
reactStyleProperties[reactStylePropertyName] = cssValue;
}
return style;
return reactStyleProperties;
};
@@ -0,0 +1,11 @@
export type StyleProxy = {
cssText: string;
setProperty: (
cssPropertyName: string,
value: string | null,
priority?: string,
) => void;
removeProperty: (cssPropertyName: string) => string;
getPropertyValue: (cssPropertyName: string) => string;
[stylePropertyName: string]: unknown;
};
@@ -0,0 +1,137 @@
import { createStyleProxy } from '../createStyleProxy';
describe('createStyleProxy', () => {
it('should round trip a property through setProperty and getPropertyValue', () => {
const style = createStyleProxy(() => {});
style.setProperty('color', 'red');
expect(style.getPropertyValue('color')).toBe('red');
expect(style.cssText).toBe('color:red');
});
it('should flush every mutation synchronously with the serialized cssText', () => {
const flush = jest.fn();
const style = createStyleProxy(flush);
style.setProperty('color', 'red');
style.width = 10;
style.removeProperty('color');
expect(
flush.mock.calls.map(([serializedCssText]) => serializedCssText),
).toEqual(['color:red', 'color:red;width:10px', 'width:10px']);
});
it('should accept an important priority and store the plain value', () => {
const style = createStyleProxy(() => {});
style.setProperty('color', 'red', 'IMPORTANT');
expect(style.getPropertyValue('color')).toBe('red');
expect(style.cssText).toBe('color:red');
});
it('should ignore setProperty with an invalid priority', () => {
const flush = jest.fn();
const style = createStyleProxy(flush);
style.setProperty('color', 'red', 'loud');
expect(style.getPropertyValue('color')).toBe('');
expect(flush).not.toHaveBeenCalled();
});
it('should ignore setProperty with a whitespace-padded important priority', () => {
const style = createStyleProxy(() => {});
style.setProperty('color', 'red', ' important ');
expect(style.getPropertyValue('color')).toBe('');
});
it('should remove the property when the value is empty even with an invalid priority', () => {
const style = createStyleProxy(() => {});
style.setProperty('color', 'red');
style.setProperty('color', '', 'loud');
expect(style.getPropertyValue('color')).toBe('');
expect(style.cssText).toBe('');
});
it('should normalize the property name passed to setProperty and getPropertyValue', () => {
const style = createStyleProxy(() => {});
style.setProperty('COLOR', 'red');
expect(style.getPropertyValue('color')).toBe('red');
expect(style.color).toBe('red');
});
it('should replace all declarations when cssText is assigned', () => {
const style = createStyleProxy(() => {});
style.setProperty('color', 'red');
style.cssText = 'width: 10px; height: 20px';
expect(style.getPropertyValue('color')).toBe('');
expect(style.cssText).toBe('width:10px;height:20px');
});
it('should resolve direct camelCase assignment to the css property name', () => {
const style = createStyleProxy(() => {});
style.backgroundColor = 'red';
expect(style.getPropertyValue('background-color')).toBe('red');
expect(style.backgroundColor).toBe('red');
});
it('should map the cssFloat alias to the float property', () => {
const style = createStyleProxy(() => {});
style.cssFloat = 'left';
expect(style.cssText).toBe('float:left');
});
it('should remove a property on empty direct assignment', () => {
const style = createStyleProxy(() => {});
style.color = 'red';
style.color = '';
expect(style.cssText).toBe('');
});
it('should append px to numeric values while keeping unitless properties unitless', () => {
const style = createStyleProxy(() => {});
style.width = 10;
style.aspectRatio = 2;
expect(style.getPropertyValue('width')).toBe('10px');
expect(style.getPropertyValue('aspect-ratio')).toBe('2');
});
it('should preserve the exact custom property key on direct assignment', () => {
const style = createStyleProxy(() => {});
style['--myVar'] = '2px';
expect(style.cssText).toBe('--myVar:2px');
expect(style['--myVar']).toBe('2px');
});
it('should keep Object.prototype methods callable', () => {
const style = createStyleProxy(() => {});
style.setProperty('color', 'red');
expect(style.hasOwnProperty('color')).toBe(true);
expect(style.hasOwnProperty('background')).toBe(false);
expect(() => `${style}`).not.toThrow();
expect(String(style)).toBe('[object Object]');
});
});
@@ -0,0 +1,27 @@
import { formatCssValue } from '../formatCssValue';
describe('formatCssValue', () => {
it('should append px to a numeric length', () => {
expect(formatCssValue(10, 'width')).toBe('10px');
});
it('should keep a unitless numeric property unitless', () => {
expect(formatCssValue(2, 'aspectRatio')).toBe('2');
});
it('should keep a vendor-prefixed unitless property unitless', () => {
expect(formatCssValue(3, 'WebkitLineClamp')).toBe('3');
});
it('should never append px to a custom property', () => {
expect(formatCssValue(4, '--gap')).toBe('4');
});
it('should keep zero unitless', () => {
expect(formatCssValue(0, 'width')).toBe('0');
});
it('should stringify non-numeric values', () => {
expect(formatCssValue('10px', 'width')).toBe('10px');
});
});
@@ -0,0 +1,33 @@
import { parseCssTextIntoStyleDeclarations } from '../parseCssTextIntoStyleDeclarations';
describe('parseCssTextIntoStyleDeclarations', () => {
it('should parse declarations into values keyed by css property name', () => {
expect(
parseCssTextIntoStyleDeclarations('color: red !important; width: 10px'),
).toEqual({ color: 'red', width: '10px' });
});
it('should let the last duplicate win', () => {
expect(
parseCssTextIntoStyleDeclarations('color: red; color: blue'),
).toEqual({ color: 'blue' });
});
it('should keep an earlier important declaration over a later normal duplicate', () => {
expect(
parseCssTextIntoStyleDeclarations('color: red !important; color: blue'),
).toEqual({ color: 'red' });
});
it('should lowercase standard property names while preserving custom ones', () => {
expect(
parseCssTextIntoStyleDeclarations('COLOR: red; --My-Var: 1px'),
).toEqual({ color: 'red', '--My-Var': '1px' });
});
it('should skip declarations without a property name or value', () => {
expect(
parseCssTextIntoStyleDeclarations(': red; color: ; width: 10px'),
).toEqual({ width: '10px' });
});
});
@@ -0,0 +1,26 @@
import { resolveCssPropertyNameFromJsPropertyName } from '../resolveCssPropertyNameFromJsPropertyName';
describe('resolveCssPropertyNameFromJsPropertyName', () => {
it('should convert a camelCase property name to kebab-case', () => {
expect(resolveCssPropertyNameFromJsPropertyName('backgroundColor')).toBe(
'background-color',
);
});
it('should preserve a custom property name verbatim', () => {
expect(resolveCssPropertyNameFromJsPropertyName('--myVar')).toBe('--myVar');
});
it('should map the cssFloat alias to the float property', () => {
expect(resolveCssPropertyNameFromJsPropertyName('cssFloat')).toBe('float');
});
it('should not resolve Object.prototype keys to inherited values', () => {
expect(resolveCssPropertyNameFromJsPropertyName('constructor')).toBe(
'constructor',
);
expect(resolveCssPropertyNameFromJsPropertyName('__proto__')).toBe(
'__proto__',
);
});
});
@@ -0,0 +1,13 @@
import { serializeStyleDeclarationsToCssText } from '../serializeStyleDeclarationsToCssText';
describe('serializeStyleDeclarationsToCssText', () => {
it('should join declarations with semicolons', () => {
expect(
serializeStyleDeclarationsToCssText({ color: 'red', width: '10px' }),
).toBe('color:red;width:10px');
});
it('should return an empty string for no declarations', () => {
expect(serializeStyleDeclarationsToCssText({})).toBe('');
});
});
@@ -0,0 +1,126 @@
import { isNonEmptyString, isString } from '@sniptt/guards';
import { type StyleProxy } from '@/polyfills/dom/types/StyleProxy';
import { formatCssValue } from '@/polyfills/dom/utils/formatCssValue';
import { isImportantPriorityKeyword } from '@/polyfills/dom/utils/isImportantPriorityKeyword';
import { isObjectPrototypeMember } from '@/polyfills/dom/utils/isObjectPrototypeMember';
import { normalizeCssPropertyName } from '@/utils/normalizeCssPropertyName';
import { parseCssTextIntoStyleDeclarations } from '@/polyfills/dom/utils/parseCssTextIntoStyleDeclarations';
import { resolveCssPropertyNameFromJsPropertyName } from '@/polyfills/dom/utils/resolveCssPropertyNameFromJsPropertyName';
import { serializeStyleDeclarationsToCssText } from '@/polyfills/dom/utils/serializeStyleDeclarationsToCssText';
export const createStyleProxy = (
flushSerializedCssTextToHost: (serializedCssText: string) => void,
): StyleProxy => {
const cssValueByCssPropertyName: Record<string, string> = {};
const flushToHost = (): void => {
flushSerializedCssTextToHost(
serializeStyleDeclarationsToCssText(cssValueByCssPropertyName),
);
};
const replaceAllDeclarationsFromCssText = (cssText: string): void => {
for (const cssPropertyName of Object.keys(cssValueByCssPropertyName)) {
delete cssValueByCssPropertyName[cssPropertyName];
}
Object.assign(
cssValueByCssPropertyName,
parseCssTextIntoStyleDeclarations(cssText),
);
};
const setPropertyValue = (
cssPropertyName: string,
value: string | null,
priority?: string,
): void => {
const normalizedCssPropertyName = normalizeCssPropertyName(cssPropertyName);
if (value === null || value === '') {
delete cssValueByCssPropertyName[normalizedCssPropertyName];
flushToHost();
return;
}
if (isNonEmptyString(priority) && !isImportantPriorityKeyword(priority)) {
return;
}
cssValueByCssPropertyName[normalizedCssPropertyName] = String(value);
flushToHost();
};
const removePropertyValue = (cssPropertyName: string): string => {
const normalizedCssPropertyName = normalizeCssPropertyName(cssPropertyName);
const previousValue =
cssValueByCssPropertyName[normalizedCssPropertyName] ?? '';
delete cssValueByCssPropertyName[normalizedCssPropertyName];
flushToHost();
return previousValue;
};
const readPropertyValue = (cssPropertyName: string): string =>
cssValueByCssPropertyName[normalizeCssPropertyName(cssPropertyName)] ?? '';
return new Proxy(cssValueByCssPropertyName, {
get: (target, property) => {
if (property === 'cssText') {
return serializeStyleDeclarationsToCssText(target);
}
if (property === 'setProperty') {
return setPropertyValue;
}
if (property === 'removeProperty') {
return removePropertyValue;
}
if (property === 'getPropertyValue') {
return readPropertyValue;
}
if (isObjectPrototypeMember(property)) {
return Reflect.get(Object.prototype, property);
}
if (isString(property)) {
return target[resolveCssPropertyNameFromJsPropertyName(property)] ?? '';
}
return undefined;
},
set: (target, property, value) => {
if (!isString(property)) {
return true;
}
if (property === 'cssText') {
replaceAllDeclarationsFromCssText(String(value));
flushToHost();
return true;
}
const cssPropertyName =
resolveCssPropertyNameFromJsPropertyName(property);
if (value === null || value === undefined || value === '') {
delete target[cssPropertyName];
flushToHost();
return true;
}
target[cssPropertyName] = formatCssValue(value, property);
flushToHost();
return true;
},
}) as unknown as StyleProxy;
};
@@ -0,0 +1,17 @@
import { isNumber } from '@sniptt/guards';
import { UNITLESS_CSS_PROPERTY_NAMES } from '@/constants/UnitlessCssPropertyNames';
import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName';
export const formatCssValue = (
value: unknown,
stylePropertyName: string,
): string => {
const shouldAppendPixelUnitToNumber =
isNumber(value) &&
value !== 0 &&
!isCssCustomPropertyName(stylePropertyName) &&
!UNITLESS_CSS_PROPERTY_NAMES.has(stylePropertyName);
return shouldAppendPixelUnitToNumber ? `${value}px` : String(value);
};
@@ -0,0 +1,2 @@
export const isImportantPriorityKeyword = (priority: string): boolean =>
priority.toLowerCase() === 'important';
@@ -0,0 +1,3 @@
export const isObjectPrototypeMember = (
propertyName: string | symbol,
): boolean => propertyName in Object.prototype;
@@ -0,0 +1,15 @@
import { normalizeCssPropertyName } from '@/utils/normalizeCssPropertyName';
import { parseCssDeclarations } from '@/utils/parseCssDeclarations';
export const parseCssTextIntoStyleDeclarations = (
cssText: string,
): Record<string, string> => {
const cssValueByCssPropertyName: Record<string, string> = {};
for (const { cssPropertyName, cssValue } of parseCssDeclarations(cssText)) {
cssValueByCssPropertyName[normalizeCssPropertyName(cssPropertyName)] =
cssValue;
}
return cssValueByCssPropertyName;
};
@@ -0,0 +1,24 @@
import { camelToKebab } from 'twenty-shared/utils';
import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName';
const CSS_PROPERTY_NAME_BY_CSSOM_ALIAS = new Map<string, string>([
['cssFloat', 'float'],
]);
export const resolveCssPropertyNameFromJsPropertyName = (
jsPropertyName: string,
): string => {
if (isCssCustomPropertyName(jsPropertyName)) {
return jsPropertyName;
}
const aliasedCssPropertyName =
CSS_PROPERTY_NAME_BY_CSSOM_ALIAS.get(jsPropertyName);
if (aliasedCssPropertyName !== undefined) {
return aliasedCssPropertyName;
}
return camelToKebab(jsPropertyName);
};
@@ -0,0 +1,6 @@
export const serializeStyleDeclarationsToCssText = (
cssValueByCssPropertyName: Record<string, string>,
): string =>
Object.entries(cssValueByCssPropertyName)
.map(([cssPropertyName, cssValue]) => `${cssPropertyName}:${cssValue}`)
.join(';');
@@ -1,210 +1,52 @@
import { isNonEmptyString, isString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { ALLOWED_HTML_ELEMENTS } from '@/constants/AllowedHtmlElements';
const camelToKebab = (property: string): string =>
property.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
const UNITLESS_CSS_PROPERTIES = new Set([
'animationIterationCount',
'borderImageOutset',
'borderImageSlice',
'borderImageWidth',
'boxFlex',
'boxFlexGroup',
'boxOrdinalGroup',
'columnCount',
'columns',
'flex',
'flexGrow',
'flexPositive',
'flexShrink',
'flexNegative',
'flexOrder',
'gridArea',
'gridRow',
'gridRowEnd',
'gridRowSpan',
'gridRowStart',
'gridColumn',
'gridColumnEnd',
'gridColumnSpan',
'gridColumnStart',
'fontWeight',
'lineClamp',
'lineHeight',
'opacity',
'order',
'orphans',
'tabSize',
'widows',
'zIndex',
'zoom',
'fillOpacity',
'floodOpacity',
'stopOpacity',
'strokeDasharray',
'strokeDashoffset',
'strokeMiterlimit',
'strokeOpacity',
'strokeWidth',
]);
type FlushFn = (cssText: string) => void;
const createStyleProxy = (flush: FlushFn): Record<string, unknown> => {
const styleStore: Record<string, string> = {};
const flushToRemoteProperty = (): void => {
const cssText = Object.entries(styleStore)
.map(([key, value]) => `${key}:${value}`)
.join(';');
flush(cssText);
};
return new Proxy(styleStore, {
get: (target, property) => {
if (property === 'cssText') {
return Object.entries(target)
.map(([key, value]) => `${key}:${value}`)
.join(';');
}
if (property === 'setProperty') {
return (name: string, value: string | null) => {
if (value === null || value === '') {
delete target[name];
} else {
target[name] = String(value);
}
flushToRemoteProperty();
};
}
if (property === 'removeProperty') {
return (name: string): string => {
const oldValue = target[name] ?? '';
delete target[name];
flushToRemoteProperty();
return oldValue;
};
}
if (property === 'getPropertyValue') {
return (name: string): string => target[name] ?? '';
}
if (typeof property === 'string') {
const kebabKey = camelToKebab(property);
return target[kebabKey] ?? '';
}
return undefined;
},
set: (target, property, value) => {
if (property === 'cssText') {
for (const key of Object.keys(target)) {
delete target[key];
}
String(value)
.split(';')
.forEach((pair) => {
const colonIndex = pair.indexOf(':');
if (colonIndex > 0) {
const key = pair.slice(0, colonIndex).trim();
const val = pair.slice(colonIndex + 1).trim();
if (key && val) {
target[key] = val;
}
}
});
flushToRemoteProperty();
return true;
}
if (typeof property === 'string') {
const kebabKey = camelToKebab(property);
if (value === null || value === undefined || value === '') {
delete target[kebabKey];
} else {
let stringValue = String(value);
if (
typeof value === 'number' &&
value !== 0 &&
!UNITLESS_CSS_PROPERTIES.has(property)
) {
stringValue = `${value}px`;
}
target[kebabKey] = stringValue;
}
flushToRemoteProperty();
return true;
}
return true;
},
});
};
import { type StyleProxy } from '@/polyfills/dom/types/StyleProxy';
import { createStyleProxy } from '@/polyfills/dom/utils/createStyleProxy';
type RemoteElementLike = Element & {
updateRemoteProperty: (name: string, value: unknown) => void;
};
const createRemoteStyleProxy = (element: RemoteElementLike): StyleProxy =>
createStyleProxy((serializedCssText) => {
element.updateRemoteProperty(
'style',
isNonEmptyString(serializedCssText) ? serializedCssText : undefined,
);
});
export const installStylePropertyOnRemoteElements = (): void => {
const styleProxies = new WeakMap<Element, StyleProxy>();
const resolveStyleProxy = (element: RemoteElementLike): StyleProxy => {
const existingProxy = styleProxies.get(element);
if (isDefined(existingProxy)) {
return existingProxy;
}
const createdProxy = createRemoteStyleProxy(element);
styleProxies.set(element, createdProxy);
return createdProxy;
};
for (const elementConfig of ALLOWED_HTML_ELEMENTS) {
const elementConstructor = customElements.get(elementConfig.tag);
if (!elementConstructor) {
if (!isDefined(elementConstructor)) {
continue;
}
const styleProxies = new WeakMap<Element, Record<string, unknown>>();
Object.defineProperty(elementConstructor.prototype, 'style', {
get(this: RemoteElementLike) {
let proxy = styleProxies.get(this);
if (!proxy) {
const element = this;
const flush: FlushFn = (cssText: string) => {
element.updateRemoteProperty('style', cssText || undefined);
};
proxy = createStyleProxy(flush);
styleProxies.set(this, proxy);
}
return proxy;
return resolveStyleProxy(this);
},
set(this: RemoteElementLike, value: unknown) {
let proxy = styleProxies.get(this);
if (!proxy) {
const element = this;
const flush: FlushFn = (cssText: string) => {
element.updateRemoteProperty('style', cssText || undefined);
};
proxy = createStyleProxy(flush);
styleProxies.set(this, proxy);
}
if (typeof value === 'string') {
(proxy as Record<string, unknown>).cssText = value;
if (isString(value)) {
resolveStyleProxy(this).cssText = value;
}
},
configurable: true,
@@ -0,0 +1,4 @@
export type CssDeclaration = {
cssPropertyName: string;
cssValue: string;
};
@@ -0,0 +1,11 @@
import { isCssCustomPropertyName } from '../isCssCustomPropertyName';
describe('isCssCustomPropertyName', () => {
it('should detect a custom property name', () => {
expect(isCssCustomPropertyName('--gap')).toBe(true);
});
it('should reject a standard property name', () => {
expect(isCssCustomPropertyName('color')).toBe(false);
});
});
@@ -0,0 +1,77 @@
import { parseCssDeclarations } from '../parseCssDeclarations';
describe('parseCssDeclarations', () => {
it('should parse declarations into property names and values', () => {
expect(parseCssDeclarations('color: red; width: 10px')).toEqual([
{ cssPropertyName: 'color', cssValue: 'red' },
{ cssPropertyName: 'width', cssValue: '10px' },
]);
});
it('should strip an important priority from values', () => {
expect(parseCssDeclarations('color: red !important')).toEqual([
{ cssPropertyName: 'color', cssValue: 'red' },
]);
});
it('should let the last duplicate win among normal declarations', () => {
expect(parseCssDeclarations('color: red; color: blue')).toEqual([
{ cssPropertyName: 'color', cssValue: 'blue' },
]);
});
it('should keep an earlier important declaration over a later normal duplicate', () => {
expect(parseCssDeclarations('color: red !important; color: blue')).toEqual([
{ cssPropertyName: 'color', cssValue: 'red' },
]);
});
it('should let a later important duplicate replace an earlier important one', () => {
expect(
parseCssDeclarations('color: red !important; color: blue !important'),
).toEqual([{ cssPropertyName: 'color', cssValue: 'blue' }]);
});
it('should share priority state between case variants of a property name', () => {
expect(parseCssDeclarations('COLOR: red !important; color: blue')).toEqual([
{ cssPropertyName: 'COLOR', cssValue: 'red' },
]);
});
it('should keep case-sensitive custom properties as separate declarations', () => {
expect(parseCssDeclarations('--My-Var: 1px; --my-var: 2px')).toEqual([
{ cssPropertyName: '--My-Var', cssValue: '1px' },
{ cssPropertyName: '--my-var', cssValue: '2px' },
]);
});
it('should skip declarations without a colon', () => {
expect(parseCssDeclarations('color: red; invalid')).toEqual([
{ cssPropertyName: 'color', cssValue: 'red' },
]);
});
it('should skip a declaration starting with a colon', () => {
expect(parseCssDeclarations(': red; color: blue')).toEqual([
{ cssPropertyName: 'color', cssValue: 'blue' },
]);
});
it('should skip declarations whose value is empty', () => {
expect(
parseCssDeclarations('color: !important; width: ; height: 10px'),
).toEqual([{ cssPropertyName: 'height', cssValue: '10px' }]);
});
it('should only split on the first colon so values may contain colons', () => {
expect(parseCssDeclarations('background: url(http://example.com)')).toEqual(
[{ cssPropertyName: 'background', cssValue: 'url(http://example.com)' }],
);
});
it('should tolerate a trailing semicolon', () => {
expect(parseCssDeclarations('color: red;')).toEqual([
{ cssPropertyName: 'color', cssValue: 'red' },
]);
});
});
@@ -0,0 +1,54 @@
import { splitCssDeclarations } from '../splitCssDeclarations';
describe('splitCssDeclarations', () => {
it('should split plain declarations on semicolons', () => {
expect(splitCssDeclarations('color: red; font-size: 14px')).toEqual([
'color: red',
' font-size: 14px',
]);
});
it('should keep semicolons inside quoted strings', () => {
expect(splitCssDeclarations('content: "a;b"; color: red')).toEqual([
'content: "a;b"',
' color: red',
]);
});
it('should keep semicolons inside single quoted strings', () => {
expect(splitCssDeclarations("content: 'a;b'")).toEqual(["content: 'a;b'"]);
});
it('should keep semicolons after an escaped quote inside a string', () => {
expect(splitCssDeclarations('content: "a\\";b"; color: red')).toEqual([
'content: "a\\";b"',
' color: red',
]);
});
it('should keep semicolons inside url parentheses', () => {
expect(
splitCssDeclarations(
'background: url(data:image/png;base64,abc); color: red',
),
).toEqual(['background: url(data:image/png;base64,abc)', ' color: red']);
});
it('should keep slash star sequences as plain characters', () => {
expect(
splitCssDeclarations(
'background: url(http://example.com/a/*/b.png); color: red',
),
).toEqual(['background: url(http://example.com/a/*/b.png)', ' color: red']);
});
it('should handle nested parentheses', () => {
expect(
splitCssDeclarations('width: calc(min(10px; 2px)); color: red'),
).toEqual(['width: calc(min(10px; 2px))', ' color: red']);
});
it('should return the whole text when there is no top level semicolon', () => {
expect(splitCssDeclarations('color: red')).toEqual(['color: red']);
});
});
@@ -0,0 +1,23 @@
import { stripImportantPriorityFromCssValue } from '../stripImportantPriorityFromCssValue';
describe('stripImportantPriorityFromCssValue', () => {
it('should strip a trailing important priority', () => {
expect(stripImportantPriorityFromCssValue('red !important')).toBe('red');
});
it('should keep a value without an important priority unchanged', () => {
expect(stripImportantPriorityFromCssValue('red')).toBe('red');
});
it('should ignore the word important inside a quoted value', () => {
expect(stripImportantPriorityFromCssValue('"hello !important"')).toBe(
'"hello !important"',
);
});
it('should strip a real priority following a quoted value', () => {
expect(stripImportantPriorityFromCssValue('"hello" !important')).toBe(
'"hello"',
);
});
});
@@ -0,0 +1,2 @@
export const isCssCustomPropertyName = (propertyName: string): boolean =>
propertyName.startsWith('--');
@@ -0,0 +1,6 @@
import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName';
export const normalizeCssPropertyName = (cssPropertyName: string): string =>
isCssCustomPropertyName(cssPropertyName)
? cssPropertyName
: cssPropertyName.toLowerCase();
@@ -0,0 +1,49 @@
import { type CssDeclaration } from '@/types/CssDeclaration';
import { normalizeCssPropertyName } from '@/utils/normalizeCssPropertyName';
import { splitCssDeclarations } from '@/utils/splitCssDeclarations';
import { stripImportantPriorityFromCssValue } from '@/utils/stripImportantPriorityFromCssValue';
export const parseCssDeclarations = (cssText: string): CssDeclaration[] => {
const declarationsByNormalizedCssPropertyName = new Map<
string,
CssDeclaration
>();
const importantNormalizedCssPropertyNames = new Set<string>();
for (const declaration of splitCssDeclarations(cssText)) {
const propertyNameEndIndex = declaration.indexOf(':');
if (propertyNameEndIndex <= 0) {
continue;
}
const cssPropertyName = declaration.slice(0, propertyNameEndIndex).trim();
const rawCssValue = declaration.slice(propertyNameEndIndex + 1).trim();
const cssValue = stripImportantPriorityFromCssValue(rawCssValue);
if (cssPropertyName === '' || cssValue === '') {
continue;
}
const normalizedCssPropertyName = normalizeCssPropertyName(cssPropertyName);
const hasImportantPriority = cssValue !== rawCssValue;
if (
!hasImportantPriority &&
importantNormalizedCssPropertyNames.has(normalizedCssPropertyName)
) {
continue;
}
if (hasImportantPriority) {
importantNormalizedCssPropertyNames.add(normalizedCssPropertyName);
}
declarationsByNormalizedCssPropertyName.set(normalizedCssPropertyName, {
cssPropertyName,
cssValue,
});
}
return [...declarationsByNormalizedCssPropertyName.values()];
};
@@ -0,0 +1,55 @@
export const splitCssDeclarations = (cssText: string): string[] => {
const declarations: string[] = [];
let currentDeclaration = '';
let quoteCharacter: string | null = null;
let isEscaped = false;
let parenthesisDepth = 0;
for (const character of cssText) {
if (quoteCharacter !== null) {
currentDeclaration += character;
if (isEscaped) {
isEscaped = false;
} else if (character === '\\') {
isEscaped = true;
} else if (character === quoteCharacter) {
quoteCharacter = null;
}
continue;
}
if (character === '"' || character === "'") {
quoteCharacter = character;
currentDeclaration += character;
continue;
}
if (character === '(') {
parenthesisDepth += 1;
currentDeclaration += character;
continue;
}
if (character === ')') {
if (parenthesisDepth > 0) {
parenthesisDepth -= 1;
}
currentDeclaration += character;
continue;
}
if (character === ';' && parenthesisDepth === 0) {
declarations.push(currentDeclaration);
currentDeclaration = '';
continue;
}
currentDeclaration += character;
}
declarations.push(currentDeclaration);
return declarations;
};
@@ -0,0 +1,5 @@
const CSS_IMPORTANT_PRIORITY_PATTERN = /\s*!\s*important\s*$/i;
export const stripImportantPriorityFromCssValue = (
rawCssValue: string,
): string => rawCssValue.replace(CSS_IMPORTANT_PRIORITY_PATTERN, '');