Skip to content

fix(native): evaluate container and media conditions correctly on both axes and every operator - #425

Open
YevheniiKotyrlo wants to merge 8 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/container-query-defects
Open

fix(native): evaluate container and media conditions correctly on both axes and every operator#425
YevheniiKotyrlo wants to merge 8 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/container-query-defects

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Eight defects in the container-query and media-query condition evaluators. Every fix is mutation-proven — the defect is reintroduced, the red cases counted, then reverted — and each commit carries its own figures.

The defects

  1. Every min-width / max-width container query was a strict greater-than. >=, < and <= all returned left > right in native/conditions/container-query.ts; only > and = were correct. So @container (max-width: 400px) matched exactly when it should not.
  2. Every container reported its height as its width. containerHeightFamily read ?.width, so @container (orientation: …) answered portrait for every real container — width > height was unsatisfiable — and every height-based query answered on the wrong axis.
  3. inline-size and block-size never matched. Both compile cleanly, and getContainerFeatureValue answered undefined for each, so @container (min-inline-size: 400px) matched nothing at any size. That is the axis container-type: inline-size names, which makes it the feature most container queries are written against. React Native lays out in one writing mode, so inline is the width axis and block the height axis.
  4. Interval (range-pair) conditions were never evaluated. Both evaluators carried case "[]": return false, so (400px < width < 800px) was a no-op on both at-rules. The compiler emitted the pair faithfully throughout; only the evaluation was missing.
  5. aspect-ratio queries were dropped instead of evaluated — parseMediaFeatureValue had no ratio arm, so the whole condition was unresolvable.
  6. A <ratio> with no finite quotient compiled to a value the bundle cannot carry. @media (min-aspect-ratio: 1/0) produced [">=", "aspect-ratio", Infinity]. The native injection path serialises through JSON.stringify, which writes Infinity and NaN as null, so the condition meant one thing under jest and another on a device. A degenerate ratio is refused instead, which routes it into the same "did not compile" path as every other unresolvable value.
  7. A conditional block whose condition did not compile was kept rather than dropped, so its declarations applied to every element carrying the class — the opposite of what the author wrote.
  8. Importing react-native-css/compiler booted the native runtime. compiler/compiler.types.ts had a value import of VAR_SYMBOL, and verbatimModuleSyntax means it is not elided, so the emitted dist/commonjs/compiler/compiler.types.js required native reactivity and registered Dimensions and Appearance listeners in a bundler process.

One comparison, shared

@media and @container speak one MediaFeatureComparison vocabulary, and defect 1 is what a second hand-written copy of it costs. native/conditions/compare.ts now holds the only implementation, and both evaluators call it with whatever the feature answered rather than narrowing first — narrowing at the call site is exactly how the second copy gets written, since an evaluator that has to reject a keyword before it can call the primitive ends up deciding = itself.

Measured: making = wrong inside that primitive reddens 14 cases, six of them under @container, including the orientation keyword rows. Split across two implementations the same mutation reddened 8, and none of them under @container.

Evidence

The figures below are the ones re-measured for the final pass, all under one rule: whole-suite, excluding the three pre-existing Windows babel-plugin-tester failures that are present on main.

reintroduced defect red
= decided wrongly in the shared comparison 14
the <ratio> arm removed 17
an interval's bounds swapped in the @media emit 9
inline-size / block-size back to unanswered 5
the finite-quotient check removed 3
the numeric guard removed from the shared comparison 3
an operator dropped from the shared census 5

Two of those need a word.

  • The swapped-bounds emit. 9 splits into 5 compiler-plane pins and 4 runtime rows. Against the commit that first evaluated intervals it is 4, because the compiler-plane @media interval table did not exist yet; against the base it is 0, because an unevaluated interval cannot observe its own layout. @media had no compiler-plane table at all for either the comparison operators or the interval layout, while @container was pinned twice — and one primitive now decides a range comparison for both, which is precisely where a divergence between their emits would land unseen.
  • An operator dropped from the census. src/__tests__/_media-features.ts is one census (five operators, two axes, the min-/max- spellings) feeding five tables, and an incomplete census silently generates fewer cases while every suite stays green — deleting "<" removes 19 cases. Coverage is therefore asserted against COMPARISON_MATCHES, a total Record over MediaFeatureComparison whose keys are the union itself, not against the length of the generator each table was built from: that length is the census's length by construction and agrees with a census of any size, zero included.

