Skip to content
Open
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
8 changes: 4 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Native runtime:
- During resolve, `ScopedVariables` overrides are overlaid onto a prototype-chained clone of the theme vars so unset variables fall through to the theme.
- Resolved styles subscribe to only dependencies they use, then invalidate cache entries on change.
- Runtime dependencies are represented by `StyleDependency`: theme, dimensions, orientation, insets, font scale, RTL, adaptive themes, and variables.
- Native style resolution filters rules by screen width, orientation, theme, RTL, active/focus/disabled state, and `data-*` props.
- Native style resolution calls each rule's build-generated `matches(runtime, props, state, context)` predicate for screen dimensions, orientation, theme, RTL, active/focus/disabled state, and `data-*` props. Predicates read current runtime values and honor scoped theme/direction overrides. The runtime retains dependency subscriptions even for rules that do not currently match; rules with data conditions bypass the style cache.
- Native post-processing adapts CSS concepts to RN shapes, including line-height multipliers, shadows, transforms, gradients, visibility, borders, outlines, font variants, and filters.

Web runtime:
Expand Down Expand Up @@ -112,7 +112,7 @@ Compilation flow:
- `compileTailwind` reads `cssEntryFile`, runs Tailwind v4 compile, scans files under the CSS entry directory, and builds final CSS.
- `compileCSS` routes to web or native by platform.
- `compileWebCSS` runs Lightning CSS with `UniwindCSSVisitor` and returns CSS.
- `compileNativeCSS` runs `ProcessorBuilder`, serializes variables, scoped variables, and native stylesheet metadata into JS source.
- `compileNativeCSS` runs `ProcessorBuilder`, serializes variables, scoped variables, and native stylesheet records with build-generated matching predicates into JS source.
- `UniwindBundlerConfig.generateArtifacts` writes CSS artifacts and generated theme typings.
- Generated artifacts are rewritten in place and Metro regenerates them from a worker pool, so `buildCSS` and `buildDtsFile` write through `writeFileAtomicSync`: a unique temporary file next to the target, renamed over it. Readers racing the write see the whole old file or the whole new one, the rename breaks the package manager's hardlink into its content-addressable store instead of mutating the shared copy, and a rename a lock refuses is retried before it fails the build.
- Internal package aliases such as `@/*` are only safe inside `packages/uniwind/src/bundler`. Bundler files are built and transformed to JS, but runtime/component/hook/HOC files are published directly as `.ts`/`.tsx` React Native entrypoints, so aliases in those files are not rewritten.
Expand Down Expand Up @@ -143,13 +143,13 @@ Native processing converts Tailwind-generated CSS into metadata-rich style recor

Important concepts:

- A `Style` record stores entries, breakpoint bounds, orientation, theme, RTL, native flag, dependencies, source index, class name, important properties, selector complexity, pseudo-states, and data attributes.
- Processor style templates separate declarations (`styles`) from matching and specificity metadata (`meta`). Generated runtime `Style` records replace matching conditions with a `matches` function and a `hasDataAttributes` cache flag; they retain entries, minimum breakpoint width and height for cascade precedence, dependencies, source index, class name, important properties, and selector complexity. Platform filtering happens at build time, so generated records do not carry a native flag.
- 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. Native exclusive width bounds use the generated artifact's `0.01pt` numeric precision to exclude equality, including bounds expressed with viewport-relative units.
- Media queries drive dimensions, orientation, color scheme, platform, and native/web-specific metadata. Generated matchers preserve inclusive and exclusive width and height bounds and evaluate viewport-relative bounds against current dimensions.
- Important declarations are preserved as `importantProperties`.
- Unsupported CSS features may be silently ignored on native. Prefer documenting support coverage over adding noisy runtime failures for every unsupported CSS construct.
- Tailwind composes `filter` from per-utility `--tw-*` variables and relies on `var(--x,)` empty fallbacks for unset parts, so `Var` resolves those to an empty string. Each filter function compiles to `rt.filterFn(name, amount, unit)` because `addMissingSpaces` would otherwise corrupt an inline `blur(${...}px)` template.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Platform, StyleDependency } from '@/common/consts'
import { isDefined } from '@/common/utils'
import { generateStyleMatcher } from './generateStyleMatcher'
import type { ProcessorBuilder } from './processor'
import { serialize } from './serialize'
import type { StyleSheetTemplate } from './types'
import { toCamelCase } from './utils'

