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
3 changes: 2 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ Important concepts:
- CSS variables live in `vars`; theme and platform-scoped variables live in `scopedVars` with internal prefixes.
- The processor treats declarations under `:root` or outside class rules as variables.
- Theme variants are recognized from known theme names.
- Variant tokens (`:active`, `:focus`, `:disabled`, `:where(.theme)`, `:dir()`, `[data-x]`) are read from two selector shapes: nested under the class as `&:active` (Tailwind < 4.3.3) and flattened into the class selector as `.active\:x:active` (Tailwind >= 4.3.3). A selector carrying any token the runtime cannot observe (e.g. `[aria-disabled="true"]`, alone or stacked with a supported variant) is skipped, never applied under a weaker condition.
- Data attribute variants support boolean `data-x` and exact `data-x="value"` matching against component props.
- Media queries drive dimensions, orientation, color scheme, platform, and native/web-specific metadata.
- Important declarations are preserved as `importantProperties`.
Expand All @@ -151,7 +152,7 @@ Important concepts:

Web visitor behavior:

- Theme root rules in Tailwind theme layer become theme class rules.
- Theme root rules in Tailwind theme layer become theme class rules, whether the variant is nested under `:root` (Tailwind < 4.3.3) or flattened into `:root:where(.dark, .dark *)` (Tailwind >= 4.3.3).
- Theme-prefixed class rules are scoped with CSS `@scope` to selected theme classes and excluded from other themes.
- Visitor state is cleaned between transforms.

Expand Down
2 changes: 1 addition & 1 deletion apps/vite-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "4.3.2",
"@tailwindcss/vite": "4.3.3",
"react": "catalog:",
"react-dom": "catalog:",
"react-native": "catalog:",
Expand Down
342 changes: 45 additions & 297 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"expo": "57.0.1",
"react-native": "0.86.0",
"react-native-web": "0.21.2",
"tailwindcss": "4.3.2",
"tailwindcss": "4.3.3",
"vite": "8.0.14"
}
},
Expand Down
4 changes: 2 additions & 2 deletions packages/uniwind/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@
"LICENSE"
],
"dependencies": {
"@tailwindcss/node": "4.3.2",
"@tailwindcss/oxide": "4.3.2",
"@tailwindcss/node": "4.3.3",
"@tailwindcss/oxide": "4.3.3",
"culori": "4.0.2",
"lightningcss": "1.30.1"
},
Expand Down
196 changes: 133 additions & 63 deletions packages/uniwind/src/bundler/css-processor/processor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { UNIWIND_PLATFORM_VARIABLES, UNIWIND_THEME_VARIABLES } from '@/common/consts'
import { isDefined } from '@/common/utils'
import type { Declaration, MediaQuery, Rule } from 'lightningcss'
import type { Declaration, MediaQuery, Rule, Selector } from 'lightningcss'
import { transform } from 'lightningcss'
import type { UniwindBundlerConfig } from '../config'
import { Color } from './color'
Expand Down Expand Up @@ -123,6 +123,113 @@ export class ProcessorBuilder {
}
}

