Skip to content

fix(native): answer an undecidable media condition with unknown rather than false - #426

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

fix(native): answer an undecidable media condition with unknown rather than false#426
YevheniiKotyrlo wants to merge 11 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/media-condition-semantics

Conversation

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor

The media- and container-condition evaluators answer several questions wrongly, and one of them wrongly in a way that cannot be distinguished from a legitimate "no".

The core problem: a condition the runtime cannot decide answered false

An operand that failed to resolve was treated as a negative rather than as unknown. false and "I could not work this out" are different facts, and collapsing them means a rule silently does not apply and nothing anywhere reports why. This branch introduces the unknown state, carries the unresolved-operand marker in a shape JSON can actually hold, and refuses an operand that could not be resolved rather than guessing.

The rest

  • A comma-separated media query list did not compile as a union. @media (a), (b) is a disjunction; one refused branch dropped the whole list.
  • Boolean media and container features were not evaluated at all — the bare (hover) / (pointer) form, as distinct from (hover: hover).
  • hover, color and prefers-color-scheme were answered from different places. They now come from one source, which is what stops them disagreeing.
  • Every container range operator now has its own comparison>=, < and <= previously all evaluated as left > right.
  • A container reported its height as its width, so orientation and every height query answered on the wrong axis.

Evidence

A reviewer pair audited this branch and converged on SHIP-WITH-CHANGES; the one point they disagreed on was settled by measurement rather than by argument, and the changes they asked for are in.

The last two commits are worth reading as a pair: one pins the two condition arms no test observed, and the next deliberately hands the media-prelude backstop to the branch that actually tests it, rather than keeping a second copy here.

Relationship to the container-query-defects branch

I am submitting both, and they overlap — better said here than discovered at merge. They share 10 files and git merge-tree reports 9 conflict sites.

No commit is duplicated between them: patch-id overlap is zero. But two defects — the container range operators, and the height axis — are fixed on both branches by different implementations. That is my doing, from working the two problems in parallel.

Tell me which you would rather take first and I will rebase the other onto it, or collapse the overlapping commits into one branch. I would rather restructure than hand you nine conflicts.

`extractMedia` pushed every query of a comma-separated `@media` prelude into
`StyleRule.m` as a separate entry. That array is a conjunction — the runtime
tests it with `.every()` — so `@media (a), (b)` required both `(a)` and `(b)`
to match. CSS specifies a comma list as a union: the block applies when any
one query matches.

`parseMediaQuery` now returns its condition instead of writing it into the
builder, and `extractMedia` combines the list. Two or more queries are joined
with the `"|"` condition the IR already carries for `or`, so a comma list and
`(a) or (b)` compile to the same thing. A single query is added unwrapped, so
existing output does not change and it still intersects with the conditions of
any enclosing rule. A query that cannot compile contributes nothing, which is
how CSS treats an unmatchable query in a list.

Covers both planes: the compiler emits the union, and the native runtime
applies a rule whose list has one matching branch.
A media feature value the compiler cannot resolve compiles to `undefined`,
and a container feature the runtime cannot measure reads back `undefined`.
Both evaluators then produced a match instead of refusing:

- `testComparison` dispatched on the feature name, and two features reach a
  verdict without reading the value. `hover` always answers true, and
  `orientation` treats anything other than `"landscape"` as portrait, so
  `@media ((orientation: env(safe-area-inset-top)) and (min-width: 0px))`
  applied its block.
- `testContainerMediaCondition` compared `undefined === undefined` for `=`,
  so `@container ((block-size: env(safe-area-inset-top)) and (width > 0px))`
  applied its block on a container it never measured.

Both now refuse an unresolved operand before the feature decides anything, so
the comparison is false and `and` / `or` / `not` compose it as CSS requires.

Forcing the operand false at the comparison is what keeps the composition
right. Dropping the whole block would discard the sibling branch of an `or`,
and dropping the operand alone would let the remaining branch of an `and`
apply a block that asked for more than the runtime can answer - so the
operand stays in the compiled condition, pinned by a compiler test.
`@media (height)` and `@container (width)` ask whether a feature has a value
that is not zero. Both evaluators answered `false` for every one of them, so
a block behind a boolean feature never applied.

