From da20268b5b36f183e020514edd0fa6588d32962e Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Tue, 13 Oct 2026 11:10:30 +0200 Subject: [PATCH] refactor: separate style metadata from declarations - Store stylesheet template metadata separately from CSS declarations - Remove the runtime native flag after build-time platform filtering --- CONTEXT.md | 4 +- .../css-processor/addMetaToStylesTemplate.ts | 12 ++--- .../src/bundler/css-processor/cascade.ts | 13 ++--- .../src/bundler/css-processor/processor.ts | 50 ++++++++++++------- .../src/bundler/css-processor/types.ts | 7 ++- packages/uniwind/src/core/types.ts | 1 - .../native/styles-parsing/root-state.test.ts | 18 +++---- .../styles-parsing/selector-variants.test.ts | 30 +++++------ 8 files changed, 76 insertions(+), 59 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index e996c7d6..29ad3eef 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -142,9 +142,11 @@ Vite integration: Native processing converts Tailwind-generated CSS into metadata-rich style records. +Build-time stylesheet templates collect each rule as `{ style, meta }`: CSS declarations live in `style`, while media/selector conditions, importance, and cascade ranks live in `meta`. Native conversion reads declarations directly from `style` and emits the existing runtime `Style` record format. + Important concepts: -- A `Style` record stores entries, breakpoint bounds, orientation, theme, RTL, native flag, dependencies, per-class index, class name, important properties, normal and important cascade ranks (`complexity` and `importantComplexity`), pseudo-states, and data attributes. +- A `Style` record stores entries, breakpoint bounds, orientation, theme, RTL, dependencies, per-class index, class name, important properties, normal and important cascade ranks (`complexity` and `importantComplexity`), pseudo-states, and data attributes. Platform conditions are filtered at build time and are not emitted as a runtime flag. - Native property precedence is computed at build time from importance, cascade-layer priority, supported selector specificity, and compiled CSS order. Named layer statements, reopened layers, nested layers, anonymous layers, and unlayered rules participate; important declarations reverse layer priority. Every important rank is above every normal rank. Runtime resolution filters matching rules and compares the appropriate rank per property, independent of className token order or minimum-width priority. - Specificity covers the selectors native already supports: classes, state pseudo-classes and data attributes contribute; `:where(...)` and media queries do not. Nested selectors inherit their parent's specificity. This is author-rule precedence for supported selectors, not a general DOM selector engine or support for inheritance, animations, transitions, `@scope`, or `revert-layer`. - CSS variables live in `vars`; theme and platform-scoped variables live in `scopedVars` with internal prefixes. diff --git a/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts b/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts index 6d864f95..a880af38 100644 --- a/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts +++ b/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts @@ -2,7 +2,6 @@ import { Platform, StyleDependency } from '@/common/consts' import { isDefined } from '@/common/utils' import type { ProcessorBuilder } from './processor' import { serialize } from './serialize' -import type { StyleSheetTemplate } from './types' const extractVarsFromString = (value: string) => { const varsIndexes = [...value.matchAll(/vars\[/g)].map(m => m.index) @@ -53,9 +52,9 @@ const hasThemedVarDependency = (varName: string, Processor: ProcessorBuilder, vi } export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlatform: Platform) => { - const stylesheetsEntries = Object.entries(Processor.stylesheets as StyleSheetTemplate) + const stylesheetsEntries = Object.entries(Processor.stylesheets) .map(([className, stylesPerMediaQuery]) => { - const styles = stylesPerMediaQuery.map((style, index) => { + const styles = stylesPerMediaQuery.map(({ style, meta }, index) => { const { platform, rtl, @@ -64,7 +63,6 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat minWidth, maxWidth, colorScheme, - important: _, importantProperties, active, focus, @@ -72,13 +70,12 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat dataAttributes, complexity, importantComplexity, - ...rest - } = style + } = meta const nativeEntries = new Map() const nativeImportantProperties = new Set() - for (const [property, value] of Object.entries(rest)) { + for (const [property, value] of Object.entries(style)) { const important = importantProperties?.includes(property) ?? false for (const [nativeProperty, nativeValue] of Processor.RN.cssToRN(property, value)) { @@ -155,7 +152,6 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat orientation: makeSafeForSerialization(orientation), rtl, colorScheme: makeSafeForSerialization(colorScheme), - native: platform !== null, dependencies: dependencies.length > 0 ? dependencies : null, index, className: makeSafeForSerialization(className), diff --git a/packages/uniwind/src/bundler/css-processor/cascade.ts b/packages/uniwind/src/bundler/css-processor/cascade.ts index d35bb2b6..769ff6c4 100644 --- a/packages/uniwind/src/bundler/css-processor/cascade.ts +++ b/packages/uniwind/src/bundler/css-processor/cascade.ts @@ -1,4 +1,5 @@ import type { Selector } from 'lightningcss' +import type { StyleTemplate } from './types' type Layer = { order: Array @@ -6,7 +7,7 @@ type Layer = { } type CascadeRule = { - style: Record + meta: StyleTemplate['meta'] layer: Array specificity: number order: number @@ -44,7 +45,7 @@ export class Cascade { order: [], children: new Map(), } - private rules = new Map, CascadeRule>() + private rules = new Map() declareLayer(name: Array | null) { return (name ?? [Symbol()]).reduce((parent, part) => { @@ -70,8 +71,8 @@ export class Cascade { this.layer = parent } - add(style: Record, specificity: number, order: number) { - this.rules.set(style, { style, layer: this.layer.order, specificity, order }) + add(meta: StyleTemplate['meta'], specificity: number, order: number) { + this.rules.set(meta, { meta, layer: this.layer.order, specificity, order }) } assignRanks() { @@ -83,8 +84,8 @@ export class Cascade { || a.specificity - b.specificity || a.order - b.order ) - rules.forEach(({ style }, index) => { - style[important ? 'importantComplexity' : 'complexity'] = index + (important ? rules.length : 0) + rules.forEach(({ meta }, index) => { + meta[important ? 'importantComplexity' : 'complexity'] = index + (important ? rules.length : 0) }) } } diff --git a/packages/uniwind/src/bundler/css-processor/processor.ts b/packages/uniwind/src/bundler/css-processor/processor.ts index efa01a1f..a1d557d2 100644 --- a/packages/uniwind/src/bundler/css-processor/processor.ts +++ b/packages/uniwind/src/bundler/css-processor/processor.ts @@ -9,12 +9,12 @@ import { CSS } from './css' import { Functions } from './functions' import { MQ } from './mq' import { RN } from './rn' -import type { ProcessMetaValues } from './types' +import type { ProcessMetaValues, StyleSheetTemplate, StyleTemplate } from './types' import { Units } from './units' import { Var } from './var' export class ProcessorBuilder { - stylesheets = {} as Record> + stylesheets = {} as StyleSheetTemplate vars = {} as Record scopedVars = {} as Record> CSS = new CSS(this) @@ -65,12 +65,24 @@ export class ProcessorBuilder { }) } + private getStyleTemplate(): StyleTemplate { + return { + style: {}, + meta: { + ...this.MQ.processMediaQueries([]), + complexity: 0, + importantComplexity: 0, + }, + } + } + private addDeclaration(declaration: Declaration, important = false) { const isVar = this.declarationConfig.root || this.declarationConfig.className === null const mq = this.MQ.processMediaQueries(this.declarationConfig.mediaQueries) + const styleTemplate = isVar ? undefined : this.stylesheets[this.declarationConfig.className!]!.at(-1)! const style = (() => { if (!isVar) { - return this.stylesheets[this.declarationConfig.className!]?.at(-1) + return styleTemplate!.style } if (mq.platform !== null) { @@ -90,16 +102,18 @@ export class ProcessorBuilder { return this.scopedVars[themeKey] })() - if (!isVar) { - Object.assign(style, mq) - this.cascade.add(style, this.declarationConfig.specificity, this.declarationConfig.sourceOrder) - style.importantProperties ??= [] - style.rtl = this.declarationConfig.rtl - style.theme = mq.colorScheme ?? this.declarationConfig.theme - style.active = this.declarationConfig.active - style.focus = this.declarationConfig.focus - style.disabled = this.declarationConfig.disabled - style.dataAttributes = this.declarationConfig.dataAttributes + if (styleTemplate) { + const { meta } = styleTemplate + + Object.assign(meta, mq) + this.cascade.add(meta, this.declarationConfig.specificity, this.declarationConfig.sourceOrder) + meta.importantProperties ??= [] + meta.rtl = this.declarationConfig.rtl + meta.theme = mq.colorScheme ?? this.declarationConfig.theme + meta.active = this.declarationConfig.active + meta.focus = this.declarationConfig.focus + meta.disabled = this.declarationConfig.disabled + meta.dataAttributes = this.declarationConfig.dataAttributes this.meta.className = this.declarationConfig.className } @@ -116,8 +130,8 @@ export class ProcessorBuilder { delete style[property] style[property] = value - if (!isVar && important) { - style.importantProperties.push(property) + if (styleTemplate && important) { + styleTemplate.meta.importantProperties!.push(property) } } @@ -235,8 +249,8 @@ export class ProcessorBuilder { if (this.declarationConfig.className !== null) { const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1) - if (lastStyle !== undefined && Object.keys(lastStyle).length > 0) { - this.stylesheets[this.declarationConfig.className]?.push({}) + if (lastStyle !== undefined && Object.keys(lastStyle.style).length > 0) { + this.stylesheets[this.declarationConfig.className]?.push(this.getStyleTemplate()) } } @@ -267,7 +281,7 @@ export class ProcessorBuilder { if (newClassName !== undefined) { this.declarationConfig.className = newClassName this.stylesheets[newClassName] ??= [] - this.stylesheets[newClassName].push({}) + this.stylesheets[newClassName].push(this.getStyleTemplate()) // Tailwind >= 4.3.3 emits `.active\:x:active {}` instead of nesting // `&:active` under the class, so the variant tokens follow the class token. diff --git a/packages/uniwind/src/bundler/css-processor/types.ts b/packages/uniwind/src/bundler/css-processor/types.ts index 0c8d4b17..9c09fe50 100644 --- a/packages/uniwind/src/bundler/css-processor/types.ts +++ b/packages/uniwind/src/bundler/css-processor/types.ts @@ -52,6 +52,11 @@ export type ProcessMetaValues = { className?: string | null } +export type StyleTemplate = { + style: Record + meta: MediaQueryResolver & { complexity: number; importantComplexity: number } +} + export type StyleSheetTemplate = { - [K: string]: Array> + [K: string]: Array } diff --git a/packages/uniwind/src/core/types.ts b/packages/uniwind/src/core/types.ts index cc993ba7..8f8cff19 100644 --- a/packages/uniwind/src/core/types.ts +++ b/packages/uniwind/src/core/types.ts @@ -13,7 +13,6 @@ export type Style = { orientation: Orientation | null theme: ThemeName | null rtl: boolean | null - native: boolean dependencies: Array | null index: number className: string diff --git a/packages/uniwind/tests/native/styles-parsing/root-state.test.ts b/packages/uniwind/tests/native/styles-parsing/root-state.test.ts index b15d2549..364e901d 100644 --- a/packages/uniwind/tests/native/styles-parsing/root-state.test.ts +++ b/packages/uniwind/tests/native/styles-parsing/root-state.test.ts @@ -25,9 +25,9 @@ describe(':root parser state', () => { } `) - expect(processor.stylesheets.sibling[0].width).toBe(5) - expect(processor.stylesheets.sibling[0].active).toBe(active || null) - expect(processor.stylesheets.sibling[0].importantProperties).toContain('width') + expect(processor.stylesheets.sibling[0].style.width).toBe(5) + expect(processor.stylesheets.sibling[0].meta.active).toBe(active || null) + expect(processor.stylesheets.sibling[0].meta.importantProperties).toContain('width') expect(processor.vars.width).toBeUndefined() expect(processor.vars['--inner']).toBeDefined() }) @@ -42,9 +42,9 @@ describe(':root parser state', () => { `) expect(processor.vars['--after']).toBeDefined() - expect(processor.stylesheets.before[0].height).toBe(3) + expect(processor.stylesheets.before[0].style.height).toBe(3) expect(processor.vars.height).toBeUndefined() - expect(processor.stylesheets.before.some(style => '--after' in style)).toBe(false) + expect(processor.stylesheets.before.some(({ style }) => '--after' in style)).toBe(false) }) test.each(['.box, :root', ':root, .box'])('preserves the outer root state in %s', selector => { @@ -59,7 +59,7 @@ describe(':root parser state', () => { expect(processor.vars['--inner']).toBeDefined() expect(processor.vars['--after']).toBeDefined() expect(processor.vars.width).toBeUndefined() - expect(processor.stylesheets['after-root'][0].width).toBe(42) + expect(processor.stylesheets['after-root'][0].style.width).toBe(42) }) test.each([ @@ -71,7 +71,7 @@ describe(':root parser state', () => { expect(processor.vars['--root-width']).toBeDefined() expect(processor.vars.width).toBeUndefined() - expect(processor.stylesheets['after-root'][0].width).toBe(42) + expect(processor.stylesheets['after-root'][0].style.width).toBe(42) }) test.each([ @@ -81,8 +81,8 @@ describe(':root parser state', () => { const processor = compile(`@media ios { ${css} }`) const style = processor.stylesheets['after-root'][0] - expect(style.width).toBe(42) - expect(style.platform).toBe(Platform.iOS) + expect(style.style.width).toBe(42) + expect(style.meta.platform).toBe(Platform.iOS) expect(processor.vars.width).toBeUndefined() }) }) diff --git a/packages/uniwind/tests/native/styles-parsing/selector-variants.test.ts b/packages/uniwind/tests/native/styles-parsing/selector-variants.test.ts index ac9d30ba..60d23505 100644 --- a/packages/uniwind/tests/native/styles-parsing/selector-variants.test.ts +++ b/packages/uniwind/tests/native/styles-parsing/selector-variants.test.ts @@ -26,45 +26,45 @@ describe('Selector variants', () => { test('active', () => { const [style] = compile(rule('active\\:opacity-50', '&:active'))['active:opacity-50'] - expect(style.active).toBe(true) - expect(style.opacity).toBe(0.5) + expect(style.meta.active).toBe(true) + expect(style.style.opacity).toBe(0.5) }) test('focus', () => { const [style] = compile(rule('focus\\:opacity-50', '&:focus'))['focus:opacity-50'] - expect(style.focus).toBe(true) + expect(style.meta.focus).toBe(true) }) test('disabled', () => { const [style] = compile(rule('disabled\\:opacity-50', '&:disabled'))['disabled:opacity-50'] - expect(style.disabled).toBe(true) + expect(style.meta.disabled).toBe(true) }) test('theme', () => { const [style] = compile(rule('sepia\\:opacity-50', '&:where(.sepia, .sepia *)'))['sepia:opacity-50'] - expect(style.theme).toBe('sepia') + expect(style.meta.theme).toBe('sepia') }) test('rtl', () => { const [style] = compile(rule('rtl\\:opacity-50', '&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *)'))['rtl:opacity-50'] - expect(style.rtl).toBe(true) + expect(style.meta.rtl).toBe(true) }) test('data attribute', () => { const [style] = compile(rule('data-\\[x\\=on\\]\\:opacity-50', '&[data-x="on"]'))['data-[x=on]:opacity-50'] - expect(style.dataAttributes).toEqual({ 'data-x': '"on"' }) + expect(style.meta.dataAttributes).toEqual({ 'data-x': '"on"' }) }) test('a compound native cannot observe never becomes unconditional', () => { // `disabled:` also emits `[aria-disabled="true"]`. const styles = compile(rule('disabled\\:opacity-50', '&[aria-disabled="true"]'))['disabled:opacity-50'] - expect(styles.every(style => style.opacity === undefined)).toBe(true) + expect(styles.every(style => style.style.opacity === undefined)).toBe(true) }) test('an unobservable compound stacked on a supported variant is skipped, not weakened', () => { @@ -72,23 +72,23 @@ describe('Selector variants', () => { // The second must not survive as a plain `active` style. const styles = compile(rule('disabled\\:active\\:opacity-50', '&[aria-disabled="true"]:active'))['disabled:active:opacity-50'] - expect(styles.every(style => style.opacity === undefined)).toBe(true) + expect(styles.every(style => style.style.opacity === undefined)).toBe(true) }) test('stacked supported variants keep every condition', () => { const [style] = compile(rule('disabled\\:active\\:opacity-50', '&:disabled:active'))['disabled:active:opacity-50'] - expect(style.disabled).toBe(true) - expect(style.active).toBe(true) - expect(style.opacity).toBe(0.5) + expect(style.meta.disabled).toBe(true) + expect(style.meta.active).toBe(true) + expect(style.style.opacity).toBe(0.5) }) }) test('a plain class keeps no variant flags', () => { const [style] = compile('.opacity-50 { opacity: 0.5; }')['opacity-50'] - expect(style.active).toBeNull() - expect(style.disabled).toBeNull() - expect(style.opacity).toBe(0.5) + expect(style.meta.active).toBeNull() + expect(style.meta.disabled).toBeNull() + expect(style.style.opacity).toBe(0.5) }) })