Defect 8 is pinned from two sides. src/__tests__/compiler/native-runtime-isolation.test.ts walks the module graph and refuses any specifier out of src/compiler/ that can reach native/reactivity on any platform — asking about reachability rather than about a list of runtime directory names, because the ways in are the package's own entry points: react-native-css re-exports runtime, which is runtime.native on native, and none of those three ids sits under a runtime directory. src/__tests__/native/runtime-boot.test.ts is the other side, loading a module into a fresh registry and counting what it attached to Dimensions and Appearance. Before writing it I measured that babel-preset-expo does not elide an import whose only specifier sits in a type position — had it elided, the test would have passed for an unrelated reason.

Suite: 1072 → 1361 tests, two runs with identical totals and zero suite-load failures. yarn typecheck and yarn lint exit 0. The 3 failures are the known Windows babel-plugin-tester baseline (an unrewritten relative require("../View")), present on main.

Known limits, deliberately left standing

  • A comma-separated media query list is intersected, not unioned. rule.m is a flat array fed from two places with opposite meanings — one entry per comma branch, which CSS unions, and one per enclosing @media block or media-carrying selector, which CSS intersects — and testMediaQuery intersects the whole array. Nesting is therefore right and a comma list is not: @media (min-width: 400px), (min-height: 300px) at 600x200 does not match, where CSS asks for either. Switching the evaluator to .some(...) fixes the list and breaks nesting, measured both ways; the emit has to say which kind of entry it is producing. That is a change to what is emitted rather than to how a condition is evaluated, so it is out of scope here and noted where the decision is made.
  • The boolean context is unimplemented. @media (width) and @container (width) compile to ["!!", name] and both evaluators answer false, so the query reads as valid and can never match. Answering it needs a truthiness rule per feature.
  • @container has no counterpart to the @media all cases: CompiledContainerCondition Excludes the always state, so it is unrepresentable rather than untested.

Relationship to the media-condition-semantics branch

These two branches overlap and I would rather say so than have it surface at merge. They touch 10 files in common, and git merge-tree against fix/media-condition-semantics reports 7 conflicted files with 17 conflict hunks. No commit is duplicated — patch-id overlap is zero — but defects 1 and 2 are each fixed on both branches by different implementations.

I am happy to rebase this onto whichever lands first, or to drop the overlapping commits from one side. Say which you prefer and I will restructure rather than ask you to resolve it.

…ctly

The container query evaluator's comparison switch returned `left > right`
for `>=`, `<` and `<=` as well as for `>`. Only `>` and `=` were correct, so
`@container (min-width: 400px)` and `@container (max-width: 400px)` both
behaved as a strict greater-than: a 400px container matched neither, and a
`max-width` query matched every container wider than its threshold.

The compiler is not at fault. lightningcss normalises `min-`/`max-` into
range conditions and the compiler emits `>=` / `<=` faithfully; the operator
was discarded one hop later, at evaluation.

The media query evaluator carried a correct copy of the same five-armed
switch, so this was drift between two hand-written copies of one decision.
Both now call `compareMediaFeature`, a single exhaustive primitive, and the
media query side is narrowed to `MediaCondition`'s comparison arm (derived
with `Extract`, not restated) so it can call the primitive without a cast —
which also removes the unreachable `default` arm that let the wide parameter
type hide the duplication.

Tests: a 15-case cross product of the five operators against left<right,
left===right and left>right over the primitive; a 14-case runtime table
against a 400x200 container covering both false negatives and false
positives; and a compiler-plane table pinning the emitted condition IR so a
future normalisation change cannot silently reintroduce the same symptom.
`containerHeightFamily` read `layout.width`, so every container reported its
width as its height. Three container query features are derived from it and
all three were wrong:

