Skip to content

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

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

YevheniiKotyrlo wants to merge 18 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. The compiler emits (400px < width < 800px) as an interval, and both runtimes answer every interval false.
  2. A negated term the runtime cannot decide matches. Both runtimes answer such a term false, so not (fictional-feature: 3) applies everywhere. A keyword a feature does not take fares no better: lightningcss passes any identifier through as a feature's value, the media runtime reads (orientation: bogus) as portrait, and the container runtime answers it false, so @container not (orientation: bogus) applies in every container. MQ5 §3.1 makes each term unknown and its negation unknown, and Chromium, Firefox and WebKit apply neither.
  3. The compiler drops a media term whose value it cannot resolve. Under or the term disappears, so not ((width: env(safe-area-inset-top)) or (width > 9999px)) applies; under and the whole block disappears. All three engines answer the opposite in both.
  4. Container conditions keep what CSS Conditional 5 §5.4 discards. not (fictional-feature > 10px) and (fictional-feature > 10px) or (width > 0px) apply, where §5.4 selects no container.
  5. A feature in a boolean context never matches: (width), (hover), (color), (prefers-color-scheme) (MQ5 §2.4.2).
  6. The device answers several features wrongly. (hover: hover) and (hover: none) both match on every device; pointer and any-pointer are never measured, so not (pointer: fine) applies on every device; color, color-index, monochrome, grid, scripting, update and -webkit-device-pixel-ratio are never measured; and (grid: 0) never matches, because grid's value compiles to a boolean.
  7. A keyword is compared as written, and so is a media type: (prefers-color-scheme: DARK) never matches, nor does @media IOS on iOS, and the media runtime reads every orientation keyword but landscape as portrait, so (orientation: LANDSCAPE) and (orientation: Landscape) match in portrait and never in landscape. A keyword is ASCII case-insensitive (css-values-4 §4.1), as is every token of a media query (MQ4 §3), and all three engines match each keyword as its lowercase form.

Solution

  • Media and container queries evaluate through one walk, evaluateCondition, and compare through one function, compareMediaFeature, so each connective and each operator has one meaning, and a range pair is its two comparisons. The compiler parses a feature of either kind with one function, parseQueryFeature.
  • Both evaluate in three-valued logic (MQ5 §3.1), through a small kleene.ts. A term is unknown when the runtime does not measure its feature, when its feature holds an env(), which no browser takes as a media feature's value, when the compiler could not otherwise resolve its operand (which now compiles to null), or when the runtime cannot order it (an em or vw bound, a calc()). A condition lightningcss's types do not list, such as the <general-enclosed> a newer lightningcss's error recovery emits, compiles to an unknown term rather than failing the stylesheet. The compiler makes a term unknown when its keyword is not one Media Queries 4 or 5 gives the feature: orientation, hover, any-hover, pointer, any-pointer, prefers-color-scheme, prefers-reduced-motion, scripting and update. A rule applies only when its condition is true, and the compiler emits no rule for a condition that the terms it already knows are unknown, and the operands it could not resolve, leave no way to be true; a term only the runtime finds unknown, such as an unmeasured feature, still compiles.
  • A container condition holding a feature that is no size feature, a keyword its feature does not take or an env() value compiles to no rule, as Chromium and WebKit drop it (§5.4), where Firefox resolves the env(); any other value the compiler cannot resolve leaves its term unknown, so an or keeps its other terms. A style() query compiles to an unknown term.
  • In a boolean context, zero, none and no-preference are false (MQ5 §2.4.2, §12), and every other value is true.
  • The runtime reports the features whose value is the same on every React Native screen: color is 8, color-index, monochrome and grid are 0, scripting is enabled and update is fast. An <mq-boolean> compiles to its integer (MQ4 §5.3). -webkit-device-pixel-ratio, which -webkit-min-device-pixel-ratio compiles to, reads the pixel ratio resolution reads (Compatibility Standard).
  • hover, any-hover, pointer and any-pointer answer for the primary input (MQ5 §7.1, §7.2), as Safari and Chrome answer on a phone or tablet whether or not a mouse is attached: iOS and Android answer none and coarse; a TV, whose remote is no pointing device, none to both, as Chrome answers a device with no touchscreen, mouse or stylus (TouchDevice.java); Mac Catalyst and every other platform hover and fine. React Native reports no other input device, so any-hover and any-pointer answer for the primary input too. Tailwind's hover:, which guards :hover with (hover: hover), then applies where the primary input hovers, as in those browsers.
  • The compiler folds a keyword to lowercase where it reads a feature's value, and a media type where it reads the query's, so the keyword check, the comparison and the platform types read one spelling.
  • The runtime's min-width, max-width, min-height and max-height arms go: lightningcss compiles each to a range on width or height.

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

