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