Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -64,21 +63,19 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat
minWidth,
maxWidth,
colorScheme,
important: _,
importantProperties,
active,
focus,
disabled,
dataAttributes,
complexity,
importantComplexity,
...rest
} = style
} = meta

const nativeEntries = new Map<string, any>()
const nativeImportantProperties = new Set<string>()

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)) {
Expand Down Expand Up @@ -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),
Expand Down
13 changes: 7 additions & 6 deletions packages/uniwind/src/bundler/css-processor/cascade.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { Selector } from 'lightningcss'
import type { StyleTemplate } from './types'

type Layer = {
order: Array<number>
children: Map<string | symbol, Layer>
}

type CascadeRule = {
style: Record<string, any>
meta: StyleTemplate['meta']
layer: Array<number>
specificity: number
order: number
Expand Down Expand Up @@ -44,7 +45,7 @@ export class Cascade {
order: [],
children: new Map(),
}
private rules = new Map<Record<string, any>, CascadeRule>()
private rules = new Map<StyleTemplate['meta'], CascadeRule>()

declareLayer(name: Array<string> | null) {
return (name ?? [Symbol()]).reduce<Layer>((parent, part) => {
Expand All @@ -70,8 +71,8 @@ export class Cascade {
this.layer = parent
}

add(style: Record<string, any>, 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() {
Expand All @@ -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)
})
}
}
Expand Down
50 changes: 32 additions & 18 deletions packages/uniwind/src/bundler/css-processor/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Array<any>>
stylesheets = {} as StyleSheetTemplate
vars = {} as Record<string, any>
scopedVars = {} as Record<string, Record<string, any>>
CSS = new CSS(this)
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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())
}
}

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion packages/uniwind/src/bundler/css-processor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export type ProcessMetaValues = {
className?: string | null
}

export type StyleTemplate = {
style: Record<string, unknown>
meta: MediaQueryResolver & { complexity: number; importantComplexity: number }
}

export type StyleSheetTemplate = {
[K: string]: Array<MediaQueryResolver & { complexity: number; importantComplexity: number } & Record<string, unknown>>
[K: string]: Array<StyleTemplate>
}
1 change: 0 additions & 1 deletion packages/uniwind/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export type Style = {
orientation: Orientation | null
theme: ThemeName | null
rtl: boolean | null
native: boolean
dependencies: Array<StyleDependency> | null
index: number
className: string
Expand Down
18 changes: 9 additions & 9 deletions packages/uniwind/tests/native/styles-parsing/root-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand All @@ -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 => {
Expand All @@ -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([
Expand All @@ -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([
Expand All @@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -26,69 +26,69 @@ 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', () => {
// `disabled:active:` emits `:disabled:active` and `[aria-disabled="true"]:active`.
// 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)
})
})
Loading