From c0028b1ac8ba145eea571636f7eed0d055f5d8e3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 11 Oct 2026 20:00:46 +0300 Subject: [PATCH 01/18] fix(native): evaluate range pairs through one shared comparison Both evaluators answered a range pair such as (400px < width < 800px) with false, though the compiler emits it faithfully. Media and container queries now share compareMediaFeature, so each operator has one meaning, and testMediaFeatureInterval answers a pair as its two comparisons. --- src/__tests__/native/compare.test.ts | 38 ++++++++++ .../native/condition-semantics.test.tsx | 65 +++++++++++++++++ src/native/conditions/compare.ts | 51 +++++++++++++ src/native/conditions/container-query.ts | 38 +++------- src/native/conditions/media-query.ts | 71 ++++++++++--------- 5 files changed, 203 insertions(+), 60 deletions(-) create mode 100644 src/__tests__/native/compare.test.ts create mode 100644 src/__tests__/native/condition-semantics.test.tsx create mode 100644 src/native/conditions/compare.ts diff --git a/src/__tests__/native/compare.test.ts b/src/__tests__/native/compare.test.ts new file mode 100644 index 00000000..637b4e45 --- /dev/null +++ b/src/__tests__/native/compare.test.ts @@ -0,0 +1,38 @@ +import type { StyleDescriptor } from "react-native-css/compiler"; + +import { + compareMediaFeature, + testMediaFeatureInterval, + type MediaInterval, +} from "../../native/conditions/compare"; + +test.each([ + [">", [false, false, true]], + [">=", [false, true, true]], + ["<", [true, false, false]], + ["<=", [true, true, false]], + ["=", [false, true, false]], +] as const)("%s compares 39, 40 and 41 against 40", (operator, expected) => { + expect( + [39, 40, 41].map((value) => compareMediaFeature(operator, value, 40)), + ).toStrictEqual(expected); +}); + +const intervals: [ + label: string, + condition: MediaInterval, + value: StyleDescriptor, + matches: boolean, +][] = [ + ["inside", ["[]", "width", 400, "<", 800, "<"], 600, true], + ["below", ["[]", "width", 400, "<", 800, "<"], 300, false], + ["above", ["[]", "width", 400, "<", 800, "<"], 900, false], + ["on an open bound", ["[]", "width", 400, "<", 800, "<"], 400, false], + ["on a closed bound", ["[]", "width", 400, "<=", 800, "<"], 400, true], + ["written descending", ["[]", "width", 800, ">", 400, ">"], 600, true], + ["unmeasured", ["[]", "width", 400, "<", 800, "<"], undefined, false], +]; + +test.each(intervals)("an interval %s", (_label, condition, value, matches) => { + expect(testMediaFeatureInterval(condition, value)).toBe(matches); +}); diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx new file mode 100644 index 00000000..a9cca61d --- /dev/null +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -0,0 +1,65 @@ +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; + +import { dimensions } from "../../native/reactivity"; + +function mediaMatches(condition: string): boolean { + registerCSS(` + .child { width: 10px; } + @media ${condition} { .child { width: 37px; } } + `); + act(() => { + dimensions.set({ ...dimensions.get(), width: 600, height: 400 }); + }); + const { unmount } = render(); + const { width } = screen.getByTestId("child").props.style; + unmount(); + return width === 37; +} + +function containerMatches( + condition: string, + size = { width: 400, height: 200 }, +): boolean { + registerCSS(` + .parent { container-type: size; } + .child { width: 10px; } + @container ${condition} { .child { width: 37px; } } + `); + const { unmount } = render( + + + , + ); + fireEvent(screen.getByTestId("parent"), "layout", { + nativeEvent: { layout: { x: 0, y: 0, ...size } }, + }); + const { width } = screen.getByTestId("child").props.style; + unmount(); + return width === 37; +} + +describe("@media on a 600x400 screen", () => { + test.each([ + ["(400px < width < 800px)", true], + ["(400px < width < 500px)", false], + ["(600px < width < 800px)", false], + ["(600px <= width < 800px)", true], + ["(800px > width > 400px)", true], + ["(300px < height < 500px)", true], + ])("a range pair %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); +}); + +describe("@container on a 400x200 container", () => { + test.each([ + ["(300px < width < 500px)", true], + ["(400px < width < 500px)", false], + ["(500px > width > 300px)", true], + ["(100px < height < 300px)", true], + ])("a range pair %s matches: %s", (condition, expected) => { + expect(containerMatches(condition)).toBe(expected); + }); +}); diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts new file mode 100644 index 00000000..4f8c2e14 --- /dev/null +++ b/src/native/conditions/compare.ts @@ -0,0 +1,51 @@ +import type { + MediaCondition, + MediaFeatureComparison, + StyleDescriptor, +} from "react-native-css/compiler"; + +export type MediaInterval = Extract; + +// Media and container queries share this, so each operator has one meaning +export function compareMediaFeature( + operator: MediaFeatureComparison, + left: StyleDescriptor, + right: StyleDescriptor, +): boolean { + // = also compares keywords such as landscape + if (operator === "=") { + return left === right; + } + + // The rest are arithmetic, where a string would coerce + if (typeof left !== "number" || typeof right !== "number") { + return false; + } + + switch (operator) { + case ">": + return left > right; + case ">=": + return left >= right; + case "<": + return left < right; + case "<=": + return left <= right; + default: + operator satisfies never; + return false; + } +} + +// The compiler emits a range pair in source order: start, operator, value, operator, end +export function testMediaFeatureInterval( + condition: MediaInterval, + value: StyleDescriptor, +): boolean { + const [, , start, startOperator, end, endOperator] = condition; + + return ( + compareMediaFeature(startOperator, start, value) && + compareMediaFeature(endOperator, value, end) + ); +} diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index e25df564..54869535 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -17,6 +17,7 @@ import { type Getter, } from "../reactivity"; import { testAttributes } from "./attributes"; +import { compareMediaFeature, testMediaFeatureInterval } from "./compare"; import type { RenderGuard } from "./guards"; export const DEFAULT_CONTAINER_NAME = "c:___default___"; @@ -100,37 +101,20 @@ function testContainerMediaCondition( case "!!": return false; case "[]": - return false; + return testMediaFeatureInterval( + condition, + getContainerFeatureValue(condition[1], containerKey, get), + ); case ">": case ">=": case "<": case "<=": - case "=": { - const left = getContainerFeatureValue(condition[1], containerKey, get); - const right = condition[2]; - - if (condition[0] === "=") { - return left === right; - } - - if (typeof left !== "number" || typeof right !== "number") { - return false; - } - - switch (condition[0]) { - case ">": - return left > right; - case ">=": - return left >= right; - case "<": - return left < right; - case "<=": - return left <= right; - default: - condition[0] satisfies never; - return false; - } - } + case "=": + return compareMediaFeature( + condition[0], + getContainerFeatureValue(condition[1], containerKey, get), + condition[2], + ); default: condition satisfies never; return false; diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index afd51250..07549722 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,19 +1,38 @@ /* eslint-disable */ import { I18nManager, PixelRatio, Platform } from "react-native"; -import type { MediaCondition } from "react-native-css/compiler"; +import type { + MediaCondition, + MediaFeatureComparison, +} from "react-native-css/compiler"; import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { + compareMediaFeature, + testMediaFeatureInterval, + type MediaInterval, +} from "./compare"; + +type MediaComparison = Extract< + MediaCondition, + [MediaFeatureComparison, ...unknown[]] +>; + +type MediaFeatureName = MediaComparison[1] | MediaInterval[1]; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); } -function test(mediaQuery: MediaCondition, get: Getter): Boolean { +function test(mediaQuery: MediaCondition, get: Getter): boolean { switch (mediaQuery[0]) { - case "[]": case "!!": return false; + case "[]": + return testMediaFeatureInterval( + mediaQuery, + getMediaFeatureValue(mediaQuery[1], get), + ); case "!": return !test(mediaQuery[1], get); case "&": @@ -34,7 +53,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]; switch (mediaQuery[1]) { @@ -61,39 +80,25 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { return value === "landscape" ? get(vh) < get(vw) : get(vh) >= get(vw); } - if (typeof value !== "number") { - return false; - } - - let left: number | undefined; - const right = value; + return compareMediaFeature( + mediaQuery[0], + getMediaFeatureValue(mediaQuery[1], get), + value, + ); +} - switch (mediaQuery[1]) { +function getMediaFeatureValue( + name: MediaFeatureName, + get: Getter, +): number | undefined { + switch (name) { case "width": - left = get(vw); - break; + return get(vw); case "height": - left = get(vh); - break; + return get(vh); case "resolution": - left = PixelRatio.get(); - break; - default: - return false; - } - - switch (mediaQuery[0]) { - case "=": - return left === right; - case ">": - return left > right; - case ">=": - return left >= right; - case "<": - return left < right; - case "<=": - return left <= right; + return PixelRatio.get(); default: - return false; + return undefined; } } From a05bebaafb37284d213dbaf542caa5134068ffca Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 11 Oct 2026 20:01:20 +0300 Subject: [PATCH 02/18] fix: answer a condition the runtime cannot decide with unknown, not false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MQ5 §3.1 evaluates a media condition in three-valued logic: a term the runtime cannot decide is unknown, its negation is unknown, and a rule applies only when the whole condition is true. Both runtimes answered such a term false, so its negation matched: not (fictional-feature: 3) and not (pointer: fine) applied everywhere. A media feature value the compiler cannot resolve compiles to null, and both runtimes evaluate in Kleene logic, answering unknown for a null operand, a feature they do not measure, an operand they cannot order and a container style() query. A container condition holding a size feature the runtime does not know, or a value the compiler cannot resolve, selects no container (CSS Conditional 5 §5.4), so the compiler emits no rule for it. A feature in a boolean context is evaluated (MQ5 §2.4.2). The runtime answers the features whose value is the same on every React Native screen, (hover: none) no longer matches, a device with no color scheme preference matches light, and an value compiles to its integer. --- .../compiler/condition-semantics.test.ts | 57 +++++++ src/__tests__/native/compare.test.ts | 56 ++++++- .../native/condition-semantics.test.tsx | 142 +++++++++++++++++- .../native/container-rejection.test.tsx | 27 +++- src/__tests__/native/kleene.test.ts | 60 ++++++++ src/compiler/compiler.types.ts | 11 +- src/compiler/container-query.ts | 103 +++++++------ src/compiler/media-query.ts | 60 ++++---- src/native/conditions/compare.ts | 57 +++++-- src/native/conditions/container-query.ts | 49 ++++-- src/native/conditions/kleene.ts | 57 +++++++ src/native/conditions/media-query.ts | 110 +++++++++----- 12 files changed, 630 insertions(+), 159 deletions(-) create mode 100644 src/__tests__/compiler/condition-semantics.test.ts create mode 100644 src/__tests__/native/kleene.test.ts create mode 100644 src/native/conditions/kleene.ts diff --git a/src/__tests__/compiler/condition-semantics.test.ts b/src/__tests__/compiler/condition-semantics.test.ts new file mode 100644 index 00000000..3793b49d --- /dev/null +++ b/src/__tests__/compiler/condition-semantics.test.ts @@ -0,0 +1,57 @@ +import { compile, type StyleRule } from "react-native-css/compiler"; + +function compileChildConditions(css: string): Pick[] { + return (compile(css).stylesheet().s ?? []).flatMap(([className, rules]) => + className === "child" ? rules.map(({ m, cq }) => ({ m, cq })) : [], + ); +} + +// CSS Conditional 5 §5.4: an unknown size feature or an unresolvable value selects no container +test.each([ + "@container (fictional-feature > 10px)", + "@container (fictional-feature > 10px) or (width > 400px)", + "@container not (fictional-feature > 10px)", + "@container (width > env(safe-area-inset-top))", + "@container (width > env(safe-area-inset-top)) or (width > 400px)", +])("%s emits no block", (prelude) => { + expect( + compileChildConditions(`${prelude} { .child { color: red } }`), + ).toStrictEqual([]); +}); + +test.each<[string, Pick]>([ + ["@container style(--foo: bar)", { m: undefined, cq: [{ m: ["?"] }] }], + [ + "@container not style(--foo: bar)", + { m: undefined, cq: [{ m: ["!", ["?"]] }] }, + ], + [ + "@container style(--foo: bar) or (width > 400px)", + { m: undefined, cq: [{ m: ["|", [["?"], [">", "width", 400]]] }] }, + ], + [ + "@media (width > env(safe-area-inset-top))", + { m: [[">", "width", null]], cq: undefined }, + ], + [ + "@media not (width: env(safe-area-inset-top))", + { m: [["!", ["=", "width", null]]], cq: undefined }, + ], + ["@media (grid: 0)", { m: [["=", "grid", 0]], cq: undefined }], + ["@media (grid: 1)", { m: [["=", "grid", 1]], cq: undefined }], +])("%s compiles to %j", (prelude, conditions) => { + expect( + compileChildConditions(`${prelude} { .child { color: red } }`), + ).toStrictEqual([conditions]); +}); + +test("a media query list keeps a branch whose value the compiler cannot resolve", () => { + expect( + compileChildConditions( + "@media (width > env(safe-area-inset-top)), (width > 400px) { .child { color: red } }", + ), + ).toStrictEqual([ + { m: [[">", "width", null]], cq: undefined }, + { m: [[">", "width", 400]], cq: undefined }, + ]); +}); diff --git a/src/__tests__/native/compare.test.ts b/src/__tests__/native/compare.test.ts index 637b4e45..9b00f3ba 100644 --- a/src/__tests__/native/compare.test.ts +++ b/src/__tests__/native/compare.test.ts @@ -1,10 +1,16 @@ -import type { StyleDescriptor } from "react-native-css/compiler"; +import type { + MediaFeatureComparison, + MediaFeatureOperand, +} from "react-native-css/compiler"; import { compareMediaFeature, + isTruthyFeatureValue, testMediaFeatureInterval, + type MeasuredFeatureValue, type MediaInterval, } from "../../native/conditions/compare"; +import { UNKNOWN, type Truth } from "../../native/conditions/kleene"; test.each([ [">", [false, false, true]], @@ -18,11 +24,30 @@ test.each([ ).toStrictEqual(expected); }); +const operands: [ + label: string, + operator: MediaFeatureComparison, + left: MediaFeatureOperand | undefined, + right: MediaFeatureOperand, + truth: Truth, +][] = [ + ["an unmeasured feature", ">", undefined, 400, UNKNOWN], + ["an unresolved operand", ">", 400, null, UNKNOWN], + ["an operand only an element resolves", ">", 400, [{}, "em", 10, 1], UNKNOWN], + ["a keyword ordered against a number", ">", "landscape", 400, UNKNOWN], + ["an equal keyword", "=", "landscape", "landscape", true], + ["a different keyword", "=", "portrait", "landscape", false], +]; + +test.each(operands)("%s", (_label, operator, left, right, truth) => { + expect(compareMediaFeature(operator, left, right)).toBe(truth); +}); + const intervals: [ label: string, condition: MediaInterval, - value: StyleDescriptor, - matches: boolean, + value: MeasuredFeatureValue, + truth: Truth, ][] = [ ["inside", ["[]", "width", 400, "<", 800, "<"], 600, true], ["below", ["[]", "width", 400, "<", 800, "<"], 300, false], @@ -30,9 +55,28 @@ const intervals: [ ["on an open bound", ["[]", "width", 400, "<", 800, "<"], 400, false], ["on a closed bound", ["[]", "width", 400, "<=", 800, "<"], 400, true], ["written descending", ["[]", "width", 800, ">", 400, ">"], 600, true], - ["unmeasured", ["[]", "width", 400, "<", 800, "<"], undefined, false], + ["unmeasured", ["[]", "width", 400, "<", 800, "<"], undefined, UNKNOWN], + [ + "with an unresolved bound", + ["[]", "width", null, "<", 800, "<"], + 600, + UNKNOWN, + ], + ["false on one bound", ["[]", "width", 700, "<", null, "<"], 600, false], ]; -test.each(intervals)("an interval %s", (_label, condition, value, matches) => { - expect(testMediaFeatureInterval(condition, value)).toBe(matches); +test.each(intervals)("an interval %s", (_label, condition, value, truth) => { + expect(testMediaFeatureInterval(condition, value)).toBe(truth); +}); + +test.each([ + [400, true], + [0, false], + [Number.NaN, false], + ["hover", true], + ["none", false], + ["no-preference", false], + [undefined, UNKNOWN], +] as const)("%p in a boolean context is %p", (value, truth) => { + expect(isTruthyFeatureValue(value)).toBe(truth); }); diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx index a9cca61d..6d118dd1 100644 --- a/src/__tests__/native/condition-semantics.test.tsx +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -1,8 +1,18 @@ import { act, fireEvent, render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; -import { dimensions } from "../../native/reactivity"; +import { testMediaQuery } from "../../native/conditions/media-query"; +import { dimensions, vw, type Getter } from "../../native/reactivity"; + +const initialColorScheme = colorScheme.get(); + +afterEach(() => { + act(() => { + colorScheme.set(initialColorScheme); + }); +}); function mediaMatches(condition: string): boolean { registerCSS(` @@ -51,6 +61,69 @@ describe("@media on a 600x400 screen", () => { ])("a range pair %s matches: %s", (condition, expected) => { expect(mediaMatches(condition)).toBe(expected); }); + + // MQ5 §3.1: a term the runtime cannot decide is unknown, and a rule applies only when its query is true + test.each([ + ["(width > env(safe-area-inset-top))", false], + ["not (width > env(safe-area-inset-top))", false], + ["(width: env(safe-area-inset-top)) or (width > 400px)", true], + ["not ((width: env(safe-area-inset-top)) and (width > 9999px))", true], + ["not all and (width > 10em)", false], + ["not (fictional-feature: 3)", false], + ["(fictional-thing) or (width > 400px)", true], + ["(width > 400px) and (fictional-thing)", false], + ["not (pointer: fine)", false], + ])("an undecidable term in %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + + // MQ5 §2.4.2: a feature in a boolean context is false only for zero, none and no-preference + test.each([ + ["(width)", true], + ["(orientation)", true], + ["(resolution)", true], + ["(hover)", true], + ["(color)", true], + ["(prefers-color-scheme)", true], + ["(scripting)", true], + ["(monochrome)", false], + ["(grid)", false], + ["not (monochrome)", true], + ["(dir)", false], + ])("the boolean context %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + + test.each([ + ["(hover: hover)", true], + ["(hover: none)", false], + ["(min-color: 8)", true], + ["(min-color: 9)", false], + ["(monochrome: 0)", true], + ["(grid: 1)", false], + ["(scripting: enabled)", true], + ["(update: fast)", true], + ])( + "a feature every screen answers alike %s matches: %s", + (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }, + ); + + test.each([ + [null, "(prefers-color-scheme: light)", true], + [null, "(prefers-color-scheme: dark)", false], + ["dark", "(prefers-color-scheme: dark)", true], + ["dark", "(prefers-color-scheme: light)", false], + ] as const)( + "with a %p preference, %s matches: %s", + (scheme, condition, expected) => { + act(() => { + colorScheme.set(scheme); + }); + expect(mediaMatches(condition)).toBe(expected); + }, + ); }); describe("@container on a 400x200 container", () => { @@ -62,4 +135,71 @@ describe("@container on a 400x200 container", () => { ])("a range pair %s matches: %s", (condition, expected) => { expect(containerMatches(condition)).toBe(expected); }); + + // CSS Conditional 5 §5.4: an unknown size feature or an unresolvable value selects no container + test.each([ + "(fictional-feature > 10px)", + "not (fictional-feature > 10px)", + "(fictional-feature > 10px) or (width > 0px)", + "(width > env(safe-area-inset-top))", + "not (width > env(safe-area-inset-top))", + ])("%s selects no container", (condition) => { + expect(containerMatches(condition)).toBe(false); + }); + + // em is relative to the container's font size, which the runtime does not read + test.each([ + ["not (width > 10em)", false], + ["(width > 10em) or (width > 0px)", true], + ])("%s matches: %s", (condition, expected) => { + expect(containerMatches(condition)).toBe(expected); + }); + + test.each([ + ["(width)", true], + ["(orientation)", true], + ["(aspect-ratio)", true], + ["not (width)", false], + ])("the boolean context %s matches: %s", (condition, expected) => { + expect(containerMatches(condition)).toBe(expected); + }); + + test("(width) does not match a container of zero width", () => { + expect(containerMatches("(width)", { width: 0, height: 200 })).toBe(false); + }); +}); + +test("a decided conjunction reads only the term that decided it", () => { + const read: unknown[] = []; + const get: Getter = (observable) => { + read.push(observable); + return observable.get(); + }; + act(() => { + dimensions.set({ ...dimensions.get(), width: 600, height: 400 }); + }); + + expect( + testMediaQuery( + [ + [ + "&", + [ + [">=", "width", 9999], + ["=", "prefers-color-scheme", "dark"], + ], + ], + ], + get, + ), + ).toBe(false); + expect(read).toStrictEqual([vw]); +}); + +test("an unknown term applies nowhere, negated or not", () => { + const get: Getter = (observable) => observable.get(); + + expect(testMediaQuery([["?"]], get)).toBe(false); + expect(testMediaQuery([["!", ["?"]]], get)).toBe(false); + expect(testMediaQuery([["|", [["?"], [">=", "width", 0]]]], get)).toBe(true); }); diff --git a/src/__tests__/native/container-rejection.test.tsx b/src/__tests__/native/container-rejection.test.tsx index 6308af19..2da5b872 100644 --- a/src/__tests__/native/container-rejection.test.tsx +++ b/src/__tests__/native/container-rejection.test.tsx @@ -10,7 +10,6 @@ test.each([ "(width > 100px) and style(--theme: dark)", "style(--theme: dark) and (width > 100px)", "style(--theme: dark) or style(--theme: light)", - "not ((width > 100px) and style(--theme: dark))", ])("unsupported container predicate does not apply: %s", (condition) => { registerCSS(` .container { container-type: inline-size; } @@ -30,6 +29,32 @@ test.each([ } }); +test("negated conjunction applies only where its size condition decides it", () => { + registerCSS(` + .container { container-type: inline-size; } + .child { width: 10px; } + @container not ((width > 100px) and style(--theme: dark)) { + .child { width: 37px; } + } + `); + render( + + + , + ); + // Below 100px the conjunction is false whatever style() answers, so its negation is true + for (const [width, expected] of [ + [200, 10], + [50, 37], + [200, 10], + ]) { + fireEvent(screen.getByTestId("parent"), "layout", { + nativeEvent: { layout: { width, height: 200 } }, + }); + expect(screen.getByTestId("child")).toHaveStyle({ width: expected }); + } +}); + test("supported disjunction retains its size condition and responds to resizing", () => { registerCSS(` .container { container-type: inline-size; } diff --git a/src/__tests__/native/kleene.test.ts b/src/__tests__/native/kleene.test.ts new file mode 100644 index 00000000..d09079e5 --- /dev/null +++ b/src/__tests__/native/kleene.test.ts @@ -0,0 +1,60 @@ +import { + conjoin, + disjoin, + matches, + negate, + UNKNOWN, + type Truth, +} from "../../native/conditions/kleene"; + +const identity = (value: Truth): Truth => value; + +test.each([ + [true, false], + [false, true], + [UNKNOWN, UNKNOWN], +] as const)("not %s is %s", (value, expected) => { + expect(negate(value)).toBe(expected); +}); + +test.each([ + [true, true], + [false, false], + [UNKNOWN, false], +] as const)("a rule applies on %s: %s", (value, expected) => { + expect(matches(value)).toBe(expected); +}); + +test.each([ + [true, true, true, true], + [true, false, false, true], + [true, UNKNOWN, UNKNOWN, true], + [false, false, false, false], + [false, UNKNOWN, false, UNKNOWN], + [UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN], +] as const)( + "%s with %s: and is %s, or is %s", + (left, right, conjunction, disjunction) => { + expect(conjoin([left, right], identity)).toBe(conjunction); + expect(conjoin([right, left], identity)).toBe(conjunction); + expect(disjoin([left, right], identity)).toBe(disjunction); + expect(disjoin([right, left], identity)).toBe(disjunction); + }, +); + +test("an empty conjunction is true and an empty disjunction false", () => { + expect(conjoin([], identity)).toBe(true); + expect(disjoin([], identity)).toBe(false); +}); + +test("a decided term stops the evaluation, and an unknown one does not", () => { + const evaluated: Truth[] = []; + const record = (value: Truth): Truth => { + evaluated.push(value); + return value; + }; + + expect(conjoin([UNKNOWN, false, true], record)).toBe(false); + expect(disjoin([UNKNOWN, true, false], record)).toBe(true); + expect(evaluated).toStrictEqual([UNKNOWN, false, UNKNOWN, true]); +}); diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..9a23e8b0 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -174,6 +174,8 @@ export type AnimationKeyframes = [string | number, StyleDeclaration[]]; /****************************** Conditions ******************************/ export type MediaCondition = + // Unknown, as a container style() query is to the runtime + | ["?"] // Boolean | ["!!", MediaFeatureNameFor_MediaFeatureId] // Not @@ -186,18 +188,21 @@ 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 ]; +// JSON writes undefined in an array as null, so null is what a device reads for a value the compiler could not resolve +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 4d525493..2ad8c00a 100644 --- a/src/compiler/container-query.ts +++ b/src/compiler/container-query.ts @@ -1,5 +1,5 @@ -/* eslint-disable */ import type { + ContainerSizeFeatureId, ContainerCondition as CSSContainerCondition, QueryFeatureFor_ContainerSizeFeatureId, } from "lightningcss"; @@ -11,42 +11,38 @@ import { } from "./media-query"; import type { StylesheetBuilder } from "./stylesheet"; -export function parseContainerCondition( - condition: CSSContainerCondition, - builder: StylesheetBuilder, -) { - let containerQuery = parseContainerQueryCondition(condition, builder); +const SIZE_FEATURES: Record = { + "width": true, + "height": true, + "inline-size": true, + "block-size": true, + "aspect-ratio": true, + "orientation": true, +}; - // If any of these are undefined, the media query is invalid - if (!containerQuery || containerQuery.some((v) => v === undefined)) { - return; - } - - return containerQuery; -} - -function parseContainerQueryCondition( +// A condition holding a size feature the runtime does not know, or a value it cannot resolve, selects no container (CSS Conditional 5 §5.4), so it compiles to nothing +export function parseContainerCondition( condition: CSSContainerCondition, builder: StylesheetBuilder, ): MediaCondition | undefined { switch (condition.type) { case "feature": return parseFeature(condition.value, builder); - case "not": + case "not": { const query = parseContainerCondition(condition.value, builder); return query ? ["!", query] : undefined; - case "operation": - const parsed = condition.conditions.map((c) => - parseContainerCondition(c, builder), - ); - if (condition.operator === "and" && parsed.some((c) => !c)) { - // Dropping a conjunct would broaden the query to unrelated containers. - return; - } - const conditions = parsed.filter((c): c is MediaCondition => !!c); + } + case "operation": { + const conditions: MediaCondition[] = []; + + for (const child of condition.conditions) { + const query = parseContainerCondition(child, builder); + + if (!query) { + return; + } - if (conditions.length === 0) { - return; + conditions.push(query); } switch (condition.operator) { @@ -58,9 +54,9 @@ function parseContainerQueryCondition( condition.operator satisfies never; return; } + } case "style": - // We don't support these yet - return; + return ["?"]; default: condition satisfies never; return; @@ -71,30 +67,37 @@ function parseFeature( feature: QueryFeatureFor_ContainerSizeFeatureId, builder: StylesheetBuilder, ): MediaCondition | undefined { + if (!Object.prototype.hasOwnProperty.call(SIZE_FEATURES, feature.name)) { + return; + } + switch (feature.type) { case "boolean": return ["!!", feature.name]; - case "plain": - return [ - "=", - feature.name, - parseMediaFeatureValue(feature.value, builder), - ]; - case "range": - return [ - parseMediaFeatureOperator(feature.operator), - feature.name, - parseMediaFeatureValue(feature.value, builder), - ]; - case "interval": - return [ - "[]", - feature.name, - parseMediaFeatureValue(feature.start, builder), - parseMediaFeatureOperator(feature.startOperator), - parseMediaFeatureValue(feature.end, builder), - parseMediaFeatureOperator(feature.endOperator), - ]; + case "plain": { + const value = parseMediaFeatureValue(feature.value, builder); + return value === undefined ? undefined : ["=", feature.name, value]; + } + case "range": { + const value = parseMediaFeatureValue(feature.value, builder); + return value === undefined + ? undefined + : [parseMediaFeatureOperator(feature.operator), feature.name, value]; + } + case "interval": { + const start = parseMediaFeatureValue(feature.start, builder); + const end = parseMediaFeatureValue(feature.end, builder); + return start === undefined || end === undefined + ? undefined + : [ + "[]", + feature.name, + start, + parseMediaFeatureOperator(feature.startOperator), + end, + parseMediaFeatureOperator(feature.endOperator), + ]; + } default: feature satisfies never; return; diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 1408adc0..72b28325 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"; @@ -38,11 +39,6 @@ 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)) { - return false; - } } let mediaQuery: MediaCondition | undefined = @@ -66,29 +62,16 @@ export function parseMediaQuery( function parseMediaQueryCondition( query: CSSMediaCondition, builder: StylesheetBuilder, -): MediaCondition | undefined { +): MediaCondition { switch (query.type) { - case "feature": { - const feature = parseFeature(query.value, builder); - return feature?.some((value) => value === undefined) - ? undefined - : feature; - } + case "feature": + return parseFeature(query.value, builder); case "not": - const mediaQuery = parseMediaQueryCondition(query.value, builder); - return mediaQuery ? ["!", mediaQuery] : undefined; - case "operation": - const parsed = query.conditions.map((c) => - parseMediaQueryCondition(c, builder), + return ["!", parseMediaQueryCondition(query.value, builder)]; + case "operation": { + const mediaQueries = query.conditions.map((condition) => + parseMediaQueryCondition(condition, builder), ); - if (query.operator === "and" && parsed.some((c) => !c)) { - return; - } - const mediaQueries = parsed.filter((v): v is MediaCondition => !!v); - - if (mediaQueries.length === 0) { - return; - } switch (query.operator) { case "and": @@ -97,19 +80,19 @@ function parseMediaQueryCondition( return ["|", mediaQueries]; default: query.operator satisfies never; - return; + throw new Error(`Unknown media condition operator ${query.operator}`); } + } default: query satisfies never; + throw new Error(`Unknown media condition ${JSON.stringify(query)}`); } - - return; } function parseFeature( feature: QueryFeatureFor_MediaFeatureId, builder: StylesheetBuilder, -): MediaCondition | undefined { +): MediaCondition { switch (feature.type) { case "boolean": return ["!!", feature.name]; @@ -117,27 +100,34 @@ 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: feature satisfies never; + throw new Error(`Unknown media feature ${JSON.stringify(feature)}`); } - return; +} + +function parseMediaFeatureOperand( + value: CSSMediaFeatureValue, + builder: StylesheetBuilder, +): MediaFeatureOperand { + return parseMediaFeatureValue(value, builder) ?? null; } export function parseMediaFeatureValue( @@ -145,7 +135,9 @@ export function parseMediaFeatureValue( builder: StylesheetBuilder, ): StyleDescriptor { switch (value.type) { + // is the integer 0 or 1 (MQ4 §5.3) case "boolean": + return value.value ? 1 : 0; case "ident": case "integer": case "number": diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts index 4f8c2e14..2ebe60b6 100644 --- a/src/native/conditions/compare.ts +++ b/src/native/conditions/compare.ts @@ -1,25 +1,38 @@ import type { MediaCondition, MediaFeatureComparison, - StyleDescriptor, + MediaFeatureOperand, } from "react-native-css/compiler"; +import { conjoin, UNKNOWN, type Truth } from "./kleene"; + export type MediaInterval = Extract; -// Media and container queries share this, so each operator has one meaning +// undefined for a feature the runtime does not measure +export type MeasuredFeatureValue = number | string | undefined; + +// Media and container queries share this, so each operator has one meaning; an unmeasured feature or an unresolved operand is unknown (MQ5 §3.1) export function compareMediaFeature( operator: MediaFeatureComparison, - left: StyleDescriptor, - right: StyleDescriptor, -): boolean { + left: MediaFeatureOperand | undefined, + right: MediaFeatureOperand | undefined, +): Truth { + if ( + left === undefined || + left === null || + right === undefined || + right === null + ) { + return UNKNOWN; + } + // = also compares keywords such as landscape if (operator === "=") { return left === right; } - // The rest are arithmetic, where a string would coerce if (typeof left !== "number" || typeof right !== "number") { - return false; + return UNKNOWN; } switch (operator) { @@ -33,19 +46,37 @@ export function compareMediaFeature( return left <= right; default: operator satisfies never; - return false; + return UNKNOWN; } } // The compiler emits a range pair in source order: start, operator, value, operator, end export function testMediaFeatureInterval( condition: MediaInterval, - value: StyleDescriptor, -): boolean { + value: MeasuredFeatureValue, +): Truth { const [, , start, startOperator, end, endOperator] = condition; - return ( - compareMediaFeature(startOperator, start, value) && - compareMediaFeature(endOperator, value, end) + return conjoin( + [ + compareMediaFeature(startOperator, start, value), + compareMediaFeature(endOperator, value, end), + ], + (truth) => truth, ); } + +// A feature in a boolean context is false for zero, none and the no-preference of a user preference, and true for every other value (MQ5 §2.4.2, §12) +export function isTruthyFeatureValue(value: MeasuredFeatureValue): Truth { + switch (typeof value) { + case "undefined": + return UNKNOWN; + case "number": + return value !== 0 && !Number.isNaN(value); + case "string": + return value !== "none" && value !== "no-preference"; + default: + value satisfies never; + return UNKNOWN; + } +} diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index 54869535..f7cb715a 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -4,7 +4,6 @@ import type { ContainerQuery, MediaCondition, PseudoClassesQuery, - StyleDescriptor, } from "react-native-css/compiler"; import { @@ -17,8 +16,21 @@ import { type Getter, } from "../reactivity"; import { testAttributes } from "./attributes"; -import { compareMediaFeature, testMediaFeatureInterval } from "./compare"; +import { + compareMediaFeature, + isTruthyFeatureValue, + testMediaFeatureInterval, + type MeasuredFeatureValue, +} from "./compare"; import type { RenderGuard } from "./guards"; +import { + conjoin, + disjoin, + matches, + negate, + UNKNOWN, + type Truth, +} from "./kleene"; export const DEFAULT_CONTAINER_NAME = "c:___default___"; @@ -54,7 +66,10 @@ export function testContainerQuery( return false; } - if (query.m && !testContainerMediaCondition(query.m, container.key, get)) { + if ( + query.m && + !matches(testContainerMediaCondition(query.m, container.key, get)) + ) { return false; } @@ -86,20 +101,26 @@ 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); - }); + return disjoin(condition[1], (query) => + testContainerMediaCondition(query, containerKey, get), + ); case "!!": - return false; + return isTruthyFeatureValue( + getContainerFeatureValue(condition[1], containerKey, get), + ); case "[]": return testMediaFeatureInterval( condition, @@ -117,7 +138,7 @@ function testContainerMediaCondition( ); default: condition satisfies never; - return false; + return UNKNOWN; } } @@ -125,7 +146,7 @@ function getContainerFeatureValue( name: MediaFeatureNameFor_ContainerSizeFeatureId, containerKey: WeakKey, get: Getter, -): StyleDescriptor { +): MeasuredFeatureValue { switch (name) { case "width": return get(containerWidthFamily(containerKey)); diff --git a/src/native/conditions/kleene.ts b/src/native/conditions/kleene.ts new file mode 100644 index 00000000..61285c49 --- /dev/null +++ b/src/native/conditions/kleene.ts @@ -0,0 +1,57 @@ +// Kleene three-valued logic, which MQ5 §3.1 gives a condition holding a term the runtime cannot decide +export type Truth = boolean | "unknown"; + +export const UNKNOWN = "unknown"; + +export function negate(value: Truth): Truth { + return value === UNKNOWN ? UNKNOWN : !value; +} + +// Evaluating a term subscribes to what it reads, so a decided conjunction evaluates no further terms +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; +} + +// Evaluating a term subscribes to what it reads, so a decided disjunction evaluates no further terms +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; +} + +// A rule applies only on true, so unknown converts to false (MQ5 §3.1) +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 07549722..cb54cedf 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -9,95 +9,131 @@ import type { import { colorScheme, vh, vw, type Getter } from "../reactivity"; import { compareMediaFeature, + isTruthyFeatureValue, testMediaFeatureInterval, + type MeasuredFeatureValue, type MediaInterval, } from "./compare"; +import { + conjoin, + disjoin, + matches, + negate, + UNKNOWN, + type Truth, +} from "./kleene"; type MediaComparison = Extract< MediaCondition, [MediaFeatureComparison, ...unknown[]] >; -type MediaFeatureName = MediaComparison[1] | MediaInterval[1]; +type MediaFeatureName = + | MediaComparison[1] + | MediaInterval[1] + | Extract[1]; + +// The color feature counts bits per color component +const BITS_PER_COLOR_COMPONENT = 8; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { - return mediaQueries.every((query) => test(query, get)); + return mediaQueries.every((query) => matches(test(query, get))); } -function test(mediaQuery: MediaCondition, get: Getter): boolean { +function test(mediaQuery: MediaCondition, get: Getter): Truth { switch (mediaQuery[0]) { + case "?": + return UNKNOWN; case "!!": - return false; + return isTruthyFeatureValue(getMediaFeatureValue(mediaQuery[1], get)); case "[]": return testMediaFeatureInterval( mediaQuery, getMediaFeatureValue(mediaQuery[1], get), ); 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 "<": case "<=": - case "=": { + case "=": return testComparison(mediaQuery, get); - } + default: + mediaQuery satisfies never; + return UNKNOWN; } } -function testComparison(mediaQuery: MediaComparison, get: Getter): boolean { - const value = mediaQuery[2]; +function testComparison(mediaQuery: MediaComparison, get: Getter): Truth { + const [operator, name, value] = mediaQuery; - switch (mediaQuery[1]) { + switch (name) { + // dir and platform are this library's conditions rather than media features, so neither has a boolean context case "dir": - return value === (I18nManager.isRTL ? "rtl" : "ltr"); - case "hover": - return true; + return compareMediaFeature( + operator, + I18nManager.isRTL ? "rtl" : "ltr", + value, + ); 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; + return ( + value === "native" || compareMediaFeature(operator, Platform.OS, value) + ); case "min-width": - return typeof value === "number" && get(vw) >= value; + return compareMediaFeature(">=", get(vw), value); case "max-width": - return typeof value === "number" && get(vw) <= value; + return compareMediaFeature("<=", get(vw), value); case "min-height": - return typeof value === "number" && get(vh) >= value; + return compareMediaFeature(">=", get(vh), value); case "max-height": - return typeof value === "number" && get(vh) <= value; - case "orientation": - return value === "landscape" ? get(vh) < get(vw) : get(vh) >= get(vw); + return compareMediaFeature("<=", get(vh), value); + default: + return compareMediaFeature( + operator, + getMediaFeatureValue(name, get), + value, + ); } - - return compareMediaFeature( - mediaQuery[0], - getMediaFeatureValue(mediaQuery[1], get), - value, - ); } function getMediaFeatureValue( name: MediaFeatureName, get: Getter, -): number | undefined { +): MeasuredFeatureValue { switch (name) { + // A Pressable reports hover, so the device answers as one that can hover + case "hover": + return "hover"; + case "display-mode": + return Platform.OS; + // A device with no preference matches light (MQ5 §12.5.1) + case "prefers-color-scheme": + return get(colorScheme) ?? "light"; + case "color": + return BITS_PER_COLOR_COMPONENT; + // A React Native screen is a full-color bitmap that redraws as fast as scripts change it + case "color-index": + case "monochrome": + case "grid": + return 0; + case "scripting": + return "enabled"; + case "update": + return "fast"; 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 981e2051b4474b762b093c76f1aec9e3c83fca6f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 14:51:55 +0300 Subject: [PATCH 03/18] fix(compiler): leave a discrete feature unknown when its keyword is not one of its own lightningcss passes any identifier through as a feature's value, so `(orientation: bogus)` compiled to a comparison that is false, and its negation matched everywhere. A keyword the feature does not take now compiles to an unknown term in a media query, and to no rule in a container query, as an unresolvable value already does, so neither the term nor its negation matches, as in Chromium, Firefox and WebKit. --- .../native/condition-semantics.test.tsx | 8 +++++ src/compiler/container-query.ts | 5 ++- src/compiler/media-query.ts | 32 +++++++++++++++---- src/utilities/ascii-case.ts | 4 +++ 4 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 src/utilities/ascii-case.ts diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx index 6d118dd1..9b98c72b 100644 --- a/src/__tests__/native/condition-semantics.test.tsx +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -73,6 +73,12 @@ describe("@media on a 600x400 screen", () => { ["(fictional-thing) or (width > 400px)", true], ["(width > 400px) and (fictional-thing)", false], ["not (pointer: fine)", false], + ["(orientation: bogus)", false], + ["not (orientation: bogus)", false], + ["not (hover: bogus)", false], + ["not (prefers-color-scheme: bogus)", false], + ["not (scripting: bogus)", false], + ["not (update: bogus)", false], ])("an undecidable term in %s matches: %s", (condition, expected) => { expect(mediaMatches(condition)).toBe(expected); }); @@ -143,6 +149,8 @@ describe("@container on a 400x200 container", () => { "(fictional-feature > 10px) or (width > 0px)", "(width > env(safe-area-inset-top))", "not (width > env(safe-area-inset-top))", + "(orientation: bogus)", + "not (orientation: bogus)", ])("%s selects no container", (condition) => { expect(containerMatches(condition)).toBe(false); }); diff --git a/src/compiler/container-query.ts b/src/compiler/container-query.ts index 2ad8c00a..e11ae1fa 100644 --- a/src/compiler/container-query.ts +++ b/src/compiler/container-query.ts @@ -6,6 +6,7 @@ import type { import type { MediaCondition } from "./compiler.types"; import { + isUnknownKeyword, parseMediaFeatureOperator, parseMediaFeatureValue, } from "./media-query"; @@ -76,7 +77,9 @@ function parseFeature( return ["!!", feature.name]; case "plain": { const value = parseMediaFeatureValue(feature.value, builder); - return value === undefined ? undefined : ["=", feature.name, value]; + return value === undefined || isUnknownKeyword(feature.name, value) + ? undefined + : ["=", feature.name, value]; } case "range": { const value = parseMediaFeatureValue(feature.value, builder); diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 72b28325..4eaf341b 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -8,6 +8,7 @@ import type { QueryFeatureFor_MediaFeatureId, } from "lightningcss"; +import { asciiLowerCase } from "../utilities/ascii-case"; import type { MediaCondition, MediaFeatureComparison, @@ -89,6 +90,25 @@ function parseMediaQueryCondition( } } +// The keywords Media Queries 4 and 5 give each discrete feature the runtime answers; any other keyword leaves the feature unknown (MQ5 §3.1) +const FEATURE_KEYWORDS: Readonly>> = { + "hover": ["none", "hover"], + "orientation": ["portrait", "landscape"], + "prefers-color-scheme": ["light", "dark"], + "prefers-reduced-motion": ["no-preference", "reduce"], + "scripting": ["none", "initial-only", "enabled"], + "update": ["none", "slow", "fast"], +}; + +export function isUnknownKeyword(name: string, operand: MediaFeatureOperand) { + const keywords = FEATURE_KEYWORDS[name]; + return ( + keywords !== undefined && + typeof operand === "string" && + !keywords.includes(asciiLowerCase(operand)) + ); +} + function parseFeature( feature: QueryFeatureFor_MediaFeatureId, builder: StylesheetBuilder, @@ -96,12 +116,12 @@ function parseFeature( switch (feature.type) { case "boolean": return ["!!", feature.name]; - case "plain": - return [ - "=", - feature.name, - parseMediaFeatureOperand(feature.value, builder), - ]; + case "plain": { + const operand = parseMediaFeatureOperand(feature.value, builder); + return isUnknownKeyword(feature.name, operand) + ? ["?"] + : ["=", feature.name, operand]; + } case "range": return [ parseMediaFeatureOperator(feature.operator), diff --git a/src/utilities/ascii-case.ts b/src/utilities/ascii-case.ts new file mode 100644 index 00000000..748b92c5 --- /dev/null +++ b/src/utilities/ascii-case.ts @@ -0,0 +1,4 @@ +// Selectors §6.3 and HTML fold ASCII letters only: toLowerCase would also fold U+212A KELVIN SIGN onto k +export function asciiLowerCase(text: string): string { + return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); +} From 437238a1aae99f8b25dcb59defde1c86494a531a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 16:37:10 +0300 Subject: [PATCH 04/18] fix(compiler): fold a media feature's keyword to lowercase before comparing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A keyword is ASCII case-insensitive (css-values-4 §4.1). The keyword check folded it while the compiled comparison kept it as written, so `(orientation: PORTRAIT)` never matched and `not (orientation: PORTRAIT)` matched in portrait. The value is folded where the compiler reads it, which both the check and the comparison then use. --- .../native/condition-semantics.test.tsx | 44 ++++++++++++++++++- src/compiler/media-query.ts | 4 +- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx index 9b98c72b..b604c660 100644 --- a/src/__tests__/native/condition-semantics.test.tsx +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -14,13 +14,16 @@ afterEach(() => { }); }); -function mediaMatches(condition: string): boolean { +function mediaMatches( + condition: string, + size = { width: 600, height: 400 }, +): boolean { registerCSS(` .child { width: 10px; } @media ${condition} { .child { width: 37px; } } `); act(() => { - dimensions.set({ ...dimensions.get(), width: 600, height: 400 }); + dimensions.set({ ...dimensions.get(), ...size }); }); const { unmount } = render(); const { width } = screen.getByTestId("child").props.style; @@ -95,11 +98,23 @@ describe("@media on a 600x400 screen", () => { ["(monochrome)", false], ["(grid)", false], ["not (monochrome)", true], + ["(color-index)", false], + ["(display-mode)", true], ["(dir)", false], ])("the boolean context %s matches: %s", (condition, expected) => { expect(mediaMatches(condition)).toBe(expected); }); + // css-values-4 §4.1: a keyword matches in any case + test.each([ + ["(orientation: LANDSCAPE)", true], + ["not (orientation: LANDSCAPE)", false], + ["(orientation: PORTRAIT)", false], + ["not (orientation: PORTRAIT)", true], + ])("the keyword in %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + test.each([ ["(hover: hover)", true], ["(hover: none)", false], @@ -121,6 +136,8 @@ describe("@media on a 600x400 screen", () => { [null, "(prefers-color-scheme: dark)", false], ["dark", "(prefers-color-scheme: dark)", true], ["dark", "(prefers-color-scheme: light)", false], + ["dark", "(prefers-color-scheme: DARK)", true], + ["dark", "(prefers-color-scheme: Light)", false], ] as const)( "with a %p preference, %s matches: %s", (scheme, condition, expected) => { @@ -132,6 +149,20 @@ describe("@media on a 600x400 screen", () => { ); }); +describe("@media on a 400x600 screen", () => { + const portrait = { width: 400, height: 600 }; + + // css-values-4 §4.1: a keyword matches in any case + test.each([ + ["(orientation: portrait)", true], + ["(orientation: PORTRAIT)", true], + ["not (orientation: PORTRAIT)", false], + ["(orientation: Landscape)", false], + ])("the keyword in %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition, portrait)).toBe(expected); + }); +}); + describe("@container on a 400x200 container", () => { test.each([ ["(300px < width < 500px)", true], @@ -172,6 +203,15 @@ describe("@container on a 400x200 container", () => { expect(containerMatches(condition)).toBe(expected); }); + // css-values-4 §4.1: a keyword matches in any case + test.each([ + ["(orientation: LANDSCAPE)", true], + ["not (orientation: LANDSCAPE)", false], + ["(orientation: Portrait)", false], + ])("the keyword in %s matches: %s", (condition, expected) => { + expect(containerMatches(condition)).toBe(expected); + }); + test("(width) does not match a container of zero width", () => { expect(containerMatches("(width)", { width: 0, height: 200 })).toBe(false); }); diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 4eaf341b..9eb18d23 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -105,7 +105,7 @@ export function isUnknownKeyword(name: string, operand: MediaFeatureOperand) { return ( keywords !== undefined && typeof operand === "string" && - !keywords.includes(asciiLowerCase(operand)) + !keywords.includes(operand) ); } @@ -159,6 +159,8 @@ export function parseMediaFeatureValue( case "boolean": return value.value ? 1 : 0; case "ident": + // Keywords are ASCII case-insensitive (css-values-4 §4.1) + return asciiLowerCase(value.value); case "integer": case "number": return value.value; From a2ab9224688e9e911abc488e91c5d078f6e9bc68 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 16:37:19 +0300 Subject: [PATCH 05/18] refactor(native): drop the min- and max- feature arms the compiler never emits lightningcss compiles every `min-` and `max-` media feature to a range on `width` or `height`, so no compiled condition names them. --- src/native/conditions/media-query.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index cb54cedf..7e98b779 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -85,14 +85,6 @@ function testComparison(mediaQuery: MediaComparison, get: Getter): Truth { return ( value === "native" || compareMediaFeature(operator, Platform.OS, value) ); - case "min-width": - return compareMediaFeature(">=", get(vw), value); - case "max-width": - return compareMediaFeature("<=", get(vw), value); - case "min-height": - return compareMediaFeature(">=", get(vh), value); - case "max-height": - return compareMediaFeature("<=", get(vh), value); default: return compareMediaFeature( operator, From eeb79d15df621d8ee31e1827af5b950ca121f830 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 16:44:40 +0300 Subject: [PATCH 06/18] test(native): select no container for a range pair with an unresolvable bound --- src/__tests__/native/condition-semantics.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx index b604c660..570049a7 100644 --- a/src/__tests__/native/condition-semantics.test.tsx +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -180,6 +180,8 @@ describe("@container on a 400x200 container", () => { "(fictional-feature > 10px) or (width > 0px)", "(width > env(safe-area-inset-top))", "not (width > env(safe-area-inset-top))", + "(env(safe-area-inset-top) < width < 500px)", + "not (env(safe-area-inset-top) < width < 500px)", "(orientation: bogus)", "not (orientation: bogus)", ])("%s selects no container", (condition) => { From e1233af3f631ca590909705f0f495677f438d273 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 16:51:44 +0300 Subject: [PATCH 07/18] refactor(native): lint the media evaluator with no file-wide disable Its new lines hid a duplicated union and an unused import behind the file-wide `eslint-disable`, and the compiler's operator default wrote a `never` into its error message. --- src/compiler/media-query.ts | 4 +++- src/native/conditions/media-query.ts | 7 +------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 9eb18d23..fb8c18b8 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -81,7 +81,9 @@ function parseMediaQueryCondition( return ["|", mediaQueries]; default: query.operator satisfies never; - throw new Error(`Unknown media condition operator ${query.operator}`); + throw new Error( + `Unknown media condition operator ${JSON.stringify(query)}`, + ); } } default: diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 7e98b779..ca312fd9 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { @@ -12,7 +11,6 @@ import { isTruthyFeatureValue, testMediaFeatureInterval, type MeasuredFeatureValue, - type MediaInterval, } from "./compare"; import { conjoin, @@ -28,10 +26,7 @@ type MediaComparison = Extract< [MediaFeatureComparison, ...unknown[]] >; -type MediaFeatureName = - | MediaComparison[1] - | MediaInterval[1] - | Extract[1]; +type MediaFeatureName = MediaComparison[1]; // The color feature counts bits per color component const BITS_PER_COLOR_COMPONENT = 8; From d21d3ce4cbb63045b94d2264116a449307b396ca Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 22:49:16 +0300 Subject: [PATCH 08/18] fix: keep an unresolvable term unknown, prune what no device can match, and answer hover by input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A container term the compiler cannot encode, such as (width > 10in), is unknown rather than dropping the whole query with its `or` siblings, and `=` with an operand the runtime cannot order is unknown, as the other operators are. A condition no device can make true, such as a style() query or an unknown keyword alone or negated, compiles to no rule. An unknown media feature named like an Object.prototype member compiles again. hover and any-hover answer none on iOS and Android until a pointer hovers an element, and hover from then on and on other platforms (MQ5 §7.2, §7.3), so Tailwind's hover: keeps working wherever a pointer hovers. A held color scheme is compared as held, so a null one is unknown. The two files that kept a file-wide lint disable lint clean without it. --- .../compiler/condition-semantics.test.ts | 77 +++++++++--- .../native/condition-semantics.test.tsx | 114 ++++++++++++++++-- src/compiler/compiler.ts | 4 +- src/compiler/container-query.ts | 40 +++--- src/compiler/media-query.ts | 75 +++++++++--- src/jest/index.ts | 7 +- src/native/conditions/compare.ts | 5 + src/native/conditions/container-query.ts | 17 ++- src/native/conditions/guards.ts | 2 +- src/native/conditions/media-query.ts | 19 ++- src/native/react/interaction.ts | 2 + src/native/reactivity.ts | 2 + 12 files changed, 299 insertions(+), 65 deletions(-) diff --git a/src/__tests__/compiler/condition-semantics.test.ts b/src/__tests__/compiler/condition-semantics.test.ts index 3793b49d..2e4f9dcf 100644 --- a/src/__tests__/compiler/condition-semantics.test.ts +++ b/src/__tests__/compiler/condition-semantics.test.ts @@ -19,23 +19,75 @@ test.each([ ).toStrictEqual([]); }); +// An unknown keeps an `and` or a `not` unknown (MQ5 §3.2), so a condition no device can make true compiles to no rule +test.each([ + "@container style(--foo: bar)", + "@container not style(--foo: bar)", + "@container style(--foo: bar) and (width > 400px)", + "@media (width > env(safe-area-inset-top))", + "@media not (width: env(safe-area-inset-top))", + "@media (orientation: bogus)", + "@media not (orientation: bogus)", + "@media not ((width > 100px) or (orientation: bogus))", + "@media not (env(safe-area-inset-top) < width < env(safe-area-inset-bottom))", +])("%s, which no device can make true, emits no block", (prelude) => { + expect( + compileChildConditions(`${prelude} { .child { color: red } }`), + ).toStrictEqual([]); +}); + test.each<[string, Pick]>([ - ["@container style(--foo: bar)", { m: undefined, cq: [{ m: ["?"] }] }], - [ - "@container not style(--foo: bar)", - { m: undefined, cq: [{ m: ["!", ["?"]] }] }, - ], [ "@container style(--foo: bar) or (width > 400px)", { m: undefined, cq: [{ m: ["|", [["?"], [">", "width", 400]]] }] }, ], [ - "@media (width > env(safe-area-inset-top))", - { m: [[">", "width", null]], cq: undefined }, + "@media (width > env(safe-area-inset-top)) or (width > 400px)", + { + m: [ + [ + "|", + [ + [">", "width", null], + [">", "width", 400], + ], + ], + ], + cq: undefined, + }, + ], + [ + "@container (width > 10in) or (width > 100px)", + { + m: undefined, + cq: [ + { + m: [ + "|", + [ + [">", "width", null], + [">", "width", 100], + ], + ], + }, + ], + }, + ], + [ + "@media not (400px < width < 800px)", + { m: [["!", ["[]", "width", 400, "<", 800, "<"]]], cq: undefined }, + ], + [ + "@media not (env(safe-area-inset-top) < width < 800px)", + { m: [["!", ["[]", "width", null, "<", 800, "<"]]], cq: undefined }, + ], + [ + "@container (width > 10em)", + { m: undefined, cq: [{ m: [">", "width", [{}, "em", 10, 1]] }] }, ], [ - "@media not (width: env(safe-area-inset-top))", - { m: [["!", ["=", "width", null]]], cq: undefined }, + "@media (constructor: x)", + { m: [["=", "constructor", "x"]], cq: undefined }, ], ["@media (grid: 0)", { m: [["=", "grid", 0]], cq: undefined }], ["@media (grid: 1)", { m: [["=", "grid", 1]], cq: undefined }], @@ -45,13 +97,10 @@ test.each<[string, Pick]>([ ).toStrictEqual([conditions]); }); -test("a media query list keeps a branch whose value the compiler cannot resolve", () => { +test("a media query list keeps the branch a device can match beside one none can", () => { expect( compileChildConditions( "@media (width > env(safe-area-inset-top)), (width > 400px) { .child { color: red } }", ), - ).toStrictEqual([ - { m: [[">", "width", null]], cq: undefined }, - { m: [[">", "width", 400]], cq: undefined }, - ]); + ).toStrictEqual([{ m: [[">", "width", 400]], cq: undefined }]); }); diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx index 570049a7..43ecb8fb 100644 --- a/src/__tests__/native/condition-semantics.test.tsx +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -1,8 +1,14 @@ +import { Platform } from "react-native"; + import { act, fireEvent, render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS } from "react-native-css/jest"; import { colorScheme } from "react-native-css/runtime"; +import { + DEFAULT_CONTAINER_NAME, + testContainerQuery, +} from "../../native/conditions/container-query"; import { testMediaQuery } from "../../native/conditions/media-query"; import { dimensions, vw, type Getter } from "../../native/reactivity"; @@ -71,7 +77,7 @@ describe("@media on a 600x400 screen", () => { ["not (width > env(safe-area-inset-top))", false], ["(width: env(safe-area-inset-top)) or (width > 400px)", true], ["not ((width: env(safe-area-inset-top)) and (width > 9999px))", true], - ["not all and (width > 10em)", false], + ["not all and (width > 10vw)", false], ["not (fictional-feature: 3)", false], ["(fictional-thing) or (width > 400px)", true], ["(width > 400px) and (fictional-thing)", false], @@ -91,9 +97,8 @@ describe("@media on a 600x400 screen", () => { ["(width)", true], ["(orientation)", true], ["(resolution)", true], - ["(hover)", true], + ["(hover)", false], ["(color)", true], - ["(prefers-color-scheme)", true], ["(scripting)", true], ["(monochrome)", false], ["(grid)", false], @@ -116,8 +121,6 @@ describe("@media on a 600x400 screen", () => { }); test.each([ - ["(hover: hover)", true], - ["(hover: none)", false], ["(min-color: 8)", true], ["(min-color: 9)", false], ["(monochrome: 0)", true], @@ -132,8 +135,7 @@ describe("@media on a 600x400 screen", () => { ); test.each([ - [null, "(prefers-color-scheme: light)", true], - [null, "(prefers-color-scheme: dark)", false], + ["dark", "(prefers-color-scheme)", true], ["dark", "(prefers-color-scheme: dark)", true], ["dark", "(prefers-color-scheme: light)", false], ["dark", "(prefers-color-scheme: DARK)", true], @@ -149,6 +151,74 @@ describe("@media on a 600x400 screen", () => { ); }); +test.each([ + ["not (width: 10em)", false], + ["not (width: 10vw)", false], + ["(width: 10vw) or (width > 0px)", true], + ["not (min-width: calc(10em + 1vw))", false], + ["(min-width: calc(10em + 1vw)) or (width > 0px)", true], +])( + "@media %s, whose length the runtime does not resolve, matches: %s", + (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }, +); + +// MQ5 §7.2: hover is the primary input's, and a touchscreen cannot hover +describe.each([ + ["ios", "none"], + ["android", "none"], + ["macos", "hover"], + ["windows", "hover"], +] as const)("on %s the primary input answers hover as %s", (os, hover) => { + let platform: jest.ReplaceProperty; + + beforeEach(() => { + platform = jest.replaceProperty(Platform, "OS", os); + }); + + afterEach(() => { + platform.restore(); + }); + + test.each([ + ["(hover: hover)", hover === "hover"], + ["(hover: none)", hover === "none"], + ["(hover)", hover === "hover"], + ])("%s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); +}); + +describe("on iOS, once a pointer hovers an element", () => { + test.each([ + ["(hover: hover)", true], + ["(hover: none)", false], + ["(any-hover: hover)", true], + ["(any-hover: none)", false], + ])("%s matches: %s", (condition, expected) => { + registerCSS(` + .target:hover { opacity: 0.5; } + .child { width: 10px; } + @media ${condition} { .child { width: 37px; } } + `); + render( + <> + + + , + ); + const widthBefore = screen.getByTestId("child").props.style.width; + + fireEvent(screen.getByTestId("target"), "hoverIn"); + + expect({ + before: widthBefore === 37, + after: screen.getByTestId("child").props.style.width === 37, + }).toStrictEqual({ before: !expected, after: expected }); + }); +}); + describe("@media on a 400x600 screen", () => { const portrait = { width: 400, height: 600 }; @@ -217,6 +287,14 @@ describe("@container on a 400x200 container", () => { test("(width) does not match a container of zero width", () => { expect(containerMatches("(width)", { width: 0, height: 200 })).toBe(false); }); + + // A length the runtime cannot resolve leaves its term unknown, and an `or` keeps its other terms + test.each([ + ["(width > 10in) or (width > 100px)", true], + ["not (width > 10in)", false], + ])("%s matches: %s", (condition, expected) => { + expect(containerMatches(condition)).toBe(expected); + }); }); test("a decided conjunction reads only the term that decided it", () => { @@ -246,6 +324,28 @@ test("a decided conjunction reads only the term that decided it", () => { expect(read).toStrictEqual([vw]); }); +test("a container feature the runtime does not measure applies nowhere, negated or not", () => { + const get: Getter = (observable) => observable.get(); + const containers = { [DEFAULT_CONTAINER_NAME]: { key: {}, props: {} } }; + + expect( + testContainerQuery( + { m: [">", "fictional-feature", 10] }, + containers, + [], + get, + ), + ).toBe(false); + expect( + testContainerQuery( + { m: ["!", [">", "fictional-feature", 10]] }, + containers, + [], + get, + ), + ).toBe(false); +}); + test("an unknown term applies nowhere, negated or not", () => { const get: Getter = (observable) => observable.get(); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 363ded58..2fb9986d 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -36,7 +36,7 @@ import { import { inlineVariables } from "./inline-variables"; import { extractKeyFrames } from "./keyframes"; import { lightningcssLoader } from "./lightningcss-loader"; -import { parseMediaQuery } from "./media-query"; +import { canMatch, parseMediaQuery } from "./media-query"; import { StylesheetBuilder } from "./stylesheet"; import { supportsConditionValid } from "./supports"; @@ -404,7 +404,7 @@ function extractContainer( // Iterate over all rules inside the containerRule and extract their styles using the updated CompilerCollection const condition = parseContainerCondition(containerRule.condition, builder); - if (!condition) { + if (!condition || !canMatch(condition)) { // An unsupported condition must not turn into an unconditional container. return; } diff --git a/src/compiler/container-query.ts b/src/compiler/container-query.ts index e11ae1fa..3bae83ed 100644 --- a/src/compiler/container-query.ts +++ b/src/compiler/container-query.ts @@ -1,10 +1,11 @@ import type { ContainerSizeFeatureId, ContainerCondition as CSSContainerCondition, + MediaFeatureValue as CSSMediaFeatureValue, QueryFeatureFor_ContainerSizeFeatureId, } from "lightningcss"; -import type { MediaCondition } from "./compiler.types"; +import type { MediaCondition, MediaFeatureOperand } from "./compiler.types"; import { isUnknownKeyword, parseMediaFeatureOperator, @@ -12,16 +13,16 @@ import { } from "./media-query"; import type { StylesheetBuilder } from "./stylesheet"; -const SIZE_FEATURES: Record = { - "width": true, - "height": true, - "inline-size": true, - "block-size": true, - "aspect-ratio": true, - "orientation": true, -}; +const SIZE_FEATURES: ReadonlySet = new Set([ + "width", + "height", + "inline-size", + "block-size", + "aspect-ratio", + "orientation", +]); -// A condition holding a size feature the runtime does not know, or a value it cannot resolve, selects no container (CSS Conditional 5 §5.4), so it compiles to nothing +// Chromium and WebKit drop a container query holding an unknown size feature or env(), so it compiles to nothing; any other value the runtime cannot resolve leaves its feature unknown (CSS Conditional 5 §5.4) export function parseContainerCondition( condition: CSSContainerCondition, builder: StylesheetBuilder, @@ -68,7 +69,7 @@ function parseFeature( feature: QueryFeatureFor_ContainerSizeFeatureId, builder: StylesheetBuilder, ): MediaCondition | undefined { - if (!Object.prototype.hasOwnProperty.call(SIZE_FEATURES, feature.name)) { + if (!SIZE_FEATURES.has(feature.name)) { return; } @@ -76,20 +77,20 @@ function parseFeature( case "boolean": return ["!!", feature.name]; case "plain": { - const value = parseMediaFeatureValue(feature.value, builder); + const value = parseContainerOperand(feature.value, builder); return value === undefined || isUnknownKeyword(feature.name, value) ? undefined : ["=", feature.name, value]; } case "range": { - const value = parseMediaFeatureValue(feature.value, builder); + const value = parseContainerOperand(feature.value, builder); return value === undefined ? undefined : [parseMediaFeatureOperator(feature.operator), feature.name, value]; } case "interval": { - const start = parseMediaFeatureValue(feature.start, builder); - const end = parseMediaFeatureValue(feature.end, builder); + const start = parseContainerOperand(feature.start, builder); + const end = parseContainerOperand(feature.end, builder); return start === undefined || end === undefined ? undefined : [ @@ -106,3 +107,12 @@ function parseFeature( return; } } + +function parseContainerOperand( + value: CSSMediaFeatureValue, + builder: StylesheetBuilder, +): MediaFeatureOperand | undefined { + return value.type === "env" + ? undefined + : (parseMediaFeatureValue(value, builder) ?? null); +} diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index fb8c18b8..482ca5fc 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ import type { CalcFor_Length, MediaCondition as CSSMediaCondition, @@ -45,7 +44,7 @@ export function parseMediaQuery( let mediaQuery: MediaCondition | undefined = platformCondition && condition ? ["&", [platformCondition, condition]] - : platformCondition || condition; + : (platformCondition ?? condition); if (!mediaQuery) { // Unqualified all/screen apply; not all and not screen do not. @@ -56,10 +55,53 @@ export function parseMediaQuery( mediaQuery = ["!", mediaQuery]; } + if (!canMatch(mediaQuery)) { + return false; + } + builder.addMediaQuery(mediaQuery); return true; } +// A condition that no device can make true, as an unknown keeps an `and` or a `not` unknown (MQ5 §3.1), compiles to no rule +export function canMatch(condition: MediaCondition): boolean { + return canEvaluateTo(condition, true); +} + +function canEvaluateTo(condition: MediaCondition, truth: boolean): boolean { + switch (condition[0]) { + case "?": + return false; + case "!": + return canEvaluateTo(condition[1], !truth); + case "&": + return truth + ? condition[1].every((child) => canEvaluateTo(child, true)) + : condition[1].some((child) => canEvaluateTo(child, false)); + case "|": + return truth + ? condition[1].some((child) => canEvaluateTo(child, true)) + : condition[1].every((child) => canEvaluateTo(child, false)); + case "!!": + return true; + case "[]": { + const [, , start, , end] = condition; + return truth + ? start !== null && end !== null + : start !== null || end !== null; + } + case "=": + case ">": + case ">=": + case "<": + case "<=": + return condition[2] !== null; + default: + condition satisfies never; + return true; + } +} + function parseMediaQueryCondition( query: CSSMediaCondition, builder: StylesheetBuilder, @@ -93,17 +135,18 @@ function parseMediaQueryCondition( } // The keywords Media Queries 4 and 5 give each discrete feature the runtime answers; any other keyword leaves the feature unknown (MQ5 §3.1) -const FEATURE_KEYWORDS: Readonly>> = { - "hover": ["none", "hover"], - "orientation": ["portrait", "landscape"], - "prefers-color-scheme": ["light", "dark"], - "prefers-reduced-motion": ["no-preference", "reduce"], - "scripting": ["none", "initial-only", "enabled"], - "update": ["none", "slow", "fast"], -}; +const FEATURE_KEYWORDS: ReadonlyMap = new Map([ + ["hover", ["none", "hover"]], + ["any-hover", ["none", "hover"]], + ["orientation", ["portrait", "landscape"]], + ["prefers-color-scheme", ["light", "dark"]], + ["prefers-reduced-motion", ["no-preference", "reduce"]], + ["scripting", ["none", "initial-only", "enabled"]], + ["update", ["none", "slow", "fast"]], +]); export function isUnknownKeyword(name: string, operand: MediaFeatureOperand) { - const keywords = FEATURE_KEYWORDS[name]; + const keywords = FEATURE_KEYWORDS.get(name); return ( keywords !== undefined && typeof operand === "string" && @@ -192,9 +235,11 @@ export function parseMediaFeatureValue( } case "ratio": case "env": + return undefined; + default: + value satisfies never; + return undefined; } - - return; } export function parseMediaFeatureOperator( @@ -213,7 +258,9 @@ export function parseMediaFeatureOperator( return "<="; default: operator satisfies never; - throw new Error(`Unknown MediaFeatureComparison operator ${operator}`); + throw new Error( + `Unknown MediaFeatureComparison operator ${JSON.stringify(operator)}`, + ); } } diff --git a/src/jest/index.ts b/src/jest/index.ts index a8d249f5..97fb81e0 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -5,7 +5,11 @@ import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; -import { colorScheme, dimensions } from "../native/reactivity"; +import { + colorScheme, + dimensions, + hoverCapableInput, +} from "../native/reactivity"; declare global { /* eslint-disable @typescript-eslint/no-namespace */ @@ -23,6 +27,7 @@ beforeEach(() => { dimensions.set(Dimensions.get("window")); Appearance.setColorScheme("unspecified"); colorScheme.set(null); + hoverCapableInput.set(false); }); const debugDefault = Boolean( diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts index 2ebe60b6..5b5ec787 100644 --- a/src/native/conditions/compare.ts +++ b/src/native/conditions/compare.ts @@ -26,6 +26,11 @@ export function compareMediaFeature( return UNKNOWN; } + // A descriptor the runtime does not resolve, such as an em or a calc(), has no value to compare with + if (typeof left === "object" || typeof right === "object") { + return UNKNOWN; + } + // = also compares keywords such as landscape if (operator === "=") { return left === right; diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index f7cb715a..6595e030 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -1,8 +1,7 @@ -/* eslint-disable */ -import type { MediaFeatureNameFor_ContainerSizeFeatureId } from "lightningcss"; import type { ContainerQuery, MediaCondition, + MediaFeatureComparison, PseudoClassesQuery, } from "react-native-css/compiler"; @@ -32,6 +31,11 @@ import { type Truth, } from "./kleene"; +type ContainerFeatureName = Extract< + MediaCondition, + [MediaFeatureComparison, ...unknown[]] +>[1]; + export const DEFAULT_CONTAINER_NAME = "c:___default___"; export function testContainerQueries( @@ -52,7 +56,7 @@ export function testContainerQuery( get: Getter, ): boolean { const name = query.n ?? DEFAULT_CONTAINER_NAME; - const container = inheritedContainers[name]!; + const container = inheritedContainers[name]; guards.push(["c", name, container]); @@ -143,7 +147,7 @@ function testContainerMediaCondition( } function getContainerFeatureValue( - name: MediaFeatureNameFor_ContainerSizeFeatureId, + name: ContainerFeatureName, containerKey: WeakKey, get: Getter, ): MeasuredFeatureValue { @@ -157,13 +161,14 @@ function getContainerFeatureValue( const height = get(containerHeightFamily(containerKey)); return width / height; } - case "orientation": + case "orientation": { const width = get(containerWidthFamily(containerKey)); const height = get(containerHeightFamily(containerKey)); return width > height ? "landscape" : "portrait"; + } case "inline-size": case "block-size": default: - return; + return undefined; } } diff --git a/src/native/conditions/guards.ts b/src/native/conditions/guards.ts index d3c9dd84..f4e6fc64 100644 --- a/src/native/conditions/guards.ts +++ b/src/native/conditions/guards.ts @@ -9,7 +9,7 @@ export type RenderGuard = | ["a", string, any] | ["d", string, any] | ["v", string, any] - | ["c", string, WeakKey]; + | ["c", string, ContainerContextValue[string] | undefined]; export function testGuards( state: ComponentState, diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index ca312fd9..42bad894 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -5,7 +5,13 @@ import type { MediaFeatureComparison, } from "react-native-css/compiler"; -import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { + colorScheme, + hoverCapableInput, + vh, + vw, + type Getter, +} from "../reactivity"; import { compareMediaFeature, isTruthyFeatureValue, @@ -94,14 +100,17 @@ function getMediaFeatureValue( get: Getter, ): MeasuredFeatureValue { switch (name) { - // A Pressable reports hover, so the device answers as one that can hover + // A touchscreen, the input of iOS and Android until a pointer hovers, cannot hover (MQ5 §7.2, §7.3) case "hover": - return "hover"; + case "any-hover": + return get(hoverCapableInput) || + (Platform.OS !== "ios" && Platform.OS !== "android") + ? "hover" + : "none"; case "display-mode": return Platform.OS; - // A device with no preference matches light (MQ5 §12.5.1) case "prefers-color-scheme": - return get(colorScheme) ?? "light"; + return get(colorScheme) ?? undefined; case "color": return BITS_PER_COLOR_COMPONENT; // A React Native screen is a full-color bitmap that redraws as fast as scripts change it diff --git a/src/native/react/interaction.ts b/src/native/react/interaction.ts index d412b15e..a254483a 100644 --- a/src/native/react/interaction.ts +++ b/src/native/react/interaction.ts @@ -5,6 +5,7 @@ import { activeFamily, containerLayoutFamily, focusFamily, + hoverCapableInput, hoverFamily, type Getter as WeakKey, } from "../reactivity"; @@ -67,6 +68,7 @@ export function getInteractionHandler( ); break; case "onHoverIn": + hoverCapableInput.set(true); hoverFamily(weakKey).set(true); break; case "onHoverOut": diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 296988a6..311ee58f 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -201,6 +201,8 @@ export type VariableContextValue = Record & { /** Pseudo Classes ************************************************************/ export const hoverFamily = weakFamily(() => observable(false)); +// A hover event proves an input that can hover, which a touchscreen alone cannot +export const hoverCapableInput = observable(false); export const activeFamily = weakFamily(() => observable(false)); export const focusFamily = weakFamily(() => observable(false)); From 551a78d88c689021b5bf6139143ecaf9093d3c65 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 23:22:33 +0300 Subject: [PATCH 09/18] fix(jest): reset the hover signal through the package's internal entry, as the components read it The jest helper resolves to the package's source in a consumer's suite while the components it renders resolve to the build, so a reset through a relative import missed the signal they read. --- src/jest/index.ts | 7 ++----- src/native-internal/index.ts | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/jest/index.ts b/src/jest/index.ts index 97fb81e0..27e786d6 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -4,12 +4,9 @@ import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; +import { hoverCapableInput } from "react-native-css/native-internal"; -import { - colorScheme, - dimensions, - hoverCapableInput, -} from "../native/reactivity"; +import { colorScheme, dimensions } from "../native/reactivity"; declare global { /* eslint-disable @typescript-eslint/no-namespace */ diff --git a/src/native-internal/index.ts b/src/native-internal/index.ts index e80c9bd6..2df9e0e6 100644 --- a/src/native-internal/index.ts +++ b/src/native-internal/index.ts @@ -1,3 +1,4 @@ export * from "./root"; export * from "./style-collection"; export * from "./variables"; +export { hoverCapableInput } from "../native/reactivity"; From e6efb308add70d9dbc7fd15899a31fbda673269f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 01:26:48 +0300 Subject: [PATCH 10/18] fix: measure pointer and any-pointer, and make a media feature holding env() unknown --- .../compiler/condition-semantics.test.ts | 22 ++--- .../native/condition-semantics.test.tsx | 87 ++++++++++++------- src/compiler/container-query.ts | 22 ++--- src/compiler/media-query.ts | 66 ++++++++------ src/native/conditions/compare.ts | 13 ++- src/native/conditions/container-query.ts | 9 +- src/native/conditions/media-query.ts | 49 +++++++---- 7 files changed, 152 insertions(+), 116 deletions(-) diff --git a/src/__tests__/compiler/condition-semantics.test.ts b/src/__tests__/compiler/condition-semantics.test.ts index 2e4f9dcf..84ff753c 100644 --- a/src/__tests__/compiler/condition-semantics.test.ts +++ b/src/__tests__/compiler/condition-semantics.test.ts @@ -19,7 +19,7 @@ test.each([ ).toStrictEqual([]); }); -// An unknown keeps an `and` or a `not` unknown (MQ5 §3.2), so a condition no device can make true compiles to no rule +// An unknown keeps an `and` or a `not` unknown (MQ5 §3.1), so a condition no device can make true compiles to no rule test.each([ "@container style(--foo: bar)", "@container not style(--foo: bar)", @@ -30,6 +30,9 @@ test.each([ "@media not (orientation: bogus)", "@media not ((width > 100px) or (orientation: bogus))", "@media not (env(safe-area-inset-top) < width < env(safe-area-inset-bottom))", + "@media not (env(safe-area-inset-top) < width < 800px)", + "@media not (400px < width < env(safe-area-inset-bottom))", + "@media not ((env(safe-area-inset-top) < width < 800px) or (width < 0px))", ])("%s, which no device can make true, emits no block", (prelude) => { expect( compileChildConditions(`${prelude} { .child { color: red } }`), @@ -43,18 +46,7 @@ test.each<[string, Pick]>([ ], [ "@media (width > env(safe-area-inset-top)) or (width > 400px)", - { - m: [ - [ - "|", - [ - [">", "width", null], - [">", "width", 400], - ], - ], - ], - cq: undefined, - }, + { m: [["|", [["?"], [">", "width", 400]]]], cq: undefined }, ], [ "@container (width > 10in) or (width > 100px)", @@ -77,10 +69,6 @@ test.each<[string, Pick]>([ "@media not (400px < width < 800px)", { m: [["!", ["[]", "width", 400, "<", 800, "<"]]], cq: undefined }, ], - [ - "@media not (env(safe-area-inset-top) < width < 800px)", - { m: [["!", ["[]", "width", null, "<", 800, "<"]]], cq: undefined }, - ], [ "@container (width > 10em)", { m: undefined, cq: [{ m: [">", "width", [{}, "em", 10, 1]] }] }, diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx index 43ecb8fb..8b9bf2d3 100644 --- a/src/__tests__/native/condition-semantics.test.tsx +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -81,7 +81,6 @@ describe("@media on a 600x400 screen", () => { ["not (fictional-feature: 3)", false], ["(fictional-thing) or (width > 400px)", true], ["(width > 400px) and (fictional-thing)", false], - ["not (pointer: fine)", false], ["(orientation: bogus)", false], ["not (orientation: bogus)", false], ["not (hover: bogus)", false], @@ -164,39 +163,65 @@ test.each([ }, ); -// MQ5 §7.2: hover is the primary input's, and a touchscreen cannot hover -describe.each([ - ["ios", "none"], - ["android", "none"], - ["macos", "hover"], - ["windows", "hover"], -] as const)("on %s the primary input answers hover as %s", (os, hover) => { - let platform: jest.ReplaceProperty; - - beforeEach(() => { - platform = jest.replaceProperty(Platform, "OS", os); - }); - - afterEach(() => { - platform.restore(); - }); - - test.each([ - ["(hover: hover)", hover === "hover"], - ["(hover: none)", hover === "none"], - ["(hover)", hover === "hover"], - ])("%s matches: %s", (condition, expected) => { - expect(mediaMatches(condition)).toBe(expected); - }); +// No browser takes env() as a feature's value, so a range pair with an env() bound is unknown whatever its other bound answers (MQ5 §3.1) +test.each([ + ["not (env(safe-area-inset-top) < width < 800px)", 900], + ["not (400px < width < env(safe-area-inset-bottom))", 300], + ["not ((env(safe-area-inset-top) < width < 800px) or (width < 0px))", 900], +])("@media %s on a %ipx-wide screen matches: false", (condition, width) => { + expect(mediaMatches(condition, { width, height: 400 })).toBe(false); }); +// MQ5 §7.1, §7.2: pointer and hover are the primary input's, and a touchscreen cannot hover and points coarsely +describe.each([ + ["ios", "none", "coarse"], + ["android", "none", "coarse"], + ["macos", "hover", "fine"], + ["windows", "hover", "fine"], +] as const)( + "on %s the primary input answers hover as %s and pointer as %s", + (os, hover, pointer) => { + let platform: jest.ReplaceProperty; + + beforeEach(() => { + platform = jest.replaceProperty(Platform, "OS", os); + }); + + afterEach(() => { + platform.restore(); + }); + + test.each([ + ["(hover: hover)", hover === "hover"], + ["(hover: none)", hover === "none"], + ["(hover)", hover === "hover"], + ["(pointer: fine)", pointer === "fine"], + ["(pointer: coarse)", pointer === "coarse"], + ["not (pointer: fine)", pointer === "coarse"], + ["(pointer)", true], + ["(any-pointer: fine)", pointer === "fine"], + ["(any-pointer: coarse)", pointer === "coarse"], + ["(any-pointer: none)", false], + ["(any-pointer)", true], + ["(hover: none) and (pointer: coarse)", hover === "none"], + ])("%s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + }, +); + +// MQ5 §7.3: any-pointer matches the touchscreen's coarse pointer beside a fine one that hovers describe("on iOS, once a pointer hovers an element", () => { test.each([ - ["(hover: hover)", true], - ["(hover: none)", false], - ["(any-hover: hover)", true], - ["(any-hover: none)", false], - ])("%s matches: %s", (condition, expected) => { + ["(hover: hover)", false, true], + ["(hover: none)", true, false], + ["(any-hover: hover)", false, true], + ["(any-hover: none)", true, false], + ["(pointer: fine)", false, true], + ["(pointer: coarse)", true, false], + ["(any-pointer: fine)", false, true], + ["(any-pointer: coarse)", true, true], + ])("%s matches: %s before and %s after", (condition, before, after) => { registerCSS(` .target:hover { opacity: 0.5; } .child { width: 10px; } @@ -215,7 +240,7 @@ describe("on iOS, once a pointer hovers an element", () => { expect({ before: widthBefore === 37, after: screen.getByTestId("child").props.style.width === 37, - }).toStrictEqual({ before: !expected, after: expected }); + }).toStrictEqual({ before, after }); }); }); diff --git a/src/compiler/container-query.ts b/src/compiler/container-query.ts index 3bae83ed..b06a5215 100644 --- a/src/compiler/container-query.ts +++ b/src/compiler/container-query.ts @@ -1,15 +1,14 @@ import type { ContainerSizeFeatureId, ContainerCondition as CSSContainerCondition, - MediaFeatureValue as CSSMediaFeatureValue, QueryFeatureFor_ContainerSizeFeatureId, } from "lightningcss"; -import type { MediaCondition, MediaFeatureOperand } from "./compiler.types"; +import type { MediaCondition } from "./compiler.types"; import { isUnknownKeyword, + parseFeatureOperand, parseMediaFeatureOperator, - parseMediaFeatureValue, } from "./media-query"; import type { StylesheetBuilder } from "./stylesheet"; @@ -77,20 +76,20 @@ function parseFeature( case "boolean": return ["!!", feature.name]; case "plain": { - const value = parseContainerOperand(feature.value, builder); + const value = parseFeatureOperand(feature.value, builder); return value === undefined || isUnknownKeyword(feature.name, value) ? undefined : ["=", feature.name, value]; } case "range": { - const value = parseContainerOperand(feature.value, builder); + const value = parseFeatureOperand(feature.value, builder); return value === undefined ? undefined : [parseMediaFeatureOperator(feature.operator), feature.name, value]; } case "interval": { - const start = parseContainerOperand(feature.start, builder); - const end = parseContainerOperand(feature.end, builder); + const start = parseFeatureOperand(feature.start, builder); + const end = parseFeatureOperand(feature.end, builder); return start === undefined || end === undefined ? undefined : [ @@ -107,12 +106,3 @@ function parseFeature( return; } } - -function parseContainerOperand( - value: CSSMediaFeatureValue, - builder: StylesheetBuilder, -): MediaFeatureOperand | undefined { - return value.type === "env" - ? undefined - : (parseMediaFeatureValue(value, builder) ?? null); -} diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 482ca5fc..df86e5ed 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -63,12 +63,15 @@ export function parseMediaQuery( return true; } -// A condition that no device can make true, as an unknown keeps an `and` or a `not` unknown (MQ5 §3.1), compiles to no rule -export function canMatch(condition: MediaCondition): boolean { +// A condition its unknown terms and unresolved operands leave no way to be true compiles to no rule, as an unknown keeps an `and` or a `not` unknown (MQ5 §3.1) +export function canMatch(condition: Readonly): boolean { return canEvaluateTo(condition, true); } -function canEvaluateTo(condition: MediaCondition, truth: boolean): boolean { +function canEvaluateTo( + condition: Readonly, + truth: boolean, +): boolean { switch (condition[0]) { case "?": return false; @@ -134,10 +137,12 @@ function parseMediaQueryCondition( } } -// The keywords Media Queries 4 and 5 give each discrete feature the runtime answers; any other keyword leaves the feature unknown (MQ5 §3.1) +// The keywords Media Queries 4 and 5 give these discrete features, and any other keyword leaves the feature unknown (MQ5 §3.1); display-mode is left out, since the library answers it with platform names const FEATURE_KEYWORDS: ReadonlyMap = new Map([ ["hover", ["none", "hover"]], ["any-hover", ["none", "hover"]], + ["pointer", ["none", "coarse", "fine"]], + ["any-pointer", ["none", "coarse", "fine"]], ["orientation", ["portrait", "landscape"]], ["prefers-color-scheme", ["light", "dark"]], ["prefers-reduced-motion", ["no-preference", "reduce"]], @@ -145,7 +150,10 @@ const FEATURE_KEYWORDS: ReadonlyMap = new Map([ ["update", ["none", "slow", "fast"]], ]); -export function isUnknownKeyword(name: string, operand: MediaFeatureOperand) { +export function isUnknownKeyword( + name: string, + operand: Readonly, +) { const keywords = FEATURE_KEYWORDS.get(name); return ( keywords !== undefined && @@ -162,37 +170,45 @@ function parseFeature( case "boolean": return ["!!", feature.name]; case "plain": { - const operand = parseMediaFeatureOperand(feature.value, builder); - return isUnknownKeyword(feature.name, operand) + const operand = parseFeatureOperand(feature.value, builder); + return operand === undefined || isUnknownKeyword(feature.name, operand) ? ["?"] : ["=", feature.name, operand]; } - case "range": - return [ - parseMediaFeatureOperator(feature.operator), - feature.name, - parseMediaFeatureOperand(feature.value, builder), - ]; - case "interval": - return [ - "[]", - feature.name, - parseMediaFeatureOperand(feature.start, builder), - parseMediaFeatureOperator(feature.startOperator), - parseMediaFeatureOperand(feature.end, builder), - parseMediaFeatureOperator(feature.endOperator), - ]; + case "range": { + const operand = parseFeatureOperand(feature.value, builder); + return operand === undefined + ? ["?"] + : [parseMediaFeatureOperator(feature.operator), feature.name, operand]; + } + case "interval": { + const start = parseFeatureOperand(feature.start, builder); + const end = parseFeatureOperand(feature.end, builder); + return start === undefined || end === undefined + ? ["?"] + : [ + "[]", + feature.name, + start, + parseMediaFeatureOperator(feature.startOperator), + end, + parseMediaFeatureOperator(feature.endOperator), + ]; + } default: feature satisfies never; throw new Error(`Unknown media feature ${JSON.stringify(feature)}`); } } -function parseMediaFeatureOperand( +// No browser takes env() as a feature's value, so the caller drops a feature holding one; any other value the compiler cannot resolve compiles to null, which leaves its term unknown +export function parseFeatureOperand( value: CSSMediaFeatureValue, builder: StylesheetBuilder, -): MediaFeatureOperand { - return parseMediaFeatureValue(value, builder) ?? null; +): MediaFeatureOperand | undefined { + return value.type === "env" + ? undefined + : (parseMediaFeatureValue(value, builder) ?? null); } export function parseMediaFeatureValue( diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts index 5b5ec787..6fc71445 100644 --- a/src/native/conditions/compare.ts +++ b/src/native/conditions/compare.ts @@ -8,14 +8,21 @@ import { conjoin, UNKNOWN, type Truth } from "./kleene"; export type MediaInterval = Extract; +export type MediaComparison = Extract< + MediaCondition, + [MediaFeatureComparison, ...unknown[]] +>; + +export type MediaFeatureName = MediaComparison[1]; + // undefined for a feature the runtime does not measure export type MeasuredFeatureValue = number | string | undefined; // Media and container queries share this, so each operator has one meaning; an unmeasured feature or an unresolved operand is unknown (MQ5 §3.1) export function compareMediaFeature( operator: MediaFeatureComparison, - left: MediaFeatureOperand | undefined, - right: MediaFeatureOperand | undefined, + left: Readonly | undefined, + right: Readonly | undefined, ): Truth { if ( left === undefined || @@ -57,7 +64,7 @@ export function compareMediaFeature( // The compiler emits a range pair in source order: start, operator, value, operator, end export function testMediaFeatureInterval( - condition: MediaInterval, + condition: Readonly, value: MeasuredFeatureValue, ): Truth { const [, , start, startOperator, end, endOperator] = condition; diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index 6595e030..a5a7db9f 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -1,7 +1,6 @@ import type { ContainerQuery, MediaCondition, - MediaFeatureComparison, PseudoClassesQuery, } from "react-native-css/compiler"; @@ -20,6 +19,7 @@ import { isTruthyFeatureValue, testMediaFeatureInterval, type MeasuredFeatureValue, + type MediaFeatureName, } from "./compare"; import type { RenderGuard } from "./guards"; import { @@ -31,11 +31,6 @@ import { type Truth, } from "./kleene"; -type ContainerFeatureName = Extract< - MediaCondition, - [MediaFeatureComparison, ...unknown[]] ->[1]; - export const DEFAULT_CONTAINER_NAME = "c:___default___"; export function testContainerQueries( @@ -147,7 +142,7 @@ function testContainerMediaCondition( } function getContainerFeatureValue( - name: ContainerFeatureName, + name: MediaFeatureName, containerKey: WeakKey, get: Getter, ): MeasuredFeatureValue { diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 42bad894..505cf837 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,9 +1,6 @@ import { I18nManager, PixelRatio, Platform } from "react-native"; -import type { - MediaCondition, - MediaFeatureComparison, -} from "react-native-css/compiler"; +import type { MediaCondition } from "react-native-css/compiler"; import { colorScheme, @@ -17,6 +14,8 @@ import { isTruthyFeatureValue, testMediaFeatureInterval, type MeasuredFeatureValue, + type MediaComparison, + type MediaFeatureName, } from "./compare"; import { conjoin, @@ -27,13 +26,6 @@ import { type Truth, } from "./kleene"; -type MediaComparison = Extract< - MediaCondition, - [MediaFeatureComparison, ...unknown[]] ->; - -type MediaFeatureName = MediaComparison[1]; - // The color feature counts bits per color component const BITS_PER_COLOR_COMPONENT = 8; @@ -70,7 +62,10 @@ function test(mediaQuery: MediaCondition, get: Getter): Truth { } } -function testComparison(mediaQuery: MediaComparison, get: Getter): Truth { +function testComparison( + mediaQuery: Readonly, + get: Getter, +): Truth { const [operator, name, value] = mediaQuery; switch (name) { @@ -86,6 +81,11 @@ function testComparison(mediaQuery: MediaComparison, get: Getter): Truth { return ( value === "native" || compareMediaFeature(operator, Platform.OS, value) ); + // any-pointer matches each kind of pointer the device has, where pointer names the primary one (MQ5 §7.3) + case "any-pointer": + return operator === "=" && typeof value === "string" + ? availablePointers(get).includes(value) + : UNKNOWN; default: return compareMediaFeature( operator, @@ -100,13 +100,12 @@ function getMediaFeatureValue( get: Getter, ): MeasuredFeatureValue { switch (name) { - // A touchscreen, the input of iOS and Android until a pointer hovers, cannot hover (MQ5 §7.2, §7.3) case "hover": case "any-hover": - return get(hoverCapableInput) || - (Platform.OS !== "ios" && Platform.OS !== "android") - ? "hover" - : "none"; + return pointsFinely(get) ? "hover" : "none"; + case "pointer": + case "any-pointer": + return pointsFinely(get) ? "fine" : "coarse"; case "display-mode": return Platform.OS; case "prefers-color-scheme": @@ -134,3 +133,19 @@ function getMediaFeatureValue( return undefined; } } + +// A touchscreen, the input of iOS and Android until a pointer hovers an element with a :hover style, cannot hover and points coarsely (MQ5 §7.1, §7.2) +function pointsFinely(get: Getter): boolean { + return ( + get(hoverCapableInput) || + (Platform.OS !== "ios" && Platform.OS !== "android") + ); +} + +function availablePointers(get: Getter): readonly string[] { + if (Platform.OS !== "ios" && Platform.OS !== "android") { + return ["fine"]; + } + + return get(hoverCapableInput) ? ["coarse", "fine"] : ["coarse"]; +} From 6527b4e953ea3c649dcd0c16112799ef8f366ac6 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 12:25:03 +0300 Subject: [PATCH 11/18] refactor(native): match any-pointer through the shared comparison, and test an unresolved bound --- src/__tests__/native/condition-semantics.test.tsx | 13 +++++++++++++ src/native/conditions/media-query.ts | 8 ++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx index 8b9bf2d3..0d877c6a 100644 --- a/src/__tests__/native/condition-semantics.test.tsx +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -172,6 +172,19 @@ test.each([ expect(mediaMatches(condition, { width, height: 400 })).toBe(false); }); +// MQ5 §3.1: a bound the compiler cannot resolve leaves its comparison unknown, so the other bound alone can make a range pair false +test.each([ + [900, true], + [600, false], +])( + "@media not (1in < width < 800px) on a %ipx-wide screen matches: %s", + (width, expected) => { + expect( + mediaMatches("not (1in < width < 800px)", { width, height: 400 }), + ).toBe(expected); + }, +); + // MQ5 §7.1, §7.2: pointer and hover are the primary input's, and a touchscreen cannot hover and points coarsely describe.each([ ["ios", "none", "coarse"], diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 505cf837..2080b457 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -81,11 +81,11 @@ function testComparison( return ( value === "native" || compareMediaFeature(operator, Platform.OS, value) ); - // any-pointer matches each kind of pointer the device has, where pointer names the primary one (MQ5 §7.3) + // any-pointer matches when any pointer the device has matches, where pointer answers for the primary one (MQ5 §7.3) case "any-pointer": - return operator === "=" && typeof value === "string" - ? availablePointers(get).includes(value) - : UNKNOWN; + return disjoin(availablePointers(get), (pointer) => + compareMediaFeature(operator, pointer, value), + ); default: return compareMediaFeature( operator, From fbd7bb801fb9658403cee0de1615e246aac67059 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 16:04:53 +0300 Subject: [PATCH 12/18] fix: read an unlisted condition as unknown and a TV or Mac Catalyst input, and share one walk --- .../compiler/condition-semantics.test.ts | 44 ++++++++- .../native/condition-semantics.test.tsx | 44 ++++++++- src/compiler/container-query.ts | 56 +---------- src/compiler/media-query.ts | 35 ++++--- src/native/conditions/compare.ts | 37 ++++++- src/native/conditions/container-query.ts | 58 ++--------- src/native/conditions/media-query.ts | 98 ++++++++++--------- 7 files changed, 204 insertions(+), 168 deletions(-) diff --git a/src/__tests__/compiler/condition-semantics.test.ts b/src/__tests__/compiler/condition-semantics.test.ts index 84ff753c..7423996e 100644 --- a/src/__tests__/compiler/condition-semantics.test.ts +++ b/src/__tests__/compiler/condition-semantics.test.ts @@ -1,5 +1,13 @@ +import type { + ContainerCondition as CSSContainerCondition, + MediaCondition as CSSMediaCondition, +} from "lightningcss"; import { compile, type StyleRule } from "react-native-css/compiler"; +import { parseContainerCondition } from "../../compiler/container-query"; +import { parseMediaQueryCondition } from "../../compiler/media-query"; +import { StylesheetBuilder } from "../../compiler/stylesheet"; + function compileChildConditions(css: string): Pick[] { return (compile(css).stylesheet().s ?? []).flatMap(([className, rules]) => className === "child" ? rules.map(({ m, cq }) => ({ m, cq })) : [], @@ -49,7 +57,7 @@ test.each<[string, Pick]>([ { m: [["|", [["?"], [">", "width", 400]]]], cq: undefined }, ], [ - "@container (width > 10in) or (width > 100px)", + "@container (width > 10ch) or (width > 100px)", { m: undefined, cq: [ @@ -92,3 +100,37 @@ test("a media query list keeps the branch a device can match beside one none can ), ).toStrictEqual([{ m: [[">", "width", 400]], cq: undefined }]); }); + +// lightningcss 1.33's error recovery emits as a condition the 1.30 types do not list, which MQ5 §3.1 leaves unknown +const UNLISTED_CONDITIONS = [ + ["condition", { type: "unknown", value: [] }], + ["operator", { type: "operation", operator: "xor", conditions: [] }], +] as const; + +test.each(UNLISTED_CONDITIONS)( + "a media %s lightningcss's types do not list compiles to an unknown term", + (_, unlisted) => { + const condition: unknown = unlisted; + + expect( + parseMediaQueryCondition( + condition as CSSMediaCondition, + new StylesheetBuilder({}), + ), + ).toStrictEqual(["?"]); + }, +); + +test.each(UNLISTED_CONDITIONS)( + "a container %s lightningcss's types do not list selects no container", + (_, unlisted) => { + const condition: unknown = unlisted; + + expect( + parseContainerCondition( + condition as CSSContainerCondition, + new StylesheetBuilder({}), + ), + ).toBeUndefined(); + }, +); diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx index 0d877c6a..ddc255c6 100644 --- a/src/__tests__/native/condition-semantics.test.tsx +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -223,6 +223,44 @@ describe.each([ }, ); +// MQ5 §7.1: a TV's remote is no pointing device, and Mac Catalyst points with a mouse +describe.each([ + ["a TV", "none", "none", { isTV: true, isMacCatalyst: false }], + ["Mac Catalyst", "hover", "fine", { isTV: false, isMacCatalyst: true }], +] as const)( + "on %s the primary input answers hover as %s and pointer as %s", + (_, hover, pointer, flags) => { + beforeEach(() => { + jest.spyOn(Platform, "isTV", "get").mockReturnValue(flags.isTV); + if (Platform.OS !== "ios") { + throw new Error("the suite runs on the iOS preset"); + } + jest + .spyOn(Platform, "isMacCatalyst", "get") + .mockReturnValue(flags.isMacCatalyst); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test.each([ + ["(hover: hover)", hover === "hover"], + ["(hover: none)", hover === "none"], + ["(any-hover: none)", hover === "none"], + ["(pointer: fine)", pointer === "fine"], + ["(pointer: coarse)", false], + ["(pointer: none)", pointer === "none"], + ["(pointer)", pointer !== "none"], + ["(any-pointer: none)", pointer === "none"], + ["(any-pointer: fine)", pointer === "fine"], + ["(any-pointer)", pointer !== "none"], + ])("%s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + }, +); + // MQ5 §7.3: any-pointer matches the touchscreen's coarse pointer beside a fine one that hovers describe("on iOS, once a pointer hovers an element", () => { test.each([ @@ -326,10 +364,10 @@ describe("@container on a 400x200 container", () => { expect(containerMatches("(width)", { width: 0, height: 200 })).toBe(false); }); - // A length the runtime cannot resolve leaves its term unknown, and an `or` keeps its other terms + // A length the runtime cannot resolve, such as one that needs a font's metrics, leaves its term unknown, and an `or` keeps its other terms test.each([ - ["(width > 10in) or (width > 100px)", true], - ["not (width > 10in)", false], + ["(width > 10ch) or (width > 100px)", true], + ["not (width > 10ch)", false], ])("%s matches: %s", (condition, expected) => { expect(containerMatches(condition)).toBe(expected); }); diff --git a/src/compiler/container-query.ts b/src/compiler/container-query.ts index b06a5215..3a71dae8 100644 --- a/src/compiler/container-query.ts +++ b/src/compiler/container-query.ts @@ -1,15 +1,10 @@ import type { ContainerSizeFeatureId, ContainerCondition as CSSContainerCondition, - QueryFeatureFor_ContainerSizeFeatureId, } from "lightningcss"; import type { MediaCondition } from "./compiler.types"; -import { - isUnknownKeyword, - parseFeatureOperand, - parseMediaFeatureOperator, -} from "./media-query"; +import { parseQueryFeature } from "./media-query"; import type { StylesheetBuilder } from "./stylesheet"; const SIZE_FEATURES: ReadonlySet = new Set([ @@ -21,14 +16,16 @@ const SIZE_FEATURES: ReadonlySet = new Set([ "orientation", ]); -// Chromium and WebKit drop a container query holding an unknown size feature or env(), so it compiles to nothing; any other value the runtime cannot resolve leaves its feature unknown (CSS Conditional 5 §5.4) +// Chromium and WebKit drop a container query holding an unknown size feature, a keyword its feature does not take or env(), so it compiles to nothing; any other value the runtime cannot resolve leaves its feature unknown (CSS Conditional 5 §5.4) export function parseContainerCondition( condition: CSSContainerCondition, builder: StylesheetBuilder, ): MediaCondition | undefined { switch (condition.type) { case "feature": - return parseFeature(condition.value, builder); + return SIZE_FEATURES.has(condition.value.name) + ? parseQueryFeature(condition.value, builder) + : undefined; case "not": { const query = parseContainerCondition(condition.value, builder); return query ? ["!", query] : undefined; @@ -63,46 +60,3 @@ export function parseContainerCondition( return; } } - -function parseFeature( - feature: QueryFeatureFor_ContainerSizeFeatureId, - builder: StylesheetBuilder, -): MediaCondition | undefined { - if (!SIZE_FEATURES.has(feature.name)) { - return; - } - - switch (feature.type) { - case "boolean": - return ["!!", feature.name]; - case "plain": { - const value = parseFeatureOperand(feature.value, builder); - return value === undefined || isUnknownKeyword(feature.name, value) - ? undefined - : ["=", feature.name, value]; - } - case "range": { - const value = parseFeatureOperand(feature.value, builder); - return value === undefined - ? undefined - : [parseMediaFeatureOperator(feature.operator), feature.name, value]; - } - case "interval": { - const start = parseFeatureOperand(feature.start, builder); - const end = parseFeatureOperand(feature.end, builder); - return start === undefined || end === undefined - ? undefined - : [ - "[]", - feature.name, - start, - parseMediaFeatureOperator(feature.startOperator), - end, - parseMediaFeatureOperator(feature.endOperator), - ]; - } - default: - feature satisfies never; - return; - } -} diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index df86e5ed..4b665ff1 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -4,6 +4,7 @@ import type { MediaFeatureComparison as CSSMediaFeatureComparison, MediaFeatureValue as CSSMediaFeatureValue, MediaQuery as CSSMediaQuery, + QueryFeatureFor_ContainerSizeFeatureId, QueryFeatureFor_MediaFeatureId, } from "lightningcss"; @@ -105,13 +106,13 @@ function canEvaluateTo( } } -function parseMediaQueryCondition( +export function parseMediaQueryCondition( query: CSSMediaCondition, builder: StylesheetBuilder, ): MediaCondition { switch (query.type) { case "feature": - return parseFeature(query.value, builder); + return parseQueryFeature(query.value, builder) ?? ["?"]; case "not": return ["!", parseMediaQueryCondition(query.value, builder)]; case "operation": { @@ -126,14 +127,13 @@ function parseMediaQueryCondition( return ["|", mediaQueries]; default: query.operator satisfies never; - throw new Error( - `Unknown media condition operator ${JSON.stringify(query)}`, - ); + return ["?"]; } } default: query satisfies never; - throw new Error(`Unknown media condition ${JSON.stringify(query)}`); + // A lightningcss newer than its types emits conditions they do not list, such as error recovery's , which MQ5 §3.1 leaves unknown + return ["?"]; } } @@ -150,7 +150,7 @@ const FEATURE_KEYWORDS: ReadonlyMap = new Map([ ["update", ["none", "slow", "fast"]], ]); -export function isUnknownKeyword( +function isUnknownKeyword( name: string, operand: Readonly, ) { @@ -162,30 +162,33 @@ export function isUnknownKeyword( ); } -function parseFeature( - feature: QueryFeatureFor_MediaFeatureId, +// A feature holding env() or a keyword it does not take compiles to undefined, which a media query reads as unknown and a container query as no query +export function parseQueryFeature( + feature: + | QueryFeatureFor_MediaFeatureId + | QueryFeatureFor_ContainerSizeFeatureId, builder: StylesheetBuilder, -): MediaCondition { +): MediaCondition | undefined { switch (feature.type) { case "boolean": return ["!!", feature.name]; case "plain": { const operand = parseFeatureOperand(feature.value, builder); return operand === undefined || isUnknownKeyword(feature.name, operand) - ? ["?"] + ? undefined : ["=", feature.name, operand]; } case "range": { const operand = parseFeatureOperand(feature.value, builder); return operand === undefined - ? ["?"] + ? undefined : [parseMediaFeatureOperator(feature.operator), feature.name, operand]; } case "interval": { const start = parseFeatureOperand(feature.start, builder); const end = parseFeatureOperand(feature.end, builder); return start === undefined || end === undefined - ? ["?"] + ? undefined : [ "[]", feature.name, @@ -197,12 +200,12 @@ function parseFeature( } default: feature satisfies never; - throw new Error(`Unknown media feature ${JSON.stringify(feature)}`); + return undefined; } } -// No browser takes env() as a feature's value, so the caller drops a feature holding one; any other value the compiler cannot resolve compiles to null, which leaves its term unknown -export function parseFeatureOperand( +// No browser takes env() as a media feature's value, and Chromium and WebKit drop a container query holding one, so a feature holding it compiles to undefined; any other value the compiler cannot resolve compiles to null, which leaves its term unknown +function parseFeatureOperand( value: CSSMediaFeatureValue, builder: StylesheetBuilder, ): MediaFeatureOperand | undefined { diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts index 6fc71445..0058f10b 100644 --- a/src/native/conditions/compare.ts +++ b/src/native/conditions/compare.ts @@ -4,7 +4,7 @@ import type { MediaFeatureOperand, } from "react-native-css/compiler"; -import { conjoin, UNKNOWN, type Truth } from "./kleene"; +import { conjoin, disjoin, negate, UNKNOWN, type Truth } from "./kleene"; export type MediaInterval = Extract; @@ -18,6 +18,41 @@ export type MediaFeatureName = MediaComparison[1]; // undefined for a feature the runtime does not measure export type MeasuredFeatureValue = number | string | undefined; +// A media query and a container query differ only in how a feature is read and compared +export interface FeatureReader { + readonly read: (name: MediaFeatureName) => MeasuredFeatureValue; + readonly compare: (comparison: Readonly) => Truth; +} + +export function evaluateCondition( + condition: MediaCondition, + reader: FeatureReader, +): Truth { + switch (condition[0]) { + case "?": + return UNKNOWN; + case "!!": + return isTruthyFeatureValue(reader.read(condition[1])); + case "[]": + return testMediaFeatureInterval(condition, reader.read(condition[1])); + case "!": + return negate(evaluateCondition(condition[1], reader)); + case "&": + return conjoin(condition[1], (term) => evaluateCondition(term, reader)); + case "|": + return disjoin(condition[1], (term) => evaluateCondition(term, reader)); + case ">": + case ">=": + case "<": + case "<=": + case "=": + return reader.compare(condition); + default: + condition satisfies never; + return UNKNOWN; + } +} + // Media and container queries share this, so each operator has one meaning; an unmeasured feature or an unresolved operand is unknown (MQ5 §3.1) export function compareMediaFeature( operator: MediaFeatureComparison, diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index a5a7db9f..d27253a4 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -16,20 +16,12 @@ import { import { testAttributes } from "./attributes"; import { compareMediaFeature, - isTruthyFeatureValue, - testMediaFeatureInterval, + evaluateCondition, type MeasuredFeatureValue, type MediaFeatureName, } from "./compare"; import type { RenderGuard } from "./guards"; -import { - conjoin, - disjoin, - matches, - negate, - UNKNOWN, - type Truth, -} from "./kleene"; +import { matches, type Truth } from "./kleene"; export const DEFAULT_CONTAINER_NAME = "c:___default___"; @@ -101,44 +93,14 @@ function testContainerMediaCondition( containerKey: WeakKey, get: Getter, ): Truth { - switch (condition[0]) { - case "?": - return UNKNOWN; - case "!": - return negate( - testContainerMediaCondition(condition[1], containerKey, get), - ); - case "&": - return conjoin(condition[1], (query) => - testContainerMediaCondition(query, containerKey, get), - ); - case "|": - return disjoin(condition[1], (query) => - testContainerMediaCondition(query, containerKey, get), - ); - case "!!": - return isTruthyFeatureValue( - getContainerFeatureValue(condition[1], containerKey, get), - ); - case "[]": - return testMediaFeatureInterval( - condition, - getContainerFeatureValue(condition[1], containerKey, get), - ); - case ">": - case ">=": - case "<": - case "<=": - case "=": - return compareMediaFeature( - condition[0], - getContainerFeatureValue(condition[1], containerKey, get), - condition[2], - ); - default: - condition satisfies never; - return UNKNOWN; - } + const read = (name: MediaFeatureName) => + getContainerFeatureValue(name, containerKey, get); + + return evaluateCondition(condition, { + read, + compare: ([operator, name, value]) => + compareMediaFeature(operator, read(name), value), + }); } function getContainerFeatureValue( diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 2080b457..900a73b6 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -11,20 +11,12 @@ import { } from "../reactivity"; import { compareMediaFeature, - isTruthyFeatureValue, - testMediaFeatureInterval, + evaluateCondition, type MeasuredFeatureValue, type MediaComparison, type MediaFeatureName, } from "./compare"; -import { - conjoin, - disjoin, - matches, - negate, - UNKNOWN, - type Truth, -} from "./kleene"; +import { disjoin, matches, type Truth } from "./kleene"; // The color feature counts bits per color component const BITS_PER_COLOR_COMPONENT = 8; @@ -34,32 +26,10 @@ export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { } function test(mediaQuery: MediaCondition, get: Getter): Truth { - switch (mediaQuery[0]) { - case "?": - return UNKNOWN; - case "!!": - return isTruthyFeatureValue(getMediaFeatureValue(mediaQuery[1], get)); - case "[]": - return testMediaFeatureInterval( - mediaQuery, - getMediaFeatureValue(mediaQuery[1], get), - ); - case "!": - return negate(test(mediaQuery[1], get)); - case "&": - return conjoin(mediaQuery[1], (query) => test(query, get)); - case "|": - return disjoin(mediaQuery[1], (query) => test(query, get)); - case ">": - case ">=": - case "<": - case "<=": - case "=": - return testComparison(mediaQuery, get); - default: - mediaQuery satisfies never; - return UNKNOWN; - } + return evaluateCondition(mediaQuery, { + read: (name) => getMediaFeatureValue(name, get), + compare: (comparison) => testComparison(comparison, get), + }); } function testComparison( @@ -102,10 +72,10 @@ function getMediaFeatureValue( switch (name) { case "hover": case "any-hover": - return pointsFinely(get) ? "hover" : "none"; + return pointerAccuracy(get) === "fine" ? "hover" : "none"; case "pointer": case "any-pointer": - return pointsFinely(get) ? "fine" : "coarse"; + return pointerAccuracy(get); case "display-mode": return Platform.OS; case "prefers-color-scheme": @@ -134,18 +104,50 @@ function getMediaFeatureValue( } } -// A touchscreen, the input of iOS and Android until a pointer hovers an element with a :hover style, cannot hover and points coarsely (MQ5 §7.1, §7.2) -function pointsFinely(get: Getter): boolean { - return ( - get(hoverCapableInput) || - (Platform.OS !== "ios" && Platform.OS !== "android") - ); +type PrimaryInput = "touchscreen" | "mouse" | "remote"; + +// A TV's remote moves focus rather than a pointer, and Mac Catalyst runs an iOS app under a mouse +function primaryInput(): PrimaryInput { + if (Platform.isTV) { + return "remote"; + } + + if (Platform.OS === "ios") { + return Platform.isMacCatalyst === true ? "mouse" : "touchscreen"; + } + + return Platform.OS === "android" ? "touchscreen" : "mouse"; } -function availablePointers(get: Getter): readonly string[] { - if (Platform.OS !== "ios" && Platform.OS !== "android") { - return ["fine"]; +// A touchscreen cannot hover and points coarsely until a pointer hovers an element with a :hover style, which React Native reports on iOS and Android only with its shouldPressibilityUseW3CPointerEventsForHover flag on (MQ5 §7.1, §7.2) +function pointerAccuracy(get: Getter): "none" | "coarse" | "fine" { + const input = primaryInput(); + + switch (input) { + case "remote": + return "none"; + case "mouse": + return "fine"; + case "touchscreen": + return get(hoverCapableInput) ? "fine" : "coarse"; + default: + input satisfies never; + return "none"; } +} - return get(hoverCapableInput) ? ["coarse", "fine"] : ["coarse"]; +function availablePointers(get: Getter): readonly string[] { + const input = primaryInput(); + + switch (input) { + case "remote": + return ["none"]; + case "mouse": + return ["fine"]; + case "touchscreen": + return get(hoverCapableInput) ? ["coarse", "fine"] : ["coarse"]; + default: + input satisfies never; + return ["none"]; + } } From 01c2f5b8bac06d78e31b3dfaa76aa07c6333e24e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 20:00:30 +0300 Subject: [PATCH 13/18] refactor: keep the feature parsers private, and read a condition without writing it --- src/compiler/media-query.ts | 4 ++-- src/native/conditions/compare.ts | 2 +- src/native/conditions/container-query.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 4b665ff1..fc73dd83 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -214,7 +214,7 @@ function parseFeatureOperand( : (parseMediaFeatureValue(value, builder) ?? null); } -export function parseMediaFeatureValue( +function parseMediaFeatureValue( value: CSSMediaFeatureValue, builder: StylesheetBuilder, ): StyleDescriptor { @@ -261,7 +261,7 @@ export function parseMediaFeatureValue( } } -export function parseMediaFeatureOperator( +function parseMediaFeatureOperator( operator: CSSMediaFeatureComparison, ): MediaFeatureComparison { switch (operator) { diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts index 0058f10b..d7ab1961 100644 --- a/src/native/conditions/compare.ts +++ b/src/native/conditions/compare.ts @@ -25,7 +25,7 @@ export interface FeatureReader { } export function evaluateCondition( - condition: MediaCondition, + condition: Readonly, reader: FeatureReader, ): Truth { switch (condition[0]) { diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index d27253a4..92039a39 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -89,7 +89,7 @@ function testContainerPseudoCondition( } function testContainerMediaCondition( - condition: MediaCondition, + condition: Readonly, containerKey: WeakKey, get: Getter, ): Truth { From ccff8826d4b0138f31b9dba17eeadc1cffc9d17e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 20:00:37 +0300 Subject: [PATCH 14/18] =?UTF-8?q?fix(native):=20answer=20pointer=20and=20h?= =?UTF-8?q?over=20from=20the=20primary=20input,=20as=20MQ5=20=C2=A77=20and?= =?UTF-8?q?=20phones'=20browsers=20do?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../native/condition-semantics.test.tsx | 57 ++++++------ src/__tests__/vendor/tailwind/groups.test.tsx | 92 ++++++++++++------- src/__tests__/vendor/tailwind/states.test.tsx | 73 ++++++++++----- src/jest/index.ts | 2 - src/native-internal/index.ts | 1 - src/native/conditions/media-query.ts | 61 +++--------- src/native/react/interaction.ts | 2 - src/native/reactivity.ts | 2 - 8 files changed, 152 insertions(+), 138 deletions(-) diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx index ddc255c6..17436b3a 100644 --- a/src/__tests__/native/condition-semantics.test.tsx +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -208,6 +208,8 @@ describe.each([ ["(hover: hover)", hover === "hover"], ["(hover: none)", hover === "none"], ["(hover)", hover === "hover"], + ["(any-hover: hover)", hover === "hover"], + ["(any-hover: none)", hover === "none"], ["(pointer: fine)", pointer === "fine"], ["(pointer: coarse)", pointer === "coarse"], ["not (pointer: fine)", pointer === "coarse"], @@ -261,38 +263,39 @@ describe.each([ }, ); -// MQ5 §7.3: any-pointer matches the touchscreen's coarse pointer beside a fine one that hovers -describe("on iOS, once a pointer hovers an element", () => { +// React Native's hover flag sends a hover for a finger too, and a hover does not change the primary input +describe("on iOS, a hover event", () => { test.each([ - ["(hover: hover)", false, true], - ["(hover: none)", true, false], - ["(any-hover: hover)", false, true], - ["(any-hover: none)", true, false], - ["(pointer: fine)", false, true], - ["(pointer: coarse)", true, false], - ["(any-pointer: fine)", false, true], - ["(any-pointer: coarse)", true, true], - ])("%s matches: %s before and %s after", (condition, before, after) => { - registerCSS(` + ["(hover: hover)", false, false], + ["(hover: none)", true, true], + ["(any-hover: hover)", false, false], + ["(pointer: fine)", false, false], + ["(pointer: coarse)", true, true], + ["(any-pointer: fine)", false, false], + ])( + "leaves %s matching %s before and %s after", + (condition, before, after) => { + registerCSS(` .target:hover { opacity: 0.5; } .child { width: 10px; } @media ${condition} { .child { width: 37px; } } `); - render( - <> - - - , - ); - const widthBefore = screen.getByTestId("child").props.style.width; - - fireEvent(screen.getByTestId("target"), "hoverIn"); - - expect({ - before: widthBefore === 37, - after: screen.getByTestId("child").props.style.width === 37, - }).toStrictEqual({ before, after }); - }); + render( + <> + + + , + ); + const widthBefore = screen.getByTestId("child").props.style.width; + + fireEvent(screen.getByTestId("target"), "hoverIn"); + + expect({ + before: widthBefore === 37, + after: screen.getByTestId("child").props.style.width === 37, + }).toStrictEqual({ before, after }); + }, + ); }); describe("@media on a 400x600 screen", () => { diff --git a/src/__tests__/vendor/tailwind/groups.test.tsx b/src/__tests__/vendor/tailwind/groups.test.tsx index f3e2e84e..421b7795 100644 --- a/src/__tests__/vendor/tailwind/groups.test.tsx +++ b/src/__tests__/vendor/tailwind/groups.test.tsx @@ -1,3 +1,5 @@ +import { Platform } from "react-native"; + import { fireEvent, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components"; @@ -7,49 +9,75 @@ const grandparentID = "grandparentID"; const parentID = "parent"; const childID = "child"; -test("Styling based on parent state (group-{modifier})", async () => { - await render( - - - , - ); +describe("on a primary input that hovers", () => { + beforeEach(() => { + if (Platform.OS !== "ios") { + throw new Error("the suite runs on the iOS preset"); + } + jest.spyOn(Platform, "isMacCatalyst", "get").mockReturnValue(true); + }); - const parent = screen.getByTestId(parentID); - const child = screen.getByTestId(childID); + afterEach(() => { + jest.restoreAllMocks(); + }); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + test("Styling based on parent state (group-{modifier})", async () => { + await render( + + + , + ); - fireEvent(parent, "hoverIn"); + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); - expect(child).toHaveStyle({ color: "#fff" }); -}); + expect(parent).toHaveStyle(undefined); + expect(child).toHaveStyle(undefined); -test("Differentiating nested groups", async () => { - await render( - - - - - - , - ); + fireEvent(parent, "hoverIn"); - const grandparent = screen.getByTestId(grandparentID); - const parent = screen.getByTestId(parentID); - const child = screen.getByTestId(childID); + expect(child).toHaveStyle({ color: "#fff" }); + }); - expect(grandparent).toHaveStyle(undefined); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + test("Differentiating nested groups", async () => { + await render( + + + + + + , + ); - fireEvent(grandparent, "hoverIn"); + const grandparent = screen.getByTestId(grandparentID); + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); - expect(child).toHaveStyle(undefined); + expect(grandparent).toHaveStyle(undefined); + expect(parent).toHaveStyle(undefined); + expect(child).toHaveStyle(undefined); - fireEvent(parent, "hoverIn"); + fireEvent(grandparent, "hoverIn"); - expect(child).toHaveStyle({ color: "#fff" }); + expect(child).toHaveStyle(undefined); + + fireEvent(parent, "hoverIn"); + + expect(child).toHaveStyle({ color: "#fff" }); + }); +}); + +// Tailwind's group-hover: holds only where the primary input can hover, which a touchscreen cannot +test("group-hover does not apply on a touchscreen", async () => { + await render( + + + , + ); + + fireEvent(screen.getByTestId(parentID), "hoverIn"); + + expect(screen.getByTestId(childID)).toHaveStyle(undefined); }); test("arbitrary groups - single className", async () => { diff --git a/src/__tests__/vendor/tailwind/states.test.tsx b/src/__tests__/vendor/tailwind/states.test.tsx index c4abd1e0..4ebce9cf 100644 --- a/src/__tests__/vendor/tailwind/states.test.tsx +++ b/src/__tests__/vendor/tailwind/states.test.tsx @@ -1,3 +1,5 @@ +import { Platform } from "react-native"; + import { fireEvent, screen } from "@testing-library/react-native"; import { Switch, TextInput, View } from "react-native-css/components"; @@ -5,17 +7,58 @@ import { render } from "./_tailwind"; const testID = "component"; -test("hover", async () => { - await render(); +describe("on a primary input that hovers", () => { + beforeEach(() => { + if (Platform.OS !== "ios") { + throw new Error("the suite runs on the iOS preset"); + } + jest.spyOn(Platform, "isMacCatalyst", "get").mockReturnValue(true); + }); - const component = screen.getByTestId(testID); + afterEach(() => { + jest.restoreAllMocks(); + }); - expect(component).toHaveStyle(undefined); + test("hover", async () => { + await render(); + + const component = screen.getByTestId(testID); + + expect(component).toHaveStyle(undefined); + + fireEvent(component, "hoverIn"); + expect(component).toHaveStyle({ color: "#fff" }); + + fireEvent(component, "hoverOut"); + expect(component).toHaveStyle(undefined); + }); + + test("mixed", async () => { + await render( + , + ); + + const component = screen.getByTestId(testID); + expect(component).toHaveStyle(undefined); + + fireEvent(component, "pressIn"); + expect(component).toHaveStyle(undefined); + + fireEvent(component, "hoverIn"); + expect(component).toHaveStyle(undefined); + + fireEvent(component, "focus"); + expect(component).toHaveStyle({ color: "#fff" }); + }); +}); + +// Tailwind's hover: holds only where the primary input can hover, which a touchscreen cannot +test("hover does not apply on a touchscreen", async () => { + await render(); + const component = screen.getByTestId(testID); fireEvent(component, "hoverIn"); - expect(component).toHaveStyle({ color: "#fff" }); - fireEvent(component, "hoverOut"); expect(component).toHaveStyle(undefined); }); @@ -47,24 +90,6 @@ test("active", async () => { expect(component).toHaveStyle(undefined); }); -test("mixed", async () => { - await render( - , - ); - - const component = screen.getByTestId(testID); - expect(component).toHaveStyle(undefined); - - fireEvent(component, "pressIn"); - expect(component).toHaveStyle(undefined); - - fireEvent(component, "hoverIn"); - expect(component).toHaveStyle(undefined); - - fireEvent(component, "focus"); - expect(component).toHaveStyle({ color: "#fff" }); -}); - test("selection", async () => { await render(); diff --git a/src/jest/index.ts b/src/jest/index.ts index 27e786d6..a8d249f5 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -4,7 +4,6 @@ import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; -import { hoverCapableInput } from "react-native-css/native-internal"; import { colorScheme, dimensions } from "../native/reactivity"; @@ -24,7 +23,6 @@ beforeEach(() => { dimensions.set(Dimensions.get("window")); Appearance.setColorScheme("unspecified"); colorScheme.set(null); - hoverCapableInput.set(false); }); const debugDefault = Boolean( diff --git a/src/native-internal/index.ts b/src/native-internal/index.ts index 2df9e0e6..e80c9bd6 100644 --- a/src/native-internal/index.ts +++ b/src/native-internal/index.ts @@ -1,4 +1,3 @@ export * from "./root"; export * from "./style-collection"; export * from "./variables"; -export { hoverCapableInput } from "../native/reactivity"; diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 900a73b6..f0fb7e3b 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -2,13 +2,7 @@ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; -import { - colorScheme, - hoverCapableInput, - vh, - vw, - type Getter, -} from "../reactivity"; +import { colorScheme, vh, vw, type Getter } from "../reactivity"; import { compareMediaFeature, evaluateCondition, @@ -16,7 +10,7 @@ import { type MediaComparison, type MediaFeatureName, } from "./compare"; -import { disjoin, matches, type Truth } from "./kleene"; +import { matches, type Truth } from "./kleene"; // The color feature counts bits per color component const BITS_PER_COLOR_COMPONENT = 8; @@ -25,7 +19,7 @@ export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => matches(test(query, get))); } -function test(mediaQuery: MediaCondition, get: Getter): Truth { +function test(mediaQuery: Readonly, get: Getter): Truth { return evaluateCondition(mediaQuery, { read: (name) => getMediaFeatureValue(name, get), compare: (comparison) => testComparison(comparison, get), @@ -51,11 +45,6 @@ function testComparison( return ( value === "native" || compareMediaFeature(operator, Platform.OS, value) ); - // any-pointer matches when any pointer the device has matches, where pointer answers for the primary one (MQ5 §7.3) - case "any-pointer": - return disjoin(availablePointers(get), (pointer) => - compareMediaFeature(operator, pointer, value), - ); default: return compareMediaFeature( operator, @@ -72,10 +61,10 @@ function getMediaFeatureValue( switch (name) { case "hover": case "any-hover": - return pointerAccuracy(get) === "fine" ? "hover" : "none"; + return PRIMARY_INPUT_FEATURES[primaryInput()].hover; case "pointer": case "any-pointer": - return pointerAccuracy(get); + return PRIMARY_INPUT_FEATURES[primaryInput()].pointer; case "display-mode": return Platform.OS; case "prefers-color-scheme": @@ -119,35 +108,11 @@ function primaryInput(): PrimaryInput { return Platform.OS === "android" ? "touchscreen" : "mouse"; } -// A touchscreen cannot hover and points coarsely until a pointer hovers an element with a :hover style, which React Native reports on iOS and Android only with its shouldPressibilityUseW3CPointerEventsForHover flag on (MQ5 §7.1, §7.2) -function pointerAccuracy(get: Getter): "none" | "coarse" | "fine" { - const input = primaryInput(); - - switch (input) { - case "remote": - return "none"; - case "mouse": - return "fine"; - case "touchscreen": - return get(hoverCapableInput) ? "fine" : "coarse"; - default: - input satisfies never; - return "none"; - } -} - -function availablePointers(get: Getter): readonly string[] { - const input = primaryInput(); - - switch (input) { - case "remote": - return ["none"]; - case "mouse": - return ["fine"]; - case "touchscreen": - return get(hoverCapableInput) ? ["coarse", "fine"] : ["coarse"]; - default: - input satisfies never; - return ["none"]; - } -} +// MQ5 §7 answers for the primary input, as Safari and Chrome do on a phone or tablet with or without a mouse attached; the inputs beside it are not visible to JavaScript, so any-pointer and any-hover answer for it too +const PRIMARY_INPUT_FEATURES = { + touchscreen: { pointer: "coarse", hover: "none" }, + mouse: { pointer: "fine", hover: "hover" }, + remote: { pointer: "none", hover: "none" }, +} as const satisfies Readonly< + Record +>; diff --git a/src/native/react/interaction.ts b/src/native/react/interaction.ts index a254483a..d412b15e 100644 --- a/src/native/react/interaction.ts +++ b/src/native/react/interaction.ts @@ -5,7 +5,6 @@ import { activeFamily, containerLayoutFamily, focusFamily, - hoverCapableInput, hoverFamily, type Getter as WeakKey, } from "../reactivity"; @@ -68,7 +67,6 @@ export function getInteractionHandler( ); break; case "onHoverIn": - hoverCapableInput.set(true); hoverFamily(weakKey).set(true); break; case "onHoverOut": diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 311ee58f..296988a6 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -201,8 +201,6 @@ export type VariableContextValue = Record & { /** Pseudo Classes ************************************************************/ export const hoverFamily = weakFamily(() => observable(false)); -// A hover event proves an input that can hover, which a touchscreen alone cannot -export const hoverCapableInput = observable(false); export const activeFamily = weakFamily(() => observable(false)); export const focusFamily = weakFamily(() => observable(false)); From 781496df30a624632854c9b30a63f8792646323a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 20:02:51 +0300 Subject: [PATCH 15/18] test: assert the absent colour where toHaveStyle(undefined) asserted nothing --- src/__tests__/vendor/tailwind/groups.test.tsx | 30 +++++++++---------- src/__tests__/vendor/tailwind/states.test.tsx | 20 ++++++------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/__tests__/vendor/tailwind/groups.test.tsx b/src/__tests__/vendor/tailwind/groups.test.tsx index 421b7795..777c8236 100644 --- a/src/__tests__/vendor/tailwind/groups.test.tsx +++ b/src/__tests__/vendor/tailwind/groups.test.tsx @@ -31,8 +31,8 @@ describe("on a primary input that hovers", () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); fireEvent(parent, "hoverIn"); @@ -53,13 +53,13 @@ describe("on a primary input that hovers", () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(grandparent).toHaveStyle(undefined); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + expect(grandparent).not.toHaveStyle({ color: "#fff" }); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); fireEvent(grandparent, "hoverIn"); - expect(child).toHaveStyle(undefined); + expect(child).not.toHaveStyle({ color: "#fff" }); fireEvent(parent, "hoverIn"); @@ -77,7 +77,7 @@ test("group-hover does not apply on a touchscreen", async () => { fireEvent(screen.getByTestId(parentID), "hoverIn"); - expect(screen.getByTestId(childID)).toHaveStyle(undefined); + expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#fff" }); }); test("arbitrary groups - single className", async () => { @@ -90,8 +90,8 @@ test("arbitrary groups - single className", async () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); rerender( @@ -112,8 +112,8 @@ test("arbitrary groups - multiple className", async () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); rerender( @@ -121,8 +121,8 @@ test("arbitrary groups - multiple className", async () => { , ); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); rerender( @@ -146,8 +146,8 @@ test("arbitrary groups - props", async () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(parent).toHaveStyle(undefined); - expect(child).toHaveStyle(undefined); + expect(parent).not.toHaveStyle({ color: "#fff" }); + expect(child).not.toHaveStyle({ color: "#fff" }); rerender( diff --git a/src/__tests__/vendor/tailwind/states.test.tsx b/src/__tests__/vendor/tailwind/states.test.tsx index 4ebce9cf..e941a557 100644 --- a/src/__tests__/vendor/tailwind/states.test.tsx +++ b/src/__tests__/vendor/tailwind/states.test.tsx @@ -24,13 +24,13 @@ describe("on a primary input that hovers", () => { const component = screen.getByTestId(testID); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); fireEvent(component, "hoverIn"); expect(component).toHaveStyle({ color: "#fff" }); fireEvent(component, "hoverOut"); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); }); test("mixed", async () => { @@ -39,13 +39,13 @@ describe("on a primary input that hovers", () => { ); const component = screen.getByTestId(testID); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); fireEvent(component, "pressIn"); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); fireEvent(component, "hoverIn"); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); fireEvent(component, "focus"); expect(component).toHaveStyle({ color: "#fff" }); @@ -59,7 +59,7 @@ test("hover does not apply on a touchscreen", async () => { const component = screen.getByTestId(testID); fireEvent(component, "hoverIn"); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); }); test("focus", async () => { @@ -67,13 +67,13 @@ test("focus", async () => { const component = screen.getByTestId(testID); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); fireEvent(component, "focus"); expect(component).toHaveStyle({ color: "#fff" }); fireEvent(component, "blur"); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); }); test("active", async () => { @@ -81,13 +81,13 @@ test("active", async () => { const component = screen.getByTestId(testID); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); fireEvent(component, "pressIn"); expect(component).toHaveStyle({ color: "#fff" }); fireEvent(component, "pressOut"); - expect(component).toHaveStyle(undefined); + expect(component).not.toHaveStyle({ color: "#fff" }); }); test("selection", async () => { From 0b2fbbee9e1975427e0a2827b197d5a2b4112ec3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 22 Oct 2026 00:05:46 +0300 Subject: [PATCH 16/18] perf(native): evaluate a condition without allocating, and measure -webkit-device-pixel-ratio A media query's reader is a module constant that takes the getter, a container's takes one context object, and the walk folds and and or itself, so evaluating a term allocates nothing. -webkit-device-pixel-ratio reads PixelRatio.get() as resolution does. The nested conditions are readonly, the container tests assert the colour absent before layout, and the condition test's afterEach, which the Jest preset's reset made dead, goes. --- src/__tests__/native/compare.test.ts | 59 +++++++++++++++++ .../native/condition-semantics.test.tsx | 19 +++--- .../native/container-queries.test.tsx | 2 +- src/__tests__/native/kleene.test.ts | 32 ++------- .../tailwind/container-queries.test.tsx | 4 +- src/compiler/compiler.types.ts | 4 +- src/native/conditions/compare.ts | 66 +++++++++++++------ src/native/conditions/container-query.ts | 27 ++++++-- src/native/conditions/kleene.ts | 46 +++---------- src/native/conditions/media-query.ts | 29 +++++--- 10 files changed, 173 insertions(+), 115 deletions(-) diff --git a/src/__tests__/native/compare.test.ts b/src/__tests__/native/compare.test.ts index 9b00f3ba..4963b410 100644 --- a/src/__tests__/native/compare.test.ts +++ b/src/__tests__/native/compare.test.ts @@ -5,9 +5,12 @@ import type { import { compareMediaFeature, + evaluateCondition, isTruthyFeatureValue, testMediaFeatureInterval, + type FeatureReader, type MeasuredFeatureValue, + type MediaFeatureName, type MediaInterval, } from "../../native/conditions/compare"; import { UNKNOWN, type Truth } from "../../native/conditions/kleene"; @@ -80,3 +83,59 @@ test.each([ ] as const)("%p in a boolean context is %p", (value, truth) => { expect(isTruthyFeatureValue(value)).toBe(truth); }); + +const recordingReader = ( + values: Readonly>>, + read: MediaFeatureName[], +): FeatureReader => ({ + read: (name) => { + read.push(name); + return values[name]; + }, + compare: ([operator, name, value]) => { + read.push(name); + return compareMediaFeature(operator, values[name], value); + }, +}); + +test("an empty conjunction is true and an empty disjunction false", () => { + const reader = recordingReader({}, []); + + expect(evaluateCondition(["&", []], reader, undefined)).toBe(true); + expect(evaluateCondition(["|", []], reader, undefined)).toBe(false); +}); + +test("a decided term stops the evaluation, and an unknown one does not", () => { + const read: MediaFeatureName[] = []; + const reader = recordingReader({ width: 400, height: 300 }, read); + + expect( + evaluateCondition( + [ + "&", + [ + ["=", "scan", "progressive"], + [">", "width", 500], + [">", "height", 100], + ], + ], + reader, + undefined, + ), + ).toBe(false); + expect( + evaluateCondition( + [ + "|", + [ + ["=", "scan", "progressive"], + [">", "width", 300], + [">", "height", 100], + ], + ], + reader, + undefined, + ), + ).toBe(true); + expect(read).toStrictEqual(["scan", "width", "scan", "width"]); +}); diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx index 17436b3a..0b990557 100644 --- a/src/__tests__/native/condition-semantics.test.tsx +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -12,14 +12,6 @@ import { import { testMediaQuery } from "../../native/conditions/media-query"; import { dimensions, vw, type Getter } from "../../native/reactivity"; -const initialColorScheme = colorScheme.get(); - -afterEach(() => { - act(() => { - colorScheme.set(initialColorScheme); - }); -}); - function mediaMatches( condition: string, size = { width: 600, height: 400 }, @@ -133,6 +125,17 @@ describe("@media on a 600x400 screen", () => { }, ); + // The Compatibility Standard's -webkit-device-pixel-ratio is resolution in dppx, and PixelRatio.get() is 2 under Jest + test.each([ + ["(min-resolution: 2dppx)", true], + ["(-webkit-min-device-pixel-ratio: 2)", true], + ["(-webkit-device-pixel-ratio: 2)", true], + ["(-webkit-min-device-pixel-ratio: 3)", false], + ["not (-webkit-min-device-pixel-ratio: 3)", true], + ])("at a pixel ratio of 2, %s matches: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + test.each([ ["dark", "(prefers-color-scheme)", true], ["dark", "(prefers-color-scheme: dark)", true], diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index 3ea60394..23c09ff5 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -29,7 +29,7 @@ test("Unnamed containers", () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(child).toHaveStyle(undefined); + expect(child).not.toHaveStyle({ color: "#fff" }); // Jest does not fire layout events, so we need to manually fireEvent(parent, "layout", { diff --git a/src/__tests__/native/kleene.test.ts b/src/__tests__/native/kleene.test.ts index d09079e5..1f1f4ff9 100644 --- a/src/__tests__/native/kleene.test.ts +++ b/src/__tests__/native/kleene.test.ts @@ -1,14 +1,11 @@ import { - conjoin, - disjoin, + and, matches, negate, + or, UNKNOWN, - type Truth, } from "../../native/conditions/kleene"; -const identity = (value: Truth): Truth => value; - test.each([ [true, false], [false, true], @@ -35,26 +32,9 @@ test.each([ ] as const)( "%s with %s: and is %s, or is %s", (left, right, conjunction, disjunction) => { - expect(conjoin([left, right], identity)).toBe(conjunction); - expect(conjoin([right, left], identity)).toBe(conjunction); - expect(disjoin([left, right], identity)).toBe(disjunction); - expect(disjoin([right, left], identity)).toBe(disjunction); + expect(and(left, right)).toBe(conjunction); + expect(and(right, left)).toBe(conjunction); + expect(or(left, right)).toBe(disjunction); + expect(or(right, left)).toBe(disjunction); }, ); - -test("an empty conjunction is true and an empty disjunction false", () => { - expect(conjoin([], identity)).toBe(true); - expect(disjoin([], identity)).toBe(false); -}); - -test("a decided term stops the evaluation, and an unknown one does not", () => { - const evaluated: Truth[] = []; - const record = (value: Truth): Truth => { - evaluated.push(value); - return value; - }; - - expect(conjoin([UNKNOWN, false, true], record)).toBe(false); - expect(disjoin([UNKNOWN, true, false], record)).toBe(true); - expect(evaluated).toStrictEqual([UNKNOWN, false, UNKNOWN, true]); -}); diff --git a/src/__tests__/vendor/tailwind/container-queries.test.tsx b/src/__tests__/vendor/tailwind/container-queries.test.tsx index d67b9b0c..2be56a4f 100644 --- a/src/__tests__/vendor/tailwind/container-queries.test.tsx +++ b/src/__tests__/vendor/tailwind/container-queries.test.tsx @@ -16,7 +16,7 @@ test("Unnamed containers", async () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(child).toHaveStyle(undefined); + expect(child).not.toHaveStyle({ color: "#fff" }); // Jest does not fire layout events, so we need to manually fireEvent(parent, "layout", { @@ -41,7 +41,7 @@ test("Named containers", async () => { const parent = screen.getByTestId(parentID); const child = screen.getByTestId(childID); - expect(child).toHaveStyle(undefined); + expect(child).not.toHaveStyle({ color: "#fff" }); fireEvent(parent, "layout", { nativeEvent: { diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 9a23e8b0..00de1161 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -181,9 +181,9 @@ export type MediaCondition = // Not | ["!", MediaCondition] // And - | ["&", MediaCondition[]] + | ["&", readonly MediaCondition[]] // Or - | ["|", MediaCondition[]] + | ["|", readonly MediaCondition[]] // Comparison | [ MediaFeatureComparison, diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts index d7ab1961..faf66a2e 100644 --- a/src/native/conditions/compare.ts +++ b/src/native/conditions/compare.ts @@ -4,7 +4,7 @@ import type { MediaFeatureOperand, } from "react-native-css/compiler"; -import { conjoin, disjoin, negate, UNKNOWN, type Truth } from "./kleene"; +import { and, negate, or, UNKNOWN, type Truth } from "./kleene"; export type MediaInterval = Extract; @@ -18,35 +18,62 @@ export type MediaFeatureName = MediaComparison[1]; // undefined for a feature the runtime does not measure export type MeasuredFeatureValue = number | string | undefined; -// A media query and a container query differ only in how a feature is read and compared -export interface FeatureReader { - readonly read: (name: MediaFeatureName) => MeasuredFeatureValue; - readonly compare: (comparison: Readonly) => Truth; +// A media query and a container query differ only in how a feature is read and compared; the context is what a reading needs, passed along so an evaluation allocates nothing +export interface FeatureReader { + readonly read: ( + name: MediaFeatureName, + context: Context, + ) => MeasuredFeatureValue; + readonly compare: ( + comparison: Readonly, + context: Context, + ) => Truth; } -export function evaluateCondition( +export function evaluateCondition( condition: Readonly, - reader: FeatureReader, + reader: FeatureReader, + context: Context, ): Truth { switch (condition[0]) { case "?": return UNKNOWN; case "!!": - return isTruthyFeatureValue(reader.read(condition[1])); + return isTruthyFeatureValue(reader.read(condition[1], context)); case "[]": - return testMediaFeatureInterval(condition, reader.read(condition[1])); + return testMediaFeatureInterval( + condition, + reader.read(condition[1], context), + ); case "!": - return negate(evaluateCondition(condition[1], reader)); - case "&": - return conjoin(condition[1], (term) => evaluateCondition(term, reader)); - case "|": - return disjoin(condition[1], (term) => evaluateCondition(term, reader)); + return negate(evaluateCondition(condition[1], reader, context)); + // Evaluating a term subscribes to what it reads, so a decided conjunction or disjunction evaluates no further terms + case "&": { + let truth: Truth = true; + for (const term of condition[1]) { + truth = and(truth, evaluateCondition(term, reader, context)); + if (truth === false) { + return false; + } + } + return truth; + } + case "|": { + let truth: Truth = false; + for (const term of condition[1]) { + truth = or(truth, evaluateCondition(term, reader, context)); + if (truth === true) { + return true; + } + } + return truth; + } case ">": case ">=": case "<": case "<=": case "=": - return reader.compare(condition); + return reader.compare(condition, context); default: condition satisfies never; return UNKNOWN; @@ -104,12 +131,9 @@ export function testMediaFeatureInterval( ): Truth { const [, , start, startOperator, end, endOperator] = condition; - return conjoin( - [ - compareMediaFeature(startOperator, start, value), - compareMediaFeature(endOperator, value, end), - ], - (truth) => truth, + return and( + compareMediaFeature(startOperator, start, value), + compareMediaFeature(endOperator, value, end), ); } diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index 92039a39..c7c17f39 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -17,6 +17,7 @@ import { testAttributes } from "./attributes"; import { compareMediaFeature, evaluateCondition, + type FeatureReader, type MeasuredFeatureValue, type MediaFeatureName, } from "./compare"; @@ -93,16 +94,28 @@ function testContainerMediaCondition( containerKey: WeakKey, get: Getter, ): Truth { - const read = (name: MediaFeatureName) => - getContainerFeatureValue(name, containerKey, get); - - return evaluateCondition(condition, { - read, - compare: ([operator, name, value]) => - compareMediaFeature(operator, read(name), value), + return evaluateCondition(condition, CONTAINER_FEATURES, { + containerKey, + get, }); } +interface ContainerFeatureContext { + readonly containerKey: WeakKey; + readonly get: Getter; +} + +const CONTAINER_FEATURES: FeatureReader = { + read: (name, { containerKey, get }) => + getContainerFeatureValue(name, containerKey, get), + compare: ([operator, name, value], { containerKey, get }) => + compareMediaFeature( + operator, + getContainerFeatureValue(name, containerKey, get), + value, + ), +}; + function getContainerFeatureValue( name: MediaFeatureName, containerKey: WeakKey, diff --git a/src/native/conditions/kleene.ts b/src/native/conditions/kleene.ts index 61285c49..2b0819d4 100644 --- a/src/native/conditions/kleene.ts +++ b/src/native/conditions/kleene.ts @@ -7,48 +7,18 @@ export function negate(value: Truth): Truth { return value === UNKNOWN ? UNKNOWN : !value; } -// Evaluating a term subscribes to what it reads, so a decided conjunction evaluates no further terms -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; - } +export function and(left: Truth, right: Truth): Truth { + if (left === false || right === false) { + return false; } - - return unknown ? UNKNOWN : true; + return left === UNKNOWN || right === UNKNOWN ? UNKNOWN : true; } -// Evaluating a term subscribes to what it reads, so a decided disjunction evaluates no further terms -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; - } +export function or(left: Truth, right: Truth): Truth { + if (left === true || right === true) { + return true; } - - return unknown ? UNKNOWN : false; + return left === UNKNOWN || right === UNKNOWN ? UNKNOWN : false; } // A rule applies only on true, so unknown converts to false (MQ5 §3.1) diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index f0fb7e3b..fc3db973 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -6,6 +6,7 @@ import { colorScheme, vh, vw, type Getter } from "../reactivity"; import { compareMediaFeature, evaluateCondition, + type FeatureReader, type MeasuredFeatureValue, type MediaComparison, type MediaFeatureName, @@ -15,15 +16,16 @@ import { matches, type Truth } from "./kleene"; // The color feature counts bits per color component const BITS_PER_COLOR_COMPONENT = 8; -export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { - return mediaQueries.every((query) => matches(test(query, get))); -} - -function test(mediaQuery: Readonly, get: Getter): Truth { - return evaluateCondition(mediaQuery, { - read: (name) => getMediaFeatureValue(name, get), - compare: (comparison) => testComparison(comparison, get), - }); +export function testMediaQuery( + mediaQueries: readonly MediaCondition[], + get: Getter, +): boolean { + for (const query of mediaQueries) { + if (!matches(evaluateCondition(query, MEDIA_FEATURES, get))) { + return false; + } + } + return true; } function testComparison( @@ -84,7 +86,9 @@ function getMediaFeatureValue( return get(vw); case "height": return get(vh); + // -webkit-device-pixel-ratio is resolution in dppx (https://compat.spec.whatwg.org/#css-media-queries-webkit-device-pixel-ratio) case "resolution": + case "-webkit-device-pixel-ratio": return PixelRatio.get(); case "orientation": return get(vh) < get(vw) ? "landscape" : "portrait"; @@ -93,6 +97,11 @@ function getMediaFeatureValue( } } +const MEDIA_FEATURES: FeatureReader = { + read: getMediaFeatureValue, + compare: testComparison, +}; + type PrimaryInput = "touchscreen" | "mouse" | "remote"; // A TV's remote moves focus rather than a pointer, and Mac Catalyst runs an iOS app under a mouse @@ -108,7 +117,7 @@ function primaryInput(): PrimaryInput { return Platform.OS === "android" ? "touchscreen" : "mouse"; } -// MQ5 §7 answers for the primary input, as Safari and Chrome do on a phone or tablet with or without a mouse attached; the inputs beside it are not visible to JavaScript, so any-pointer and any-hover answer for it too +// MQ5 §7 answers for the primary input, as Safari and Chrome do on a phone or tablet; React Native reports no other input device, so any-pointer and any-hover answer for the primary input too const PRIMARY_INPUT_FEATURES = { touchscreen: { pointer: "coarse", hover: "none" }, mouse: { pointer: "fine", hover: "hover" }, From 328c8da2a8cb8b13ab2e2ef30b3cf36a32b90aac Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 22 Oct 2026 00:11:28 +0300 Subject: [PATCH 17/18] perf(native): read a condition's tuple by index on the evaluation path --- src/native/conditions/compare.ts | 8 +++----- src/native/conditions/container-query.ts | 8 ++++---- src/native/conditions/media-query.ts | 5 ++++- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts index faf66a2e..eccbdc03 100644 --- a/src/native/conditions/compare.ts +++ b/src/native/conditions/compare.ts @@ -124,16 +124,14 @@ export function compareMediaFeature( } } -// The compiler emits a range pair in source order: start, operator, value, operator, end +// The compiler emits a range pair in source order: start, operator, value, operator, end; it is read by index, since Babel compiles a destructured tuple to a helper call that costs more than the comparisons export function testMediaFeatureInterval( condition: Readonly, value: MeasuredFeatureValue, ): Truth { - const [, , start, startOperator, end, endOperator] = condition; - return and( - compareMediaFeature(startOperator, start, value), - compareMediaFeature(endOperator, value, end), + compareMediaFeature(condition[3], condition[2], value), + compareMediaFeature(condition[5], value, condition[4]), ); } diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index c7c17f39..c4ba97b7 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -108,11 +108,11 @@ interface ContainerFeatureContext { const CONTAINER_FEATURES: FeatureReader = { read: (name, { containerKey, get }) => getContainerFeatureValue(name, containerKey, get), - compare: ([operator, name, value], { containerKey, get }) => + compare: (comparison, { containerKey, get }) => compareMediaFeature( - operator, - getContainerFeatureValue(name, containerKey, get), - value, + comparison[0], + getContainerFeatureValue(comparison[1], containerKey, get), + comparison[2], ), }; diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index fc3db973..3f2b2266 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -32,7 +32,10 @@ function testComparison( mediaQuery: Readonly, get: Getter, ): Truth { - const [operator, name, value] = mediaQuery; + // Read by index, since Babel compiles a destructured tuple to a helper call that costs more than the comparison + const operator = mediaQuery[0]; + const name = mediaQuery[1]; + const value = mediaQuery[2]; switch (name) { // dir and platform are this library's conditions rather than media features, so neither has a boolean context From 883632686933b5ac49df58e7ba0b8b1437b75aa8 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 22 Oct 2026 00:50:51 +0300 Subject: [PATCH 18/18] fix(compiler): match a media type in any case, as a keyword already does --- src/__tests__/native/condition-semantics.test.tsx | 8 ++++++++ src/compiler/media-query.ts | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/__tests__/native/condition-semantics.test.tsx b/src/__tests__/native/condition-semantics.test.tsx index 0b990557..9d6c00a9 100644 --- a/src/__tests__/native/condition-semantics.test.tsx +++ b/src/__tests__/native/condition-semantics.test.tsx @@ -101,6 +101,14 @@ describe("@media on a 600x400 screen", () => { expect(mediaMatches(condition)).toBe(expected); }); + // MQ4 §3: a media type, such as this library's platform types, matches in any case + test.each([ + ["IOS", true], + ["not IOS", false], + ])("the media type in %s matches on iOS: %s", (condition, expected) => { + expect(mediaMatches(condition)).toBe(expected); + }); + // css-values-4 §4.1: a keyword matches in any case test.each([ ["(orientation: LANDSCAPE)", true], diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index fc73dd83..11bd1cfa 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -34,7 +34,8 @@ export function parseMediaQuery( // These all/screen are not conditions, they always apply if (query.mediaType !== "all" && query.mediaType !== "screen") { - platformCondition = ["=", "platform", query.mediaType]; + // Media types are ASCII case-insensitive (MQ4 §3), and lightningcss lowercases only all, print and screen + platformCondition = ["=", "platform", asciiLowerCase(query.mediaType)]; } }