const extractVarsFromString = (value: string) => {
Expand Down Expand Up @@ -54,35 +54,18 @@ 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 {
platform,
rtl,
theme,
orientation,
minWidth,
maxWidth,
colorScheme,
important: _,
importantProperties,
active,
focus,
disabled,
dataAttributes,
...rest
} = style

const entries = Object.entries(rest)
const compiledStyles = stylesPerMediaQuery.map(({ styles, meta }, index) => {
const entries = Object.entries(styles)
.flatMap(([property, value]) => Processor.RN.cssToRN(property, value))
.map(([property, value]) => [`"${property}"`, `function(vars) { return ${serialize(value)} }`])

if (platform) {
if (meta.platform) {
const isTV = currentPlatform === Platform.AndroidTV || currentPlatform === Platform.AppleTV
const commonPlatform = isTV ? Platform.TV : Platform.Native

if (platform !== commonPlatform && platform !== currentPlatform) {
if (meta.platform !== commonPlatform && meta.platform !== currentPlatform) {
return null
}
}
Expand All @@ -100,21 +83,23 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat
dependencies.push(StyleDependency.Variables)
}

if (theme !== null || isUsingThemedVar || stringifiedEntries.includes('rt.lightDark')) {
if (meta.theme !== null || isUsingThemedVar || stringifiedEntries.includes('rt.lightDark')) {
dependencies.push(StyleDependency.Theme)
}

if (orientation !== null) {
if (meta.orientation !== null) {
dependencies.push(StyleDependency.Orientation)
}

if (rtl !== null) {
if (meta.rtl !== null) {
dependencies.push(StyleDependency.Rtl)
}

if (
Number(minWidth) !== 0
|| Number(maxWidth) !== Number.MAX_VALUE
meta.minWidthOperator !== null
|| meta.maxWidthOperator !== null
|| meta.minHeightOperator !== null
|| meta.maxHeightOperator !== null
|| stringifiedEntries.includes('rt.screen')
) {
dependencies.push(StyleDependency.Dimensions)
Expand All @@ -130,38 +115,32 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat

return {
entries,
minWidth,
maxWidth,
theme: makeSafeForSerialization(theme),
orientation: makeSafeForSerialization(orientation),
rtl,
colorScheme: makeSafeForSerialization(colorScheme),
native: platform !== null,
matches: generateStyleMatcher(meta),
minWidth: meta.minWidth,
minHeight: meta.minHeight,
dependencies: dependencies.length > 0 ? dependencies : null,
index,
className: makeSafeForSerialization(className),
active,
focus,
disabled,
importantProperties: importantProperties
?.map(property => property.startsWith('--') ? property : toCamelCase(property))
.map(makeSafeForSerialization) ?? [],
dataAttributes,
importantProperties: meta.importantProperties
.map(property => property.startsWith('--') ? property : toCamelCase(property))
.map(makeSafeForSerialization),
hasDataAttributes: meta.dataAttributes !== null,
complexity: [
minWidth !== 0,
theme !== null,
orientation !== null,
rtl !== null,
platform !== null,
active !== null,
focus !== null,
disabled !== null,
dataAttributes !== null,
meta.minWidthOperator !== null,
meta.minHeightOperator !== null,
meta.theme !== null,
meta.orientation !== null,
meta.rtl !== null,
meta.platform !== null,
meta.active !== null,
meta.focus !== null,
meta.disabled !== null,
meta.dataAttributes !== null,
].filter(Boolean).length,
}
})

const filteredStyles = styles.filter(isDefined)
const filteredStyles = compiledStyles.filter(isDefined)

if (filteredStyles.length === 0) {
return null
Expand Down
60 changes: 60 additions & 0 deletions packages/uniwind/src/bundler/css-processor/generateStyleMatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { serialize } from './serialize'
import type { MediaQueryResolver } from './types'

const serializeDimension = (dimension: number | string) => typeof dimension === 'number' ? String(dimension) : serialize(dimension)

export const generateStyleMatcher = (style: MediaQueryResolver) => {
const conditions: Array<string> = []

if (style.minWidthOperator !== null) {
conditions.push(`rt.screen.width ${style.minWidthOperator} (${serializeDimension(style.minWidth)})`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Em bounds throw during lookup

When a media bound uses em, generateStyleMatcher puts a vars lookup inside matches. The function receives no vars argument, so resolving a class with that bound throws instead of returning a style. Pass the needed value into the matcher.

Knowledge Base Used: CSS compilation and processing

Fix in Claude Code Fix in Codex

}

if (style.maxWidthOperator !== null) {
conditions.push(`rt.screen.width ${style.maxWidthOperator} (${serializeDimension(style.maxWidth)})`)
}

if (style.minHeightOperator !== null) {
conditions.push(`rt.screen.height ${style.minHeightOperator} (${serializeDimension(style.minHeight)})`)
}

if (style.maxHeightOperator !== null) {
conditions.push(`rt.screen.height ${style.maxHeightOperator} (${serializeDimension(style.maxHeight)})`)
}

if (style.theme !== null) {
conditions.push(`(context.scopedTheme ?? rt.currentThemeName) === ${JSON.stringify(style.theme)}`)
}

if (style.orientation !== null) {
conditions.push(`rt.orientation === ${JSON.stringify(style.orientation)}`)
}

if (style.rtl !== null) {
conditions.push(`(context.rtl ?? rt.rtl) === ${style.rtl}`)
}

if (style.active !== null) {
conditions.push(`state?.isPressed === ${style.active}`)
}

if (style.focus !== null) {
conditions.push(`state?.isFocused === ${style.focus}`)
}

if (style.disabled !== null) {
conditions.push(`state?.isDisabled === ${style.disabled}`)
}

for (const [attribute, expectedValue] of Object.entries(style.dataAttributes ?? {})) {
const value = `props?.[${JSON.stringify(attribute)}]`

if (expectedValue === '"true"' || expectedValue === '"false"') {
conditions.push(`(${value} === ${expectedValue.slice(1, -1)} || ${value} === ${expectedValue})`)
} else {
conditions.push(`${value} === ${expectedValue}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Data values lack escaping

The generated data-value comparison inserts the selector value directly into JavaScript source. If the parsed value contains a quote or backslash, it can change the comparison or break the generated module. Escape the value as a JavaScript string before building matches.

Knowledge Base Used: CSS compilation and processing

Fix in Claude Code Fix in Codex

}
}

return `function(rt, props, state, context) { return ${conditions.join(' && ') || 'true'} }`
}
68 changes: 45 additions & 23 deletions packages/uniwind/src/bundler/css-processor/mq.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type { ColorScheme, Orientation } from '@/common/consts'
import { Platform } from '@/common/consts'
import type { MediaQuery, QueryFeatureFor_MediaFeatureId } from 'lightningcss'
import type { MediaCondition, MediaQuery, QueryFeatureFor_MediaFeatureId } from 'lightningcss'
import type { ProcessorBuilder } from './processor'
import type { MediaQueryResolver } from './types'

const EXCLUSIVE_BOUND_EPSILON = 0.01

export class MQ {
constructor(private readonly Processor: ProcessorBuilder) {}

Expand All @@ -30,44 +28,62 @@ export class MQ {
return
}

if (condition?.type !== 'feature') {
return
}

if (condition.value.type === 'range') {
this.processWidthMediaQuery(condition.value, mq)
}

if (condition.value.type === 'plain') {
this.processPlainMediaQuery(condition.value, mq)
if (condition) {
this.processCondition(condition, mq)
}
})

return mq
}

private processWidthMediaQuery(query: QueryFeatureFor_MediaFeatureId & { type: 'range' }, mq: MediaQueryResolver) {
const { operator, value } = query
private processCondition(condition: MediaCondition, mq: MediaQueryResolver) {
if (condition.type === 'operation' && condition.operator === 'and') {
condition.conditions.forEach(condition => this.processCondition(condition, mq))
Comment on lines +40 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stricter screen bound gets lost

When an and media query has two lower bounds for the same dimension, the second replaces the first. For (width >= 600px) and (width >= 400px), the generated matcher can apply the style below 600px. Keep the stricter bound when combining conditions.

Knowledge Base Used: CSS build pipeline

Fix in Claude Code Fix in Codex


return
}

if (condition.type !== 'feature') {
return
}

if (condition.value.type === 'range') {
this.processDimensionMediaQuery(condition.value, mq)
}

if (condition.value.type === 'plain') {
this.processPlainMediaQuery(condition.value, mq)
}
}

private processDimensionMediaQuery(query: QueryFeatureFor_MediaFeatureId & { type: 'range' }, mq: MediaQueryResolver) {
const { name, operator, value } = query

if (name !== 'width' && name !== 'height') {
return
}

const dimension = name === 'width' ? 'Width' : 'Height'
const result = this.Processor.CSS.processValue(value)

if (operator === 'greater-than-equal') {
mq.minWidth = result
mq[`min${dimension}`] = result
mq[`min${dimension}Operator`] = '>='
}

if (operator === 'greater-than') {
mq.minWidth = typeof result === 'number'
? result + EXCLUSIVE_BOUND_EPSILON
: `(${result}) + ${EXCLUSIVE_BOUND_EPSILON}`
mq[`min${dimension}`] = result
mq[`min${dimension}Operator`] = '>'
}

if (operator === 'less-than-equal') {
mq.maxWidth = result
mq[`max${dimension}`] = result
mq[`max${dimension}Operator`] = '<='
}

if (operator === 'less-than') {
mq.maxWidth = typeof result === 'number'
? result - EXCLUSIVE_BOUND_EPSILON
: `(${result}) - ${EXCLUSIVE_BOUND_EPSILON}`
mq[`max${dimension}`] = result
mq[`max${dimension}Operator`] = '<'
}
}

Expand All @@ -92,6 +108,12 @@ export class MQ {
return {
minWidth: 0,
maxWidth: Number.MAX_VALUE,
minWidthOperator: null,
maxWidthOperator: null,
minHeight: 0,
maxHeight: Number.MAX_VALUE,
minHeightOperator: null,
maxHeightOperator: null,
platform: null,
rtl: null,
important: false,
Expand Down
Loading
Loading