/**
* Variant tokens (`:active`, `:disabled`, `:where(.dark)`, `[data-x]`, ...) found in a selector.
* Returns null when it carries none, or `unsupported` when a compound the runtime cannot observe
* (e.g. `[aria-disabled="true"]`) is present: the declarations must then be skipped, not applied
* under a weaker condition. Tailwind < 4.3.3 nests variants under the class as `&:active`,
* Tailwind >= 4.3.3 flattens them into the class selector, so both call sites share this.
*/
private readSelectorVariants(selector: Selector) {
let rtl = null as boolean | null
let theme = null as string | null
let active = null as boolean | null
let focus = null as boolean | null
let disabled = null as boolean | null
let dataAttributes = null as Record<string, string> | null
let unsupported = false

selector.forEach(component => {
// `&` in a nested rule and `:root` carry no condition of their own.
if (component.type === 'nesting' || (component.type === 'pseudo-class' && component.kind === 'root')) {
return
}

if (component.type === 'pseudo-class' && component.kind === 'where') {
component.selectors.forEach(selector => {
selector.forEach(component => {
if (component.type === 'class' && this.bundlerConfig.themes.includes(component.name)) {
theme = component.name
}

if (component.type === 'pseudo-class' && component.kind === 'dir') {
rtl = component.direction === 'rtl'
}
})
})

return
}

if (component.type === 'pseudo-class' && component.kind === 'active') {
active = true

return
}

if (component.type === 'pseudo-class' && component.kind === 'focus') {
focus = true

return
}

if (component.type === 'pseudo-class' && component.kind === 'disabled') {
disabled = true

return
}

// data-x
if (component.type === 'attribute' && component.operation === null && component.name.startsWith('data-')) {
dataAttributes ??= {}
dataAttributes[component.name] = `"true"`

return
}

// data-x=
if (component.type === 'attribute' && component.operation?.operator === 'equal' && component.name.startsWith('data-')) {
dataAttributes ??= {}
dataAttributes[component.name] = `"${component.operation.value}"`

return
}

unsupported = true
})

if (unsupported) {
return 'unsupported'
}

if (![rtl, theme, active, focus, disabled, dataAttributes].some(isDefined)) {
return null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return { rtl, theme, active, focus, disabled, dataAttributes }
}

private withSelectorVariants(
variants: Exclude<ReturnType<ProcessorBuilder['readSelectorVariants']>, null | 'unsupported'>,
parse: () => void,
) {
this.declarationConfig.rtl ??= variants.rtl
this.declarationConfig.theme ??= variants.theme
this.declarationConfig.active ??= variants.active
this.declarationConfig.focus ??= variants.focus
this.declarationConfig.disabled ??= variants.disabled
this.declarationConfig.dataAttributes ??= variants.dataAttributes

parse()

this.declarationConfig.rtl = null
this.declarationConfig.theme = null
this.declarationConfig.active = null
this.declarationConfig.focus = null
this.declarationConfig.disabled = null
this.declarationConfig.dataAttributes = null
Comment thread
Brentlok marked this conversation as resolved.
}

private parseRuleRec(rule: Rule<Declaration, MediaQuery>) {
if (this.declarationConfig.className !== null) {
const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1)
Expand All @@ -142,78 +249,41 @@ export class ProcessorBuilder {
this.stylesheets[newClassName] ??= []
this.stylesheets[newClassName].push({})

rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration))
rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true))
rule.value.rules?.forEach(rule => this.parseRuleRec(rule))

return
}
// Tailwind >= 4.3.3 emits `.active\:x:active {}` instead of nesting
// `&:active` under the class, so the variant tokens follow the class token.
const variants = this.readSelectorVariants(selector.slice(1))

let rtl = null as boolean | null
let theme = null as string | null
let active = null as boolean | null
let focus = null as boolean | null
let disabled = null as boolean | null
let dataAttributes = null as Record<string, string> | null

selector.forEach(selector => {
if (selector.type === 'pseudo-class' && selector.kind === 'where') {
selector.selectors.forEach(selector => {
selector.forEach(selector => {
if (selector.type === 'class' && this.bundlerConfig.themes.includes(selector.name)) {
theme = selector.name
}

if (selector.type === 'pseudo-class' && selector.kind === 'dir') {
rtl = selector.direction === 'rtl'
}
})
})
if (variants === 'unsupported') {
return
}

if (selector.type === 'pseudo-class' && selector.kind === 'active') {
active = true
const parseClassRule = () => {
rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration))
rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true))
rule.value.rules?.forEach(rule => this.parseRuleRec(rule))
}

if (selector.type === 'pseudo-class' && selector.kind === 'focus') {
focus = true
if (variants === null) {
parseClassRule()
} else {
this.withSelectorVariants(variants, parseClassRule)
}

if (selector.type === 'pseudo-class' && selector.kind === 'disabled') {
disabled = true
}
return
}

// data-x
if (selector.type === 'attribute' && selector.operation === null && selector.name.startsWith('data-')) {
dataAttributes ??= {}
dataAttributes[selector.name] = `"true"`
}
const variants = this.readSelectorVariants(selector)