- `height` / `min-height` / `max-height` answered with the container's width.
- `orientation` could never be `landscape`, because it compares width against
  height and both sides were the same number.
- `aspect-ratio` was always 1.

The compiler emits `height` and `orientation` faithfully; the measurement was
substituted one hop later, when the layout rectangle was projected onto the
two axis observables.

Tests: a 13-case height table and a 6-case orientation table, both against a
400x200 container so the two axes hold different values and reading the wrong
one cannot pass by coincidence — square and portrait containers are included
as controls, since those are the shapes under which the defect is invisible.
The compiler-plane assertion is differential: identical syntax on the two
axes has to compile to two different conditions.
`compiler.types.ts` declares nothing but types, yet imported `VAR_SYMBOL`
with a value import. `verbatimModuleSyntax` is on, so the declaration is not
elided and the emitted module is one unused side-effectful require of the
native runtime:

    // dist/commonjs/compiler/compiler.types.js
    "use strict";
    var _reactivity = require("../native/reactivity.js");

Evaluating that module registers a `Dimensions` and an `Appearance` listener,
neither of which a build-time compiler has any use for. Nothing loads it
today — every other reference to `compiler.types` is `import type`, so the
emitted file is an orphan rather than a live cost — but the compiler entry is
one ordinary re-export away from pulling the whole native runtime into Metro,
and nothing in the source says so.

`VAR_SYMBOL` is used as a computed property key in a type declaration, which
`import type` supports; the emitted declaration file is unchanged.

The guard is the invariant, not the line: a scan of every file under
`src/compiler/` for a module reference that survives emit — `import type` and
`export type` elided, `import { type X }` and `import defer` not, since
neither of those is. The detector is unit-tested against all ten declaration
shapes, and two vacuity guards fail if the scan stops reaching files or stops
recognising imports, so the invariant cannot pass by finding nothing.
A condition the compiler could not compile was discarded and the block was
emitted anyway, with no condition on it — so its declarations applied to every
element carrying the class, at every size. That is worse than the block being a
no-op: the rule fires where the author said it must not.

    @container style(--foo: bar)              cq: [{ m: undefined }]
    @container (width > env(safe-area-inset-top))  cq: [{ m: undefined }]
    @media (width > env(safe-area-inset-top))      no `m` at all

`@container` reached it one way and `@media` the other. `extractContainer`
built `{ m: parseContainerCondition(...) }` and never looked at the `undefined`;
`parseMediaQuery` returned early without adding anything, which `extractMedia`
could not tell apart from `@media all` — the case where there is genuinely no
condition and the block really does always apply. Both extractors then walked
the block's rules regardless.

So the fault is one missing distinction, not two bugs: "there is no condition"
and "the condition did not compile" were the same value. `CompiledCondition`
separates them into `always` / `never` / `condition`, and the container half is
`Exclude`d from it rather than restated, because a container prelude is always
a condition. `parseMediaQuery` now returns that verdict instead of mutating the
builder, which is what lets `extractMedia` aggregate over a comma-separated
list before deciding: the list is a union, so one uncompilable branch still
contributes nothing while the others apply, and only a list where no branch can
match drops the block. `extractMedia` already dropped a block no query could
match — that is what the `@media print` filter does — so this extends an
existing decision rather than adding a new one.

Behaviour change, deliberately: styles under an uncompilable condition used to
apply everywhere and now apply nowhere. Nowhere is what the runtime already
does with an unsupported feature it can see — `getContainerFeatureValue`
returns `undefined` and the comparison is false — so the two planes now agree.

`@media not print and (width > 400px)` is the one case that must stay
unconditional: it reads `not (print and ...)`, which is true on every non-print
device. It is in the table, and inverting it is one of the mutations below.

Tests: a compiler-plane table of four uncompilable preludes across both
at-rules asserting nothing is emitted, against six controls asserting that a
compilable one still is — without the controls a compiler that emitted nothing
at all would pass. Runtime tables on both at-rules confirm the styles do not
apply. Each uncompilable case is also a vacuity guard on the others: if support
for `env()` or `style()` lands, that row starts emitting and fails, which is the
signal to move the case rather than delete it.