`isTruthyFeatureValue` holds that question for both planes, and each plane
supplies the value: `getMediaFeatureValue` reads the viewport, the color
scheme, the direction and the pixel ratio, and `getContainerFeatureValue`
already reads the container. A feature neither can answer has no value and
stays false, and a zero or non-finite measurement is false as well - a
container that has not laid out yet does not satisfy `(width)`.

`getMediaFeatureValue` also replaces the numeric lookup inside
`testComparison`, so the viewport is read from one place rather than two.
…hold

A media or container feature value with no compile-time answer - `env()`, a
ratio, an unsupported `calc()` - compiles to a marker the runtime refuses. That
marker was `undefined`, which is not a value the transport can carry: a
stylesheet reaches a native bundle as JSON source text, and `JSON.stringify`
writes `undefined` inside an array as `null`. An operand is an array slot, so a
device never saw the marker the runtime tested for, and every block behind an
unresolvable operand applied.

The compiler now writes `null`, the one spelling of "no value" that survives the
transport, and `MediaFeatureOperand` excludes `undefined` from the slot so a
regression is a compile error rather than a device-only bug.

Two things follow from the marker being the same on both planes:

- The runtime refuses `null`. The container evaluator needs no refusal of its
  own - a feature it cannot measure has no value, `null` neither equals that nor
  is a number, so every comparison already answers false.
- A query is no longer dropped for carrying an unresolvable operand. Dropping it
  removed the condition entirely, and a rule with no condition applies
  unconditionally, which is the opposite of refusing it. Measured on a device's
  shape, `@media (orientation: env(safe-area-inset-top))` applied its block
  everywhere.

`registerCSS` injects through the same serializer Metro writes into the bundle,
so a test asserts against the shape a device holds. Nothing drove that path
before, which is how the marker could be wrong on every device while the suite
stayed green.

The container comparison operators and `containerHeightFamily` carry separate
defects that I fix on fix/container-query-defects. This commit is ordered behind
that branch and leaves them alone.
…urce

Three media features contradicted themselves or the spec.

`hover` reaches its verdict without reading the operand, so `(hover: hover)`,
`(hover: none)` and `(hover)` all match at once - a combination no UA can
produce. The comparison now reads the value `getMediaFeatureValue` reports, so
one of the two values matches and the other does not. Which one stays a
deliberate deviation from MQ5 5.1, where `none` covers a touchscreen: React
Native raises `onHoverIn` / `onHoverOut` wherever a pointer exists, and a
utility framework's `hover:` variant compiles to this feature, so the runtime
answers `hover` on every platform. The comment says so rather than leaving it
to be rediscovered.

`prefers-color-scheme` had two answers that disagreed for a user who has
expressed no preference: `getMediaFeatureValue` reported no value, while the
comparison compared the operand against `null`. MQ5 12.5 makes `light` the
answer in that case, so `getMediaFeatureValue` returns it and the comparison
reads from there. `(prefers-color-scheme)` and `(prefers-color-scheme: light)`
now agree.

`(color)` answered false. MQ5 6.1 uses that query as its own example of one
matching every color device, and React Native renders to one, so the runtime
reports the conventional eight bits per component. The test that wanted a
feature with no answer at all asks for `environment-blending` instead.
`containerHeightFamily` read `.width` from the layout rectangle, so every
container answered its own width for both axes. That makes every container
square: `width > height` is never true, so `orientation` is `portrait` for
every container however it is laid out, and `aspect-ratio` is always 1.

A 500x200 container now answers `landscape`, and `(height > 300px)` refuses
it instead of comparing 500 against 300.
`>=`, `<` and `<=` all evaluated `left > right`, so only `>` and `=` were
correct. The arms differ from the right ones by a single character each, which
is why the switch reads as correct.

The damage is invisible on most inputs - `500 > 100` and `500 >= 100` agree -
and shows up at the boundary and in the reversed direction: `(min-width: 500px)`
refused a 500px container, and `(width < 400px)` matched one.

The media evaluator already had these right; only the container copy drifted.
…alse