// data-x=
if (selector.type === 'attribute' && selector.operation?.operator === 'equal' && selector.name.startsWith('data-')) {
dataAttributes ??= {}
dataAttributes[selector.name] = `"${selector.operation.value}"`
}
})
if (variants === 'unsupported') {
return
}

if ([rtl, theme, active, focus, disabled, dataAttributes].some(isDefined)) {
this.declarationConfig.rtl ??= rtl
this.declarationConfig.theme ??= theme
this.declarationConfig.active ??= active
this.declarationConfig.focus ??= focus
this.declarationConfig.disabled ??= disabled
this.declarationConfig.dataAttributes ??= dataAttributes

rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration))
rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true))
rule.value.rules?.forEach(rule => this.parseRuleRec(rule))

this.declarationConfig.rtl = null
this.declarationConfig.theme = null
this.declarationConfig.active = null
this.declarationConfig.focus = null
this.declarationConfig.disabled = null
this.declarationConfig.dataAttributes = null
if (variants !== null) {
this.withSelectorVariants(variants, () => {
rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration))
rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true))
rule.value.rules?.forEach(rule => this.parseRuleRec(rule))
})

return
}
Expand Down
7 changes: 7 additions & 0 deletions packages/uniwind/src/bundler/css-visitor/rule-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ export class RuleVisitor implements LightningRuleVisitors {

style = (styleRule: Extract<LightningRuleVisitor, { type: 'style' }>) => {
const firstSelector = styleRule.value.selectors.at(0)?.at(0)
const secondSelector = styleRule.value.selectors.at(0)?.at(1)

if (this.currentLayerName === 'theme' && firstSelector?.type === 'pseudo-class' && firstSelector.kind === 'root') {
// Tailwind >= 4.3.3 flattens `:root { &:where(.dark, .dark *) {} }` into a sibling
// `:root:where(.dark, .dark *) {}` rule, so the theme variant sits on the root rule itself.
if (secondSelector?.type === 'pseudo-class' && secondSelector.kind === 'where') {
return this.removeNulls(this.processThemeStyle(styleRule, secondSelector)) as ReturnedRule
}

return this.removeNulls(this.processThemeRoot(styleRule)) as Array<ReturnedRule>
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { UniwindBundlerConfig } from '../../../src/bundler/config'
import { ProcessorBuilder } from '../../../src/bundler/css-processor'
import { Platform } from '../../../src/common/consts'

const compile = (css: string) => {
const bundlerConfig = UniwindBundlerConfig.fromMetroConfig({
cssEntryFile: './tests/test.css',
extraThemes: ['sepia'],
}, Platform.iOS)
const processor = new ProcessorBuilder(bundlerConfig)

processor.transform(css)

return processor.stylesheets
}

// Tailwind < 4.3.3 nests variants under the class, Tailwind >= 4.3.3 flattens
// them into the class selector. The processor must read both.
const shapes = [
['nested', (className: string, variant: string) => `.${className} { ${variant} { opacity: 0.5; } }`],
['flattened', (className: string, variant: string) => `.${className}${variant.slice(1)} { opacity: 0.5; }`],
] as const

describe('Selector variants', () => {
describe.each(shapes)('%s selectors', (_shape, rule) => {
test('active', () => {
const [style] = compile(rule('active\\:opacity-50', '&:active'))['active:opacity-50']

expect(style.active).toBe(true)
expect(style.opacity).toBe(0.5)
})

test('focus', () => {
const [style] = compile(rule('focus\\:opacity-50', '&:focus'))['focus:opacity-50']

expect(style.focus).toBe(true)
})

test('disabled', () => {
const [style] = compile(rule('disabled\\:opacity-50', '&:disabled'))['disabled:opacity-50']

expect(style.disabled).toBe(true)
})

test('theme', () => {
const [style] = compile(rule('sepia\\:opacity-50', '&:where(.sepia, .sepia *)'))['sepia:opacity-50']

expect(style.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)
})

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"' })
})

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)
})

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)
})

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)
})
})

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)
})
})
Loading