Mutation-proved. Deleting the container guard fails exactly the four container
cases; deleting the media guard fails exactly the four media cases; returning
`never` for print fails `@media not print`; returning `never` for the
no-condition case fails `@media all` and `@media screen`.

The native container-query helper now takes the condition in full, parentheses
included. It used to add them, which made `style(--foo: bar)` and a leading
container name inexpressible — both are `<container-condition>` forms — and it
disagreed with the compiler-plane helper, which already took the full prelude.
`parseMediaFeatureValue` had no `ratio` arm, so every `<ratio>` compiled to
`undefined` and took its whole condition with it. `@container (aspect-ratio >
1)` and `@media (min-aspect-ratio: 16/9)` reached the runtime as nothing at all.

The runtime was ready for the container half and had been all along:
`getContainerFeatureValue` already answers `aspect-ratio` with the container's
width over its height. No IR ever named the feature, so that arm could not run
— the compiler is where the support was missing, not the evaluator.

A `<ratio>` is a pair of numbers standing for their quotient, which is the same
number both runtimes derive from their two axes, so the pair is compiled to the
quotient and every comparison operator then works on it unchanged. A bare
number is a ratio too, so `1` arrives as `[1, 1]`.

The media evaluator needed the feature itself, which it did not have: viewport
aspect ratio is `vw / vh`, read off the two observables it already uses for
`width` and `height`, and it joins them in the same numeric-feature switch.

Tests: compiler tables on both at-rules pinning the emitted condition, with
`min-`/`max-` prefixed forms included because lightningcss normalises those
into `>=`/`<=` range conditions the same way it does for lengths. Runtime
tables measure against containers and viewports whose ratio is exactly 2, 0.5
and 1, so a value read off the wrong axis cannot pass by coincidence.

Mutation-proved. Removing the `ratio` arm fails all 17 cases across both planes
and both at-rules; removing the media evaluator's `aspect-ratio` arm fails only
the 3 media runtime cases, the compiler ones staying green; inverting the
container evaluator to `height / width` fails 7 of the 11 container cases,
including two that flip from false to true.
`@container (400px < width < 800px)` matched nothing, at any size. Both
evaluators carried `case "[]": return false`, so every range pair was a no-op
on both at-rules.

The compiler was never at fault. It emits the pair faithfully, in source order
— `["[]", "width", 400, "<", 800, "<"]` — including the descending form
`(800px > width > 400px)`. Only the evaluation was missing, which is why the
compiler-plane tests here are pins rather than fixes.

An interval is two comparisons, and the shared `compareMediaFeature` already
had the operator semantics, so the addition is which operand goes on which
side: the start bound is on the left of its operator and the measured value on
the right, the end bound the other way round. Getting that backwards produces
a well-formed interval that means something else, so both evaluators call one
`testMediaFeatureInterval` rather than each writing the destructuring out.
`MediaInterval` is `Extract`ed from `MediaCondition`, matching how the
comparison arm is derived.

The media evaluator had no way to answer a feature outside a comparison tuple —
its numeric features were resolved by a `let left` switch inside
`testComparison` — so that resolution is now `getMediaFeatureValue`, which the
comparison arm and the interval arm share. `testComparison` reads better for it:
the value it compares is fetched, not accumulated through `break`s.

Two `Boolean` return annotations became `boolean` on the way past. They are the
boxed object type, and both are on functions this change restructures.

Tests: a nine-case table over the primitive varying which side of each bound
the value falls on, with strict and non-strict operators paired so the two are
never interchangeable, plus four unanswerable cases — an unmeasurable feature
and an unresolved bound are no answer, not no bound. Runtime tables on both
at-rules against a 600x200 box, with the measured value placed exactly on each
bound open and closed. The compiler-plane table pins the tuple layout the
evaluation depends on.

