Skip to content

fix: evaluate range pairs, and answer a condition the runtime cannot decide with unknown - #426

Open
YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/media-condition-semantics
Open

YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/media-condition-semantics

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On main:

  1. A range pair never matches. Both runtimes answer (400px < width < 800px) and every other interval false.
  2. A negated term the runtime cannot decide matches. Such a term answers false, so not (fictional-feature: 3) and @container not (orientation: bogus) apply everywhere. MQ5 §3.1 makes the term and its negation unknown, and Chromium, Firefox and WebKit apply neither.
  3. The compiler drops a media term it cannot resolve, so not ((width: env(safe-area-inset-top)) or (width > 9999px)) applies, where the engines apply it nowhere.
  4. Container conditions keep what CSS Conditional 5 §5.4 discards: not (fictional-feature > 10px) applies.
  5. A feature in a boolean context never matches: (width), (hover), (prefers-color-scheme) (MQ5 §2.4.2).
  6. The device answers features wrongly. (hover: hover) and (hover: none) both match on every device; pointer and the features every screen answers alike are never measured, so (pointer: coarse), (overflow-block: scroll) and (color-gamut: srgb) never match.
  7. A keyword and a media type compare as written, so (prefers-color-scheme: DARK) and @media IOS never match (css-values-4 §4.1, MQ4 §3).
  8. A negative resolution compiles to a negative pixel ratio, so (min-resolution: -1dpi) applies on every screen, where css-values-4 §7.4 excludes it and the three engines leave the feature unknown.
  9. A viewport length never compares: (width > 50vw) never matches and not (width: 100vw) always does.

Solution

  • Media and container conditions evaluate through one walk, evaluateCondition, and one comparison, compareMediaFeature, in three-valued logic (kleene.ts, MQ5 §3.1). A rule applies only when its condition is true; one the compiler's unknown terms leave no way to be true compiles to no rule.
  • A term is unknown when the runtime does not measure its feature, when its value is outside the feature's syntax — a keyword or a type of value MQ5 does not give it, env(), a negative resolution — or when the compiler cannot resolve its operand, which compiles to null so JSON.stringify carries it. A number past the largest float compiles to the largest safe integer for the same reason.
  • A container condition §5.4 discards compiles to no rule, as Chromium and WebKit drop it; a style() query compiles to an unknown term.
  • A viewport length in a condition resolves against the window (css-values-4 §6.1.2).
  • In a boolean context, zero, none and no-preference are false, and every other value is true.
  • The runtime answers the features every React Native screen answers alike: color 8; color-index, monochrome and grid 0; scripting enabled; update fast; overflow-block and overflow-inline scroll; scan matching neither of its values; and color-gamut, dynamic-range and their video- forms srgb and standard, with p3, rec2020 and high unknown (MQ5 §6.4, §6.5, §8). -webkit-device-pixel-ratio answers as resolution (Compatibility Standard). An <mq-boolean> compiles to its integer.
  • hover, any-hover, pointer and any-pointer answer for the primary input (MQ5 §7.1, §7.2), as Safari and Chrome do on a phone or tablet: iOS and Android none and coarse, a TV none to both, Mac Catalyst and other platforms hover and fine. Tailwind's hover: then applies where the primary input hovers.
  • Keywords and media types fold to lowercase.

#451's container-rejection test expected not ((width > 100px) and style(--theme: dark)) to apply at no width. At 50px the conjunction is false whatever style() answers, so its negation applies, as in all three engines, and I changed that expectation.

Tests

  • compiler/condition-semantics.test.ts: what each condition the native tests render compiles to, a value of another type, a non-finite operand, and a condition lightningcss's types do not list.
  • native/condition-semantics.test.tsx: each condition rendered against a screen or a container, the input features on each platform, and the unresolved and overflowing operands in memory and after a JSON round trip. compare.test.ts, kleene.test.ts and units.test.tsx cover the shared helpers.
  • vendor/tailwind/states.test.tsx and groups.test.tsx: hover: and group-hover: on Mac Catalyst and on a touchscreen. These and both container-queries.test.tsx assert the colour a case expects absent, where toHaveStyle(undefined) matched any style.
  • Each expected value is what Chromium 156, Firefox 157 and WebKit 27.2 compute, and a test that departs from one says which and why. On main, 223 of the 505 cases in the two condition-semantics files and container-rejection.test.tsx fail.

