From 82bfcc2647f6ac7a6779569deee35b253d15a39e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:38:38 +0300 Subject: [PATCH 01/11] fix(compiler): compile a comma-separated media query list as a union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/__tests__/compiler/media-query.test.ts | 81 ++++++++++++++++++++++ src/__tests__/native/media-query.test.tsx | 74 ++++++++++++++++++++ src/compiler/compiler.ts | 20 +++++- src/compiler/media-query.ts | 11 ++- 4 files changed, 183 insertions(+), 3 deletions(-) diff --git a/src/__tests__/compiler/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index 760ede29..503f0dfc 100644 --- a/src/__tests__/compiler/media-query.test.ts +++ b/src/__tests__/compiler/media-query.test.ts @@ -1,5 +1,16 @@ +import type { MediaCondition } from "react-native-css/compiler"; import { compile } from "react-native-css/compiler"; +/** The media conditions of every rule compiled for `className`. */ +function mediaConditions(css: string, className: string) { + const rules = + compile(css) + .stylesheet() + .s?.find(([name]) => name === className)?.[1] ?? []; + + return rules.map((rule): MediaCondition[] | undefined => rule.m); +} + describe.skip("platform media queries", () => { test("android", () => { const compiled = compile(` @@ -62,6 +73,76 @@ describe.skip("platform media queries", () => { }); }); +describe("comma-separated media query lists", () => { + test("compile to a union, not an intersection", () => { + expect( + mediaConditions( + `@media (min-width: 100px), (min-width: 9999px) { + .my-class { background-color: red; } + }`, + "my-class", + ), + ).toStrictEqual([ + [ + [ + "|", + [ + [">=", "width", 100], + [">=", "width", 9999], + ], + ], + ], + ]); + }); + + test("a single query is not wrapped", () => { + expect( + mediaConditions( + `@media (min-width: 100px) { + .my-class { background-color: red; } + }`, + "my-class", + ), + ).toStrictEqual([[[">=", "width", 100]]]); + }); + + test("a comma list and an `or` condition compile identically", () => { + const comma = mediaConditions( + `@media (min-width: 100px), (min-width: 9999px) { + .my-class { background-color: red; } + }`, + "my-class", + ); + + const or = mediaConditions( + `@media ((min-width: 100px) or (min-width: 9999px)) { + .my-class { background-color: red; } + }`, + "my-class", + ); + + expect(comma).toStrictEqual(or); + }); + + test("nested @media rules still intersect", () => { + expect( + mediaConditions( + `@media (min-width: 100px) { + @media (min-height: 200px) { + .my-class { background-color: red; } + } + }`, + "my-class", + ), + ).toStrictEqual([ + [ + [">=", "width", 100], + [">=", "height", 200], + ], + ]); + }); +}); + test("@media (hover: hover)", () => { const compiled = compile(` @media (hover: hover) { diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 020b4aad..c7177f9e 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -283,3 +283,77 @@ describe("max-resolution", () => { expect(component.props.style).toStrictEqual(undefined); }); }); + +describe("comma-separated media query lists", () => { + test("apply when only the first query matches", () => { + registerCSS(` +@media (min-width: 100px), (min-width: 9999px) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("apply when only the last query matches", () => { + registerCSS(` +@media (min-width: 9999px), (min-width: 100px) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("do not apply when no query matches", () => { + registerCSS(` +@media (min-width: 9999px), (max-width: 10px) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual(undefined); + }); + + test("react to a query becoming true", () => { + registerCSS(` +.my-class { color: blue; } + +@media (min-width: 9999px), (min-height: 400px) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500, height: 100 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500, height: 500 }); + }); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); +}); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 214cd615..5e91d735 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -17,6 +17,7 @@ import { maybeMutateReactNativeOptions, parsePropAtRule } from "./atRules"; import type { CompilerOptions, ContainerQuery, + MediaCondition, StyleDescriptor, StyleRuleMapping, UniqueVarInfo, @@ -364,8 +365,25 @@ function extractMedia( return; } + const conditions: MediaCondition[] = []; + for (const m of media) { - parseMediaQuery(m, builder); + const condition = parseMediaQuery(m, builder); + + if (condition) { + conditions.push(condition); + } + } + + // A comma-separated list is a union - the block applies when any one query + // matches. A single query is added as-is so it composes with the conditions + // of any enclosing rule, which intersect. + const [firstCondition, ...remainingConditions] = conditions; + + if (firstCondition) { + builder.addMediaQuery( + remainingConditions.length === 0 ? firstCondition : ["|", conditions], + ); } // Iterate over all rules in the mediaRule and extract their styles using the updated CompilerCollection diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index c8733c12..859cf3ed 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -16,10 +16,17 @@ import type { import { parseLength } from "./declarations"; import type { StylesheetBuilder } from "./stylesheet"; +/** + * Parses a single media query out of a comma-separated list. + * + * Returns `undefined` when the query cannot apply on native, which the caller + * treats the way CSS treats an unmatchable query in a list: it contributes + * nothing, and the remaining queries still decide the block. + */ export function parseMediaQuery( query: CSSMediaQuery, builder: StylesheetBuilder, -) { +): MediaCondition | undefined { let platformCondition: MediaCondition | undefined; let condition: MediaCondition | undefined; @@ -57,7 +64,7 @@ export function parseMediaQuery( mediaQuery = ["!", mediaQuery]; } - builder.addMediaQuery(mediaQuery); + return mediaQuery; } function parseMediaQueryCondition( From 22d388b653b84fab371525ac2cf18b72073254ef Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:48:20 +0300 Subject: [PATCH 02/11] fix(native): refuse a condition operand that could not be resolved 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. --- src/__tests__/compiler/media-query.test.ts | 25 +++++++ .../native/container-queries.test.tsx | 70 +++++++++++++++++++ src/__tests__/native/media-query.test.tsx | 56 +++++++++++++++ src/native/conditions/container-query.ts | 7 ++ src/native/conditions/media-query.ts | 6 ++ 5 files changed, 164 insertions(+) diff --git a/src/__tests__/compiler/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index 503f0dfc..6ab83fd5 100644 --- a/src/__tests__/compiler/media-query.test.ts +++ b/src/__tests__/compiler/media-query.test.ts @@ -143,6 +143,31 @@ describe("comma-separated media query lists", () => { }); }); +test("an operand the compiler cannot resolve stays in the condition", () => { + // `env()` has no compile-time value, so the operand compiles to `undefined`. + // It has to survive into the condition: dropping it would leave the width + // alone deciding a query that also asks about orientation. The runtime is + // what refuses an unresolved operand. + expect( + mediaConditions( + `@media ((orientation: env(safe-area-inset-top)) and (min-width: 0px)) { + .my-class { background-color: red; } + }`, + "my-class", + ), + ).toStrictEqual([ + [ + [ + "&", + [ + ["=", "orientation", undefined], + [">=", "width", 0], + ], + ], + ], + ]); +}); + test("@media (hover: hover)", () => { const compiled = compile(` @media (hover: hover) { diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index 3ea60394..2c4f2d0f 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -113,3 +113,73 @@ test("container query width", () => { color: "#00f", }); }); + +describe("unresolvable operands", () => { + test("a feature the runtime cannot measure never matches", () => { + registerCSS(` + .container { + container-name: my-container; + width: 200px; + } + + .child { + color: red; + } + + @container ((block-size: env(safe-area-inset-top)) and (width > 0px)) { + .child { + color: blue; + } + } + `); + + render( + + + , + ); + + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); + + fireEvent(parent, "layout", { + nativeEvent: { layout: { width: 500, height: 200 } }, + }); + + expect(child.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("a feature the runtime can measure still matches", () => { + registerCSS(` + .container { + container-name: my-container; + width: 200px; + } + + .child { + color: red; + } + + @container ((width: 500px) and (width > 0px)) { + .child { + color: blue; + } + } + `); + + render( + + + , + ); + + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); + + fireEvent(parent, "layout", { + nativeEvent: { layout: { width: 500, height: 200 } }, + }); + + expect(child.props.style).toStrictEqual({ color: "#00f" }); + }); +}); diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index c7177f9e..8550e67b 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -357,3 +357,59 @@ describe("comma-separated media query lists", () => { expect(component.props.style).toStrictEqual({ color: "#f00" }); }); }); + +describe("unresolvable operands", () => { + test("an orientation the compiler could not resolve never matches", () => { + registerCSS(` +.my-class { color: blue; } + +@media ((orientation: env(safe-area-inset-top)) and (min-width: 0px)) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500, height: 1000 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("a hover value the compiler could not resolve never matches", () => { + registerCSS(` +.my-class { color: blue; } + +@media ((hover: env(safe-area-inset-top)) and (min-width: 0px)) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500, height: 1000 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("a resolved orientation still matches", () => { + registerCSS(` +.my-class { color: blue; } + +@media ((orientation: portrait) and (min-width: 0px)) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500, height: 1000 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); +}); diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index ac546c9a..4d0dcce4 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -107,6 +107,13 @@ function testContainerMediaCondition( const left = getContainerFeatureValue(condition[1], containerKey, get); const right = condition[2]; + // An operand the runtime cannot measure, or one the compiler could not + // resolve, satisfies no comparison. Two of them are not equal to each + // other. + if (left === undefined || right === undefined) { + return false; + } + if (condition[0] === "=") { return left === right; } diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 75cd9006..9dc6dbc8 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -37,6 +37,12 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { const value = mediaQuery[2]; + // An operand the compiler could not resolve satisfies no comparison. Features + // whose verdict does not read the value would otherwise match on nothing. + if (value === undefined) { + return false; + } + switch (mediaQuery[1]) { case "dir": return (I18nManager.isRTL && value === "rtl") || value === "ltr"; From 138b43031ca9c6cc4ded0bb15c0fd64344f25c53 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:55:25 +0300 Subject: [PATCH 03/11] feat(native): evaluate boolean media and container features `@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. --- src/__tests__/compiler/media-query.test.ts | 11 +++ .../native/container-queries.test.tsx | 80 +++++++++++++++++++ src/__tests__/native/media-query.test.tsx | 66 +++++++++++++++ src/native/conditions/container-query.ts | 5 +- src/native/conditions/media-query.ts | 78 ++++++++++++++---- 5 files changed, 223 insertions(+), 17 deletions(-) diff --git a/src/__tests__/compiler/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index 6ab83fd5..73a39d9c 100644 --- a/src/__tests__/compiler/media-query.test.ts +++ b/src/__tests__/compiler/media-query.test.ts @@ -168,6 +168,17 @@ test("an operand the compiler cannot resolve stays in the condition", () => { ]); }); +test("a boolean feature compiles to a boolean condition", () => { + expect( + mediaConditions( + `@media (width) { + .my-class { background-color: red; } + }`, + "my-class", + ), + ).toStrictEqual([[["!!", "width"]]]); +}); + test("@media (hover: hover)", () => { const compiled = compile(` @media (hover: hover) { diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index 2c4f2d0f..e9c04daa 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -183,3 +183,83 @@ describe("unresolvable operands", () => { expect(child.props.style).toStrictEqual({ color: "#00f" }); }); }); + +describe("boolean features", () => { + test("width matches a container that has one", () => { + registerCSS(` + .container { container-name: my-container; } + .child { color: red; } + + @container (width) { + .child { color: blue; } + } + `); + + render( + + + , + ); + + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); + + fireEvent(parent, "layout", { + nativeEvent: { layout: { width: 500, height: 200 } }, + }); + + expect(child.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("width does not match a container of zero width", () => { + registerCSS(` + .container { container-name: my-container; } + .child { color: red; } + + @container (width) { + .child { color: blue; } + } + `); + + render( + + + , + ); + + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); + + fireEvent(parent, "layout", { + nativeEvent: { layout: { width: 0, height: 200 } }, + }); + + expect(child.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("a feature the runtime cannot measure does not match", () => { + registerCSS(` + .container { container-name: my-container; } + .child { color: red; } + + @container (inline-size) { + .child { color: blue; } + } + `); + + render( + + + , + ); + + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); + + fireEvent(parent, "layout", { + nativeEvent: { layout: { width: 500, height: 200 } }, + }); + + expect(child.props.style).toStrictEqual({ color: "#f00" }); + }); +}); diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 8550e67b..f1d59344 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -413,3 +413,69 @@ describe("unresolvable operands", () => { expect(component.props.style).toStrictEqual({ color: "#f00" }); }); }); + +describe("boolean features", () => { + test("height matches when the viewport has one", () => { + registerCSS(` +.my-class { color: blue; } + +@media (height) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500, height: 1000 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("width does not match a viewport of zero width", () => { + registerCSS(` +.my-class { color: blue; } + +@media (width) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 0, height: 1000 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("hover matches, because the runtime always reports hover", () => { + registerCSS(` +.my-class { color: blue; } + +@media (hover) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("a feature the runtime cannot answer does not match", () => { + registerCSS(` +.my-class { color: blue; } + +@media (color) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); +}); diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index 4d0dcce4..b094201e 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -18,6 +18,7 @@ import { } from "../reactivity"; // import { testAttributes } from "./attributes"; import type { RenderGuard } from "./guards"; +import { isTruthyFeatureValue } from "./media-query"; export const DEFAULT_CONTAINER_NAME = "c:___default___"; @@ -96,7 +97,9 @@ function testContainerMediaCondition( return testContainerMediaCondition(query, containerKey, get); }); case "!!": - return false; + return isTruthyFeatureValue( + getContainerFeatureValue(condition[1], containerKey, get), + ); case "[]": return false; case ">": diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 9dc6dbc8..da9545dd 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,19 +1,46 @@ /* eslint-disable */ import { I18nManager, PixelRatio, Platform } from "react-native"; -import type { MediaCondition } from "react-native-css/compiler"; +import type { MediaFeatureNameFor_MediaFeatureId } from "lightningcss"; +import type { + MediaCondition, + MediaFeatureComparison, + StyleDescriptor, +} from "react-native-css/compiler"; import { colorScheme, vh, vw, type Getter } from "../reactivity"; +type MediaFeatureName = MediaFeatureNameFor_MediaFeatureId | "dir"; + +type MediaComparison = [ + MediaFeatureComparison, + MediaFeatureName, + StyleDescriptor, +]; + export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); } +/** + * Whether a feature is true in a boolean context, which is every value except + * zero, `none` and `false`. A feature the runtime cannot answer has no value + * and is false. + */ +export function isTruthyFeatureValue(value: StyleDescriptor): boolean { + if (typeof value === "number") { + return Number.isFinite(value) && value !== 0; + } + + return value !== undefined && value !== false && value !== "none"; +} + function test(mediaQuery: MediaCondition, get: Getter): Boolean { switch (mediaQuery[0]) { case "[]": - case "!!": return false; + case "!!": + return isTruthyFeatureValue(getMediaFeatureValue(mediaQuery[1], get)); case "!": return !test(mediaQuery[1], get); case "&": @@ -34,7 +61,7 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { } } -function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { +function testComparison(mediaQuery: MediaComparison, get: Getter): Boolean { const value = mediaQuery[2]; // An operand the compiler could not resolve satisfies no comparison. Features @@ -71,21 +98,11 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { return false; } - let left: number | undefined; + const left = getMediaFeatureValue(mediaQuery[1], get); const right = value; - switch (mediaQuery[1]) { - case "width": - left = get(vw); - break; - case "height": - left = get(vh); - break; - case "resolution": - left = PixelRatio.get(); - break; - default: - return false; + if (typeof left !== "number") { + return false; } switch (mediaQuery[0]) { @@ -103,3 +120,32 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { return false; } } + +/** The runtime's current value for a media feature, if it has one. */ +function getMediaFeatureValue( + name: MediaFeatureName, + get: Getter, +): StyleDescriptor { + switch (name) { + case "dir": + return I18nManager.isRTL ? "rtl" : "ltr"; + case "hover": + // The runtime reports hover on every platform + return "hover"; + case "platform": + case "display-mode": + return Platform.OS; + case "prefers-color-scheme": + return get(colorScheme) ?? undefined; + case "width": + return get(vw); + case "height": + return get(vh); + case "resolution": + return PixelRatio.get(); + case "orientation": + return get(vh) < get(vw) ? "landscape" : "portrait"; + default: + return undefined; + } +} From e811e013d2c77794485669dd35a2ec98577ee342 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 11:09:02 +0300 Subject: [PATCH 04/11] fix(native): carry the unresolved-operand marker in a shape JSON can 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. --- src/__tests__/compiler/media-query.test.ts | 62 ++++++++++++----- .../native/container-queries.test.tsx | 68 +++++++++++++++++++ src/__tests__/native/media-query.test.tsx | 54 +++++++++++++++ src/compiler/compiler.types.ts | 16 ++++- src/compiler/container-query.ts | 17 +++-- src/compiler/media-query.ts | 33 +++++++-- src/jest/index.ts | 25 ++++++- src/metro/injection-code.ts | 18 ++++- src/native/conditions/container-query.ts | 10 +-- src/native/conditions/media-query.ts | 5 +- 10 files changed, 262 insertions(+), 46 deletions(-) diff --git a/src/__tests__/compiler/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index 73a39d9c..4303a5b7 100644 --- a/src/__tests__/compiler/media-query.test.ts +++ b/src/__tests__/compiler/media-query.test.ts @@ -1,6 +1,8 @@ import type { MediaCondition } from "react-native-css/compiler"; import { compile } from "react-native-css/compiler"; +import { serializeStyleSheet } from "../../metro/injection-code"; + /** The media conditions of every rule compiled for `className`. */ function mediaConditions(css: string, className: string) { const rules = @@ -143,29 +145,55 @@ describe("comma-separated media query lists", () => { }); }); -test("an operand the compiler cannot resolve stays in the condition", () => { - // `env()` has no compile-time value, so the operand compiles to `undefined`. - // It has to survive into the condition: dropping it would leave the width - // alone deciding a query that also asks about orientation. The runtime is - // what refuses an unresolved operand. - expect( - mediaConditions( - `@media ((orientation: env(safe-area-inset-top)) and (min-width: 0px)) { +describe("an operand the compiler cannot resolve", () => { + // `env()` has no compile-time value. The operand compiles to `null`, the one + // spelling of "no value" that survives `JSON.stringify` into a native bundle, + // and it has to survive into the condition: a condition that is absent applies + // unconditionally, so dropping the query is the opposite of refusing it. + test("compiles to null beside a sibling operand", () => { + expect( + mediaConditions( + `@media ((orientation: env(safe-area-inset-top)) and (min-width: 0px)) { .my-class { background-color: red; } }`, - "my-class", - ), - ).toStrictEqual([ - [ + "my-class", + ), + ).toStrictEqual([ [ - "&", [ - ["=", "orientation", undefined], - [">=", "width", 0], + "&", + [ + ["=", "orientation", null], + [">=", "width", 0], + ], ], ], - ], - ]); + ]); + }); + + test("compiles to null as the only operand", () => { + expect( + mediaConditions( + `@media (orientation: env(safe-area-inset-top)) { + .my-class { background-color: red; } + }`, + "my-class", + ), + ).toStrictEqual([[["=", "orientation", null]]]); + }); + + test("survives the serializer that carries it to a device", () => { + const conditions = mediaConditions( + `@media (orientation: env(safe-area-inset-top)) { + .my-class { background-color: red; } + }`, + "my-class", + ); + + expect(JSON.parse(serializeStyleSheet(conditions))).toStrictEqual( + conditions, + ); + }); }); test("a boolean feature compiles to a boolean condition", () => { diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index e9c04daa..fb7b3493 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -149,6 +149,74 @@ describe("unresolvable operands", () => { expect(child.props.style).toStrictEqual({ color: "#f00" }); }); + test("a feature alone in a condition never matches", () => { + registerCSS(` + .container { + container-name: my-container; + width: 200px; + } + + .child { + color: red; + } + + @container (block-size: env(safe-area-inset-top)) { + .child { + color: blue; + } + } + `); + + render( + + + , + ); + + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); + + fireEvent(parent, "layout", { + nativeEvent: { layout: { width: 500, height: 200 } }, + }); + + expect(child.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("a measurable feature with an unresolvable operand never matches", () => { + registerCSS(` + .container { + container-name: my-container; + width: 200px; + } + + .child { + color: red; + } + + @container (width: env(safe-area-inset-top)) { + .child { + color: blue; + } + } + `); + + render( + + + , + ); + + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); + + fireEvent(parent, "layout", { + nativeEvent: { layout: { width: 500, height: 200 } }, + }); + + expect(child.props.style).toStrictEqual({ color: "#f00" }); + }); + test("a feature the runtime can measure still matches", () => { registerCSS(` .container { diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index f1d59344..84285523 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -395,6 +395,60 @@ describe("unresolvable operands", () => { expect(component.props.style).toStrictEqual({ color: "#00f" }); }); + test("an orientation alone in a query never matches", () => { + registerCSS(` +.my-class { color: blue; } + +@media (orientation: env(safe-area-inset-top)) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500, height: 1000 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("a width alone in a query never matches", () => { + registerCSS(` +.my-class { color: blue; } + +@media (min-width: env(safe-area-inset-top)) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500, height: 1000 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("the sibling branch of an or still decides the query", () => { + registerCSS(` +.my-class { color: blue; } + +@media ((orientation: env(safe-area-inset-top)) or (min-width: 0px)) { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500, height: 1000 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + test("a resolved orientation still matches", () => { registerCSS(` .my-class { color: blue; } diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..b02c1290 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -186,18 +186,28 @@ export type MediaCondition = | [ MediaFeatureComparison, MediaFeatureNameFor_MediaFeatureId | "dir", - StyleDescriptor, + MediaFeatureOperand, ] // [Start, End] | [ "[]", MediaFeatureNameFor_MediaFeatureId, - StyleDescriptor, // Start + MediaFeatureOperand, // Start MediaFeatureComparison, // Start comparison - StyleDescriptor, // End + MediaFeatureOperand, // End MediaFeatureComparison, // End comparison ]; +/** + * The right-hand side of a media or container feature comparison. + * + * 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 + * `undefined` is not a value this position can hold - the compiler emits `null` + * for a feature value it cannot resolve, and the runtime refuses that operand. + */ +export type MediaFeatureOperand = Exclude | null; + export type MediaFeatureComparison = "=" | ">" | ">=" | "<" | "<="; export interface PseudoClassesQuery { diff --git a/src/compiler/container-query.ts b/src/compiler/container-query.ts index 32c25861..48cb7cfc 100644 --- a/src/compiler/container-query.ts +++ b/src/compiler/container-query.ts @@ -6,8 +6,8 @@ import type { import type { MediaCondition } from "./compiler.types"; import { + parseMediaFeatureOperand, parseMediaFeatureOperator, - parseMediaFeatureValue, } from "./media-query"; import type { StylesheetBuilder } from "./stylesheet"; @@ -17,8 +17,11 @@ export function parseContainerCondition( ) { let containerQuery = parseContainerQueryCondition(condition, builder); - // If any of these are undefined, the media query is invalid - if (!containerQuery || containerQuery.some((v) => v === undefined)) { + // A condition with nothing left to test cannot apply. An operand the compiler + // could not resolve is not that case: it compiles to `null` and stays in the + // condition, because a condition that is absent applies to every container + // while a condition that is present and refused applies to none. + if (!containerQuery) { return; } @@ -73,21 +76,21 @@ function parseFeature( return [ "=", feature.name, - parseMediaFeatureValue(feature.value, builder), + parseMediaFeatureOperand(feature.value, builder), ]; case "range": return [ parseMediaFeatureOperator(feature.operator), feature.name, - parseMediaFeatureValue(feature.value, builder), + parseMediaFeatureOperand(feature.value, builder), ]; case "interval": return [ "[]", feature.name, - parseMediaFeatureValue(feature.start, builder), + parseMediaFeatureOperand(feature.start, builder), parseMediaFeatureOperator(feature.startOperator), - parseMediaFeatureValue(feature.end, builder), + parseMediaFeatureOperand(feature.end, builder), parseMediaFeatureOperator(feature.endOperator), ]; default: diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 859cf3ed..8eab5925 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -11,6 +11,7 @@ import type { import type { MediaCondition, MediaFeatureComparison, + MediaFeatureOperand, StyleDescriptor, } from "./compiler.types"; import { parseLength } from "./declarations"; @@ -45,8 +46,11 @@ export function parseMediaQuery( if (query.condition) { condition = parseMediaQueryCondition(query.condition, builder); - // If any of these are undefined, the media query is invalid - if (!condition || condition.some((v) => v === undefined)) { + // A query with nothing left to test cannot apply. An operand the compiler + // could not resolve is not that case: it compiles to `null` and stays in + // the condition, because a query that is absent applies unconditionally + // while a query that is present and refused applies to nothing. + if (!condition) { return; } } @@ -113,21 +117,21 @@ function parseFeature( return [ "=", feature.name, - parseMediaFeatureValue(feature.value, builder), + parseMediaFeatureOperand(feature.value, builder), ]; case "range": return [ parseMediaFeatureOperator(feature.operator), feature.name, - parseMediaFeatureValue(feature.value, builder), + parseMediaFeatureOperand(feature.value, builder), ]; case "interval": return [ "[]", feature.name, - parseMediaFeatureValue(feature.start, builder), + parseMediaFeatureOperand(feature.start, builder), parseMediaFeatureOperator(feature.startOperator), - parseMediaFeatureValue(feature.end, builder), + parseMediaFeatureOperand(feature.end, builder), parseMediaFeatureOperator(feature.endOperator), ]; default: @@ -136,7 +140,22 @@ function parseFeature( return; } -export function parseMediaFeatureValue( +/** + * A feature value in the one shape an operand slot can hold. + * + * `parseMediaFeatureValue` answers `undefined` for a value with no compile-time + * answer - `env()`, a ratio, an unsupported `calc()`. That marker cannot cross + * into a native bundle, which receives the stylesheet as JSON, so it is written + * here as `null` and every operand slot is filled through this function. + */ +export function parseMediaFeatureOperand( + value: CSSMediaFeatureValue, + builder: StylesheetBuilder, +): MediaFeatureOperand { + return parseMediaFeatureValue(value, builder) ?? null; +} + +function parseMediaFeatureValue( value: CSSMediaFeatureValue, builder: StylesheetBuilder, ): StyleDescriptor { diff --git a/src/jest/index.ts b/src/jest/index.ts index cc125390..d2ccf390 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -2,9 +2,14 @@ import { Appearance, Dimensions } from "react-native"; import { inspect } from "node:util"; -import { compile, type CompilerOptions } from "react-native-css/compiler"; +import { + compile, + type CompilerOptions, + type ReactNativeCssStyleSheet, +} from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; +import { serializeStyleSheet } from "../metro/injection-code"; import { colorScheme, dimensions } from "../native/reactivity"; declare global { @@ -50,11 +55,27 @@ export function registerCSS( ); } - StyleCollection.inject(compiled.stylesheet()); + StyleCollection.inject(injectableStyleSheet(compiled.stylesheet())); return compiled; } +/** + * A stylesheet in the shape a device receives. + * + * Metro writes the stylesheet into the bundle as JSON source text and the + * bundler's parser reads it back; `JSON.parse` stands in for that parser. A test + * that injected the compiler's own object would be asserting against values - + * `undefined` in particular - that no device can hold. + */ +function injectableStyleSheet( + stylesheet: ReactNativeCssStyleSheet, +): ReactNativeCssStyleSheet { + return JSON.parse( + serializeStyleSheet(stylesheet), + ) as ReactNativeCssStyleSheet; +} + export function compileWithAutoDebug( css: string, { diff --git a/src/metro/injection-code.ts b/src/metro/injection-code.ts index 61071c87..defe2525 100644 --- a/src/metro/injection-code.ts +++ b/src/metro/injection-code.ts @@ -16,6 +16,22 @@ export function getWebInjectionCode(filePaths: string[]) { return Buffer.from(importStatements); } +/** + * A stylesheet as a native bundle carries it. + * + * `getNativeInjectionCode` writes the stylesheet into the bundle as JSON source + * text, so this is the only shape a device ever injects. `JSON.stringify` cannot + * carry `undefined`: inside an array it writes `null`, and as an object value it + * drops the key. Anything the compiler emits has to survive that, which is why + * an unresolved feature operand compiles to `null` rather than `undefined`. + * + * Tests inject through this too, so a test cannot certify a shape production + * never sees. + */ +export function serializeStyleSheet(stylesheet: unknown): string { + return JSON.stringify(stylesheet); +} + export function getNativeInjectionCode( cssFilePaths: string[], values: unknown[], @@ -25,7 +41,7 @@ export function getNativeInjectionCode( .join("\n"); const contents = values - .map((value) => `StyleCollection.inject(${JSON.stringify(value)});`) + .map((value) => `StyleCollection.inject(${serializeStyleSheet(value)});`) .join("\n"); return Buffer.from( diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index b094201e..45a81e3e 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -107,16 +107,12 @@ function testContainerMediaCondition( case "<": case "<=": case "=": { + // A feature the runtime cannot measure has no value, and an operand the + // compiler could not resolve is `null`. Neither equals the other, and + // neither is a number, so every comparison below refuses them. const left = getContainerFeatureValue(condition[1], containerKey, get); const right = condition[2]; - // An operand the runtime cannot measure, or one the compiler could not - // resolve, satisfies no comparison. Two of them are not equal to each - // other. - if (left === undefined || right === undefined) { - return false; - } - if (condition[0] === "=") { return left === right; } diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index da9545dd..176e329c 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -5,6 +5,7 @@ import type { MediaFeatureNameFor_MediaFeatureId } from "lightningcss"; import type { MediaCondition, MediaFeatureComparison, + MediaFeatureOperand, StyleDescriptor, } from "react-native-css/compiler"; @@ -15,7 +16,7 @@ type MediaFeatureName = MediaFeatureNameFor_MediaFeatureId | "dir"; type MediaComparison = [ MediaFeatureComparison, MediaFeatureName, - StyleDescriptor, + MediaFeatureOperand, ]; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { @@ -66,7 +67,7 @@ function testComparison(mediaQuery: MediaComparison, get: Getter): Boolean { // An operand the compiler could not resolve satisfies no comparison. Features // whose verdict does not read the value would otherwise match on nothing. - if (value === undefined) { + if (value === null) { return false; } From 80a2bdae650620ee1c595969eda0b3607214a075 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 11:10:21 +0300 Subject: [PATCH 05/11] fix(native): answer hover, color and prefers-color-scheme from one source 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. --- src/__tests__/native/media-query.test.tsx | 83 ++++++++++++++++++++++- src/native/conditions/media-query.ts | 20 ++++-- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 84285523..003f59b0 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -505,7 +505,7 @@ describe("boolean features", () => { expect(component.props.style).toStrictEqual({ color: "#00f" }); }); - test("hover matches, because the runtime always reports hover", () => { + test("hover matches, because the runtime reports hover", () => { registerCSS(` .my-class { color: blue; } @@ -519,7 +519,7 @@ describe("boolean features", () => { expect(component.props.style).toStrictEqual({ color: "#f00" }); }); - test("a feature the runtime cannot answer does not match", () => { + test("color matches, because the display has color components", () => { registerCSS(` .my-class { color: blue; } @@ -530,6 +530,85 @@ describe("boolean features", () => { render(); const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("a feature the runtime has no source for does not match", () => { + registerCSS(` +.my-class { color: blue; } + +@media (environment-blending) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "#00f" }); }); }); + +describe("features the runtime answers from one place", () => { + test("only the hover value the runtime reports matches", () => { + registerCSS(` +.my-class { color: blue; } + +@media (hover: hover) { .my-class { color: red; } } +@media (hover: none) { .my-class { color: green; } }`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("no color scheme preference is light, in both contexts", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-color-scheme: light) { .my-class { color: red; } } +@media (prefers-color-scheme: dark) { .my-class { color: green; } }`); + + act(() => { + colorScheme.set(null); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("a color scheme preference is answered the same way boolean context is", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-color-scheme) { .my-class { color: red; } }`); + + act(() => { + colorScheme.set(null); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("dark still matches when the user prefers it", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-color-scheme: light) { .my-class { color: red; } } +@media (prefers-color-scheme: dark) { .my-class { color: green; } }`); + + act(() => { + colorScheme.set("dark"); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#008000" }); + }); +}); diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 176e329c..2e48a4e0 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -19,6 +19,9 @@ type MediaComparison = [ MediaFeatureOperand, ]; +/** Bits per color component. React Native renders to a color display. */ +const COLOR_DEPTH = 8; + export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); } @@ -75,12 +78,10 @@ function testComparison(mediaQuery: MediaComparison, get: Getter): Boolean { case "dir": return (I18nManager.isRTL && value === "rtl") || value === "ltr"; case "hover": - return true; + case "prefers-color-scheme": + return value === getMediaFeatureValue(mediaQuery[1], get); case "platform": return value === "native" || value === Platform.OS; - case "prefers-color-scheme": { - return value === get(colorScheme); - } case "display-mode": return value === "native" || Platform.OS === value; case "min-width": @@ -131,13 +132,20 @@ function getMediaFeatureValue( case "dir": return I18nManager.isRTL ? "rtl" : "ltr"; case "hover": - // The runtime reports hover on every platform + // A deviation from MQ5 5.1, where `none` covers a touchscreen. React + // Native raises `onHoverIn` / `onHoverOut` wherever a pointer exists, and + // the `hover:` variant of a utility framework compiles to this feature, so + // the runtime answers `hover` on every platform rather than switching on + // the primary input mechanism it cannot see. return "hover"; case "platform": case "display-mode": return Platform.OS; case "prefers-color-scheme": - return get(colorScheme) ?? undefined; + // MQ5 12.5: `light` covers a user who has expressed no preference. + return get(colorScheme) ?? "light"; + case "color": + return COLOR_DEPTH; case "width": return get(vw); case "height": From de78722c43faf630dab09851435809ed3cc734f6 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 14:10:50 +0300 Subject: [PATCH 06/11] fix(native): report a container's height rather than its width `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. --- .../native/container-size-features.test.tsx | 68 +++++++++++++++++++ src/native/reactivity.ts | 2 +- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/native/container-size-features.test.tsx diff --git a/src/__tests__/native/container-size-features.test.tsx b/src/__tests__/native/container-size-features.test.tsx new file mode 100644 index 00000000..589c7935 --- /dev/null +++ b/src/__tests__/native/container-size-features.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; + +const parentID = "parent"; +const childID = "child"; + +function renderContainer(query: string, width: number, height: number) { + registerCSS(` + .container { container-name: my-container; } + .child { color: red; } + @container ${query} { .child { color: blue; } } + `); + + render( + + + , + ); + + fireEvent(screen.getByTestId(parentID), "layout", { + nativeEvent: { layout: { width, height } }, + }); + + return screen.getByTestId(childID); +} + +const APPLIES = { color: "#00f" }; +const REFUSED = { color: "#f00" }; + +/** + * A container reports its own height. Answering the width for both makes every + * container square, so `width > height` is never true and every container is + * `portrait` however it is laid out. + */ +describe("a container's height is its own height", () => { + test("a 500x200 container is landscape", () => { + expect(renderContainer("(orientation: landscape)", 500, 200)).toHaveStyle( + APPLIES, + ); + }); + + test("a 500x200 container is not portrait", () => { + expect(renderContainer("(orientation: portrait)", 500, 200)).toHaveStyle( + REFUSED, + ); + }); + + test("a 200x500 container is portrait", () => { + expect(renderContainer("(orientation: portrait)", 200, 500)).toHaveStyle( + APPLIES, + ); + }); + + test("a 200x500 container is not landscape", () => { + expect(renderContainer("(orientation: landscape)", 200, 500)).toHaveStyle( + REFUSED, + ); + }); + + test("a 500x200 container is not taller than 300px", () => { + expect(renderContainer("(height > 300px)", 500, 200)).toHaveStyle(REFUSED); + }); + + test("a 500x200 container is taller than 100px", () => { + expect(renderContainer("(height > 100px)", 500, 200)).toHaveStyle(APPLIES); + }); +}); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 0824edeb..e2d80b06 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -243,6 +243,6 @@ export const containerWidthFamily = weakFamily((key) => { export const containerHeightFamily = weakFamily((key) => { return observable((read) => { - return read(containerLayoutFamily(key))?.width || 0; + return read(containerLayoutFamily(key))?.height || 0; }); }); From a95b2cd53802767da30faa80ea72f7e25e453af5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 14:11:12 +0300 Subject: [PATCH 07/11] fix(native): give every container range operator its own comparison `>=`, `<` 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. --- .../native/container-range-operators.test.tsx | 106 ++++++++++++++++++ src/native/conditions/container-query.ts | 6 +- 2 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/native/container-range-operators.test.tsx diff --git a/src/__tests__/native/container-range-operators.test.tsx b/src/__tests__/native/container-range-operators.test.tsx new file mode 100644 index 00000000..63a651a4 --- /dev/null +++ b/src/__tests__/native/container-range-operators.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; + +const parentID = "parent"; +const childID = "child"; + +function renderContainer(query: string, width: number, height: number) { + registerCSS(` + .container { container-name: my-container; } + .child { color: red; } + @container ${query} { .child { color: blue; } } + `); + + render( + + + , + ); + + fireEvent(screen.getByTestId(parentID), "layout", { + nativeEvent: { layout: { width, height } }, + }); + + return screen.getByTestId(childID); +} + +const APPLIES = { color: "#00f" }; +const REFUSED = { color: "#f00" }; + +/** + * Every range operator means what it says. Sharing one operator's body across + * all of them is invisible on most inputs - `500 > 100` and `500 >= 100` agree + * - and shows up only at the boundary and in the reversed direction. + */ +describe("width", () => { + test("> applies above the bound", () => { + expect(renderContainer("(width > 400px)", 500, 200)).toHaveStyle(APPLIES); + }); + + test("> does not apply at the bound", () => { + expect(renderContainer("(width > 500px)", 500, 200)).toHaveStyle(REFUSED); + }); + + test(">= applies at the bound", () => { + expect(renderContainer("(width >= 500px)", 500, 200)).toHaveStyle(APPLIES); + }); + + test(">= does not apply below the bound", () => { + expect(renderContainer("(width >= 600px)", 500, 200)).toHaveStyle(REFUSED); + }); + + test("< applies below the bound", () => { + expect(renderContainer("(width < 600px)", 500, 200)).toHaveStyle(APPLIES); + }); + + test("< does not apply above the bound", () => { + expect(renderContainer("(width < 400px)", 500, 200)).toHaveStyle(REFUSED); + }); + + test("< does not apply at the bound", () => { + expect(renderContainer("(width < 500px)", 500, 200)).toHaveStyle(REFUSED); + }); + + test("<= applies at the bound", () => { + expect(renderContainer("(width <= 500px)", 500, 200)).toHaveStyle(APPLIES); + }); + + test("<= does not apply above the bound", () => { + expect(renderContainer("(width <= 400px)", 500, 200)).toHaveStyle(REFUSED); + }); +}); + +describe("the min-/max- prefixes reach the same operators", () => { + test("min-width applies at the exact boundary", () => { + expect(renderContainer("(min-width: 500px)", 500, 200)).toHaveStyle( + APPLIES, + ); + }); + + test("max-width applies at the exact boundary", () => { + expect(renderContainer("(max-width: 500px)", 500, 200)).toHaveStyle( + APPLIES, + ); + }); + + test("max-width does not apply below the width", () => { + expect(renderContainer("(max-width: 400px)", 500, 200)).toHaveStyle( + REFUSED, + ); + }); +}); + +describe("height reaches the same operators", () => { + test("< applies below the bound", () => { + expect(renderContainer("(height < 300px)", 500, 200)).toHaveStyle(APPLIES); + }); + + test("< does not apply above the bound", () => { + expect(renderContainer("(height < 100px)", 500, 200)).toHaveStyle(REFUSED); + }); + + test("<= applies at the bound", () => { + expect(renderContainer("(height <= 200px)", 500, 200)).toHaveStyle(APPLIES); + }); +}); diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index 45a81e3e..7b0756b5 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -125,11 +125,11 @@ function testContainerMediaCondition( case ">": return left > right; case ">=": - return left > right; + return left >= right; case "<": - return left > right; + return left < right; case "<=": - return left > right; + return left <= right; default: condition[0] satisfies never; return false; From 5b1f790525368a50eca473de9f0ea1412bbe3c9d Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 14:11:53 +0300 Subject: [PATCH 08/11] fix: answer a condition the runtime cannot decide with unknown, not false A term neither the compiler nor the runtime can decide was answered `false`. Two-valued logic then makes `not ` 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 `` 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. --- .../compiler/unknown-condition.test.ts | 118 ++++++++++++ .../native/container-style-query.test.tsx | 178 ++++++++++++++++++ src/__tests__/native/kleene.test.ts | 171 +++++++++++++++++ src/__tests__/native/media-unknown.test.tsx | 166 ++++++++++++++++ .../short-circuit-subscription.test.tsx | 160 ++++++++++++++++ src/compiler/compiler.ts | 15 +- src/compiler/compiler.types.ts | 11 ++ src/compiler/container-query.ts | 18 +- src/compiler/media-query.ts | 13 +- src/native/conditions/container-query.ts | 63 +++++-- src/native/conditions/kleene.ts | 82 ++++++++ src/native/conditions/media-query.ts | 53 ++++-- 12 files changed, 998 insertions(+), 50 deletions(-) create mode 100644 src/__tests__/compiler/unknown-condition.test.ts create mode 100644 src/__tests__/native/container-style-query.test.tsx create mode 100644 src/__tests__/native/kleene.test.ts create mode 100644 src/__tests__/native/media-unknown.test.tsx create mode 100644 src/__tests__/native/short-circuit-subscription.test.tsx create mode 100644 src/native/conditions/kleene.ts diff --git a/src/__tests__/compiler/unknown-condition.test.ts b/src/__tests__/compiler/unknown-condition.test.ts new file mode 100644 index 00000000..51c8ff42 --- /dev/null +++ b/src/__tests__/compiler/unknown-condition.test.ts @@ -0,0 +1,118 @@ +import { + compile, + type ReactNativeCssStyleSheet, +} from "react-native-css/compiler"; + +import { serializeStyleSheet } from "../../metro/injection-code"; + +/** + * The compiler half of the three-valued contract: a term it cannot compile is + * emitted as `["?"]` rather than dropped, and that marker has to survive the + * JSON transport a native bundle carries the stylesheet through. + */ + +function conditionsFor(css: string) { + const rules = compile(css).stylesheet().s?.[0]?.[1]; + + if (!Array.isArray(rules)) { + throw new Error("expected compiled rules"); + } + + return rules.map((rule) => + typeof rule === "object" ? (rule.cq ?? rule.m) : rule, + ); +} + +const body = `{ .child { color: red; } }`; + +describe("an unsupported container feature compiles to an unknown term", () => { + test("style() alone", () => { + expect(conditionsFor(`@container style(--foo: bar) ${body}`)).toStrictEqual( + [[{ m: ["?"] }]], + ); + }); + + test("style() inside a conjunction keeps its slot", () => { + expect( + conditionsFor( + `@container (min-width: 100px) and style(--foo: bar) ${body}`, + ), + ).toStrictEqual([[{ m: ["&", [[">=", "width", 100], ["?"]]] }]]); + }); + + test("style() inside a disjunction keeps its slot", () => { + expect( + conditionsFor( + `@container (min-width: 100px) or style(--foo: bar) ${body}`, + ), + ).toStrictEqual([[{ m: ["|", [[">=", "width", 100], ["?"]]] }]]); + }); + + test("a negated style() keeps the negation and the term", () => { + expect( + conditionsFor(`@container not style(--foo: bar) ${body}`), + ).toStrictEqual([[{ m: ["!", ["?"]] }]]); + }); +}); + +/** + * The marker exists in this shape rather than as `undefined` because the + * transport cannot carry `undefined`: `JSON.stringify` writes it as `null` + * inside an array and drops the key entirely on an object. A guard written + * against `undefined` would hold in a test that injected the compiler's own + * object and never fire on a device. + */ +test("the unknown marker survives the JSON transport unchanged", () => { + const stylesheet = compile( + `@container (min-width: 100px) and style(--foo: bar) ${body}`, + ).stylesheet(); + + const transported = JSON.parse( + serializeStyleSheet(stylesheet), + ) as ReactNativeCssStyleSheet; + + expect(transported).toStrictEqual(stylesheet); + + const rules = transported.s?.[0]?.[1]; + if (!Array.isArray(rules)) { + throw new Error("expected compiled rules"); + } + + expect(rules[0]?.cq).toStrictEqual([ + { m: ["&", [[">=", "width", 100], ["?"]]] }, + ]); +}); + +/** + * `undefined` in the same slot is what the marker exists to avoid. This pins + * the transport's behaviour, so the reason for the marker cannot quietly stop + * being true. + */ +test("undefined in an array slot becomes null across the transport", () => { + expect(JSON.parse(serializeStyleSheet([1, undefined, 3]))).toStrictEqual([ + 1, + null, + 3, + ]); + + expect(JSON.parse(serializeStyleSheet({ m: undefined }))).toStrictEqual({}); +}); + +describe("a media condition the compiler cannot compile keeps its slot", () => { + test("every media feature form compiles to a term, so no media prelude is dropped", () => { + // Each of these reaches the runtime as a term rather than as an absent + // condition: an unknown , a , and an operand + // with no compile-time value. + expect( + conditionsFor(`@media (fictional-feature: 3) ${body}`), + ).toStrictEqual([[["=", "fictional-feature", 3]]]); + + expect(conditionsFor(`@media (fictional-thing) ${body}`)).toStrictEqual([ + [["!!", "fictional-thing"]], + ]); + + expect( + conditionsFor(`@media (min-aspect-ratio: 3/4) ${body}`), + ).toStrictEqual([[[">=", "aspect-ratio", null]]]); + }); +}); diff --git a/src/__tests__/native/container-style-query.test.tsx b/src/__tests__/native/container-style-query.test.tsx new file mode 100644 index 00000000..5f249c0f --- /dev/null +++ b/src/__tests__/native/container-style-query.test.tsx @@ -0,0 +1,178 @@ +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; + +/** + * `style()` container queries are not implemented. CSS Conditional 5 § 3 makes + * an unsupported container feature `unknown` for that element, and MQ5 § 3.1 + * makes `unknown` false in the two-valued context of a conditional group rule. + * + * Dropping the term instead is a different answer: an absent condition applies + * inside every container, and a dropped operand turns `true and unknown` into + * `true`. + */ + +const parentID = "parent"; +const childID = "child"; + +function renderContainer(css: string, width: number, height: number) { + registerCSS(css); + + render( + + + , + ); + + fireEvent(screen.getByTestId(parentID), "layout", { + nativeEvent: { layout: { width, height } }, + }); + + return screen.getByTestId(childID); +} + +const base = ` +.container { container-name: my-container; } +.child { color: red; } +`; + +test("style() alone never matches", () => { + const child = renderContainer( + `${base} + @container style(--foo: bar) { .child { color: blue; } }`, + 500, + 200, + ); + + expect(child).toHaveStyle({ color: "#f00" }); +}); + +test("`and style()` never matches, even when the other operand does", () => { + const child = renderContainer( + `${base} + @container (min-width: 100px) and style(--foo: bar) { .child { color: blue; } }`, + 500, + 200, + ); + + // true and unknown is unknown, which is false here. + expect(child).toHaveStyle({ color: "#f00" }); +}); + +test("`or style()` matches on the operand that is true", () => { + const child = renderContainer( + `${base} + @container (min-width: 100px) or style(--foo: bar) { .child { color: blue; } }`, + 500, + 200, + ); + + // true or unknown is true. + expect(child).toHaveStyle({ color: "#00f" }); +}); + +test("`or style()` does not match when the other operand is false", () => { + const child = renderContainer( + `${base} + @container (min-width: 999px) or style(--foo: bar) { .child { color: blue; } }`, + 500, + 200, + ); + + // false or unknown is unknown, which is false here. + expect(child).toHaveStyle({ color: "#f00" }); +}); + +test("`not style()` never matches", () => { + const child = renderContainer( + `${base} + @container not style(--foo: bar) { .child { color: blue; } }`, + 500, + 200, + ); + + // The negation of unknown is unknown, not true. + expect(child).toHaveStyle({ color: "#f00" }); +}); + +/** + * `style()` is not the only container term with no answer. A negation over any + * of these must not turn the refusal into a match. + */ +describe("other undecidable container terms are unknown, not false", () => { + test("(aspect-ratio: 2) - a ratio has no compile-time value, so it is refused", () => { + // The container's aspect ratio is measured; it is the right-hand side that + // never arrives, because `parseMediaFeatureValue` has no `ratio` case. + const child = renderContainer( + `${base} + @container (aspect-ratio: 2) { .child { color: blue; } }`, + 400, + 200, + ); + + expect(child).toHaveStyle({ color: "#f00" }); + }); + + test("not (aspect-ratio: 3/4) - an operand with no compile-time value", () => { + const child = renderContainer( + `${base} + @container not (aspect-ratio: 3/4) { .child { color: blue; } }`, + 500, + 200, + ); + + expect(child).toHaveStyle({ color: "#f00" }); + }); + + test("not (block-size: 100px) - a feature the runtime cannot measure", () => { + const child = renderContainer( + `${base} + @container not (block-size: 100px) { .child { color: blue; } }`, + 500, + 200, + ); + + expect(child).toHaveStyle({ color: "#f00" }); + }); + + test("not (inline-size > 100px) - an unmeasurable feature in a range", () => { + const child = renderContainer( + `${base} + @container not (inline-size > 100px) { .child { color: blue; } }`, + 500, + 200, + ); + + expect(child).toHaveStyle({ color: "#f00" }); + }); + + test("not (400px < width < 500px) - an interval the runtime does not evaluate", () => { + const child = renderContainer( + `${base} + @container not (400px < width < 500px) { .child { color: blue; } }`, + 450, + 200, + ); + + expect(child).toHaveStyle({ color: "#f00" }); + }); +}); + +/** + * A single negation cannot tell Kleene's `not unknown === unknown` apart from + * JavaScript's `!"unknown" === false`: both reach the two-valued boundary as + * false. A second negation separates them - `not not unknown` is still + * unknown, while `!!"unknown"` is true - and a container condition is where + * the pair survives, since lightningcss folds `not not` away for @media but + * keeps it here. + */ +test("`not (not style())` never matches either", () => { + const child = renderContainer( + `${base} + @container not (not style(--foo: bar)) { .child { color: blue; } }`, + 500, + 200, + ); + + expect(child).toHaveStyle({ color: "#f00" }); +}); diff --git a/src/__tests__/native/kleene.test.ts b/src/__tests__/native/kleene.test.ts new file mode 100644 index 00000000..ffe4dc81 --- /dev/null +++ b/src/__tests__/native/kleene.test.ts @@ -0,0 +1,171 @@ +import { + conjoin, + disjoin, + matches, + negate, + UNKNOWN, + type Truth, +} from "../../native/conditions/kleene"; + +/** + * The whole truth table of CSS Media Queries 5 § 3.1, exhaustively. The union + * is closed at three values, so "exhaustive" is a finite, checkable claim. + */ + +const ALL: Truth[] = [true, false, UNKNOWN]; + +const identity = (value: Truth): Truth => value; + +test("the union is exactly three values", () => { + expect(ALL).toHaveLength(3); + expect(new Set(ALL).size).toBe(3); +}); + +describe("negate", () => { + const table: [Truth, Truth][] = [ + [true, false], + [false, true], + [UNKNOWN, UNKNOWN], + ]; + + test("covers every value", () => { + expect(table.map(([input]) => input)).toStrictEqual(ALL); + }); + + test.each(table)("not %s is %s", (input, expected) => { + expect(negate(input)).toStrictEqual(expected); + }); +}); + +describe("matches", () => { + const table: [Truth, boolean][] = [ + [true, true], + [false, false], + // MQ5 § 3.1: unknown becomes false in a two-valued context. + [UNKNOWN, false], + ]; + + test("covers every value", () => { + expect(table.map(([input]) => input)).toStrictEqual(ALL); + }); + + test.each(table)("matches(%s) is %s", (input, expected) => { + expect(matches(input)).toStrictEqual(expected); + }); +}); + +describe("conjoin", () => { + // true if all are true, false if at least one is false, unknown otherwise. + const table: [Truth, Truth, Truth][] = [ + [true, true, true], + [true, false, false], + [true, UNKNOWN, UNKNOWN], + [false, true, false], + [false, false, false], + [false, UNKNOWN, false], + [UNKNOWN, true, UNKNOWN], + [UNKNOWN, false, false], + [UNKNOWN, UNKNOWN, UNKNOWN], + ]; + + test("covers all nine pairs", () => { + expect(table).toHaveLength(ALL.length * ALL.length); + expect(new Set(table.map(([a, b]) => `${a}/${b}`)).size).toBe(9); + }); + + test.each(table)("%s and %s is %s", (left, right, expected) => { + expect(conjoin([left, right], identity)).toStrictEqual(expected); + }); + + test("the empty conjunction is true", () => { + expect(conjoin([], identity)).toBe(true); + }); + + test("stops at the first false", () => { + const seen: Truth[] = []; + + const result = conjoin([true, false, UNKNOWN], (term) => { + seen.push(term); + return term; + }); + + expect(result).toBe(false); + expect(seen).toStrictEqual([true, false]); + }); + + test("an unknown does not stop the search for a false", () => { + const seen: Truth[] = []; + + const result = conjoin([UNKNOWN, false], (term) => { + seen.push(term); + return term; + }); + + expect(result).toBe(false); + expect(seen).toStrictEqual([UNKNOWN, false]); + }); +}); + +describe("disjoin", () => { + // false if all are false, true if at least one is true, unknown otherwise. + const table: [Truth, Truth, Truth][] = [ + [true, true, true], + [true, false, true], + [true, UNKNOWN, true], + [false, true, true], + [false, false, false], + [false, UNKNOWN, UNKNOWN], + [UNKNOWN, true, true], + [UNKNOWN, false, UNKNOWN], + [UNKNOWN, UNKNOWN, UNKNOWN], + ]; + + test("covers all nine pairs", () => { + expect(table).toHaveLength(ALL.length * ALL.length); + expect(new Set(table.map(([a, b]) => `${a}/${b}`)).size).toBe(9); + }); + + test.each(table)("%s or %s is %s", (left, right, expected) => { + expect(disjoin([left, right], identity)).toStrictEqual(expected); + }); + + test("the empty disjunction is false", () => { + expect(disjoin([], identity)).toBe(false); + }); + + test("stops at the first true", () => { + const seen: Truth[] = []; + + const result = disjoin([false, true, UNKNOWN], (term) => { + seen.push(term); + return term; + }); + + expect(result).toBe(true); + expect(seen).toStrictEqual([false, true]); + }); + + test("an unknown does not stop the search for a true", () => { + const seen: Truth[] = []; + + const result = disjoin([UNKNOWN, true], (term) => { + seen.push(term); + return term; + }); + + expect(result).toBe(true); + expect(seen).toStrictEqual([UNKNOWN, true]); + }); +}); + +describe("De Morgan holds across all nine pairs", () => { + const pairs = ALL.flatMap((left) => + ALL.map((right): [Truth, Truth] => [left, right]), + ); + + test.each(pairs)("not(%s and %s) === (not %s) or (not %s)", (left, right) => { + expect(negate(conjoin([left, right], identity))).toStrictEqual( + disjoin([negate(left), negate(right)], identity), + ); + }); +}); diff --git a/src/__tests__/native/media-unknown.test.tsx b/src/__tests__/native/media-unknown.test.tsx new file mode 100644 index 00000000..397c6e3a --- /dev/null +++ b/src/__tests__/native/media-unknown.test.tsx @@ -0,0 +1,166 @@ +import { act, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +import { dimensions } from "../../native/reactivity"; + +/** + * MQ5 § 3.1 gives a term the runtime cannot decide the value `unknown`, and + * "the negation of unknown is unknown". Two-valued logic answers `false` + * instead, and `not false` is `true` - so every negated term this runtime + * cannot measure applies to everything. + */ + +function renderAt(css: string, width: number, height: number) { + registerCSS(css); + render(); + + act(() => { + dimensions.set({ ...dimensions.get(), width, height }); + }); + + return screen.getByTestId(testID); +} + +const base = `.my-class { color: red; }`; + +describe("a negated term the runtime cannot measure does not apply", () => { + test("not (monochrome: 1) - a feature with no runtime value", () => { + const component = renderAt( + `${base} + @media not (monochrome: 1) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("not (fictional-feature: 3) - an unknown ", () => { + const component = renderAt( + `${base} + @media not (fictional-feature: 3) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("not (fictional-thing) - MQ5's ", () => { + const component = renderAt( + `${base} + @media not (fictional-thing) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("not (aspect-ratio: 3/4) - an operand with no compile-time value", () => { + // The ratio does not compile, so the operand is `null`. lightningcss folds + // `not` into the operator for a range feature, but a plain equality keeps + // it, so this is where a null operand meets a negation. + const component = renderAt( + `${base} + @media not (aspect-ratio: 3/4) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("not (color-gamut: srgb) - a non-numeric operand on an unmeasurable feature", () => { + const component = renderAt( + `${base} + @media not (color-gamut: srgb) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("not (400px < width < 500px) - an interval the runtime does not evaluate", () => { + const component = renderAt( + `${base} + @media not (400px < width < 500px) { .my-class { color: blue; } }`, + 450, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); +}); + +describe("an unmeasurable term does not rescue a conjunction or a disjunction", () => { + test("(min-width: 100px) and (monochrome: 1) does not apply", () => { + const component = renderAt( + `${base} + @media (min-width: 100px) and (monochrome: 1) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("(min-width: 100px) or (monochrome: 1) applies on the measurable operand", () => { + const component = renderAt( + `${base} + @media (min-width: 100px) or (monochrome: 1) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("(min-width: 999px) or (monochrome: 1) does not apply", () => { + const component = renderAt( + `${base} + @media (min-width: 999px) or (monochrome: 1) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); +}); + +describe("negation of a term the runtime CAN measure is untouched", () => { + test("not (min-width: 9999px) applies on a narrow screen", () => { + const component = renderAt( + `${base} + @media not (min-width: 9999px) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("not (min-width: 100px) does not apply on a wide screen", () => { + const component = renderAt( + `${base} + @media not (min-width: 100px) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("not (prefers-color-scheme: dark) applies in light mode", () => { + const component = renderAt( + `${base} + @media not (prefers-color-scheme: dark) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); +}); diff --git a/src/__tests__/native/short-circuit-subscription.test.tsx b/src/__tests__/native/short-circuit-subscription.test.tsx new file mode 100644 index 00000000..dd705505 --- /dev/null +++ b/src/__tests__/native/short-circuit-subscription.test.tsx @@ -0,0 +1,160 @@ +import { act, render, screen } from "@testing-library/react-native"; +import { compile } from "react-native-css/compiler"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; + +import { testMediaQuery } from "../../native/conditions/media-query"; +import { + colorScheme as colorSchemeObservable, + dimensions, + vw, + type Getter, +} from "../../native/reactivity"; + +/** + * A composite condition whose FIRST operand fails cannot read its second: the + * conjunction is already decided. The operand that decided it is subscribed, + * so the change that could revive the second one is the change that re-runs + * the whole condition — and that pass reads and subscribes to it. + * + * These tests measure that claim rather than asserting it. + */ + +const NARROW = 320; + +test("a short-circuited operand does not subscribe, and does not need to", () => { + registerCSS(` +.my-class { color: blue; } + +@media (min-width: 9999px) and (prefers-color-scheme: dark) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + act(() => { + dimensions.set({ ...dimensions.get(), width: NARROW }); + }); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + // Step 1: the width fails, so the colour scheme was never read. Changing it + // must not change the answer — the width still fails. + act(() => { + colorScheme.set("dark"); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + // Step 2: widen. The width DID subscribe (reading it is what produced the + // false), so this re-runs the condition, and that pass reads the colour + // scheme, which is already dark. + act(() => { + dimensions.set({ ...dimensions.get(), width: 10000 }); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + // Step 3: the colour scheme is now genuinely subscribed, so it is live. + act(() => { + colorScheme.set("light"); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + // Step 4: and it stays live in both directions. + act(() => { + colorScheme.set("dark"); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); +}); + +test("the disjunction mirror: a satisfied first operand skips the second", () => { + registerCSS(` +.my-class { color: blue; } + +@media (min-width: 100px) or (prefers-color-scheme: dark) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + act(() => { + dimensions.set({ ...dimensions.get(), width: NARROW }); + }); + + // The first operand is true, so the disjunction is decided and the colour + // scheme is never read. + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + act(() => { + colorScheme.set("dark"); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + // Narrow below the threshold. The width was subscribed, so this re-runs the + // condition; that pass reads the colour scheme, which holds the rule on. + act(() => { + dimensions.set({ ...dimensions.get(), width: 50 }); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + act(() => { + colorScheme.set("light"); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); +}); + +/** + * The render tests above pass whether evaluation is lazy or eager, which is + * the point — soundness is what they measure. This one measures that the + * short-circuit is REAL, so those tests are not vacuously green against an + * eager evaluator. + */ +test("MEASUREMENT: a decided conjunction reads only the operand that decided it", () => { + // The real compiled condition, not a hand-written literal: this is exactly + // what the runtime receives for + // `(min-width: 9999px) and (prefers-color-scheme: dark)`. + const conditions = compile( + `@media (min-width: 9999px) and (prefers-color-scheme: dark) { + .my-class { color: red; } + }`, + ).stylesheet().s?.[0]?.[1]; + + const condition = Array.isArray(conditions) ? conditions[0]?.m : undefined; + + if (!condition) { + throw new Error("expected a compiled media condition"); + } + + expect(condition).toStrictEqual([ + [ + "&", + [ + [">=", "width", 9999], + ["=", "prefers-color-scheme", "dark"], + ], + ], + ]); + + act(() => { + dimensions.set({ ...dimensions.get(), width: NARROW }); + }); + + const read: string[] = []; + const names = new Map([ + [vw, "vw"], + [colorSchemeObservable, "colorScheme"], + ]); + + const spy: Getter = (observable) => { + read.push(names.get(observable) ?? "other"); + return observable.get(); + }; + + expect(testMediaQuery(condition, spy)).toBe(false); + + // The measurement. Under eager evaluation this reads + // `["vw", "colorScheme"]`. + expect(read).toStrictEqual(["vw"]); +}); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 5e91d735..85562b35 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -404,10 +404,19 @@ function extractContainer( ) { builder = builder.fork("container"); + const condition = parseContainerCondition(containerRule.condition, builder); + + // A prelude with no condition left at all would apply inside every container, + // which is the opposite of what a refused prelude means, so the block is not + // emitted. Every `` form now compiles to a term - an + // unsupported one to `["?"]` - so this is a backstop against a future parse + // gap rather than a path any stylesheet reaches today. + if (!condition) { + return; + } + // Iterate over all rules inside the containerRule and extract their styles using the updated CompilerCollection - const query: ContainerQuery = { - m: parseContainerCondition(containerRule.condition, builder), - }; + const query: ContainerQuery = { m: condition }; if (containerRule.name) { query.n = `c:${containerRule.name}`; diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index b02c1290..2a0d340b 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -174,6 +174,17 @@ export type AnimationKeyframes = [string | number, StyleDeclaration[]]; /****************************** Conditions ******************************/ export type MediaCondition = + /** + * A term the compiler could not compile at all - a container `style()` + * query, or a sub-condition of a form this compiler does not implement. MQ5 + * § 3.1 gives `` the value unknown, and CSS Conditional 5 + * § 3 says the same of an unsupported container feature, so the term is + * emitted and the runtime answers unknown rather than the term being dropped. + * + * Dropping it is a different answer: `true and unknown` is unknown, but with + * the operand gone the conjunction reads `true`. + */ + | ["?"] // Boolean | ["!!", MediaFeatureNameFor_MediaFeatureId] // Not diff --git a/src/compiler/container-query.ts b/src/compiler/container-query.ts index 48cb7cfc..1e75c28d 100644 --- a/src/compiler/container-query.ts +++ b/src/compiler/container-query.ts @@ -36,12 +36,17 @@ function parseContainerQueryCondition( case "feature": return parseFeature(condition.value, builder); case "not": + // MQ5 § 3.1: the negation of unknown is unknown, so an uncompilable term + // has to survive negation as a term rather than vanish. const query = parseContainerCondition(condition.value, builder); - return query ? ["!", query] : undefined; + return ["!", query ?? ["?"]]; case "operation": - const conditions = condition.conditions - .map((c) => parseContainerQueryCondition(c, builder)) - .filter((v): v is MediaCondition => !!v); + // An uncompilable branch becomes an unknown term rather than being + // filtered out: MQ5 § 3.1 makes `true and unknown` unknown, which + // dropping the branch would turn into true. + const conditions = condition.conditions.map( + (c): MediaCondition => parseContainerQueryCondition(c, builder) ?? ["?"], + ); if (conditions.length === 0) { return; @@ -57,8 +62,9 @@ function parseContainerQueryCondition( return; } case "style": - // We don't support these yet - return; + // CSS Conditional 5 § 3: an unsupported container feature makes the + // condition unknown for that element, which is not the same as absent. + return ["?"]; default: condition satisfies never; return; diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 8eab5925..705852ff 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -79,12 +79,17 @@ function parseMediaQueryCondition( case "feature": return parseFeature(query.value, builder); case "not": + // MQ5 § 3.1: the negation of unknown is unknown, so an uncompilable term + // has to survive negation as a term rather than vanish. const mediaQuery = parseMediaQueryCondition(query.value, builder); - return mediaQuery ? ["!", mediaQuery] : undefined; + return ["!", mediaQuery ?? ["?"]]; case "operation": - const mediaQueries = query.conditions - .map((c) => parseMediaQueryCondition(c, builder)) - .filter((v): v is MediaCondition => !!v); + // An uncompilable branch becomes an unknown term rather than being + // filtered out: MQ5 § 3.1 makes `true and unknown` unknown, which + // dropping the branch would turn into true. + const mediaQueries = query.conditions.map( + (c): MediaCondition => parseMediaQueryCondition(c, builder) ?? ["?"], + ); if (mediaQueries.length === 0) { return; diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index 7b0756b5..464ccc02 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -18,6 +18,14 @@ import { } from "../reactivity"; // import { testAttributes } from "./attributes"; import type { RenderGuard } from "./guards"; +import { + conjoin, + disjoin, + matches, + negate, + UNKNOWN, + type Truth, +} from "./kleene"; import { isTruthyFeatureValue } from "./media-query"; export const DEFAULT_CONTAINER_NAME = "c:___default___"; @@ -52,7 +60,9 @@ export function testContainerQuery( // return false; // } - if (query.m && !testContainerMediaCondition(query.m, container, get)) { + // A conditional group rule is a two-valued context, so a condition that is + // still unknown here does not match - MQ5 § 3.1. + if (query.m && !matches(testContainerMediaCondition(query.m, container, get))) { return false; } @@ -84,41 +94,56 @@ function testContainerMediaCondition( condition: MediaCondition, containerKey: WeakKey, get: Getter, -): boolean { +): Truth { switch (condition[0]) { + case "?": + return UNKNOWN; case "!": - return !testContainerMediaCondition(condition[1], containerKey, get); + return negate( + testContainerMediaCondition(condition[1], containerKey, get), + ); case "&": - return condition[1].every((query) => { - return testContainerMediaCondition(query, containerKey, get); - }); + return conjoin(condition[1], (query) => + testContainerMediaCondition(query, containerKey, get), + ); case "|": - return condition[1].some((query) => { - return testContainerMediaCondition(query, containerKey, get); - }); - case "!!": - return isTruthyFeatureValue( - getContainerFeatureValue(condition[1], containerKey, get), + return disjoin(condition[1], (query) => + testContainerMediaCondition(query, containerKey, get), ); + case "!!": { + const featureValue = getContainerFeatureValue( + condition[1], + containerKey, + get, + ); + return featureValue === undefined + ? UNKNOWN + : isTruthyFeatureValue(featureValue); + } case "[]": - return false; + // An interval this runtime does not evaluate has no answer, rather than + // the answer `false`. + return UNKNOWN; case ">": case ">=": case "<": case "<=": case "=": { - // A feature the runtime cannot measure has no value, and an operand the - // compiler could not resolve is `null`. Neither equals the other, and - // neither is a number, so every comparison below refuses them. const left = getContainerFeatureValue(condition[1], containerKey, get); const right = condition[2]; + // An operand the compiler could not resolve, or a feature this runtime + // cannot measure, leaves the comparison with no answer at all. + if (right === null || left === undefined) { + return UNKNOWN; + } + if (condition[0] === "=") { return left === right; } if (typeof left !== "number" || typeof right !== "number") { - return false; + return UNKNOWN; } switch (condition[0]) { @@ -132,12 +157,12 @@ function testContainerMediaCondition( return left <= right; default: condition[0] satisfies never; - return false; + return UNKNOWN; } } default: condition satisfies never; - return false; + return UNKNOWN; } } diff --git a/src/native/conditions/kleene.ts b/src/native/conditions/kleene.ts new file mode 100644 index 00000000..3e820324 --- /dev/null +++ b/src/native/conditions/kleene.ts @@ -0,0 +1,82 @@ +/** + * Kleene three-valued logic, as CSS Media Queries 5 § 3.1 defines it. + * + * A term the runtime cannot decide is `unknown`, not `false`. The distinction + * only shows up under `not`: MQ5 adopted this logic precisely because in + * two-valued logic "the only reasonable value is false, but this means that + * `not unknown(function)` is true, which can be confusing and unwanted". + * + * The combinators take the terms and an evaluator rather than already-evaluated + * values, so a decided conjunction never evaluates the rest. That matters here + * beyond the arithmetic: evaluating a term reads reactive observables, and + * reading one subscribes to it. Staying lazy keeps the subscription set to the + * operands that actually decided the answer. + */ + +export type Truth = boolean | "unknown"; + +export const UNKNOWN = "unknown"; + +/** MQ5 § 3.1: "The negation of unknown is unknown." */ +export function negate(value: Truth): Truth { + return value === UNKNOWN ? UNKNOWN : !value; +} + +/** + * MQ5 § 3.1: true if all terms are true, false if at least one is false, and + * unknown otherwise. + */ +export function conjoin( + terms: readonly Term[], + evaluate: (term: Term) => Truth, +): Truth { + let unknown = false; + + for (const term of terms) { + const value = evaluate(term); + + if (value === false) { + return false; + } + + if (value === UNKNOWN) { + unknown = true; + } + } + + return unknown ? UNKNOWN : true; +} + +/** + * MQ5 § 3.1: false if all terms are false, true if at least one is true, and + * unknown otherwise. + */ +export function disjoin( + terms: readonly Term[], + evaluate: (term: Term) => Truth, +): Truth { + let unknown = false; + + for (const term of terms) { + const value = evaluate(term); + + if (value === true) { + return true; + } + + if (value === UNKNOWN) { + unknown = true; + } + } + + return unknown ? UNKNOWN : false; +} + +/** + * MQ5 § 3.1: "If the result of any of the above productions is used in any + * context that expects a two-valued boolean, 'unknown' must be converted to + * 'false'." A conditional group rule is that context. + */ +export function matches(value: Truth): boolean { + return value === true; +} diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 2e48a4e0..947d220f 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -10,6 +10,14 @@ import type { } from "react-native-css/compiler"; import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { + conjoin, + disjoin, + matches, + negate, + UNKNOWN, + type Truth, +} from "./kleene"; type MediaFeatureName = MediaFeatureNameFor_MediaFeatureId | "dir"; @@ -23,7 +31,9 @@ type MediaComparison = [ const COLOR_DEPTH = 8; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { - return mediaQueries.every((query) => test(query, get)); + // An @media rule is a two-valued context, so MQ5 § 3.1 converts unknown to + // false here and nowhere earlier. + return mediaQueries.every((query) => matches(test(query, get))); } /** @@ -39,22 +49,26 @@ export function isTruthyFeatureValue(value: StyleDescriptor): boolean { return value !== undefined && value !== false && value !== "none"; } -function test(mediaQuery: MediaCondition, get: Getter): Boolean { +function test(mediaQuery: MediaCondition, get: Getter): Truth { switch (mediaQuery[0]) { + case "?": + return UNKNOWN; case "[]": - return false; - case "!!": - return isTruthyFeatureValue(getMediaFeatureValue(mediaQuery[1], get)); + // An interval this runtime does not evaluate has no answer, rather than + // the answer `false`. + return UNKNOWN; + case "!!": { + const featureValue = getMediaFeatureValue(mediaQuery[1], get); + return featureValue === undefined + ? UNKNOWN + : isTruthyFeatureValue(featureValue); + } case "!": - return !test(mediaQuery[1], get); + return negate(test(mediaQuery[1], get)); case "&": - return mediaQuery[1].every((query) => { - return test(query, get); - }); + return conjoin(mediaQuery[1], (query) => test(query, get)); case "|": - return mediaQuery[1].some((query) => { - return test(query, get); - }); + return disjoin(mediaQuery[1], (query) => test(query, get)); case ">": case ">=": case "<": @@ -65,13 +79,14 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { } } -function testComparison(mediaQuery: MediaComparison, get: Getter): Boolean { +function testComparison(mediaQuery: MediaComparison, get: Getter): Truth { const value = mediaQuery[2]; - // An operand the compiler could not resolve satisfies no comparison. Features - // whose verdict does not read the value would otherwise match on nothing. + // An operand with no compile-time answer leaves the comparison unknown, not + // false - MQ5 § 3.1. Collapsing it to false here is what would make + // `not (min-width: env(safe-area-inset-left))` match. if (value === null) { - return false; + return UNKNOWN; } switch (mediaQuery[1]) { @@ -97,14 +112,16 @@ function testComparison(mediaQuery: MediaComparison, get: Getter): Boolean { } if (typeof value !== "number") { - return false; + return UNKNOWN; } const left = getMediaFeatureValue(mediaQuery[1], get); const right = value; + // A feature this runtime cannot measure is unknown, which is what MQ5 § 3.2 + // assigns an unknown . if (typeof left !== "number") { - return false; + return UNKNOWN; } switch (mediaQuery[0]) { From bf79dea73ad205e7b3b0d2b6783964ce38ff0be7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 15:32:41 +0300 Subject: [PATCH 09/11] test: pin the two condition arms no test observed, and back the media 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 ``, which is a property of the installed parser and not of the grammar. --- .../native/container-style-query.test.tsx | 33 +++++++++++++++ src/__tests__/native/media-unknown.test.tsx | 20 ++++++++++ src/compiler/compiler.ts | 33 ++++++++++++--- src/compiler/container-query.ts | 9 ++++- src/compiler/media-query.ts | 40 ++++++++++++++----- src/native/conditions/container-query.ts | 13 +++++- src/native/conditions/media-query.ts | 13 ++++++ 7 files changed, 142 insertions(+), 19 deletions(-) diff --git a/src/__tests__/native/container-style-query.test.tsx b/src/__tests__/native/container-style-query.test.tsx index 5f249c0f..2eae41ca 100644 --- a/src/__tests__/native/container-style-query.test.tsx +++ b/src/__tests__/native/container-style-query.test.tsx @@ -146,6 +146,39 @@ describe("other undecidable container terms are unknown, not false", () => { expect(child).toHaveStyle({ color: "#f00" }); }); + test("not (inline-size) - an unmeasurable feature in a boolean context", () => { + // `(inline-size)` compiles to `["!!", "inline-size"]`, which is the one + // arm a boolean context reaches. The feature has no runtime value, so the + // term is unknown; reading the absent value as false instead would make + // the negation true. + const child = renderContainer( + `${base} + @container not (inline-size) { .child { color: blue; } }`, + 500, + 200, + ); + + expect(child).toHaveStyle({ color: "#f00" }); + }); + + test("not (width > 10em) - an operand no compile-time length can resolve", () => { + // `em` is relative to the element's own font size, so the compiler cannot + // fold it and emits the length descriptor `[{}, "em", 10, 1]` in the + // operand slot - from ordinary, valid CSS. `px` folds to a number and + // `rem` folds against `inlineRem`, so this is the shape that reaches the + // comparison with a right-hand side it cannot order. Comparing it anyway + // yields `NaN`, which is false for every operator, and the negation of + // that false is the match this refuses. + const child = renderContainer( + `${base} + @container not (width > 10em) { .child { color: blue; } }`, + 500, + 200, + ); + + expect(child).toHaveStyle({ color: "#f00" }); + }); + test("not (400px < width < 500px) - an interval the runtime does not evaluate", () => { const child = renderContainer( `${base} diff --git a/src/__tests__/native/media-unknown.test.tsx b/src/__tests__/native/media-unknown.test.tsx index 397c6e3a..473c60fa 100644 --- a/src/__tests__/native/media-unknown.test.tsx +++ b/src/__tests__/native/media-unknown.test.tsx @@ -83,6 +83,26 @@ describe("a negated term the runtime cannot measure does not apply", () => { expect(component.props.style).toStrictEqual({ color: "#f00" }); }); + test("not all and (width > 10em) - an operand no compile-time length can resolve", () => { + // `em` is relative to the element's own font size, so the compiler cannot + // fold it and emits the length descriptor `[{}, "em", 10, 1]` in the + // operand slot - from ordinary, valid CSS. The comparison has a measurable + // left-hand side and a right-hand side it cannot order, which is unknown + // rather than false. + // + // The negation has to come from the query's `not` qualifier rather than + // from `not (width > 10em)`, because lightningcss folds that spelling into + // `(width <= 10em)` and the term arrives with no negation left to observe. + const component = renderAt( + `${base} + @media not all and (width > 10em) { .my-class { color: blue; } }`, + 500, + 1000, + ); + + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + test("not (400px < width < 500px) - an interval the runtime does not evaluate", () => { const component = renderAt( `${base} diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 85562b35..e0a6145d 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -366,12 +366,22 @@ function extractMedia( } const conditions: MediaCondition[] = []; + let unconditional = false; for (const m of media) { - const condition = parseMediaQuery(m, builder); - - if (condition) { - conditions.push(condition); + const parsed = parseMediaQuery(m, builder); + + switch (parsed.type) { + case "condition": + conditions.push(parsed.condition); + break; + case "unconditional": + unconditional = true; + break; + case "refused": + break; + default: + parsed satisfies never; } } @@ -380,10 +390,23 @@ function extractMedia( // of any enclosing rule, which intersect. const [firstCondition, ...remainingConditions] = conditions; - if (firstCondition) { + if (unconditional) { + // A union with a query that always matches always matches, so the block + // needs no condition of its own. + } else if (firstCondition) { builder.addMediaQuery( remainingConditions.length === 0 ? firstCondition : ["|", conditions], ); + } else { + // Every query in the prelude was refused, so the block applies nowhere. + // Emitting it with no condition would apply it everywhere, which is the + // opposite answer - the same trade `extractContainer` guards against. + // Every `` form compiles to a term today, so this is a + // backstop against a future parse gap rather than a path any stylesheet + // reaches. It has to be spelled through `ParsedMediaQuery` rather than as + // an empty-`conditions` check, because an empty list is also what `all`, + // `screen` and `not print` legitimately produce. + return; } // Iterate over all rules in the mediaRule and extract their styles using the updated CompilerCollection diff --git a/src/compiler/container-query.ts b/src/compiler/container-query.ts index 1e75c28d..b6e65c4f 100644 --- a/src/compiler/container-query.ts +++ b/src/compiler/container-query.ts @@ -37,7 +37,11 @@ function parseContainerQueryCondition( return parseFeature(condition.value, builder); case "not": // MQ5 § 3.1: the negation of unknown is unknown, so an uncompilable term - // has to survive negation as a term rather than vanish. + // has to survive negation as a term rather than vanish. The fallback is + // unreachable today - every `` form below compiles + // to a term, `style()` to `["?"]` - and is kept because what makes it so + // is the set of forms this function handles, which the next feature type + // added to lightningcss changes. const query = parseContainerCondition(condition.value, builder); return ["!", query ?? ["?"]]; case "operation": @@ -45,7 +49,8 @@ function parseContainerQueryCondition( // filtered out: MQ5 § 3.1 makes `true and unknown` unknown, which // dropping the branch would turn into true. const conditions = condition.conditions.map( - (c): MediaCondition => parseContainerQueryCondition(c, builder) ?? ["?"], + (c): MediaCondition => + parseContainerQueryCondition(c, builder) ?? ["?"], ); if (conditions.length === 0) { diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 705852ff..8e0b5a3d 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -18,23 +18,37 @@ import { parseLength } from "./declarations"; import type { StylesheetBuilder } from "./stylesheet"; /** - * Parses a single media query out of a comma-separated list. + * What one query of a comma-separated prelude contributes to the block. * - * Returns `undefined` when the query cannot apply on native, which the caller - * treats the way CSS treats an unmatchable query in a list: it contributes - * nothing, and the remaining queries still decide the block. + * The three cases are distinct answers, and an absent condition cannot stand in + * for all of them: `unconditional` applies the block everywhere and `refused` + * applies it nowhere, so collapsing the pair loses whichever one it drops. */ +export type ParsedMediaQuery = + /** The query compiled to a condition the runtime evaluates. */ + | { type: "condition"; condition: MediaCondition } + /** `all`, `screen`, `not print` - nothing left to test, and it applies. */ + | { type: "unconditional" } + /** + * The query cannot apply on native, which is how CSS treats an unmatchable + * query in a list: it contributes nothing, and the remaining queries still + * decide the block. + */ + | { type: "refused" }; + +/** Parses a single media query out of a comma-separated list. */ export function parseMediaQuery( query: CSSMediaQuery, builder: StylesheetBuilder, -): MediaCondition | undefined { +): ParsedMediaQuery { let platformCondition: MediaCondition | undefined; let condition: MediaCondition | undefined; if (query.mediaType) { - // Print is for printing documents + // Print is for printing documents. `@media print` is refused before it + // reaches here, so what arrives is `not print`, which is true on native. if (query.mediaType === "print") { - return; + return { type: "unconditional" }; } // These all/screen are not conditions, they always apply @@ -51,7 +65,7 @@ export function parseMediaQuery( // the condition, because a query that is absent applies unconditionally // while a query that is present and refused applies to nothing. if (!condition) { - return; + return { type: "refused" }; } } @@ -61,14 +75,14 @@ export function parseMediaQuery( : platformCondition || condition; if (!mediaQuery) { - return; + return { type: "unconditional" }; } if (query.qualifier === "not") { mediaQuery = ["!", mediaQuery]; } - return mediaQuery; + return { type: "condition", condition: mediaQuery }; } function parseMediaQueryCondition( @@ -80,7 +94,11 @@ function parseMediaQueryCondition( return parseFeature(query.value, builder); case "not": // MQ5 § 3.1: the negation of unknown is unknown, so an uncompilable term - // has to survive negation as a term rather than vanish. + // has to survive negation as a term rather than vanish. The fallback is + // unreachable today - every `` form below compiles to a + // term - and is kept because what makes it so is the set of forms this + // function handles, which the next feature type added to lightningcss + // changes. const mediaQuery = parseMediaQueryCondition(query.value, builder); return ["!", mediaQuery ?? ["?"]]; case "operation": diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index 464ccc02..f663e410 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -62,7 +62,10 @@ export function testContainerQuery( // A conditional group rule is a two-valued context, so a condition that is // still unknown here does not match - MQ5 § 3.1. - if (query.m && !matches(testContainerMediaCondition(query.m, container, get))) { + if ( + query.m && + !matches(testContainerMediaCondition(query.m, container, get)) + ) { return false; } @@ -142,6 +145,14 @@ function testContainerMediaCondition( return left === right; } + // An operand that is a length the compiler could not fold reaches here as + // a descriptor rather than a number: `(width > 10em)` compiles to + // `[{}, "em", 10, 1]`, because `em` is relative to the element's own font + // size. `px` folds to a number and `rem` folds against `inlineRem`, so + // this arm carries ordinary CSS rather than a malformed prelude. + // Ordering an operand the runtime cannot resolve gives `NaN`, which is + // false for every operator - and false is the one answer a negation turns + // into a match. if (typeof left !== "number" || typeof right !== "number") { return UNKNOWN; } diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 947d220f..9df876de 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -52,6 +52,14 @@ export function isTruthyFeatureValue(value: StyleDescriptor): boolean { function test(mediaQuery: MediaCondition, get: Getter): Truth { switch (mediaQuery[0]) { case "?": + // Unreachable on this plane with the installed lightningcss: `["?"]` is + // emitted for a container `style()` query, which `@media` cannot carry, + // and `@media (fictional-thing)` parses as the boolean feature + // `["!!", "fictional-thing"]` rather than as MQ5's ``. + // The arm is kept rather than deleted because both halves of that are + // properties of the parser rather than of the grammar: a lightningcss + // that reports `` makes this the arm that answers it, + // and the answer it already gives is the right one. return UNKNOWN; case "[]": // An interval this runtime does not evaluate has no answer, rather than @@ -111,6 +119,11 @@ function testComparison(mediaQuery: MediaComparison, get: Getter): Truth { return value === "landscape" ? get(vh) < get(vw) : get(vh) >= get(vw); } + // A length the compiler could not fold reaches here as a descriptor rather + // than a number: `(width > 10em)` compiles to `[{}, "em", 10, 1]`, because + // `em` is relative to the element's own font size. Ordering it gives `NaN`, + // which is false for every operator - and false is the one answer a negation + // turns into a match. if (typeof value !== "number") { return UNKNOWN; } From 5b57419e3886f2831e7eb2ddd932fc9c244a661b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 15:39:10 +0300 Subject: [PATCH 10/11] refactor: leave the media prelude backstop to the branch that tests it `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. --- src/compiler/compiler.ts | 33 +++++---------------------------- src/compiler/media-query.ts | 34 ++++++++++------------------------ 2 files changed, 15 insertions(+), 52 deletions(-) diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index e0a6145d..85562b35 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -366,22 +366,12 @@ function extractMedia( } const conditions: MediaCondition[] = []; - let unconditional = false; for (const m of media) { - const parsed = parseMediaQuery(m, builder); - - switch (parsed.type) { - case "condition": - conditions.push(parsed.condition); - break; - case "unconditional": - unconditional = true; - break; - case "refused": - break; - default: - parsed satisfies never; + const condition = parseMediaQuery(m, builder); + + if (condition) { + conditions.push(condition); } } @@ -390,23 +380,10 @@ function extractMedia( // of any enclosing rule, which intersect. const [firstCondition, ...remainingConditions] = conditions; - if (unconditional) { - // A union with a query that always matches always matches, so the block - // needs no condition of its own. - } else if (firstCondition) { + if (firstCondition) { builder.addMediaQuery( remainingConditions.length === 0 ? firstCondition : ["|", conditions], ); - } else { - // Every query in the prelude was refused, so the block applies nowhere. - // Emitting it with no condition would apply it everywhere, which is the - // opposite answer - the same trade `extractContainer` guards against. - // Every `` form compiles to a term today, so this is a - // backstop against a future parse gap rather than a path any stylesheet - // reaches. It has to be spelled through `ParsedMediaQuery` rather than as - // an empty-`conditions` check, because an empty list is also what `all`, - // `screen` and `not print` legitimately produce. - return; } // Iterate over all rules in the mediaRule and extract their styles using the updated CompilerCollection diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 8e0b5a3d..6f17fd85 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -18,37 +18,23 @@ import { parseLength } from "./declarations"; import type { StylesheetBuilder } from "./stylesheet"; /** - * What one query of a comma-separated prelude contributes to the block. + * Parses a single media query out of a comma-separated list. * - * The three cases are distinct answers, and an absent condition cannot stand in - * for all of them: `unconditional` applies the block everywhere and `refused` - * applies it nowhere, so collapsing the pair loses whichever one it drops. + * Returns `undefined` when the query cannot apply on native, which the caller + * treats the way CSS treats an unmatchable query in a list: it contributes + * nothing, and the remaining queries still decide the block. */ -export type ParsedMediaQuery = - /** The query compiled to a condition the runtime evaluates. */ - | { type: "condition"; condition: MediaCondition } - /** `all`, `screen`, `not print` - nothing left to test, and it applies. */ - | { type: "unconditional" } - /** - * The query cannot apply on native, which is how CSS treats an unmatchable - * query in a list: it contributes nothing, and the remaining queries still - * decide the block. - */ - | { type: "refused" }; - -/** Parses a single media query out of a comma-separated list. */ export function parseMediaQuery( query: CSSMediaQuery, builder: StylesheetBuilder, -): ParsedMediaQuery { +): MediaCondition | undefined { let platformCondition: MediaCondition | undefined; let condition: MediaCondition | undefined; if (query.mediaType) { - // Print is for printing documents. `@media print` is refused before it - // reaches here, so what arrives is `not print`, which is true on native. + // Print is for printing documents if (query.mediaType === "print") { - return { type: "unconditional" }; + return; } // These all/screen are not conditions, they always apply @@ -65,7 +51,7 @@ export function parseMediaQuery( // the condition, because a query that is absent applies unconditionally // while a query that is present and refused applies to nothing. if (!condition) { - return { type: "refused" }; + return; } } @@ -75,14 +61,14 @@ export function parseMediaQuery( : platformCondition || condition; if (!mediaQuery) { - return { type: "unconditional" }; + return; } if (query.qualifier === "not") { mediaQuery = ["!", mediaQuery]; } - return { type: "condition", condition: mediaQuery }; + return mediaQuery; } function parseMediaQueryCondition( From 3d2e1ebda4ec27d2f9e1df16db45e89cc3571966 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 17 Aug 2026 02:02:32 +0300 Subject: [PATCH 11/11] docs(compiler): stop the operand comment claiming to enumerate every unanswerable value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 #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 #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 #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 #425 puts it there, in `case "ratio"`. Comments only. --- src/compiler/media-query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 6f17fd85..722d4b5b 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -153,7 +153,7 @@ function parseFeature( * A feature value in the one shape an operand slot can hold. * * `parseMediaFeatureValue` answers `undefined` for a value with no compile-time - * answer - `env()`, a ratio, an unsupported `calc()`. That marker cannot cross + * answer, such as `env()` or an unsupported `calc()`. That marker cannot cross * into a native bundle, which receives the stylesheet as JSON, so it is written * here as `null` and every operand slot is filled through this function. */