A term neither the compiler nor the runtime can decide was answered `false`.
Two-valued logic then makes `not <that term>` true, so every negated term this
implementation does not support applied to everything: `@media not
(monochrome: 1)`, `not (fictional-thing)`, and `not (400px < width < 500px)`
all matched unconditionally.

MQ5 3.1 defines the three-valued logic that exists for exactly this reason -
"the only reasonable value is false, but this means that `not
unknown(function)` is true, which can be confusing and unwanted". `kleene.ts`
implements it once and both evaluators use it, so the two cannot drift the way
the range operators did.

The combinators take the terms and an evaluator rather than already-evaluated
values, so a decided conjunction never evaluates the rest. That is not only
arithmetic here: evaluating a term reads reactive observables and reading one
subscribes to it, so staying lazy keeps the subscription set to the operands
that actually decided the answer. It is sound because the operand that decided
the answer is itself subscribed, so the change that could revive a skipped
operand is the change that re-runs the whole condition - measured in
`short-circuit-subscription.test.tsx`, which drives a condition through a
short-circuit and out the other side.

On the compiler side, a `<container-condition>` with no representation here -
`style(--foo: bar)` - now compiles to the term `["?"]` instead of being
dropped. Dropping it is a different answer in three places: alone it left no
condition at all, which applies inside every container; under `not` it vanished
with the same result; and inside a conjunction the operand disappeared, turning
`true and unknown` into `true`. Refusing to emit the block covers the first two
but not the third, because the condition around the dropped operand still
parses.

The marker is `["?"]` rather than `undefined` for the reason the operand marker
is `null`: Metro writes the stylesheet into the bundle as JSON, and
`JSON.stringify` cannot carry `undefined` in either position.
… prelude

Three arms of the three-valued evaluators were load-bearing and unobserved.
Removing any of them left the whole suite green, so each was one refactor away
from being deleted as dead code.

`(width > 10em)` compiles to the length descriptor `[{}, "em", 10, 1]`, because
`em` is relative to the element's own font size and no compile-time pass can
fold it. That descriptor reaches the comparison as an operand it cannot order,
and ordering it anyway gives `NaN` - false for every operator, and false is the
one answer a negation turns into a match. The guard that answers unknown
instead was reachable from ordinary CSS the whole time; what hid it is that no
test in the suite used a relative length. `not (width > 10em)` pins the
container arm and `not all and (width > 10em)` pins the media one - the query's
`not` qualifier rather than `not (...)`, because lightningcss folds that
spelling into `(width <= 10em)` and leaves no negation to observe.

`(inline-size)` is the boolean form of a feature this runtime cannot measure,
so it is unknown rather than false, and `not (inline-size)` is the only shape
that tells the two apart.

`extractMedia` now refuses a prelude whose every query was refused. It emitted
the block with no condition, which applies it everywhere - the opposite of what
a refused prelude means, and the trade `extractContainer` already guards. The
guard cannot be an empty-`conditions` check, because an empty list is equally
what `all`, `screen` and `not print` produce, so `parseMediaQuery` returns
`ParsedMediaQuery` and says which of the three happened. No stylesheet reaches
the refused branch today; the separation is what makes the backstop safe to
hold.

The `["?"]` arms that are genuinely unreachable now say so, and say what would
make them live rather than reading as dead: the native media `case "?"` is
unreachable because lightningcss parses `@media (fictional-thing)` as a boolean
feature rather than as MQ5's `<general-enclosed>`, which is a property of the
installed parser and not of the grammar.
`fix/container-query-defects` already draws this distinction, in
`7be5cf4`: `CompiledCondition` separates `always` / `never` / `condition`
so `extractMedia` can tell "there is no condition" from "the condition did
not compile", and it carries a compiler-plane table of four uncompilable
preludes across both at-rules, six controls, runtime tables on both planes,
and a mutation proof for each arm - including the two this shape has to get
right, `@media not print` and `@media all`.

The version here was a second name for that type with none of those tests,
and it covered only the media half. Two spellings of one distinction across
two branches that merge together is drift, so the concept stays where it is
already proven and this branch keeps the arms it can pin on its own.
@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.

…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