Verification

Every ci.yml job passes on this head merged into main: yarn test at 1887 passed on Ubuntu with Node 22.18.0, and the iOS dev build on Xcode 27.1, where CI pins 26.6. Evaluating a condition costs within a few nanoseconds of main. The runs, the engines' answers and the device evidence are in this comment.

Known limits

Merge order

This shares lines with #388, #412, #415, #425, #459, #463, #472, #480 and #492; whichever lands second, I re-cut. #494 builds on this one, and src/utilities/ascii-case.ts is #447's file byte for byte.

#493's error recovery hands the media compiler feature values outside their syntax, which this answers as unknown.

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Outdated: #426 is now re-written on top of #425, so the two no longer conflict.

The original note

Cross-referencing #425 and #426, which conflict — flagging it now rather than at merge time.

They are independent siblings (git merge-base is main; neither is an ancestor of the other) and git merge-tree reports content conflicts in four files: src/compiler/compiler.ts, src/compiler/container-query.ts, src/__tests__/compiler/media-query.test.ts, src/__tests__/native/container-queries.test.tsx.

The hazard is in the [] (interval) arm of testContainerQuery, because a plausible resolution silently reverts work:

Taking #426's arm wholesale therefore un-fixes #425's range container queries. Taking #425's wholesale loses #426's three-valued handling, where false is the one answer a negation turns into a match.

The resolution that is correct in both directions evaluates the interval and returns UNKNOWN only when a bound is unorderable.

One more line worth keeping whichever way it lands: #426's boolean-context arm uses isTruthyFeatureValue (Number.isFinite(value) && value !== 0), and the plain value !== 0 form admits NaN, Infinity and -Infinity. That is reachable — @container (aspect-ratio) compiles to ["!!","aspect-ratio"] and the feature value is width / height, so a zero-height container yields Infinity and the weaker form matches where it should not.

