Skip to content
Closed
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
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Native runtime:
- 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 height media queries use separate minimum/maximum height bounds in generated style metadata and subscribe to dimension changes. Height constraints are evaluated against screen height independently of width, including strict comparison boundaries and viewport-relative values. Relative height bounds are emitted as lazy callbacks so resizing does not freeze a viewport-based threshold. Dimension queries support equality, ranges, intervals, and `and` intersections, including platform-qualified conditions. Font-relative media-query units use the configured initial font size, independently of scoped variables or element declarations. Height minima break ties only between equally complex, non-important rules with equal width minima, preserving existing width-breakpoint precedence and more specific theme/state rules.
- 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
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat
orientation,
minWidth,
maxWidth,
minHeight,
maxHeight,
colorScheme,
important: _,
importantProperties,
Expand Down Expand Up @@ -115,6 +117,8 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat
if (
Number(minWidth) !== 0
|| Number(maxWidth) !== Number.MAX_VALUE
|| Number(minHeight) !== 0
|| Number(maxHeight) !== Number.MAX_VALUE
|| stringifiedEntries.includes('rt.screen')
) {
dependencies.push(StyleDependency.Dimensions)
Expand All @@ -132,6 +136,8 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat
entries,
minWidth,
maxWidth,
minHeight: typeof minHeight === 'number' ? minHeight : `function() { return ${minHeight} }`,
maxHeight: typeof maxHeight === 'number' ? maxHeight : `function() { return ${maxHeight} }`,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
theme: makeSafeForSerialization(theme),
orientation: makeSafeForSerialization(orientation),
rtl,
Expand All @@ -149,6 +155,7 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat
dataAttributes,
complexity: [
minWidth !== 0,
minHeight !== 0 || maxHeight !== Number.MAX_VALUE,
theme !== null,
orientation !== null,
rtl !== null,
Expand Down
100 changes: 73 additions & 27 deletions packages/uniwind/src/bundler/css-processor/mq.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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'

Expand All @@ -26,55 +26,99 @@ export class MQ {
].includes(mediaType as Platform)
) {
mq.platform = mediaType as Platform

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
const result = this.Processor.CSS.processValue(value)
private processCondition(condition: MediaCondition, mq: MediaQueryResolver) {
if (condition.type === 'operation' && condition.operator === 'and') {
condition.conditions.forEach(part => this.processCondition(part, 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)
}

if (condition.value.type === 'interval') {
const { name, start, startOperator, end, endOperator } = condition.value
const reverseOperator = {
'less-than': 'greater-than',
'less-than-equal': 'greater-than-equal',
'greater-than': 'less-than',
'greater-than-equal': 'less-than-equal',
'equal': 'equal',
} as const

this.processDimensionMediaQuery({ type: 'range', name, operator: reverseOperator[startOperator], value: start }, mq)
this.processDimensionMediaQuery({ type: 'range', name, operator: endOperator, value: end }, mq)
}
}

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

const minKey = name === 'height' ? 'minHeight' : 'minWidth'
const maxKey = name === 'height' ? 'maxHeight' : 'maxWidth'
// Media-query em units refer to the initial font size, just like rem,
// rather than an element's (possibly scoped) font-size variable.
const result = value.type === 'length' && value.value.type === 'value' && value.value.value.unit === 'em'
? this.Processor.Units.processLength({ ...value.value.value, unit: 'rem' })
: this.Processor.CSS.processValue(value)
const setBound = (key: typeof minKey | typeof maxKey, bound: any, operation: 'min' | 'max') => {
mq[key] = typeof mq[key] === 'number' && typeof bound === 'number'
? Math[operation](mq[key], bound)
: `Math.${operation}(${mq[key]}, ${bound})`
}

if (operator === 'greater-than-equal') {
mq.minWidth = result
if (operator === 'greater-than-equal' || operator === 'equal') {
setBound(minKey, result, 'max')
}

if (operator === 'greater-than') {
mq.minWidth = typeof result === 'number'
? result + EXCLUSIVE_BOUND_EPSILON
: `(${result}) + ${EXCLUSIVE_BOUND_EPSILON}`
setBound(
minKey,
typeof result === 'number'
? result + EXCLUSIVE_BOUND_EPSILON
: `(${result}) + ${EXCLUSIVE_BOUND_EPSILON}`,
'max',
)
}

if (operator === 'less-than-equal') {
mq.maxWidth = result
if (operator === 'less-than-equal' || operator === 'equal') {
setBound(maxKey, result, 'min')
}

if (operator === 'less-than') {
mq.maxWidth = typeof result === 'number'
? result - EXCLUSIVE_BOUND_EPSILON
: `(${result}) - ${EXCLUSIVE_BOUND_EPSILON}`
setBound(
maxKey,
typeof result === 'number'
? result - EXCLUSIVE_BOUND_EPSILON
: `(${result}) - ${EXCLUSIVE_BOUND_EPSILON}`,
'min',
)
}
}

private processPlainMediaQuery(query: QueryFeatureFor_MediaFeatureId & { type: 'plain' }, mq: MediaQueryResolver) {
const { value, name } = query

switch (name) {
case 'width':
case 'height':
this.processDimensionMediaQuery({ type: 'range', name, operator: 'equal', value }, mq)
break
case 'orientation':
mq.orientation = value.value as Orientation

Expand All @@ -92,6 +136,8 @@ export class MQ {
return {
minWidth: 0,
maxWidth: Number.MAX_VALUE,
minHeight: 0,
maxHeight: Number.MAX_VALUE,
platform: null,
rtl: null,
important: false,
Expand Down
2 changes: 2 additions & 0 deletions packages/uniwind/src/bundler/css-processor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type {
export type MediaQueryResolver = {
maxWidth: any
minWidth: any
maxHeight: any
minHeight: any
platform: Platform | null
rtl: boolean | null
important: boolean
Expand Down
13 changes: 13 additions & 0 deletions packages/uniwind/src/core/native/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type StylesResult = {

const emptyState: StylesResult = { styles: {}, dependencies: [], dependencySum: 0 }

const resolveHeightBound = (bound: Style['minHeight']) => typeof bound === 'function' ? bound() : bound

class UniwindStoreBuilder {
runtime = UniwindRuntime
vars = {} as Record<ThemeName, Vars>
Expand Down Expand Up @@ -148,9 +150,14 @@ class UniwindStoreBuilder {
hasDataAttributes = true
}

const minHeight = resolveHeightBound(style.minHeight)
const maxHeight = resolveHeightBound(style.maxHeight)

if (
style.minWidth > this.runtime.screen.width
|| style.maxWidth < this.runtime.screen.width
|| minHeight > this.runtime.screen.height
|| maxHeight < this.runtime.screen.height
|| (style.theme !== null && theme !== style.theme)
|| (style.orientation !== null && this.runtime.orientation !== style.orientation)
|| (style.rtl !== null && !this.validateDir(style.rtl, uniwindContext))
Expand All @@ -167,6 +174,12 @@ class UniwindStoreBuilder {

if (previousBest) {
const previousWins = previousBest.minWidth > style.minWidth
|| (
previousBest.minWidth === style.minWidth
&& previousBest.complexity === style.complexity
&& !style.importantProperties.includes(property)
&& resolveHeightBound(previousBest.minHeight) > minHeight
)
|| previousBest.complexity > style.complexity
|| (
previousBest.complexity === style.complexity
Expand Down
2 changes: 2 additions & 0 deletions packages/uniwind/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export type Style = {
entries: Array<[string, Var]>
minWidth: number
maxWidth: number
minHeight: number | (() => number)
maxHeight: number | (() => number)
orientation: Orientation | null
theme: ThemeName | null
rtl: boolean | null
Expand Down
106 changes: 106 additions & 0 deletions packages/uniwind/tests/native/styles-parsing/media-queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,112 @@ const resolveAtWidth = (className: string, width: number) => {
).styles
}

const resolveAtSize = (className: string, width: number, height: number) => {
UniwindStore.runtime.screen = { ...UniwindStore.runtime.screen, width, height }
UniwindListener.notify([StyleDependency.Dimensions])

return UniwindStore.getStyles(
className,
{},
{},
{ scopedTheme: null, rtl: null, variables: null },
).styles
}

describe('height media queries', () => {
const originalScreen = UniwindStore.runtime.screen

afterEach(() => {
UniwindStore.runtime.screen = originalScreen
UniwindListener.notify([StyleDependency.Dimensions])
})

test.each([
[699.99, { top: 0, right: 0, bottom: 1, left: 1 }],
[700, { top: 0, right: 1, bottom: 0, left: 1 }],
[700.01, { top: 1, right: 1, bottom: 0, left: 0 }],
])('resolves height %s independently of width', (height, expected) => {
expect(resolveAtSize('height-query-boundaries', 390, height)).toMatchObject(expected)
expect(resolveAtSize('height-query-boundaries', 1000, height)).toMatchObject(expected)
})

test('resolves custom min/max-height variants and invalidates cached styles on resize', () => {
expect(resolveAtSize('tall:top-[1px] compact:right-[1px]', 390, 699)).toEqual({ right: 1 })
expect(resolveAtSize('tall:top-[1px] compact:right-[1px]', 390, 700)).toEqual({ top: 1, right: 1 })
expect(resolveAtSize('tall:top-[1px] compact:right-[1px]', 390, 701)).toEqual({ top: 1 })
expect(resolveAtSize('tall:top-[1px] compact:right-[1px]', 390, 699)).toEqual({ right: 1 })
})

test('resolves viewport-relative height bounds', () => {
expect(resolveAtSize('height-relative-query', 400, 400)).toEqual({})
expect(resolveAtSize('height-relative-query', 400, 400.01)).toEqual({ top: 1 })
expect(resolveAtSize('height-relative-query', 401, 400.01)).toEqual({})
expect(resolveAtSize('height-self-relative', 390, 400)).toEqual({})
expect(resolveAtSize('height-self-relative', 390, 1000)).toEqual({})
})

test('resolves em height bounds against the initial font size', () => {
expect(resolveAtSize('height-em-query height-em-max-query', 390, 639)).toEqual({ right: 1 })
expect(resolveAtSize('height-em-query height-em-max-query', 390, 640)).toEqual({ top: 1, right: 1 })
expect(resolveAtSize('height-em-query height-em-max-query', 390, 641)).toEqual({ top: 1 })
})

test.each([
'taller:top-[2px] dark:tall:top-[1px]',
'dark:tall:top-[1px] taller:top-[2px]',
])('keeps theme specificity ahead of height in %s', className => {
resolveAtSize('', 390, 900)
expect(UniwindStore.getStyles(className, {}, {}, { scopedTheme: 'dark', rtl: null, variables: null }).styles)
.toEqual({ top: 1 })
})

test.each([
'taller:top-[2px] tall:top-[1px]!',
'tall:top-[1px]! taller:top-[2px]',
])('keeps important declarations ahead of height in %s', className => {
expect(resolveAtSize(className, 390, 900)).toEqual({ top: 1 })
})

test('requires both width and height constraints to match', () => {
expect(resolveAtSize('wide-and-tall', 499, 800)).toEqual({})
expect(resolveAtSize('wide-and-tall', 600, 699)).toEqual({})
expect(resolveAtSize('wide-and-tall', 600, 800)).toEqual({ top: 1 })
})

test('prefers the taller minimum-height breakpoint regardless of class order', () => {
expect(resolveAtSize('tall:top-[1px] taller:top-[2px]', 390, 900)).toEqual({ top: 2 })
expect(resolveAtSize('taller:top-[2px] tall:top-[1px]', 390, 900)).toEqual({ top: 2 })
})

test('keeps max-height variants more specific than unconditional utilities', () => {
expect(resolveAtSize('top-[2px] compact:top-[1px]', 390, 699)).toEqual({ top: 1 })
expect(resolveAtSize('compact:top-[1px] top-[2px]', 390, 699)).toEqual({ top: 1 })
expect(resolveAtSize('compact:top-[1px] top-[2px]', 390, 701)).toEqual({ top: 2 })
})

test('supports exact heights and intersects multiple height constraints', () => {
expect(resolveAtSize('exact-height', 390, 699.99)).toEqual({})
expect(resolveAtSize('exact-height', 390, 700)).toEqual({ top: 1 })
expect(resolveAtSize('exact-height', 390, 700.01)).toEqual({})
expect(resolveAtSize('height-intersection', 390, 750)).toEqual({})
expect(resolveAtSize('height-intersection', 390, 800)).toEqual({ top: 1 })
})

test.each([
[600, {}],
[600.01, { top: 1 }],
[800, { top: 1 }],
[800.01, {}],
])('resolves height intervals at %s', (height, expected) => {
expect(resolveAtSize('height-interval', 390, height)).toEqual(expected)
})

test('keeps height conditions when stacked with a platform variant', () => {
expect(resolveAtSize('ios:tall:right-[1px] android:tall:left-[1px]', 390, 699)).toEqual({})
expect(resolveAtSize('ios:tall:right-[1px] android:tall:left-[1px]', 390, 700)).toEqual({ right: 1 })
})
})

describe('media query boundaries', () => {
const originalScreen = UniwindStore.runtime.screen

Expand Down
Loading