Mutation-proved. Restoring `return false` in the container evaluator fails 5
cases and in the media evaluator 4, disjointly; assembling the two halves the
other way round fails 13 across both planes, including four of the primitive's
own cases; swapping start and end in the compiler's interval emit fails the
pins and the container runtime together.

This also dissolves the last identical-bodied switch arms under
`src/native/conditions`: an AST sweep for clauses with textually identical
bodies now reports none there.
An audit of what this branch actually observes, done by reintroducing each
defect and counting the cases that went red — first against the branch head,
then against this commit. Three code paths were reddened by nothing, and a
fourth by a single case.

    reintroduced defect                              head   now
    a `height` range compiled as `width` (@media)       0     7
    an interval's bounds swapped in the emit (@media)   0     5
    the runtime drops its Appearance subscription       -     1
    the viewport `height` feature answered with `vw`    1    12
    `>=`, `<` and `<=` all `left > right`              38    65
    `>=` compiled as `>`                                8    13
    a `height` range compiled as `width` (@container)   4     8
    `containerHeightFamily` reads the width axis       16    19
    a value import of VAR_SYMBOL                        1     2
    …plus a value re-export from the compiler entry     1     3
    `@media all` compiled as "did not compile"          2     6
    a comma list dropped for one refused branch         1     2
    an interval's bounds swapped (@container)           5     5
    the media evaluator's interval arm returns false    4     4
    the `<ratio>` arm removed                          17    17

The first four rows are the finding. `@media` had no compiler-plane table for
either the comparison operators or the interval layout, so the whole at-rule
compiled unobserved while `@container` was pinned twice over; and one
primitive now decides a range comparison for both, which is precisely the
place a divergence between them would land unseen. The viewport `height`
feature was reachable only through an interval case, on an axis this branch
rewrote. And the compiler-plane isolation scan reads source shape, so nothing
asserted the fact it rests on — that evaluating the runtime installs two
listeners on the host.

`src/__tests__/native/runtime-boot.test.ts` is the native half of that
isolation invariant: load a module into a fresh registry and count what it
attached to `Dimensions` and `Appearance`. It reaches further than the scan,
which only sees direct specifiers out of `src/compiler/` — a path into the
runtime through a third directory is invisible there and caught here.

Measured before writing it, because the jest transform decides whether the
plane can carry the defect at all: `babel-preset-expo` does NOT elide an
import whose only specifier is used in a type position, so the pre-fix source
emits `require("../native/reactivity")` under jest exactly as it does in
`dist`. Had it elided, the test would have passed for a reason unrelated to
the fix.

The operator tables are generated from one census in
`src/__tests__/_media-features.ts` rather than listed four times: the five
operators, the two size features, the `min-`/`max-` spellings, and what each
operator MEANS. The meaning is written out, never computed — deriving it from
the code under test would make every table agree with a wrong operator — and
it is shared so the primitive's own table and the four rendered ones cannot
disagree. Each consumer asserts the census reached it, since an empty one
generates no cases and leaves every suite green. The file is underscore-
prefixed, which is what `testPathIgnorePatterns` already excludes.

`@container` has no counterpart to the `@media all` cases, and that is a type
rather than a gap: `CompiledContainerCondition` `Exclude`s the `always` state,
so a container prelude with no condition is unrepresentable.

Tests: 1325, up from 1225. The three failures are the pre-existing Windows
`babel-plugin-tester` output mismatches, unchanged.
Reintroducing each defect and counting the reds surfaced four places where the
branch guarded less than it claimed, and two sibling defects of the same class
as the ones it fixes. Counts here are whole-suite, and exclude the three
pre-existing Windows babel-plugin-tester failures.

The isolation scan had a hole it could not see
----------------------------------------------

native-runtime-isolation restated its own plane census as ["native",
"native-internal"], and the ways into the native plane are not directory
names. src/index.ts re-exports runtime, and runtime is runtime.native on the
native platform, so `import "react-native-css"` in a compiler source resolves
to `index` — not under either directory, and pulling all of both. Measured
against the census form: that import, `import "../runtime.native"` and
`import "react-native-css/runtime"` each reddened zero cases while producing
exactly the dist defect the VAR_SYMBOL fix exists to prevent.