A two-case test pins both halves, and neither branch passes it alone:

  1. @container box (400px < width < 800px) over a 500x100 container must match. (fix: evaluate aspect-ratio media and container queries #425 ✅ · fix: evaluate range pairs, and answer a condition the runtime cannot decide with unknown #426 ❌)
  2. @container box (10em < width < 20em) and its not (…) twin must both fail over the same container — an unorderable bound is unknown, not false. (fix: evaluate aspect-ratio media and container queries #425 ❌, its negation matches · fix: evaluate range pairs, and answer a condition the runtime cannot decide with unknown #426 ✅)

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Outdated: #426 is now re-written on top of #425, so the two no longer conflict.

The original note

Correction to my note above — I undercounted the conflict set, and the omission was the load-bearing file.

git merge-tree --write-tree fix/container-query-defects fix/media-condition-semantics reports seven conflicted files, not four:

src/compiler/compiler.ts
src/compiler/container-query.ts
src/compiler/media-query.ts                  ← omitted above
src/native/conditions/container-query.ts     ← omitted above
src/native/conditions/media-query.ts         ← omitted above
src/__tests__/compiler/media-query.test.ts
src/__tests__/native/container-queries.test.tsx

src/native/conditions/container-query.ts is the file whose []-arm resolution my note describes, so leaving it off the list made the note harder to act on rather than merely incomplete.

A second resolution needs the same care, in src/compiler/container-query.ts's "not" arm. main has return query ? ["!", query] : undefined. #425 changes parseContainerCondition to return a discriminated union, so its arm becomes query.type === "condition" ? ["!", query.condition] : undefined — which DROPS an unrepresentable operand. #426 keeps main's return type and writes return ["!", query ?? ["?"]], so an unrepresentable operand negates to unknown (MQ5 §3.1).

Taking #426's arm onto #425's types does not compile — query is an object there and never nullish. Taking #425's arm silently reverts #426's fix. The resolution that keeps both is #425's discriminant with #426's fallback:

["!", query.type === "condition" ? query.condition : (["?"] as MediaCondition)]

And one more, in src/native/conditions/compare.ts. #426 introduces MediaFeatureOperand = Exclude<StyleDescriptor, undefined> | null and a parseMediaFeatureOperand that can hand the comparator null; compare.ts exists only on #425, where both operands are typed StyleDescriptor, which excludes null. On the merge those call sites are a compile error rather than a silent null === null → true — so it self-flags, but the fix is to widen both operands and guard left !== null before the equality shortcut.

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The control and the pink bar paint, the two blue ones do not. A range pair and (width) in a boolean context never match, and the unmeasured color-gamut answers false, so its negation applies.

FIXED — The control and both blue bars paint, the pink one does not. A range pair is its two comparisons, (width) is true because the width is not zero, and not (color-gamut: srgb) is false, since every screen shows the sRGB gamut, so it applies nowhere.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

Keywords in any case, on main and here

condition-semantics.test.tsx renders each query against the screen or container named. Chromium 156, Firefox 157 and WebKit 27.2 agree with this PR on every row.

query where main this PR
(orientation: LANDSCAPE) 600x400 screen no match match
not (orientation: LANDSCAPE) 600x400 screen match no match
(orientation: Landscape) 400x600 screen match no match
(prefers-color-scheme: DARK) dark scheme no match match
@container (orientation: LANDSCAPE) 400x200 container no match match
@container not (orientation: LANDSCAPE) 400x200 container match no match

Unknown keywords, a negative resolution and the input features, on main and here

A probe renders each query against the screen or container named, on main and on this head. Chromium 156, Firefox 157 and WebKit 27.2 answer every row but the TV ones as this PR does, the iOS and Android rows in their touch contexts and the macOS and Mac Catalyst rows on a desktop; MQ5 §7.1 and §7.2 give a touchscreen's pointer as coarse and its hover as none, and a TV's, whose remote is no pointing device, as none for both, which is what Chrome answers on a device with no touchscreen, mouse or stylus (TouchDevice.java); no engine here runs as a TV.

query where main this PR
(orientation: bogus) 400x600 screen match no match
not (orientation: bogus) 600x400 screen match no match
@container not (orientation: bogus) 400x200 container match no match
(min-resolution: -1dpi) a 2x screen match no match
not (max-resolution: -1dpi) a 2x screen match no match
not (-1dppx < resolution < 1dppx) a 2x screen match no match
(hover: hover) iOS or Android match no match
(hover: hover) iOS or Android, after a hover event match no match
(hover: none) macOS match no match
(hover) macOS no match match
(pointer: coarse) iOS or Android no match match
(any-pointer: coarse) iOS or Android no match match
(pointer: fine) macOS no match match
not (pointer: fine) macOS match no match
(pointer: fine) Mac Catalyst no match match
(hover: hover) a TV match no match
(pointer: none) a TV no match match

Viewport lengths and the features every screen answers alike, on main and here

A probe renders each query on a 600x400 screen, or in a 400x200 container on it, on main and on this head. Chromium 156, Firefox 157 and WebKit 27.2 answer every row but the last two as this PR does, Firefox answering (color-gamut) false. Firefox alone answers video-dynamic-range, as this PR does, and no engine video-color-gamut, which MQ5 §8 gives the values of color-gamut for a screen's video.

query where main this PR
(width > 50vw) 600x400 screen no match match
not (width: 100vw) 600x400 screen match no match
(50vw < width < 150vw) 600x400 screen no match match
@container (width > 50vw) 400x200 container no match match
(overflow-block: scroll) 600x400 screen no match match
(color-gamut: srgb) 600x400 screen no match match
(color-gamut) 600x400 screen no match match
(dynamic-range: standard) 600x400 screen no match match
(video-dynamic-range: standard) 600x400 screen no match match
(video-color-gamut: srgb) 600x400 screen no match match

Where a test departs from an engine

Each says so beside its rows: ios is this library's platform type, which the engines read as a type no device has; Firefox resolves env() in a container query and evaluates the known half of an or (Mozilla bug 1874687); Firefox answers the boolean context of color-gamut and dynamic-range false; lightningcss prints a calc(NaN) as 5.10424e38, which the compiler reads as infinity where the engines read 0; and an em bound, a container's em, a style() query, p3, rec2020 and high are unknown here, as the body's Known limits name.

Evaluation cost — main against this PR

media-evaluation.test.ts evaluates each query a million times after 200,000 of warm-up, seven runs a side per round, each side in a jest process of its own at high priority on the same sixteen logical processors. One warm-up round, then six rounds, the side that goes first swapping each round. ns per evaluation, medians of the rounds with interquartile ranges:

query main (2421ea77) this PR (40bfda27) per-round difference
(width >= 560px) 101.2 (100.8–102.0) 98.8 (98.7–101.7) −2.0 (−2.6 to 0.0)
(prefers-color-scheme: dark) 8.0 (7.9–8.0) 10.9 (10.8–10.9) 3.0 (2.8 to 3.0)
(width >= 560px) and (width < 800px) 245.1 (244.2–250.7) 212.4 (211.8–213.2) −32.8 (−37.0 to −31.9)
not ((width >= 9999px) or (orientation: landscape)) 440.2 (403.6–471.1) 370.8 (361.4–411.5) −49.6 (−112.7 to −24.2)
(400px < width < 800px) 7.2 (6.0–8.2) 119.6 (118.2–129.0) 112.7 (110.5 to 122.5)
(width > 50vw) 10.8 (8.3–12.7) 266.8 (231.4–286.7) 256.2 (223.9 to 275.1)

main answers a range pair and a viewport length false without resolving their bounds; here each costs its comparisons. The color scheme's 3 ns follows the size of the evaluator's switches: two case labels more or fewer move it by that much, whichever features they name. Windows 11 Pro 10.0.26300, AMD Ryzen 9 9950X3D, Node 26.12.1, jest 29.7.0.

YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-css that referenced this pull request Oct 11, 2026
…vewind#426

nativewind#426 answers every media feature in a boolean context from its current
value. This branch keeps the one arm its own feature needs.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/media-condition-semantics branch from 3d2e1eb to 1b6f6f5 Compare October 11, 2026 12:36
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(native): answer an undecidable media condition with unknown rather than false fix: answer a condition the runtime cannot decide with unknown, not false Oct 11, 2026
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/media-condition-semantics branch from 1b6f6f5 to a05beba Compare October 11, 2026 18:15
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix: answer a condition the runtime cannot decide with unknown, not false fix: evaluate range pairs, and answer a condition the runtime cannot decide with unknown Oct 11, 2026
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 20, 2026 11:46
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/media-condition-semantics branch from f560c39 to 460329a Compare October 27, 2026 01:55
…decide with unknown

Media and container queries now evaluate through one walk and one comparison in
three-valued logic (MQ5 §3.1): a term the runtime cannot decide is unknown, its negation
too, and a rule applies only when its condition is true, as in Chromium, Firefox and WebKit.

- A range pair is its two comparisons, where both runtimes answered it false.
- A term is unknown when its feature is unmeasured, its value is outside the feature's
  syntax (a keyword or a type Media Queries 5 does not give it, env(), a negative
  resolution) or its operand is unresolved, which compiles to null so JSON carries it. A
  condition the compiler's unknown terms leave no way to be true compiles to no rule, as
  does a container condition CSS Conditional 5 §5.4 discards.
- A number the compiler emits past the largest float compiles to the largest safe integer,
  since JSON writes Infinity as null.
- A feature in a boolean context is false only for zero, none and no-preference.
- A viewport length in a condition resolves against the window.
- hover, any-hover, pointer and any-pointer answer for the primary input, as Safari and
  Chrome do on a phone; color, color-index, monochrome, grid, scripting, update,
  overflow-block, overflow-inline, scan, color-gamut, dynamic-range, their video- forms
  and -webkit-device-pixel-ratio answer as every React Native screen does.
- A keyword and a media type match in any case.
…integer

A length rounds after its clamp, to 9007199254740990, as the (min-width: 1e40px) row pins.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant