From b854a0b413ab7adf9568538675231b508a0a10ef Mon Sep 17 00:00:00 2001 From: Max Lapides Date: Wed, 9 Sep 2026 16:47:25 -0700 Subject: [PATCH] fix: preserve exclusive native media query bounds --- CONTEXT.md | 2 +- .../css-processor/addMetaToStylesTemplate.ts | 8 ++- .../uniwind/src/bundler/css-processor/mq.ts | 4 ++ .../src/bundler/css-processor/types.ts | 2 + packages/uniwind/src/core/native/store.ts | 2 + packages/uniwind/src/core/types.ts | 2 + .../styles-parsing/media-queries.test.ts | 65 +++++++++++++++++++ 7 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 packages/uniwind/tests/native/styles-parsing/media-queries.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index f3ca4ab4..41646a1b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -143,7 +143,7 @@ Important concepts: - 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. +- Media queries drive dimensions, orientation, color scheme, platform, and native/web-specific metadata. Native width bounds carry explicit exclusivity flags through compilation and resolution so `>` and `<` exclude equality without rounding fractional widths. - 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..b83786ca 100644 --- a/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts +++ b/packages/uniwind/src/bundler/css-processor/addMetaToStylesTemplate.ts @@ -64,6 +64,8 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat orientation, minWidth, maxWidth, + minWidthExclusive, + maxWidthExclusive, colorScheme, important: _, importantProperties, @@ -113,7 +115,9 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat } if ( - Number(minWidth) !== 0 + minWidthExclusive + || maxWidthExclusive + || Number(minWidth) !== 0 || Number(maxWidth) !== Number.MAX_VALUE || stringifiedEntries.includes('rt.screen') ) { @@ -132,6 +136,8 @@ export const addMetaToStylesTemplate = (Processor: ProcessorBuilder, currentPlat entries, minWidth, maxWidth, + minWidthExclusive, + maxWidthExclusive, theme: makeSafeForSerialization(theme), orientation: makeSafeForSerialization(orientation), rtl, diff --git a/packages/uniwind/src/bundler/css-processor/mq.ts b/packages/uniwind/src/bundler/css-processor/mq.ts index 02be572b..6aa330a6 100644 --- a/packages/uniwind/src/bundler/css-processor/mq.ts +++ b/packages/uniwind/src/bundler/css-processor/mq.ts @@ -50,10 +50,12 @@ export class MQ { if (operator === 'greater-than-equal' || operator === 'greater-than') { mq.minWidth = result + mq.minWidthExclusive = operator === 'greater-than' } if (operator === 'less-than-equal' || operator === 'less-than') { mq.maxWidth = result + mq.maxWidthExclusive = operator === 'less-than' } } @@ -77,7 +79,9 @@ export class MQ { private getInitialMediaQueryResolver(): MediaQueryResolver { return { minWidth: 0, + minWidthExclusive: false, maxWidth: Number.MAX_VALUE, + maxWidthExclusive: false, platform: null, rtl: null, important: false, diff --git a/packages/uniwind/src/bundler/css-processor/types.ts b/packages/uniwind/src/bundler/css-processor/types.ts index 204af1f3..950d625b 100644 --- a/packages/uniwind/src/bundler/css-processor/types.ts +++ b/packages/uniwind/src/bundler/css-processor/types.ts @@ -16,6 +16,8 @@ import type { export type MediaQueryResolver = { maxWidth: any minWidth: any + minWidthExclusive: boolean + maxWidthExclusive: boolean platform: Platform | null rtl: boolean | null important: boolean diff --git a/packages/uniwind/src/core/native/store.ts b/packages/uniwind/src/core/native/store.ts index ce9c12a0..29a5f9c5 100644 --- a/packages/uniwind/src/core/native/store.ts +++ b/packages/uniwind/src/core/native/store.ts @@ -144,6 +144,8 @@ class UniwindStoreBuilder { if ( style.minWidth > this.runtime.screen.width || style.maxWidth < this.runtime.screen.width + || (style.minWidthExclusive && style.minWidth === this.runtime.screen.width) + || (style.maxWidthExclusive && 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)) diff --git a/packages/uniwind/src/core/types.ts b/packages/uniwind/src/core/types.ts index 52ea5022..6e120b36 100644 --- a/packages/uniwind/src/core/types.ts +++ b/packages/uniwind/src/core/types.ts @@ -10,6 +10,8 @@ export type Style = { entries: Array<[string, Var]> minWidth: number maxWidth: number + minWidthExclusive: boolean + maxWidthExclusive: boolean orientation: Orientation | null theme: ThemeName | null rtl: boolean | null diff --git a/packages/uniwind/tests/native/styles-parsing/media-queries.test.ts b/packages/uniwind/tests/native/styles-parsing/media-queries.test.ts new file mode 100644 index 00000000..b52bf88d --- /dev/null +++ b/packages/uniwind/tests/native/styles-parsing/media-queries.test.ts @@ -0,0 +1,65 @@ +import { UniwindBundlerConfig } from '../../../src/bundler/config' +import { compileNativeCSS } from '../../../src/bundler/css-compiler/compileNativeCSS' +import { Platform, StyleDependency } from '../../../src/common/consts' +import { UniwindListener } from '../../../src/core/listener' +import { UniwindStore } from '../../../src/core/native/store' +import type { GenerateStyleSheetsCallback } from '../../../src/core/types' + +const resolvePadding = (width: number) => { + UniwindStore.runtime.screen = { ...UniwindStore.runtime.screen, width } + UniwindListener.notify([StyleDependency.Dimensions]) + + return UniwindStore.getStyles('probe', {}, {}, { scopedTheme: null, rtl: null, variables: null }).styles.paddingLeft +} + +describe.each([Platform.iOS, Platform.Android])('%s media query boundaries', platform => { + const originalScreen = UniwindStore.runtime.screen + + afterEach(() => { + UniwindStore.runtime.screen = originalScreen + UniwindListener.notify([StyleDependency.Dimensions]) + }) + + const compileQuery = (query: string) => { + const config = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile: './tests/test.css' }, platform) + const code = compileNativeCSS( + config, + ` + .probe { padding-left: 16px; } + @media (${query}) { + .probe { padding-left: 20px; } + } + `, + ) + const generate: GenerateStyleSheetsCallback = eval(`rt => ${code}`) + UniwindStore.reinit(generate, ['light', 'dark']) + } + + test('an exclusive zero bound invalidates cached styles when dimensions change', () => { + compileQuery('width > 0px') + + expect([0, 0.25, 0].map(resolvePadding)).toEqual([16, 20, 16]) + }) + + test.each([ + ['min-width: 402px', [16, 20, 20]], + ['max-width: 402px', [20, 20, 16]], + ])('%s remains inclusive', (query, expected) => { + compileQuery(query) + + expect([401.75, 402, 402.25].map(resolvePadding)).toEqual(expected) + }) + + describe.each([402, 402.5])('boundary %s', boundary => { + test.each([ + ['>', [16, 16, 20]], + ['>=', [16, 20, 20]], + ['<', [20, 16, 16]], + ['<=', [20, 20, 16]], + ])('width %s', (operator, expected) => { + compileQuery(`width ${operator} ${boundary}px`) + + expect([boundary - 0.25, boundary, boundary + 0.25].map(resolvePadding)).toEqual(expected) + }) + }) +})