From 430b91412ed875e8c22d2e20435383792a8f4439 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Tue, 6 Oct 2026 14:18:16 +0200 Subject: [PATCH 1/2] feat: dynamic build time style matching function --- CONTEXT.md | 8 +- .../css-processor/addMetaToStylesTemplate.ts | 83 ++++----- .../css-processor/generateStyleMatcher.ts | 60 ++++++ .../uniwind/src/bundler/css-processor/mq.ts | 68 ++++--- .../src/bundler/css-processor/processor.ts | 55 +++--- .../src/bundler/css-processor/types.ts | 14 +- packages/uniwind/src/core/native/store.ts | 51 +----- packages/uniwind/src/core/types.ts | 17 +- .../styles-parsing/media-queries.test.ts | 53 +++++- .../tests/native/styles-parsing/meta.test.ts | 172 ++++++++++++++++-- .../native/styles-parsing/root-state.test.ts | 18 +- .../styles-parsing/selector-variants.test.ts | 30 +-- packages/uniwind/tests/test.css | 53 ++++++ 13 files changed, 477 insertions(+), 205 deletions(-) create mode 100644 packages/uniwind/src/bundler/css-processor/generateStyleMatcher.ts diff --git a/CONTEXT.md b/CONTEXT.md index 394aa141..f36a639c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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: @@ -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. @@ -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. diff --git a/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts b/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts index d9667122..559748d4 100644 --- a/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts +++ b/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts @@ -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) => { @@ -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 } } @@ -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) @@ -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 diff --git a/packages/uniwind/src/bundler/css-processor/generateStyleMatcher.ts b/packages/uniwind/src/bundler/css-processor/generateStyleMatcher.ts new file mode 100644 index 00000000..7c1d0c30 --- /dev/null +++ b/packages/uniwind/src/bundler/css-processor/generateStyleMatcher.ts @@ -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 = [] + + if (style.minWidthOperator !== null) { + conditions.push(`rt.screen.width ${style.minWidthOperator} (${serializeDimension(style.minWidth)})`) + } + + 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}`) + } + } + + return `function(rt, props, state, context) { return ${conditions.join(' && ') || 'true'} }` +} diff --git a/packages/uniwind/src/bundler/css-processor/mq.ts b/packages/uniwind/src/bundler/css-processor/mq.ts index 99b0a066..410532dc 100644 --- a/packages/uniwind/src/bundler/css-processor/mq.ts +++ b/packages/uniwind/src/bundler/css-processor/mq.ts @@ -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) {} @@ -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)) + + 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`] = '<' } } @@ -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, diff --git a/packages/uniwind/src/bundler/css-processor/processor.ts b/packages/uniwind/src/bundler/css-processor/processor.ts index 4e290ac7..114aafa9 100644 --- a/packages/uniwind/src/bundler/css-processor/processor.ts +++ b/packages/uniwind/src/bundler/css-processor/processor.ts @@ -8,12 +8,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) @@ -62,9 +62,10 @@ export class ProcessorBuilder { 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 ? null : this.stylesheets[this.declarationConfig.className!]!.at(-1)! const style = (() => { - if (!isVar) { - return this.stylesheets[this.declarationConfig.className!]?.at(-1) + if (styleTemplate !== null) { + return styleTemplate.styles } if (mq.platform !== null) { @@ -84,23 +85,23 @@ export class ProcessorBuilder { return this.scopedVars[themeKey] })() - if (!isVar) { - Object.assign(style, mq) - 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 !== null) { + Object.assign(styleTemplate.meta, mq, { + rtl: this.declarationConfig.rtl, + theme: mq.colorScheme ?? this.declarationConfig.theme, + active: this.declarationConfig.active, + focus: this.declarationConfig.focus, + disabled: this.declarationConfig.disabled, + dataAttributes: this.declarationConfig.dataAttributes, + }) this.meta.className = this.declarationConfig.className } if (declaration.property === 'unparsed') { style[declaration.value.propertyId.property] = this.CSS.processValue(declaration.value.value) - if (!isVar && important) { - style.importantProperties.push(declaration.value.propertyId.property) + if (styleTemplate !== null && important) { + styleTemplate.meta.importantProperties.push(declaration.value.propertyId.property) } return @@ -109,8 +110,8 @@ export class ProcessorBuilder { if (declaration.property === 'custom') { style[declaration.value.name] = this.CSS.processValue(declaration.value.value) - if (!isVar && important) { - style.importantProperties.push(declaration.value.name) + if (styleTemplate !== null && important) { + styleTemplate.meta.importantProperties.push(declaration.value.name) } return @@ -118,8 +119,18 @@ export class ProcessorBuilder { style[declaration.property] = this.CSS.processValue(declaration.value, declaration.property) - if (!isVar && important) { - style.importantProperties.push(declaration.property) + if (styleTemplate !== null && important) { + styleTemplate.meta.importantProperties.push(declaration.property) + } + } + + private createStyleTemplate(): StyleTemplate { + return { + styles: {}, + meta: { + ...this.MQ.processMediaQueries(this.declarationConfig.mediaQueries), + importantProperties: [], + }, } } @@ -234,8 +245,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.styles).length > 0) { + this.stylesheets[this.declarationConfig.className]?.push(this.createStyleTemplate()) } } @@ -250,7 +261,7 @@ export class ProcessorBuilder { if (newClassName !== undefined) { this.declarationConfig.className = newClassName this.stylesheets[newClassName] ??= [] - this.stylesheets[newClassName].push({}) + this.stylesheets[newClassName].push(this.createStyleTemplate()) // 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 204af1f3..a94e16d9 100644 --- a/packages/uniwind/src/bundler/css-processor/types.ts +++ b/packages/uniwind/src/bundler/css-processor/types.ts @@ -16,10 +16,15 @@ import type { export type MediaQueryResolver = { maxWidth: any minWidth: any + minWidthOperator: '>' | '>=' | null + maxWidthOperator: '<' | '<=' | null + minHeight: any + maxHeight: any + minHeightOperator: '>' | '>=' | null + maxHeightOperator: '<' | '<=' | null platform: Platform | null rtl: boolean | null important: boolean - importantProperties?: Array colorScheme: ColorScheme | null theme: string | null orientation: Orientation | null @@ -52,6 +57,9 @@ export type ProcessMetaValues = { className?: string | null } -export type StyleSheetTemplate = { - [K: string]: Array> +export type StyleTemplate = { + styles: Record + meta: MediaQueryResolver & { importantProperties: Array } } + +export type StyleSheetTemplate = Record> diff --git a/packages/uniwind/src/core/native/store.ts b/packages/uniwind/src/core/native/store.ts index afb5f4a7..b2e6cbef 100644 --- a/packages/uniwind/src/core/native/store.ts +++ b/packages/uniwind/src/core/native/store.ts @@ -144,21 +144,11 @@ class UniwindStoreBuilder { }) } - if (style.dataAttributes !== null) { + if (style.hasDataAttributes) { hasDataAttributes = true } - if ( - style.minWidth > this.runtime.screen.width - || style.maxWidth < this.runtime.screen.width - || (style.theme !== null && theme !== style.theme) - || (style.orientation !== null && this.runtime.orientation !== style.orientation) - || (style.rtl !== null && !this.validateDir(style.rtl, uniwindContext)) - || (style.active !== null && state?.isPressed !== style.active) - || (style.focus !== null && state?.isFocused !== style.focus) - || (style.disabled !== null && state?.isDisabled !== style.disabled) - || (style.dataAttributes !== null && !this.validateDataAttributes(style.dataAttributes, componentProps)) - ) { + if (!style.matches(this.runtime, componentProps, state, uniwindContext)) { continue } @@ -167,6 +157,7 @@ class UniwindStoreBuilder { if (previousBest) { const previousWins = previousBest.minWidth > style.minWidth + || previousBest.minHeight > style.minHeight || previousBest.complexity > style.complexity || ( previousBest.complexity === style.complexity @@ -248,42 +239,6 @@ class UniwindStoreBuilder { } } - private validateDataAttributes(dataAttributes: Record, props: Record = {}) { - for (const [attribute, expectedAttributeValue] of Object.entries(dataAttributes)) { - const attributeValue = props[attribute] - - if (expectedAttributeValue === 'true') { - if (attributeValue !== true && attributeValue !== 'true') { - return false - } - - continue - } - - if (expectedAttributeValue === 'false') { - if (attributeValue !== false && attributeValue !== 'false') { - return false - } - - continue - } - - if (attributeValue !== expectedAttributeValue) { - return false - } - } - - return true - } - - private validateDir(rtl: boolean, uniwindContext: UniwindContextType) { - if (uniwindContext.rtl !== null) { - return rtl === uniwindContext.rtl - } - - return rtl === this.runtime.rtl - } - private getCurrentPlatform() { const platform = Platform.OS diff --git a/packages/uniwind/src/core/types.ts b/packages/uniwind/src/core/types.ts index 54c371df..c2be1531 100644 --- a/packages/uniwind/src/core/types.ts +++ b/packages/uniwind/src/core/types.ts @@ -8,21 +8,20 @@ export type Var = (vars: Vars) => unknown export type Style = { entries: Array<[string, Var]> + matches: ( + rt: UniwindRuntime, + props: Record | undefined, + state: ComponentState | undefined, + context: UniwindContextType, + ) => boolean minWidth: number - maxWidth: number - orientation: Orientation | null - theme: ThemeName | null - rtl: boolean | null - native: boolean + minHeight: number dependencies: Array | null index: number className: string importantProperties: Array complexity: number - active: boolean | null - focus: boolean | null - disabled: boolean | null - dataAttributes: Record | null + hasDataAttributes: boolean } export type StyleSheets = Record> diff --git a/packages/uniwind/tests/native/styles-parsing/media-queries.test.ts b/packages/uniwind/tests/native/styles-parsing/media-queries.test.ts index 05829d24..0ea35f12 100644 --- a/packages/uniwind/tests/native/styles-parsing/media-queries.test.ts +++ b/packages/uniwind/tests/native/styles-parsing/media-queries.test.ts @@ -2,8 +2,8 @@ import { StyleDependency } from '../../../src/common/consts' import { UniwindListener } from '../../../src/core/listener' import { UniwindStore } from '../../../src/core/native/store' -const resolveAtWidth = (className: string, width: number) => { - UniwindStore.runtime.screen = { ...UniwindStore.runtime.screen, width } +const resolveAtSize = (className: string, width: number, height: number) => { + UniwindStore.runtime.screen = { width, height } UniwindListener.notify([StyleDependency.Dimensions]) return UniwindStore.getStyles( @@ -14,6 +14,9 @@ const resolveAtWidth = (className: string, width: number) => { ).styles } +const resolveAtWidth = (className: string, width: number) => resolveAtSize(className, width, UniwindStore.runtime.screen.height) +const resolveAtHeight = (className: string, height: number) => resolveAtSize(className, UniwindStore.runtime.screen.width, height) + describe('media query boundaries', () => { const originalScreen = UniwindStore.runtime.screen @@ -24,7 +27,9 @@ describe('media query boundaries', () => { test.each([ [389.99, { top: 0, right: 0, bottom: 1, left: 1 }], + [389.999, { top: 0, right: 0, bottom: 1, left: 1 }], [390, { top: 0, right: 1, bottom: 0, left: 1 }], + [390.001, { top: 1, right: 1, bottom: 0, left: 0 }], [390.01, { top: 1, right: 1, bottom: 0, left: 0 }], ])('resolves width %s', (width, expected) => { expect(resolveAtWidth('media-query-boundaries', width)).toMatchObject(expected) @@ -39,6 +44,50 @@ describe('media query boundaries', () => { }) }) +describe('height media queries', () => { + const originalScreen = UniwindStore.runtime.screen + + afterEach(() => { + UniwindStore.runtime.screen = originalScreen + UniwindListener.notify([StyleDependency.Dimensions]) + }) + + test.each([ + [843.999, { top: 0, right: 0, bottom: 1, left: 1 }], + [844, { top: 0, right: 1, bottom: 0, left: 1 }], + [844.001, { top: 1, right: 1, bottom: 0, left: 0 }], + ])('resolves height %s independently of width', (height, expected) => { + expect(resolveAtHeight('height-media-query-boundaries', height)).toMatchObject(expected) + }) + + test('height changes invalidate cached matches and non-matches', () => { + expect(resolveAtHeight('height-breakpoint-low', 599.999)).toEqual({}) + expect(resolveAtHeight('height-breakpoint-low', 600)).toEqual({ opacity: 0.5 }) + expect(resolveAtHeight('height-breakpoint-low', 599.999)).toEqual({}) + }) + + test('keeps every utility of a height block behind its breakpoint', () => { + expect(resolveAtHeight('height-breakpoint-low height-block-second', 599.999)).toEqual({}) + expect(resolveAtHeight('height-breakpoint-low height-block-second', 600)).toEqual({ opacity: 0.5, paddingTop: 2 }) + }) + + test.each([ + 'height-breakpoint-low height-breakpoint-high', + 'height-breakpoint-high height-breakpoint-low', + ])('higher minimum heights win for %s', className => { + expect(resolveAtHeight(className, 799.999)).toEqual({ opacity: 0.5 }) + expect(resolveAtHeight(className, 800)).toEqual({ opacity: 0.75 }) + }) + + test('width and height bounds must all match', () => { + expect(resolveAtSize('width-height-range', 299.999, 800)).toEqual({}) + expect(resolveAtSize('width-height-range', 300, 699.999)).toEqual({}) + expect(resolveAtSize('width-height-range', 300, 700)).toEqual({ opacity: 0.5 }) + expect(resolveAtSize('width-height-range', 300, 900)).toEqual({ opacity: 0.5 }) + expect(resolveAtSize('width-height-range', 300, 900.001)).toEqual({}) + }) +}) + describe('media blocks shared by multiple utilities', () => { const originalScreen = UniwindStore.runtime.screen diff --git a/packages/uniwind/tests/native/styles-parsing/meta.test.ts b/packages/uniwind/tests/native/styles-parsing/meta.test.ts index 81946699..f40c2c2d 100644 --- a/packages/uniwind/tests/native/styles-parsing/meta.test.ts +++ b/packages/uniwind/tests/native/styles-parsing/meta.test.ts @@ -1,8 +1,9 @@ import { UniwindBundlerConfig } from '../../../src/bundler/config' import { compileCSS } from '../../../src/bundler/css-compiler' -import { Platform, StyleDependency } from '../../../src/common/consts' -import { StyleSheets } from '../../../src/core/types' -import { SCREEN_HEIGHT, SCREEN_WIDTH } from '../../consts' +import { compileNativeCSS } from '../../../src/bundler/css-compiler/compileNativeCSS' +import { Orientation, Platform, StyleDependency } from '../../../src/common/consts' +import { UniwindStore } from '../../../src/core/native/store' +import type { StyleSheets, UniwindRuntime } from '../../../src/core/types' type CompiledResult = { stylesheet: StyleSheets @@ -14,13 +15,21 @@ const compileMetadata = async (): Promise => { }, Platform.iOS) const virtualCode = await compileCSS(bundlerConfig) - // oxlint-disable-next-line no-unused-vars - const rt = { screen: { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } } + return new Function('rt', `return ${virtualCode}`)(UniwindStore.runtime) +} + +const compileStyles = (css: string): StyleSheets => { + const bundlerConfig = UniwindBundlerConfig.fromMetroConfig({ + cssEntryFile: './tests/test.css', + extraThemes: ['sepia'], + }, Platform.iOS) + const code = compileNativeCSS(bundlerConfig, css) - // oxlint-disable-next-line no-eval - return eval(`(${virtualCode})`) + return new Function('rt', `return ${code}`)(UniwindStore.runtime).stylesheet } +const context = { scopedTheme: null, rtl: null, variables: null } + describe('Styles Metadata', () => { test('Theme Style Dependency', async () => { const { stylesheet } = await compileMetadata() @@ -32,19 +41,146 @@ describe('Styles Metadata', () => { test('Combined variants', async () => { const { stylesheet } = await compileMetadata() - const expectMeta = (className: string, expected: Partial) => { - const meta = stylesheet[className][0] + for (const className of ['dark:active:bg-purple-700', 'dark:active:focus:bg-purple-700', 'active:dark:bg-purple-700']) { + const { matches } = stylesheet[className][0] + const rt = { ...UniwindStore.runtime, currentThemeName: 'dark' } + + expect(matches(rt, undefined, { isPressed: true, isFocused: true }, context)).toBe(true) + expect(matches(rt, undefined, { isPressed: false, isFocused: true }, context)).toBe(false) + expect(matches({ ...rt, currentThemeName: 'light' }, undefined, { isPressed: true, isFocused: true }, context)).toBe(false) + expect(matches(rt, undefined, undefined, context)).toBe(false) + } + + expect(stylesheet['dark:active:focus:bg-purple-700'][0].matches( + { ...UniwindStore.runtime, currentThemeName: 'dark' }, + undefined, + { isPressed: true, isFocused: false }, + context, + )).toBe(false) + }) + + test('generated records replace matching metadata with predicates', () => { + const [style] = compileStyles('.plain { opacity: 0.5; }').plain + + expect(style.matches(UniwindStore.runtime, undefined, undefined, context)).toBe(true) + expect(style.hasDataAttributes).toBe(false) + expect(style.dependencies).toBeNull() + + for (const key of ['maxWidth', 'maxHeight', 'theme', 'orientation', 'rtl', 'active', 'focus', 'disabled', 'dataAttributes', 'colorScheme']) { + expect(style).not.toHaveProperty(key) + } + }) + + test('stacked conditions honor scoped themes and direction', () => { + const [style] = compileStyles(` + @media (width >= 200px) { + @media (orientation: landscape) { + .stacked:where(.sepia, .sepia *):where(:dir(rtl)):active:focus:disabled[data-x="on"] { opacity: 0.5; } + } + } + `).stacked + const rt = { + ...UniwindStore.runtime, + screen: { width: 200, height: 100 }, + orientation: Orientation.Landscape, + currentThemeName: 'light', + rtl: false, + } + const scopedContext = { ...context, scopedTheme: 'sepia', rtl: true } + const state = { isPressed: true, isFocused: true, isDisabled: true } + const props = { 'data-x': 'on' } + + expect(style.matches(rt, props, state, scopedContext)).toBe(true) + expect(style.matches({ ...rt, screen: { width: 199.99, height: 100 } }, props, state, scopedContext)).toBe(false) + expect(style.matches({ ...rt, orientation: Orientation.Portrait }, props, state, scopedContext)).toBe(false) + expect(style.matches(rt, props, state, { ...scopedContext, scopedTheme: 'light' })).toBe(false) + expect(style.matches(rt, props, state, { ...scopedContext, rtl: false })).toBe(false) + expect(style.matches(rt, props, { ...state, isDisabled: false }, scopedContext)).toBe(false) + expect(style.matches(rt, { 'data-x': 'off' }, state, scopedContext)).toBe(false) + expect(style.matches(rt, undefined, state, scopedContext)).toBe(false) + expect(style.hasDataAttributes).toBe(true) + expect(style.dependencies).toEqual(expect.arrayContaining([ + StyleDependency.Dimensions, + StyleDependency.Theme, + StyleDependency.Orientation, + StyleDependency.Rtl, + ])) + }) + + test.each(['true', 'false'])('data conditions accept boolean and string %s values', value => { + const [style] = compileStyles(`.data[data-enabled="${value}"] { opacity: 0.5; }`).data + + expect(style.matches(UniwindStore.runtime, { 'data-enabled': value }, undefined, context)).toBe(true) + expect(style.matches(UniwindStore.runtime, { 'data-enabled': value === 'true' }, undefined, context)).toBe(true) + expect(style.matches(UniwindStore.runtime, { 'data-enabled': value !== 'true' }, undefined, context)).toBe(false) + expect(style.matches(UniwindStore.runtime, undefined, undefined, context)).toBe(false) + }) + + test('viewport-relative bounds use current dimensions', () => { + const [style] = compileStyles('@media (width >= 50vh) { .dynamic { opacity: 0.5; } }').dynamic + const rt = { ...UniwindStore.runtime, screen: { width: 300, height: 400 } } + + expect(style.matches(rt, undefined, undefined, context)).toBe(true) + rt.screen = { width: 300, height: 800 } + expect(style.matches(rt, undefined, undefined, context)).toBe(false) + }) + + describe.each( + [ + ['width', '0px', 0], + ['width', '390.125px', 390.125], + ['width', '50vh', 390.125], + ['height', '0px', 0], + ['height', '390.125px', 390.125], + ['height', '50vw', 390.125], + ] as const, + )('%s comparisons against %s', (dimension, bound, threshold) => { + test.each( + [ + ['>', [false, false, true]], + ['>=', [false, true, true]], + ['<', [true, false, false]], + ['<=', [true, true, false]], + ] as const, + )('preserves %s at and immediately beside the boundary', (operator, expected) => { + const [style] = compileStyles(`@media (${dimension} ${operator} ${bound}) { .comparison { opacity: 0.5; } }`).comparison + const rt: UniwindRuntime = { ...UniwindStore.runtime, screen: { width: 780.25, height: 780.25 } } + + for (const [index, value] of [threshold - 0.001, threshold, threshold + 0.001].entries()) { + rt.screen[dimension] = value + expect(style.matches(rt, undefined, undefined, context)).toBe(expected[index]) + } + + expect(style.dependencies).toContain(StyleDependency.Dimensions) + expect(style).not.toHaveProperty('minWidthOperator') + expect(style).not.toHaveProperty('maxWidthOperator') + expect(style).not.toHaveProperty('minHeightOperator') + expect(style).not.toHaveProperty('maxHeightOperator') + }) + }) + + test('lower and upper width bounds keep their own operators', () => { + const [style] = compileStyles(` + @media (width > 200px) { + @media (width <= 400px) { + .interval { opacity: 0.5; } + } + } + `).interval + const rt = { ...UniwindStore.runtime, screen: { width: 200, height: 800 } } - expect(meta.theme).toBe(expected.theme ?? null) - expect(meta.active).toBe(expected.active ?? null) - expect(meta.focus).toBe(expected.focus ?? null) - expect(meta.rtl).toBe(expected.rtl ?? null) - expect(meta.disabled).toBe(expected.disabled ?? null) - expect(meta.dataAttributes).toEqual(expected.dataAttributes ?? null) + for (const [width, expected] of [[200, false], [200.001, true], [400, true], [400.001, false]] as const) { + rt.screen.width = width + expect(style.matches(rt, undefined, undefined, context)).toBe(expected) } + }) + + test('viewport-relative height bounds follow width changes', () => { + const [style] = compileStyles('@media (height >= 50vw) { .dynamic { opacity: 0.5; } }').dynamic + const rt = { ...UniwindStore.runtime, screen: { width: 400, height: 300 } } - expectMeta('dark:active:bg-purple-700', { theme: 'dark', active: true }) - expectMeta('dark:active:focus:bg-purple-700', { theme: 'dark', active: true, focus: true }) - expectMeta('active:dark:bg-purple-700', { theme: 'dark', active: true }) + expect(style.matches(rt, undefined, undefined, context)).toBe(true) + rt.screen.width = 800 + expect(style.matches(rt, undefined, undefined, context)).toBe(false) }) }) 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..630aa0b4 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].styles.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].styles.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.styles)).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].styles.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].styles.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.styles.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..55f8d59d 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.styles.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.styles.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.styles.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.styles.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.styles.opacity).toBe(0.5) }) }) diff --git a/packages/uniwind/tests/test.css b/packages/uniwind/tests/test.css index 75059d53..a3f965f0 100644 --- a/packages/uniwind/tests/test.css +++ b/packages/uniwind/tests/test.css @@ -77,6 +77,59 @@ } } +.height-media-query-boundaries { + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +@media (height > 844px) { + .height-media-query-boundaries { + top: 1px; + } +} + +@media (height >= 844px) { + .height-media-query-boundaries { + right: 1px; + } +} + +@media (height < 844px) { + .height-media-query-boundaries { + bottom: 1px; + } +} + +@media (height <= 844px) { + .height-media-query-boundaries { + left: 1px; + } +} + +@media (min-height: 600px) { + .height-breakpoint-low { + opacity: 0.5; + } + + .height-block-second { + padding-top: 2px; + } +} + +@media (min-height: 800px) { + .height-breakpoint-high { + opacity: 0.75; + } +} + +@media (min-width: 300px) and (min-height: 700px) and (max-height: 900px) { + .width-height-range { + opacity: 0.5; + } +} + /* Platform variants are emitted by tailwind as one @media ios/android block containing every matching utility. */ @media ios { From c9373786ab632b1992c835fef57f9714b4609030 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Fri, 9 Oct 2026 11:22:55 +0200 Subject: [PATCH 2/2] fix: preserve escaped data attribute selector values - Store parsed data attribute values without embedded quotes - Serialize values safely when generating style matchers --- CONTEXT.md | 2 +- .../css-processor/generateStyleMatcher.ts | 7 +++--- .../src/bundler/css-processor/processor.ts | 4 ++-- .../tests/native/styles-parsing/meta.test.ts | 24 +++++++++++++++++++ .../styles-parsing/selector-variants.test.ts | 2 +- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f36a639c..593c1ccf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -148,7 +148,7 @@ Important concepts: - 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. +- Data attribute variants support boolean `data-x` and exact `data-x="value"` matching against component props. Processor metadata keeps parsed values as strings; matcher generation serializes them as JavaScript string literals. - 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. diff --git a/packages/uniwind/src/bundler/css-processor/generateStyleMatcher.ts b/packages/uniwind/src/bundler/css-processor/generateStyleMatcher.ts index 7c1d0c30..c61a41a4 100644 --- a/packages/uniwind/src/bundler/css-processor/generateStyleMatcher.ts +++ b/packages/uniwind/src/bundler/css-processor/generateStyleMatcher.ts @@ -48,11 +48,12 @@ export const generateStyleMatcher = (style: MediaQueryResolver) => { for (const [attribute, expectedValue] of Object.entries(style.dataAttributes ?? {})) { const value = `props?.[${JSON.stringify(attribute)}]` + const serializedValue = JSON.stringify(expectedValue) - if (expectedValue === '"true"' || expectedValue === '"false"') { - conditions.push(`(${value} === ${expectedValue.slice(1, -1)} || ${value} === ${expectedValue})`) + if (expectedValue === 'true' || expectedValue === 'false') { + conditions.push(`(${value} === ${expectedValue} || ${value} === ${serializedValue})`) } else { - conditions.push(`${value} === ${expectedValue}`) + conditions.push(`${value} === ${serializedValue}`) } } diff --git a/packages/uniwind/src/bundler/css-processor/processor.ts b/packages/uniwind/src/bundler/css-processor/processor.ts index 114aafa9..c6953bfe 100644 --- a/packages/uniwind/src/bundler/css-processor/processor.ts +++ b/packages/uniwind/src/bundler/css-processor/processor.ts @@ -193,7 +193,7 @@ export class ProcessorBuilder { // data-x if (component.type === 'attribute' && component.operation === null && component.name.startsWith('data-')) { dataAttributes ??= {} - dataAttributes[component.name] = `"true"` + dataAttributes[component.name] = 'true' return } @@ -201,7 +201,7 @@ export class ProcessorBuilder { // data-x= if (component.type === 'attribute' && component.operation?.operator === 'equal' && component.name.startsWith('data-')) { dataAttributes ??= {} - dataAttributes[component.name] = `"${component.operation.value}"` + dataAttributes[component.name] = component.operation.value return } diff --git a/packages/uniwind/tests/native/styles-parsing/meta.test.ts b/packages/uniwind/tests/native/styles-parsing/meta.test.ts index f40c2c2d..71e80bcf 100644 --- a/packages/uniwind/tests/native/styles-parsing/meta.test.ts +++ b/packages/uniwind/tests/native/styles-parsing/meta.test.ts @@ -116,6 +116,30 @@ describe('Styles Metadata', () => { expect(style.matches(UniwindStore.runtime, undefined, undefined, context)).toBe(false) }) + test.each([ + [String.raw`quote\"value`, 'quote"value'], + [String.raw`back\\slash`, 'back\\slash'], + [String.raw`literal\\t`, 'literal\\t'], + [String.raw`trailing\\`, 'trailing\\'], + [String.raw`both\"and\\`, 'both"and\\'], + [String.raw`line\a break`, 'line\nbreak'], + ['literal${value}`', 'literal${value}`'], + ['', ''], + ])('data conditions preserve escaped selector value %s', (cssValue, value) => { + const stylesheet = compileStyles(` + .flat[data-value="${cssValue}"] { opacity: 0.5; } + .nested { &[data-value="${cssValue}"] { opacity: 0.5; } } + `) + + for (const className of ['flat', 'nested']) { + const [style] = stylesheet[className] + + expect(style.matches(UniwindStore.runtime, { 'data-value': value }, undefined, context)).toBe(true) + expect(style.matches(UniwindStore.runtime, { 'data-value': `${value}_different` }, undefined, context)).toBe(false) + expect(style.matches(UniwindStore.runtime, undefined, undefined, context)).toBe(false) + } + }) + test('viewport-relative bounds use current dimensions', () => { const [style] = compileStyles('@media (width >= 50vh) { .dynamic { opacity: 0.5; } }').dynamic const rt = { ...UniwindStore.runtime, screen: { width: 300, height: 400 } } 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 55f8d59d..e10f7423 100644 --- a/packages/uniwind/tests/native/styles-parsing/selector-variants.test.ts +++ b/packages/uniwind/tests/native/styles-parsing/selector-variants.test.ts @@ -57,7 +57,7 @@ describe('Selector variants', () => { test('data attribute', () => { const [style] = compile(rule('data-\\[x\\=on\\]\\:opacity-50', '&[data-x="on"]'))['data-[x=on]:opacity-50'] - expect(style.meta.dataAttributes).toEqual({ 'data-x': '"on"' }) + expect(style.meta.dataAttributes).toEqual({ 'data-x': 'on' }) }) test('a compound native cannot observe never becomes unconditional', () => {