Tests

  • src/__tests__/native/compare.test.ts: the shared comparison, range pairs, operands it cannot order, the boolean-context rule, and an empty or decided conjunction and disjunction, which evaluates no further terms.
  • src/__tests__/native/kleene.test.ts: negation, conjunction and disjunction over the three values.
  • src/__tests__/native/condition-semantics.test.tsx: each condition rendered against a screen or a 400x200 container, and hover, any-hover, pointer and any-pointer on each platform, on a TV and on Mac Catalyst, and on iOS after a hover event, and the iOS platform type written in capitals. The undecidable, boolean-context, device-feature, pixel-ratio and keyword cases render as in Chromium 153, Firefox 155 and WebKit 26.6, the iOS and Android input cases as in their touch contexts, the macOS and Mac Catalyst ones as on a desktop, the TV ones as MQ5 §7.1 and §7.2 give a device whose only input is a remote, and the §5.4 container cases as in Chromium and WebKit.
  • src/__tests__/compiler/condition-semantics.test.ts: which blocks the compiler emits for an unresolved value, an unknown container feature, a style() query, a condition no device can make true, and a condition or an operator lightningcss's types do not list.
  • src/__tests__/vendor/tailwind/states.test.tsx and groups.test.tsx: Tailwind's hover: and group-hover: apply on Mac Catalyst and not on a touchscreen.
  • These two files and both container-queries.test.tsx assert the colour a case expects absent, where toHaveStyle(undefined) matched any style.
  • On main, 107 of the 215 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 with #477's harness fix, on Ubuntu with Node 22.18.0 with yarn test at 1598 passed, and the iOS dev build on macOS 27.0.1 with Xcode 27.0, where CI pins 26.6.

Evaluating a media condition costs the same as on main or less: (prefers-color-scheme: dark) 6.8 ns against 8.0, (width >= 560px) 98 against 105. The runs and the device evidence, before and after, are in this comment.

Known limits

Merge order

Range pairs moved here from #425, so this no longer builds on it. It shares lines with #388 (native/conditions/media-query.ts, where it adds prefers-reduced-motion and drops the evaluator's file-wide eslint-disable too), #412 (compiler/compiler.ts), #415 and #425 (native/conditions/media-query.ts), #459 (compiler/compiler.types.ts, native/conditions/media-query.ts), #463 (native/conditions/container-query.ts and guards.ts, whose testContainerQuery signature the container tests call), #472 (vendor/tailwind/states.test.tsx) and #480 (native/conditions/container-query.ts); whichever lands second, I re-cut. #494 builds on this one. src/utilities/ascii-case.ts is the file #447 adds, byte for byte, so the two merge in either order.