So the census is gone and the question is reachability: can the module a
specifier resolves to reach native/reactivity, through any number of hops and
on any platform. That is the fact the invariant actually rests on — the
listeners — rather than a second name for it. All three specifiers now redden
the scan, and so does a path through a third directory, which no scan of
src/compiler/ alone could see: a value import of the runtime added to
src/utilities reddens four cases, the scan among them.

Two guards go with it. The detector walks the whole tree rather than the
top-level statements, so a require() or a dynamic import() inside a function
body is a module reference it can find; and a reachability table states what
the check discriminates, so a renamed native/reactivity fails loudly instead
of making every answer false.

compiler/inheritance.test.ts stays in the scan. It sits in that directory
rather than under __tests__, so bob compiles it into dist and the package
ships it, which makes it a compiler source like any other.

One comparison, not one and a half
----------------------------------

compare.ts claimed an operator has exactly one meaning at runtime, and `=` had
two: the container evaluator short-circuited it before reaching the primitive,
because a keyword feature like orientation answers a string. Measured:
deciding `=` wrongly inside the primitive reddened 8 cases and none of them
under @container, whose table holds (width = 400px) rows.

The primitive takes StyleDescriptor now and answers `=` itself, since
narrowing at the call site is how the second copy got written — an evaluator
that must reject a keyword before it can call the primitive ends up deciding
equality on its own. Both evaluators hand it whatever the feature answered and
nothing else. The same mutation now reddens 14, six under @container,
including the orientation rows.

The numeric guard travels with it, so testMediaFeatureInterval holds none of
its own: an unmeasured value or an unresolved bound fails whichever comparison
it is an operand of. That guard was observed by nothing — dropping it reddened
zero — because the cases that looked like they pinned it return false either
way. "landscape" < 800 is NaN < 800; only a string that COERCES tells the two
apart, and 400 < "500" is 400 < 500. Three rows cover it now, one per slot,
each mutation-proven.

Two sibling defects, same class
-------------------------------

inline-size and block-size compile cleanly and answered undefined, so
@container (min-inline-size: 400px) matched nothing at any size — on the axis
container-type: inline-size names, which is the one most container queries are
written against. React Native lays out in one writing mode, so inline is the
width axis and block the height axis. Reverting reddens 5.

A <ratio> with a zero denominator has no finite quotient, and
(min-aspect-ratio: 1/0) emitted Infinity. The native injection path serialises
through JSON.stringify, which writes Infinity and NaN as null, so the
condition meant one thing under jest and another on a device. A degenerate
ratio is refused instead, which routes it into the same "did not compile" path
as every other unresolvable value. Reverting reddens 3.

Assertions that could not fail
------------------------------

Five expect(cases).toHaveLength(census.length * ORDERINGS.length) compared a
generated table against the generator that made it, so the product held for
any census. Deleting "<" from the operator census removes 19 cases across four
files and left all five green; only compare.test.ts's comparison against
COMPARISON_MATCHES — a total Record over MediaFeatureComparison, whose keys
are the union itself — noticed. Every table asserts coverage against that
record now, and the same deletion reddens 5 tests in 5 suites.

conditional-group-rules's control table asserted only that one rule came out,
never which condition it carried, so a block emitted with the wrong condition
— or with none — passed it. Each case names the condition now, including the
two preludes that legitimately carry none.

And the aspect-ratio and interval tables get the label they were missing: a
matches: true row and a matches: false row fail under opposite defects, one
for a block that stops being emitted and one for a block emitted with nothing
to check. Neither half observes the other's direction, which is why both are
there and why the size of a table is not its coverage.

Corrections to what was claimed
-------------------------------

- The audit table's "an interval's bounds swapped in the emit (@media)" row
  reads 0 -> 5. Measured whole-suite it is 9, of which 5 are the
  compiler-plane pins and 4 the runtime rows that already existed one commit
  earlier — so the head column is 4, not 0. Zero is the figure against the
  base, where an unevaluated interval cannot observe its own layout. Both
  readings of "swapped bounds" give the same numbers.
- That table's counting rule is not constant across its rows: the 5 there
  counts the compiler plane only, while other rows count the whole suite. The
  counts above use one rule throughout.
- "import VAR_SYMBOL for its type only" says the emitted declaration file is
  unchanged. It is not: compiler.types.d.ts carries `import type { VAR_SYMBOL
  }` where it carried `import { VAR_SYMBOL }`, and a declaration emit diffs in
  exactly that one line and nowhere else. Inert for consumers, but not
  nothing.
- "evaluate interval (range pair) conditions" says it dissolves the last
  identical-bodied switch arms under src/native/conditions. That holds within
  each switch and not across the two files: `case "!!": return false` is the
  same body in both, and it is a gap rather than a decision.

Known limits, now stated where the decision is made
---------------------------------------------------

A comma-separated media query list is intersected, not unioned. rule.m is a
flat array fed from two places with opposite meanings — one entry per comma
branch, which CSS unions, and one per enclosing @media block or
media-carrying selector, which CSS intersects — and testMediaQuery intersects
the whole array. Measured: @media (min-width: 400px), (min-height: 300px) at
600x200 does not match while (min-width: 400px) alone does, and switching the
evaluator to .some(...) fixes the list and breaks nesting — @media (min-width:
400px) { @media (min-height: 900px) { ... } } starts matching at 600x200. The
existing suite catches neither direction, so the substitution passes it. Two
entries of the same shape mean two different things; the emit has to say
which, which is a change to what is produced rather than to how it is read. It
is left standing and written down at the every(never) decision it sits beside,
and at the evaluator a reader would otherwise reach for.

The boolean context is unimplemented on both planes: (width) compiles to
["!!", name] and both evaluators answer false, so the query reads as valid and
can never match. Answering it needs a truthiness rule per feature.

Suite: 1361, up from 1325. Two runs with identical totals, zero suite-load
failures. yarn typecheck and yarn lint exit 0.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

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(native): evaluate container and media conditions correctly on both axes and every operator #425 ✅ · fix(native): answer an undecidable media condition with unknown rather than false #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(native): evaluate container and media conditions correctly on both axes and every operator #425 ❌, its negation matches · fix(native): answer an undecidable media condition with unknown rather than false #426 ✅)

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

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 added a commit to YevheniiKotyrlo/react-native-css that referenced this pull request Aug 16, 2026
…unanswerable value

`parseMediaFeatureOperand`'s doc comment lists what has no compile-time answer
as `env()`, a ratio, an unsupported `calc()`. On this branch that is true, and
it stops being true the moment nativewind#425 lands: there a reducible ratio compiles to
its quotient and only a degenerate one stays unanswerable.

Naming the ratio more precisely does not fix it. A restrictive qualifier — "a
ratio the compiler cannot reduce to a finite quotient" — implies a non-empty
complement, and on this branch the complement is empty: every ratio, `16/9` and
bare `1` included, answers `undefined`, because `case "ratio"` falls through to
`case "env"`. So the qualified form is truth-conditionally fine and tells a
reader of THIS branch something false about it. There is no wording that names
ratios here and is right both before and after nativewind#425.

Dropping the ratio is not a narrower list, it is an honest one, because the
enumeration was never the case table it reads as. `parseLength` refuses 43 units
by name — every physical unit, every font-relative unit but `em`/`rem`, every
viewport and container variant but bare `vw`/`vh` — so `(min-width: 1lh)`
already answers `null` here with no `calc()` anywhere in it, unlisted. The two
members kept are the two verified stable: `env()` and an unsupported `calc()`
answer `null` on this branch and after nativewind#425 alike.

`such as` is what stops the next reader trusting the list as exhaustive, which
is the failure this comment already had. The ratio's own explanation belongs at
the hop that owns the fact, and nativewind#425 puts it there, in `case "ratio"`.

Comments only.
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