@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 unknown, 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 153, Firefox 155 and WebKit 26.6 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 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 153, Firefox 155 and WebKit 26.6 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
(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

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 (eda2b286) this PR (328c8da2) per-round difference
(width >= 560px) 104.8 (103.4–105.5) 98.0 (97.3–101.5) −6.1 (−6.7 to −3.8)
(prefers-color-scheme: dark) 8.0 (8.0–8.0) 6.8 (6.8–6.8) −1.2 (−1.3 to −1.2)
(width >= 560px) and (width < 800px) 263.8 (262.2–265.1) 216.1 (210.1–232.1) −49.4 (−52.6 to −45.7)
not ((width >= 9999px) or (orientation: landscape)) 503.1 (442.9–562.2) 439.1 (406.3–449.6) −66.4 (−146.8 to 0.1)

Windows 11 Pro 10.0.26300, AMD Ryzen 9 9950X3D, Node 26.8.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
Both evaluators answered a range pair such as (400px < width < 800px)
with false, though the compiler emits it faithfully. Media and container
queries now share compareMediaFeature, so each operator has one meaning,
and testMediaFeatureInterval answers a pair as its two comparisons.
…alse

MQ5 §3.1 evaluates a media condition in three-valued logic: a term the
runtime cannot decide is unknown, its negation is unknown, and a rule
applies only when the whole condition is true. Both runtimes answered such
a term false, so its negation matched: not (fictional-feature: 3) and
not (pointer: fine) applied everywhere.

A media feature value the compiler cannot resolve compiles to null, and
both runtimes evaluate in Kleene logic, answering unknown for a null
operand, a feature they do not measure, an operand they cannot order and a
container style() query. A container condition holding a size feature the
runtime does not know, or a value the compiler cannot resolve, selects no
container (CSS Conditional 5 §5.4), so the compiler emits no rule for it.

A feature in a boolean context is evaluated (MQ5 §2.4.2). The runtime
answers the features whose value is the same on every React Native screen,
(hover: none) no longer matches, a device with no color scheme preference
matches light, and an <mq-boolean> value compiles to its integer.
@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
…ot one of its own

lightningcss passes any identifier through as a feature's value, so
`(orientation: bogus)` compiled to a comparison that is false, and its
negation matched everywhere. A keyword the feature does not take now
compiles to an unknown term in a media query, and to no rule in a container
query, as an unresolvable value already does, so neither the term nor its
negation matches, as in Chromium, Firefox and WebKit.
…paring it

A keyword is ASCII case-insensitive (css-values-4 §4.1). The keyword
check folded it while the compiled comparison kept it as written, so
`(orientation: PORTRAIT)` never matched and `not (orientation: PORTRAIT)`
matched in portrait. The value is folded where the compiler reads it,
which both the check and the comparison then use.
…ver emits

lightningcss compiles every `min-` and `max-` media feature to a range
on `width` or `height`, so no compiled condition names them.
Its new lines hid a duplicated union and an unused import behind the
file-wide `eslint-disable`, and the compiler's operator default wrote a
`never` into its error message.
…h, and answer hover by input

A container term the compiler cannot encode, such as (width > 10in), is
unknown rather than dropping the whole query with its `or` siblings, and
`=` with an operand the runtime cannot order is unknown, as the other
operators are. A condition no device can make true, such as a style()
query or an unknown keyword alone or negated, compiles to no rule. An
unknown media feature named like an Object.prototype member compiles
again. hover and any-hover answer none on iOS and Android until a
pointer hovers an element, and hover from then on and on other
platforms (MQ5 §7.2, §7.3), so Tailwind's hover: keeps working wherever
a pointer hovers. A held color scheme is compared as held, so a null one
is unknown. The two files that kept a file-wide lint disable lint clean
without it.
…y, as the components read it

The jest helper resolves to the package's source in a consumer's suite
while the components it renders resolve to the build, so a reset through
a relative import missed the signal they read.
…ebkit-device-pixel-ratio

A media query's reader is a module constant that takes the getter, a container's
takes one context object, and the walk folds and and or itself, so evaluating a
term allocates nothing. -webkit-device-pixel-ratio reads PixelRatio.get() as
resolution does. The nested conditions are readonly, the container tests assert
the colour absent before layout, and the condition test's afterEach, which the
Jest preset's reset made dead, goes.

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