From fb69404b20a2f6c2701058cda9275da391d5d906 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 23:43:18 +0300 Subject: [PATCH 1/8] fix(native): evaluate every container query comparison operator correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container query evaluator's comparison switch returned `left > right` for `>=`, `<` and `<=` as well as for `>`. Only `>` and `=` were correct, so `@container (min-width: 400px)` and `@container (max-width: 400px)` both behaved as a strict greater-than: a 400px container matched neither, and a `max-width` query matched every container wider than its threshold. The compiler is not at fault. lightningcss normalises `min-`/`max-` into range conditions and the compiler emits `>=` / `<=` faithfully; the operator was discarded one hop later, at evaluation. The media query evaluator carried a correct copy of the same five-armed switch, so this was drift between two hand-written copies of one decision. Both now call `compareMediaFeature`, a single exhaustive primitive, and the media query side is narrowed to `MediaCondition`'s comparison arm (derived with `Extract`, not restated) so it can call the primitive without a cast — which also removes the unreachable `default` arm that let the wide parameter type hide the duplication. Tests: a 15-case cross product of the five operators against leftright over the primitive; a 14-case runtime table against a 400x200 container covering both false negatives and false positives; and a compiler-plane table pinning the emitted condition IR so a future normalisation change cannot silently reintroduce the same symptom. --- .../compiler/container-query.test.ts | 78 +++++++++++++++++ src/__tests__/native/compare.test.ts | 83 +++++++++++++++++++ .../native/container-queries.test.tsx | 68 +++++++++++++++ src/native/conditions/compare.ts | 32 +++++++ src/native/conditions/container-query.ts | 15 +--- src/native/conditions/media-query.ts | 32 +++---- 6 files changed, 279 insertions(+), 29 deletions(-) create mode 100644 src/__tests__/compiler/container-query.test.ts create mode 100644 src/__tests__/native/compare.test.ts create mode 100644 src/native/conditions/compare.ts diff --git a/src/__tests__/compiler/container-query.test.ts b/src/__tests__/compiler/container-query.test.ts new file mode 100644 index 00000000..27849c45 --- /dev/null +++ b/src/__tests__/compiler/container-query.test.ts @@ -0,0 +1,78 @@ +import { compile, type ContainerQuery } from "react-native-css/compiler"; + +/** + * Returns the container queries the compiler attached to `.child`. + * + * The rest of the rule (declarations, specificity, extracted variables) is not + * the subject of these tests, so reading just `cq` keeps them from failing on + * an unrelated change to how declarations are emitted. + */ +function compileContainerQueries(condition: string): ContainerQuery[] { + const stylesheet = compile(` + @container ${condition} { + .child { + color: red; + } + } + `).stylesheet(); + + const rules = stylesheet.s?.flatMap(([className, ruleSet]) => { + return className === "child" ? ruleSet : []; + }); + + return rules?.flatMap((rule) => rule.cq ?? []) ?? []; +} + +describe("size feature comparisons", () => { + /** + * lightningcss normalises the `min-`/`max-` prefixes into range conditions, + * so the runtime only ever sees the five comparison operators. Every one of + * them has to survive compilation with its own identity — a container query + * evaluator can only be as correct as the operator it is handed. + */ + const cases: [condition: string, query: ContainerQuery][] = [ + ["(width > 400px)", { m: [">", "width", 400] }], + ["(width >= 400px)", { m: [">=", "width", 400] }], + ["(min-width: 400px)", { m: [">=", "width", 400] }], + ["(width < 400px)", { m: ["<", "width", 400] }], + ["(width <= 400px)", { m: ["<=", "width", 400] }], + ["(max-width: 400px)", { m: ["<=", "width", 400] }], + ["(width = 400px)", { m: ["=", "width", 400] }], + ["(height > 400px)", { m: [">", "height", 400] }], + ["(min-height: 400px)", { m: [">=", "height", 400] }], + ["(max-height: 400px)", { m: ["<=", "height", 400] }], + ["(orientation: landscape)", { m: ["=", "orientation", "landscape"] }], + ["(orientation: portrait)", { m: ["=", "orientation", "portrait"] }], + [ + "my-container (min-width: 400px)", + { m: [">=", "width", 400], n: "c:my-container" }, + ], + ]; + + test.each(cases)("@container %s", (condition, query) => { + expect(compileContainerQueries(condition)).toStrictEqual([query]); + }); +}); + +test("a container query is only attached to rules inside it", () => { + const stylesheet = compile(` + .child { + color: red; + } + + @container (min-width: 400px) { + .child { + color: blue; + } + } + `).stylesheet(); + + const rules = stylesheet.s?.flatMap(([className, ruleSet]) => { + return className === "child" ? ruleSet : []; + }); + + expect(rules?.map((rule) => rule.cq)).toStrictEqual([ + undefined, + [{ m: [">=", "width", 400] }], + ]); +}); diff --git a/src/__tests__/native/compare.test.ts b/src/__tests__/native/compare.test.ts new file mode 100644 index 00000000..b21952dd --- /dev/null +++ b/src/__tests__/native/compare.test.ts @@ -0,0 +1,83 @@ +import type { MediaFeatureComparison } from "react-native-css/compiler"; + +import { compareMediaFeature } from "../../native/conditions/compare"; + +/** + * The full cross product of every comparison operator against every ordering + * of its two operands. A copy-pasted switch arm is only visible when both are + * varied: `>=` and `>` agree on two thirds of this table, and the third they + * disagree on is the one CSS authors write `min-width` for. + */ +type Ordering = "left < right" | "left === right" | "left > right"; + +const operands: Record = { + "left < right": [100, 200], + "left === right": [200, 200], + "left > right": [300, 200], +}; + +/** + * Typed as a total `Record`, so an operator added to `MediaFeatureComparison` + * is a compile error here rather than a silently uncovered arm. + */ +const expected: Record> = { + "=": { + "left < right": false, + "left === right": true, + "left > right": false, + }, + ">": { + "left < right": false, + "left === right": false, + "left > right": true, + }, + ">=": { + "left < right": false, + "left === right": true, + "left > right": true, + }, + "<": { + "left < right": true, + "left === right": false, + "left > right": false, + }, + "<=": { + "left < right": true, + "left === right": true, + "left > right": false, + }, +}; + +const operators: MediaFeatureComparison[] = ["=", ">", ">=", "<", "<="]; +const orderings: Ordering[] = [ + "left < right", + "left === right", + "left > right", +]; + +const cases = operators.flatMap((operator) => { + return orderings.map((ordering) => { + const [left, right] = operands[ordering]; + return [ + operator, + ordering, + left, + right, + expected[operator][ordering], + ] as const; + }); +}); + +test("the table covers every operator against every ordering", () => { + expect([...operators].sort()).toStrictEqual(Object.keys(expected).sort()); + expect([...orderings].sort()).toStrictEqual(Object.keys(operands).sort()); + expect(cases).toHaveLength(operators.length * orderings.length); + expect(cases.length).toBeGreaterThan(0); +}); + +test.each(cases)( + "%s with %s: compareMediaFeature(_, %d, %d) === %s", + (operator, _ordering, left, right, result) => { + expect(compareMediaFeature(operator, left, right)).toBe(result); + }, +); diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index 3ea60394..1914850e 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -113,3 +113,71 @@ test("container query width", () => { color: "#00f", }); }); + +describe("size feature comparisons", () => { + /** + * Every case is measured against the same 400x200 container, so the only + * variable is the comparison operator. `min-`/`max-` prefixes are normalised + * by lightningcss into `>=`/`<=` range conditions, which is why they belong + * in this table rather than in one of their own. + */ + const cases: [condition: string, matches: boolean][] = [ + ["width > 300px", true], + ["width > 400px", false], + ["width >= 400px", true], + ["width >= 401px", false], + ["min-width: 400px", true], + ["min-width: 401px", false], + ["width < 500px", true], + ["width < 400px", false], + ["width <= 400px", true], + ["width <= 399px", false], + ["max-width: 400px", true], + ["max-width: 399px", false], + ["width = 400px", true], + ["width = 401px", false], + ]; + + test.each(cases)( + "@container (%s) against a 400px container matches: %s", + (condition, matches) => { + registerCSS(` + .container { + container-type: inline-size; + } + + .child { + color: red; + } + + @container (${condition}) { + .child { + color: blue; + } + } + `); + + render( + + + , + ); + + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); + + fireEvent(parent, "layout", { + nativeEvent: { + layout: { + width: 400, + height: 200, + }, + }, + }); + + expect(child.props.style).toStrictEqual({ + color: matches ? "#00f" : "#f00", + }); + }, + ); +}); diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts new file mode 100644 index 00000000..5c24d6f6 --- /dev/null +++ b/src/native/conditions/compare.ts @@ -0,0 +1,32 @@ +import type { MediaFeatureComparison } from "react-native-css/compiler"; + +/** + * Evaluates a single CSS range comparison. + * + * Media queries and container queries share the `MediaFeatureComparison` + * vocabulary, so they share this one implementation of it: an operator has + * exactly one meaning at runtime, and the two evaluators cannot drift apart. + * A second hand-written copy of the switch is the defect this prevents — the + * arms differ by a single character, so a wrong one reads as correct. + */ +export function compareMediaFeature( + operator: MediaFeatureComparison, + left: number, + right: number, +): boolean { + switch (operator) { + case "=": + return left === right; + case ">": + return left > right; + case ">=": + return left >= right; + case "<": + return left < right; + case "<=": + return left <= right; + default: + operator satisfies never; + return false; + } +} diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index ac546c9a..07282e3b 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 } from "./compare"; import type { RenderGuard } from "./guards"; export const DEFAULT_CONTAINER_NAME = "c:___default___"; @@ -115,19 +116,7 @@ function testContainerMediaCondition( 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; - } + return compareMediaFeature(condition[0], left, right); } default: condition satisfies never; diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 75cd9006..d55fe4f7 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,9 +1,22 @@ /* 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 } from "./compare"; + +/** + * The comparison arm of {@link MediaCondition}, derived from the union rather + * than restated so it cannot drift from the compiler's output. + */ +type MediaComparison = Extract< + MediaCondition, + [MediaFeatureComparison, ...unknown[]] +>; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); @@ -34,7 +47,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]) { @@ -82,18 +95,5 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { 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; - default: - return false; - } + return compareMediaFeature(mediaQuery[0], left, right); } From 16c120ce58bdba9640829b441bcd4e3cc7d17128 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 23:46:53 +0300 Subject: [PATCH 2/8] fix(native): measure a container's height on the height axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `containerHeightFamily` read `layout.width`, so every container reported its width as its height. Three container query features are derived from it and all three were wrong: - `height` / `min-height` / `max-height` answered with the container's width. - `orientation` could never be `landscape`, because it compares width against height and both sides were the same number. - `aspect-ratio` was always 1. The compiler emits `height` and `orientation` faithfully; the measurement was substituted one hop later, when the layout rectangle was projected onto the two axis observables. Tests: a 13-case height table and a 6-case orientation table, both against a 400x200 container so the two axes hold different values and reading the wrong one cannot pass by coincidence — square and portrait containers are included as controls, since those are the shapes under which the defect is invisible. The compiler-plane assertion is differential: identical syntax on the two axes has to compile to two different conditions. --- .../compiler/container-query.test.ts | 12 ++ .../native/container-queries.test.tsx | 138 +++++++++++++----- src/native/reactivity.ts | 2 +- 3 files changed, 114 insertions(+), 38 deletions(-) diff --git a/src/__tests__/compiler/container-query.test.ts b/src/__tests__/compiler/container-query.test.ts index 27849c45..730d5e98 100644 --- a/src/__tests__/compiler/container-query.test.ts +++ b/src/__tests__/compiler/container-query.test.ts @@ -54,6 +54,18 @@ describe("size feature comparisons", () => { }); }); +test("each size axis keeps its own identity", () => { + // Stated differentially: identical syntax on the two axes has to produce two + // different conditions, so neither axis can be answered with the other's + // measurement. + expect(compileContainerQueries("(width > 400px)")).not.toStrictEqual( + compileContainerQueries("(height > 400px)"), + ); + expect(compileContainerQueries("(min-width: 400px)")).not.toStrictEqual( + compileContainerQueries("(min-height: 400px)"), + ); +}); + test("a container query is only attached to rules inside it", () => { const stylesheet = compile(` .child { diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index 1914850e..d68579c6 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -114,7 +114,50 @@ test("container query width", () => { }); }); -describe("size feature comparisons", () => { +/** + * Renders `.child` inside a container laid out at `width` x `height`, and + * reports whether the `@container (condition)` rule won. + * + * `.child` is red outside the query and blue inside it, so the returned colour + * is a direct reading of the condition's verdict. + */ +function containerQueryMatches( + condition: string, + { width, height }: { width: number; height: number }, +): boolean { + registerCSS(` + .container { + container-type: size; + } + + .child { + color: red; + } + + @container (${condition}) { + .child { + color: blue; + } + } + `); + + render( + + + , + ); + + const parent = screen.getByTestId(parentID); + const child = screen.getByTestId(childID); + + fireEvent(parent, "layout", { + nativeEvent: { layout: { width, height } }, + }); + + return child.props.style.color === "#00f"; +} + +describe("width comparisons", () => { /** * Every case is measured against the same 400x200 container, so the only * variable is the comparison operator. `min-`/`max-` prefixes are normalised @@ -139,45 +182,66 @@ describe("size feature comparisons", () => { ]; test.each(cases)( - "@container (%s) against a 400px container matches: %s", + "@container (%s) against a 400x200 container matches: %s", (condition, matches) => { - registerCSS(` - .container { - container-type: inline-size; - } + expect( + containerQueryMatches(condition, { width: 400, height: 200 }), + ).toBe(matches); + }, + ); +}); - .child { - color: red; - } +describe("height comparisons", () => { + /** + * The same 400x200 container. Height is deliberately the smaller of the two + * axes so that a height feature reading the container's width instead is a + * visible failure rather than a coincidence. + */ + const cases: [condition: string, matches: boolean][] = [ + ["height > 100px", true], + ["height > 200px", false], + ["height > 300px", false], + ["height >= 200px", true], + ["min-height: 200px", true], + ["min-height: 201px", false], + ["height < 300px", true], + ["height < 200px", false], + ["height <= 200px", true], + ["max-height: 300px", true], + ["max-height: 199px", false], + ["height = 200px", true], + ["height = 400px", false], + ]; - @container (${condition}) { - .child { - color: blue; - } - } - `); - - render( - - - , - ); - - const parent = screen.getByTestId(parentID); - const child = screen.getByTestId(childID); - - fireEvent(parent, "layout", { - nativeEvent: { - layout: { - width: 400, - height: 200, - }, - }, - }); - - expect(child.props.style).toStrictEqual({ - color: matches ? "#00f" : "#f00", - }); + test.each(cases)( + "@container (%s) against a 400x200 container matches: %s", + (condition, matches) => { + expect( + containerQueryMatches(condition, { width: 400, height: 200 }), + ).toBe(matches); + }, + ); +}); + +describe("orientation", () => { + const cases: [ + condition: string, + size: { width: number; height: number }, + matches: boolean, + ][] = [ + ["orientation: landscape", { width: 400, height: 200 }, true], + ["orientation: portrait", { width: 400, height: 200 }, false], + ["orientation: landscape", { width: 200, height: 400 }, false], + ["orientation: portrait", { width: 200, height: 400 }, true], + // A square container is portrait: `landscape` requires width > height. + ["orientation: landscape", { width: 300, height: 300 }, false], + ["orientation: portrait", { width: 300, height: 300 }, true], + ]; + + test.each(cases)( + "@container (%s) against a %o container matches: %s", + (condition, size, matches) => { + expect(containerQueryMatches(condition, size)).toBe(matches); }, ); }); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 0824edeb..e2d80b06 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -243,6 +243,6 @@ export const containerWidthFamily = weakFamily((key) => { export const containerHeightFamily = weakFamily((key) => { return observable((read) => { - return read(containerLayoutFamily(key))?.width || 0; + return read(containerLayoutFamily(key))?.height || 0; }); }); From bdc2013a0753eb4cc6ba8e179c46aa1eaefcb39e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 23:52:45 +0300 Subject: [PATCH 3/8] fix(compiler): import VAR_SYMBOL for its type only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compiler.types.ts` declares nothing but types, yet imported `VAR_SYMBOL` with a value import. `verbatimModuleSyntax` is on, so the declaration is not elided and the emitted module is one unused side-effectful require of the native runtime: // dist/commonjs/compiler/compiler.types.js "use strict"; var _reactivity = require("../native/reactivity.js"); Evaluating that module registers a `Dimensions` and an `Appearance` listener, neither of which a build-time compiler has any use for. Nothing loads it today — every other reference to `compiler.types` is `import type`, so the emitted file is an orphan rather than a live cost — but the compiler entry is one ordinary re-export away from pulling the whole native runtime into Metro, and nothing in the source says so. `VAR_SYMBOL` is used as a computed property key in a type declaration, which `import type` supports; the emitted declaration file is unchanged. The guard is the invariant, not the line: a scan of every file under `src/compiler/` for a module reference that survives emit — `import type` and `export type` elided, `import { type X }` and `import defer` not, since neither of those is. The detector is unit-tested against all ten declaration shapes, and two vacuity guards fail if the scan stops reaching files or stops recognising imports, so the invariant cannot pass by finding nothing. --- .../compiler/native-runtime-isolation.test.ts | 171 ++++++++++++++++++ src/compiler/compiler.types.ts | 2 +- 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/compiler/native-runtime-isolation.test.ts diff --git a/src/__tests__/compiler/native-runtime-isolation.test.ts b/src/__tests__/compiler/native-runtime-isolation.test.ts new file mode 100644 index 00000000..2b130029 --- /dev/null +++ b/src/__tests__/compiler/native-runtime-isolation.test.ts @@ -0,0 +1,171 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, posix, relative, resolve, sep } from "node:path"; + +import ts from "typescript"; + +/** + * The compiler runs at build time, inside Metro and inside this test suite. The + * native runtime is a different plane: importing it evaluates `reactivity.ts`, + * which registers a `Dimensions` and an `Appearance` listener at module scope. + * + * `verbatimModuleSyntax` is on, so only an `import type` / `export type` + * declaration is elided. Any other form emits a `require`, even when every + * specifier inside it is marked `type` and even when nothing is used — which + * is what makes this class of mistake invisible in the source and visible only + * in `dist`. + */ +const SOURCE_ROOT = resolve(__dirname, "..", ".."); +const COMPILER_ROOT = join(SOURCE_ROOT, "compiler"); +const RUNTIME_PLANES = ["native", "native-internal"]; + +interface RuntimeImport { + /** Source file, relative to `src/` and POSIX separated. */ + from: string; + /** The module specifier as written. */ + specifier: string; +} + +function toPosix(path: string): string { + return path.split(sep).join(posix.sep); +} + +/** + * Resolves a module specifier to a path relative to `src/`, or `undefined` for + * an external package. `react-native-css/*` maps onto `src/*` — the alias the + * root tsconfig declares and the one the source uses to cross plane + * boundaries. + */ +function resolveWithinSource( + specifier: string, + fromFile: string, +): string | undefined { + if (specifier.startsWith(".")) { + return toPosix(relative(SOURCE_ROOT, resolve(fromFile, "..", specifier))); + } + + if (specifier === "react-native-css") { + return "index"; + } + + if (specifier.startsWith("react-native-css/")) { + return specifier.slice("react-native-css/".length); + } + + return undefined; +} + +/** + * Every module specifier a file imports for its runtime value, i.e. every one + * that survives into the emitted JavaScript. + */ +function findEmittedSpecifiers(sourceText: string, fileName: string): string[] { + const sourceFile = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + ); + + const specifiers: string[] = []; + + for (const statement of sourceFile.statements) { + let moduleSpecifier: ts.Expression | undefined; + + if (ts.isImportDeclaration(statement)) { + // `type` is the only phase that elides the module reference. `defer` + // still evaluates it, just later. + if (statement.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword) { + continue; + } + moduleSpecifier = statement.moduleSpecifier; + } else if (ts.isExportDeclaration(statement)) { + if (statement.isTypeOnly) { + continue; + } + moduleSpecifier = statement.moduleSpecifier; + } + + if (moduleSpecifier && ts.isStringLiteral(moduleSpecifier)) { + specifiers.push(moduleSpecifier.text); + } + } + + return specifiers; +} + +function listSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + + if (entry.isDirectory()) { + return listSourceFiles(path); + } + + return /\.tsx?$/.test(entry.name) ? [path] : []; + }); +} + +function findRuntimeImports(files: string[]): RuntimeImport[] { + return files.flatMap((file) => { + return findEmittedSpecifiers(readFileSync(file, "utf8"), file).flatMap( + (specifier): RuntimeImport[] => { + const target = resolveWithinSource(specifier, file); + + const crossesPlanes = RUNTIME_PLANES.some((plane) => { + return target === plane || target?.startsWith(`${plane}/`); + }); + + return crossesPlanes + ? [{ from: toPosix(relative(SOURCE_ROOT, file)), specifier }] + : []; + }, + ); + }); +} + +describe("the emitted-specifier detector", () => { + const cases: [description: string, source: string, emitted: string[]][] = [ + ["a type-only import", `import type { A } from "./a";`, []], + ["a type-only namespace import", `import type * as A from "./a";`, []], + ["a type-only re-export", `export type { A } from "./a";`, []], + ["a type-only star re-export", `export type * from "./a";`, []], + ["a value import", `import { a } from "./a";`, ["./a"]], + ["a default import", `import a from "./a";`, ["./a"]], + ["a side-effect import", `import "./a";`, ["./a"]], + ["a value re-export", `export * from "./a";`, ["./a"]], + // verbatimModuleSyntax keeps the declaration, so the module is still + // evaluated. This is exactly the shape the invariant below exists for. + ["inline type specifiers", `import { type A } from "./a";`, ["./a"]], + ["a local export", `export const a = 1;`, []], + ]; + + test.each(cases)("%s emits %j", (_description, source, emitted) => { + expect(findEmittedSpecifiers(source, "probe.ts")).toStrictEqual(emitted); + }); +}); + +describe("compiler sources", () => { + const files = listSourceFiles(COMPILER_ROOT); + + test("the scan reaches the whole compiler directory", () => { + const scanned = files.map((file) => toPosix(relative(SOURCE_ROOT, file))); + + expect(scanned).toContain("compiler/compiler.types.ts"); + expect(scanned).toContain("compiler/compiler.ts"); + expect(scanned).toContain("compiler/index.ts"); + expect(scanned.length).toBeGreaterThan(10); + }); + + test("the scan sees the imports the compiler really has", () => { + const specifiers = files.flatMap((file) => { + return findEmittedSpecifiers(readFileSync(file, "utf8"), file); + }); + + expect(specifiers).toContain("./atRules"); + expect(specifiers).toContain("lightningcss"); + }); + + test("no compiler source imports the native runtime for its value", () => { + expect(findRuntimeImports(files)).toStrictEqual([]); + }); +}); diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..417daa7f 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -5,7 +5,7 @@ import type { TokenOrValue, } from "lightningcss"; -import { VAR_SYMBOL } from "../native/reactivity"; +import type { VAR_SYMBOL } from "../native/reactivity"; export interface CompilerOptions { filename?: string; From 7be5cf4a7bb54bb56d9a06312600e8535f8b5815 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 01:51:55 +0300 Subject: [PATCH 4/8] fix(compiler): drop a conditional block whose condition does not compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A condition the compiler could not compile was discarded and the block was emitted anyway, with no condition on it — so its declarations applied to every element carrying the class, at every size. That is worse than the block being a no-op: the rule fires where the author said it must not. @container style(--foo: bar) cq: [{ m: undefined }] @container (width > env(safe-area-inset-top)) cq: [{ m: undefined }] @media (width > env(safe-area-inset-top)) no `m` at all `@container` reached it one way and `@media` the other. `extractContainer` built `{ m: parseContainerCondition(...) }` and never looked at the `undefined`; `parseMediaQuery` returned early without adding anything, which `extractMedia` could not tell apart from `@media all` — the case where there is genuinely no condition and the block really does always apply. Both extractors then walked the block's rules regardless. So the fault is one missing distinction, not two bugs: "there is no condition" and "the condition did not compile" were the same value. `CompiledCondition` separates them into `always` / `never` / `condition`, and the container half is `Exclude`d from it rather than restated, because a container prelude is always a condition. `parseMediaQuery` now returns that verdict instead of mutating the builder, which is what lets `extractMedia` aggregate over a comma-separated list before deciding: the list is a union, so one uncompilable branch still contributes nothing while the others apply, and only a list where no branch can match drops the block. `extractMedia` already dropped a block no query could match — that is what the `@media print` filter does — so this extends an existing decision rather than adding a new one. Behaviour change, deliberately: styles under an uncompilable condition used to apply everywhere and now apply nowhere. Nowhere is what the runtime already does with an unsupported feature it can see — `getContainerFeatureValue` returns `undefined` and the comparison is false — so the two planes now agree. `@media not print and (width > 400px)` is the one case that must stay unconditional: it reads `not (print and ...)`, which is true on every non-print device. It is in the table, and inverting it is one of the mutations below. Tests: a compiler-plane table of four uncompilable preludes across both at-rules asserting nothing is emitted, against six controls asserting that a compilable one still is — without the controls a compiler that emitted nothing at all would pass. Runtime tables on both at-rules confirm the styles do not apply. Each uncompilable case is also a vacuity guard on the others: if support for `env()` or `style()` lands, that row starts emitting and fails, which is the signal to move the case rather than delete it. Mutation-proved. Deleting the container guard fails exactly the four container cases; deleting the media guard fails exactly the four media cases; returning `never` for print fails `@media not print`; returning `never` for the no-condition case fails `@media all` and `@media screen`. The native container-query helper now takes the condition in full, parentheses included. It used to add them, which made `style(--foo: bar)` and a leading container name inexpressible — both are `` forms — and it disagreed with the compiler-plane helper, which already took the full prelude. --- .../compiler/conditional-group-rules.test.ts | 78 +++++++++++++ .../native/container-queries.test.tsx | 103 +++++++++++------- src/__tests__/native/media-query.test.tsx | 27 +++++ src/compiler/compiled-condition.ts | 30 +++++ src/compiler/compiler.ts | 27 ++++- src/compiler/container-query.ts | 15 ++- src/compiler/media-query.ts | 20 ++-- 7 files changed, 246 insertions(+), 54 deletions(-) create mode 100644 src/__tests__/compiler/conditional-group-rules.test.ts create mode 100644 src/compiler/compiled-condition.ts diff --git a/src/__tests__/compiler/conditional-group-rules.test.ts b/src/__tests__/compiler/conditional-group-rules.test.ts new file mode 100644 index 00000000..cb4b04ca --- /dev/null +++ b/src/__tests__/compiler/conditional-group-rules.test.ts @@ -0,0 +1,78 @@ +import { compile, type StyleRule } from "react-native-css/compiler"; + +/** + * Returns every rule the compiler emitted for `.child`. + * + * A conditional group rule (`@media`, `@container`) contributes its inner + * rules to this list; if the block is skipped the list is empty. + */ +function compileChildRules(css: string): StyleRule[] { + const stylesheet = compile(css).stylesheet(); + + return ( + stylesheet.s?.flatMap(([className, ruleSet]) => { + return className === "child" ? ruleSet : []; + }) ?? [] + ); +} + +/** + * Conditions this compiler cannot evaluate, one per reason it cannot. + * + * A block guarded by one of these can never be shown to match, so it must not + * be emitted. Each case is also a vacuity guard on the case above it: if + * support for one of these lands, its `m`/`cq` stops being absent and the test + * fails, which is the signal to move the case rather than delete it. + */ +const uncompilable: [label: string, css: string][] = [ + [ + "a container style() query", + "@container style(--foo: bar) { .child { color: red } }", + ], + [ + "a container feature value the compiler cannot resolve", + "@container (width > env(safe-area-inset-top)) { .child { color: red } }", + ], + [ + "a media feature value the compiler cannot resolve", + "@media (width > env(safe-area-inset-top)) { .child { color: red } }", + ], + [ + "a negated media condition the compiler cannot resolve", + "@media not (width > env(safe-area-inset-top)) { .child { color: red } }", + ], +]; + +describe("a block whose condition does not compile is not emitted", () => { + test.each(uncompilable)("%s", (_label, css) => { + // Emitting the rule with no condition is worse than emitting nothing: the + // declarations then apply to every element that carries the class, which + // is the opposite of what the author wrote. + expect(compileChildRules(css)).toStrictEqual([]); + }); +}); + +describe("a block whose condition does compile is emitted", () => { + /** + * The control for the table above — without it, a compiler that emitted + * nothing at all would pass every case there. + */ + const cases: [label: string, css: string][] = [ + ["@container", "@container (width > 400px) { .child { color: red } }"], + ["@media", "@media (width > 400px) { .child { color: red } }"], + ["@media all", "@media all { .child { color: red } }"], + ["@media screen", "@media screen { .child { color: red } }"], + [ + "@media not print", + "@media not print and (width > 400px) { .child { color: red } }", + ], + [ + "a media query list with one uncompilable branch", + "@media (width > env(safe-area-inset-top)), (width > 400px) { .child { color: red } }", + ], + ]; + + test.each(cases)("%s", (_label, css) => { + expect(compileChildRules(css)).toHaveLength(1); + }); +}); diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index d68579c6..fe7cb223 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -116,10 +116,15 @@ test("container query width", () => { /** * Renders `.child` inside a container laid out at `width` x `height`, and - * reports whether the `@container (condition)` rule won. + * reports whether the `@container ` rule won. * * `.child` is red outside the query and blue inside it, so the returned colour * is a direct reading of the condition's verdict. + * + * The condition is written out in full, parentheses included, because a + * parenthesised size query is only one of the forms `` + * accepts — `style(--foo: bar)` and a leading container name are not + * expressible by a helper that adds the parentheses itself. */ function containerQueryMatches( condition: string, @@ -134,7 +139,7 @@ function containerQueryMatches( color: red; } - @container (${condition}) { + @container ${condition} { .child { color: blue; } @@ -165,24 +170,24 @@ describe("width comparisons", () => { * in this table rather than in one of their own. */ const cases: [condition: string, matches: boolean][] = [ - ["width > 300px", true], - ["width > 400px", false], - ["width >= 400px", true], - ["width >= 401px", false], - ["min-width: 400px", true], - ["min-width: 401px", false], - ["width < 500px", true], - ["width < 400px", false], - ["width <= 400px", true], - ["width <= 399px", false], - ["max-width: 400px", true], - ["max-width: 399px", false], - ["width = 400px", true], - ["width = 401px", false], + ["(width > 300px)", true], + ["(width > 400px)", false], + ["(width >= 400px)", true], + ["(width >= 401px)", false], + ["(min-width: 400px)", true], + ["(min-width: 401px)", false], + ["(width < 500px)", true], + ["(width < 400px)", false], + ["(width <= 400px)", true], + ["(width <= 399px)", false], + ["(max-width: 400px)", true], + ["(max-width: 399px)", false], + ["(width = 400px)", true], + ["(width = 401px)", false], ]; test.each(cases)( - "@container (%s) against a 400x200 container matches: %s", + "@container %s against a 400x200 container matches: %s", (condition, matches) => { expect( containerQueryMatches(condition, { width: 400, height: 200 }), @@ -198,23 +203,23 @@ describe("height comparisons", () => { * visible failure rather than a coincidence. */ const cases: [condition: string, matches: boolean][] = [ - ["height > 100px", true], - ["height > 200px", false], - ["height > 300px", false], - ["height >= 200px", true], - ["min-height: 200px", true], - ["min-height: 201px", false], - ["height < 300px", true], - ["height < 200px", false], - ["height <= 200px", true], - ["max-height: 300px", true], - ["max-height: 199px", false], - ["height = 200px", true], - ["height = 400px", false], + ["(height > 100px)", true], + ["(height > 200px)", false], + ["(height > 300px)", false], + ["(height >= 200px)", true], + ["(min-height: 200px)", true], + ["(min-height: 201px)", false], + ["(height < 300px)", true], + ["(height < 200px)", false], + ["(height <= 200px)", true], + ["(max-height: 300px)", true], + ["(max-height: 199px)", false], + ["(height = 200px)", true], + ["(height = 400px)", false], ]; test.each(cases)( - "@container (%s) against a 400x200 container matches: %s", + "@container %s against a 400x200 container matches: %s", (condition, matches) => { expect( containerQueryMatches(condition, { width: 400, height: 200 }), @@ -223,23 +228,45 @@ describe("height comparisons", () => { ); }); +describe("a condition the compiler cannot evaluate", () => { + /** + * A `@container` block the compiler cannot compile a condition for must not + * reach the runtime at all. The failure mode this pins is not a missed match + * but the reverse: a block emitted with no condition applies to every child + * that carries the class, at every container size. + */ + const cases: [label: string, condition: string][] = [ + ["style()", "style(--foo: bar)"], + ["an unresolvable feature value", "(width > env(safe-area-inset-top))"], + ]; + + test.each(cases)("@container %s never matches", (_label, condition) => { + expect(containerQueryMatches(condition, { width: 400, height: 200 })).toBe( + false, + ); + expect(containerQueryMatches(condition, { width: 200, height: 400 })).toBe( + false, + ); + }); +}); + describe("orientation", () => { const cases: [ condition: string, size: { width: number; height: number }, matches: boolean, ][] = [ - ["orientation: landscape", { width: 400, height: 200 }, true], - ["orientation: portrait", { width: 400, height: 200 }, false], - ["orientation: landscape", { width: 200, height: 400 }, false], - ["orientation: portrait", { width: 200, height: 400 }, true], + ["(orientation: landscape)", { width: 400, height: 200 }, true], + ["(orientation: portrait)", { width: 400, height: 200 }, false], + ["(orientation: landscape)", { width: 200, height: 400 }, false], + ["(orientation: portrait)", { width: 200, height: 400 }, true], // A square container is portrait: `landscape` requires width > height. - ["orientation: landscape", { width: 300, height: 300 }, false], - ["orientation: portrait", { width: 300, height: 300 }, true], + ["(orientation: landscape)", { width: 300, height: 300 }, false], + ["(orientation: portrait)", { width: 300, height: 300 }, true], ]; test.each(cases)( - "@container (%s) against a %o container matches: %s", + "@container %s against a %o container matches: %s", (condition, size, matches) => { expect(containerQueryMatches(condition, size)).toBe(matches); }, diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 020b4aad..2556f976 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -200,6 +200,33 @@ test("not all", () => { }); }); +describe("a condition the compiler cannot evaluate", () => { + /** + * A `@media` block the compiler cannot compile a condition for must not + * reach the runtime at all. The failure mode this pins is not a missed match + * but the reverse: a block emitted with no condition applies to every + * element that carries the class, at every viewport size. + */ + const cases: [label: string, prelude: string][] = [ + ["an unresolvable feature value", "(width > env(safe-area-inset-top))"], + [ + "a negated unresolvable feature value", + "not (width > env(safe-area-inset-top))", + ], + ]; + + test.each(cases)("@media %s never matches", (_label, prelude) => { + registerCSS(` +@media ${prelude} { + .my-class { color: red; } +}`); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(undefined); + }); +}); + describe("resolution", () => { test("dppx", () => { registerCSS(` diff --git a/src/compiler/compiled-condition.ts b/src/compiler/compiled-condition.ts new file mode 100644 index 00000000..116c9c6c --- /dev/null +++ b/src/compiler/compiled-condition.ts @@ -0,0 +1,30 @@ +import type { MediaCondition } from "./compiler.types"; + +/** + * The result of compiling a conditional group rule's condition — the prelude + * of an `@media` or `@container` block. + * + * The three states exist because two of them are otherwise indistinguishable, + * and confusing them inverts the rule. "There is no condition to check" + * (`@media all`) and "the condition could not be compiled" (`@container + * style(...)`, a feature value this compiler cannot resolve) both yield no + * `MediaCondition`, but the first means the block always applies and the + * second means it can never be shown to apply. Treating the second as the + * first emits the block's declarations with no condition at all, so they apply + * to every element carrying the class — the opposite of what the author wrote, + * and worse than dropping the block. + */ +export type CompiledCondition = + | { type: "always" } + | { type: "never" } + | { type: "condition"; condition: MediaCondition }; + +/** + * A container query's prelude is always a condition, so unlike `@media` it has + * no "always" state. Derived rather than restated, so a new state has to be + * ruled out here deliberately. + */ +export type CompiledContainerCondition = Exclude< + CompiledCondition, + { type: "always" } +>; diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 214cd615..aad5782d 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -364,8 +364,20 @@ function extractMedia( return; } - for (const m of media) { - parseMediaQuery(m, builder); + const compiled = media.map((m) => parseMediaQuery(m, builder)); + + // A comma-separated media query list is a union, so a branch that cannot + // match contributes nothing while the others still apply. When no branch can + // match, neither can the block, and its rules must not be emitted at all — + // emitting them with no media query applies them everywhere instead. + if (compiled.every(({ type }) => type === "never")) { + return; + } + + for (const query of compiled) { + if (query.type === "condition") { + builder.addMediaQuery(query.condition); + } } // Iterate over all rules in the mediaRule and extract their styles using the updated CompilerCollection @@ -386,9 +398,18 @@ function extractContainer( ) { builder = builder.fork("container"); + const compiled = parseContainerCondition(containerRule.condition, builder); + + // A condition that did not compile cannot be shown to match, so the block's + // rules must not be emitted at all — emitting them with no condition applies + // them inside every container instead. + if (compiled.type === "never") { + return; + } + // Iterate over all rules inside the containerRule and extract their styles using the updated CompilerCollection const query: ContainerQuery = { - m: parseContainerCondition(containerRule.condition, builder), + m: compiled.condition, }; if (containerRule.name) { diff --git a/src/compiler/container-query.ts b/src/compiler/container-query.ts index 32c25861..08c17da7 100644 --- a/src/compiler/container-query.ts +++ b/src/compiler/container-query.ts @@ -4,6 +4,7 @@ import type { QueryFeatureFor_ContainerSizeFeatureId, } from "lightningcss"; +import type { CompiledContainerCondition } from "./compiled-condition"; import type { MediaCondition } from "./compiler.types"; import { parseMediaFeatureOperator, @@ -14,15 +15,17 @@ import type { StylesheetBuilder } from "./stylesheet"; export function parseContainerCondition( condition: CSSContainerCondition, builder: StylesheetBuilder, -) { - let containerQuery = parseContainerQueryCondition(condition, builder); +): CompiledContainerCondition { + const containerQuery = parseContainerQueryCondition(condition, builder); - // If any of these are undefined, the media query is invalid + // If any of these are undefined, the container query is invalid. An invalid + // query cannot be shown to match, so it matches nothing — it does not become + // a query with no condition. if (!containerQuery || containerQuery.some((v) => v === undefined)) { - return; + return { type: "never" }; } - return containerQuery; + return { type: "condition", condition: containerQuery }; } function parseContainerQueryCondition( @@ -34,7 +37,7 @@ function parseContainerQueryCondition( return parseFeature(condition.value, builder); case "not": const query = parseContainerCondition(condition.value, builder); - return query ? ["!", query] : undefined; + return query.type === "condition" ? ["!", query.condition] : undefined; case "operation": const conditions = condition.conditions .map((c) => parseContainerQueryCondition(c, builder)) diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index c8733c12..c56e090f 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -8,6 +8,7 @@ import type { QueryFeatureFor_MediaFeatureId, } from "lightningcss"; +import type { CompiledCondition } from "./compiled-condition"; import type { MediaCondition, MediaFeatureComparison, @@ -19,14 +20,17 @@ import type { StylesheetBuilder } from "./stylesheet"; export function parseMediaQuery( query: CSSMediaQuery, builder: StylesheetBuilder, -) { +): CompiledCondition { let platformCondition: MediaCondition | undefined; let condition: MediaCondition | undefined; if (query.mediaType) { - // Print is for printing documents + // Print is for printing documents. A bare `@media print` is dropped before + // it reaches here, so what arrives is `@media not print ...` — which reads + // `not (print and ...)` and is therefore true on every non-print device, + // whatever the rest of the query says. if (query.mediaType === "print") { - return; + return { type: "always" }; } // These all/screen are not conditions, they always apply @@ -38,9 +42,11 @@ export function parseMediaQuery( if (query.condition) { condition = parseMediaQueryCondition(query.condition, builder); - // If any of these are undefined, the media query is invalid + // If any of these are undefined, the media query is invalid. An invalid + // query cannot be shown to match, so it matches nothing — it does not + // become a query with no condition. if (!condition || condition.some((v) => v === undefined)) { - return; + return { type: "never" }; } } @@ -50,14 +56,14 @@ export function parseMediaQuery( : platformCondition || condition; if (!mediaQuery) { - return; + return { type: "always" }; } if (query.qualifier === "not") { mediaQuery = ["!", mediaQuery]; } - builder.addMediaQuery(mediaQuery); + return { type: "condition", condition: mediaQuery }; } function parseMediaQueryCondition( From 85faf221e7c43dd819ba0806d34262b6eb4429b6 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 01:56:26 +0300 Subject: [PATCH 5/8] feat: evaluate aspect-ratio queries instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseMediaFeatureValue` had no `ratio` arm, so every `` compiled to `undefined` and took its whole condition with it. `@container (aspect-ratio > 1)` and `@media (min-aspect-ratio: 16/9)` reached the runtime as nothing at all. The runtime was ready for the container half and had been all along: `getContainerFeatureValue` already answers `aspect-ratio` with the container's width over its height. No IR ever named the feature, so that arm could not run — the compiler is where the support was missing, not the evaluator. A `` is a pair of numbers standing for their quotient, which is the same number both runtimes derive from their two axes, so the pair is compiled to the quotient and every comparison operator then works on it unchanged. A bare number is a ratio too, so `1` arrives as `[1, 1]`. The media evaluator needed the feature itself, which it did not have: viewport aspect ratio is `vw / vh`, read off the two observables it already uses for `width` and `height`, and it joins them in the same numeric-feature switch. Tests: compiler tables on both at-rules pinning the emitted condition, with `min-`/`max-` prefixed forms included because lightningcss normalises those into `>=`/`<=` range conditions the same way it does for lengths. Runtime tables measure against containers and viewports whose ratio is exactly 2, 0.5 and 1, so a value read off the wrong axis cannot pass by coincidence. Mutation-proved. Removing the `ratio` arm fails all 17 cases across both planes and both at-rules; removing the media evaluator's `aspect-ratio` arm fails only the 3 media runtime cases, the compiler ones staying green; inverting the container evaluator to `height / width` fails 7 of the 11 container cases, including two that flip from false to true. --- .../compiler/container-query.test.ts | 8 ++++ src/__tests__/compiler/media-query.test.ts | 33 ++++++++++++++++ .../native/container-queries.test.tsx | 32 +++++++++++++++ src/__tests__/native/media-query.test.tsx | 39 +++++++++++++++++++ src/compiler/media-query.ts | 4 ++ src/native/conditions/media-query.ts | 3 ++ 6 files changed, 119 insertions(+) diff --git a/src/__tests__/compiler/container-query.test.ts b/src/__tests__/compiler/container-query.test.ts index 730d5e98..4a035ef3 100644 --- a/src/__tests__/compiler/container-query.test.ts +++ b/src/__tests__/compiler/container-query.test.ts @@ -43,6 +43,14 @@ describe("size feature comparisons", () => { ["(max-height: 400px)", { m: ["<=", "height", 400] }], ["(orientation: landscape)", { m: ["=", "orientation", "landscape"] }], ["(orientation: portrait)", { m: ["=", "orientation", "portrait"] }], + // A `` is carried to the runtime as the number it denotes, which is + // what the runtime derives from the container's two axes. A bare number is + // a ratio too — `1` is `1/1`. + ["(aspect-ratio > 1)", { m: [">", "aspect-ratio", 1] }], + ["(aspect-ratio: 2/1)", { m: ["=", "aspect-ratio", 2] }], + ["(aspect-ratio >= 4/3)", { m: [">=", "aspect-ratio", 4 / 3] }], + ["(min-aspect-ratio: 16/9)", { m: [">=", "aspect-ratio", 16 / 9] }], + ["(max-aspect-ratio: 16/9)", { m: ["<=", "aspect-ratio", 16 / 9] }], [ "my-container (min-width: 400px)", { m: [">=", "width", 400], n: "c:my-container" }, diff --git a/src/__tests__/compiler/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index 760ede29..fe002327 100644 --- a/src/__tests__/compiler/media-query.test.ts +++ b/src/__tests__/compiler/media-query.test.ts @@ -85,3 +85,36 @@ test("@media (hover: hover)", () => { ], }); }); + +describe("aspect-ratio", () => { + /** + * Returns the media conditions the compiler attached to `.my-class`. + */ + function compileMediaConditions(prelude: string): unknown { + const stylesheet = compile(` + @media ${prelude} { + .my-class { color: red; } + } + `).stylesheet(); + + return stylesheet.s?.flatMap(([className, ruleSet]) => { + return className === "my-class" ? ruleSet.map((rule) => rule.m) : []; + }); + } + + /** + * `` is a media feature value like any other, so the same parse + * serves `@media` and `@container`. A bare number is a ratio too — `1` is + * `1/1`. + */ + const cases: [prelude: string, conditions: unknown][] = [ + ["(aspect-ratio > 1)", [[[">", "aspect-ratio", 1]]]], + ["(aspect-ratio: 2/1)", [[["=", "aspect-ratio", 2]]]], + ["(min-aspect-ratio: 16/9)", [[[">=", "aspect-ratio", 16 / 9]]]], + ["(max-aspect-ratio: 16/9)", [[["<=", "aspect-ratio", 16 / 9]]]], + ]; + + test.each(cases)("@media %s", (prelude, conditions) => { + expect(compileMediaConditions(prelude)).toStrictEqual(conditions); + }); +}); diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index fe7cb223..642d81a2 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -228,6 +228,38 @@ describe("height comparisons", () => { ); }); +describe("aspect ratio", () => { + /** + * A container's aspect ratio is its width over its height, so every case + * names the container it is measured against — the 400x200 landscape one is + * exactly 2, the 200x400 portrait one exactly 0.5, and 300x300 exactly 1. + */ + const cases: [ + condition: string, + size: { width: number; height: number }, + matches: boolean, + ][] = [ + ["(aspect-ratio > 1)", { width: 400, height: 200 }, true], + ["(aspect-ratio > 1)", { width: 200, height: 400 }, false], + ["(aspect-ratio > 1)", { width: 300, height: 300 }, false], + ["(aspect-ratio < 1)", { width: 200, height: 400 }, true], + ["(aspect-ratio < 1)", { width: 400, height: 200 }, false], + ["(aspect-ratio: 2/1)", { width: 400, height: 200 }, true], + ["(aspect-ratio: 2/1)", { width: 300, height: 300 }, false], + ["(min-aspect-ratio: 2/1)", { width: 400, height: 200 }, true], + ["(min-aspect-ratio: 2/1)", { width: 399, height: 200 }, false], + ["(max-aspect-ratio: 2/1)", { width: 400, height: 200 }, true], + ["(max-aspect-ratio: 2/1)", { width: 401, height: 200 }, false], + ]; + + test.each(cases)( + "@container %s against a %o container matches: %s", + (condition, size, matches) => { + expect(containerQueryMatches(condition, size)).toBe(matches); + }, + ); +}); + describe("a condition the compiler cannot evaluate", () => { /** * A `@container` block the compiler cannot compile a condition for must not diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 2556f976..f0bce445 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -200,6 +200,45 @@ test("not all", () => { }); }); +describe("aspect-ratio", () => { + /** + * The viewport's aspect ratio is its width over its height, measured off the + * same two observables `width` and `height` already read. + */ + const cases: [ + prelude: string, + size: { width: number; height: number }, + matches: boolean, + ][] = [ + ["(aspect-ratio > 1)", { width: 400, height: 200 }, true], + ["(aspect-ratio > 1)", { width: 200, height: 400 }, false], + ["(aspect-ratio: 2/1)", { width: 400, height: 200 }, true], + ["(aspect-ratio: 2/1)", { width: 300, height: 300 }, false], + ["(min-aspect-ratio: 2/1)", { width: 400, height: 200 }, true], + ["(min-aspect-ratio: 2/1)", { width: 399, height: 200 }, false], + ]; + + test.each(cases)( + "@media %s against a %o viewport matches: %s", + (prelude, size, matches) => { + registerCSS(` +@media ${prelude} { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), ...size }); + }); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual( + matches ? { color: "#f00" } : undefined, + ); + }, + ); +}); + describe("a condition the compiler cannot evaluate", () => { /** * A `@media` block the compiler cannot compile a condition for must not diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index c56e090f..4d086f34 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -170,6 +170,10 @@ export function parseMediaFeatureValue( return undefined; } case "ratio": + // A `` is a pair of numbers standing for their quotient, and the + // quotient is what both runtimes derive from their two axes. A bare + // number parses as a ratio too, so `1` arrives here as `[1, 1]`. + return value.value[0] / value.value[1]; case "env": } diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index d55fe4f7..47e648a3 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -88,6 +88,9 @@ function testComparison(mediaQuery: MediaComparison, get: Getter): Boolean { case "height": left = get(vh); break; + case "aspect-ratio": + left = get(vw) / get(vh); + break; case "resolution": left = PixelRatio.get(); break; From 0476f6f2943b6540edf7f1a4c380e72366e6c6cf Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:04:15 +0300 Subject: [PATCH 6/8] feat(native): evaluate interval (range pair) conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@container (400px < width < 800px)` matched nothing, at any size. Both evaluators carried `case "[]": return false`, so every range pair was a no-op on both at-rules. The compiler was never at fault. It emits the pair faithfully, in source order — `["[]", "width", 400, "<", 800, "<"]` — including the descending form `(800px > width > 400px)`. Only the evaluation was missing, which is why the compiler-plane tests here are pins rather than fixes. An interval is two comparisons, and the shared `compareMediaFeature` already had the operator semantics, so the addition is which operand goes on which side: the start bound is on the left of its operator and the measured value on the right, the end bound the other way round. Getting that backwards produces a well-formed interval that means something else, so both evaluators call one `testMediaFeatureInterval` rather than each writing the destructuring out. `MediaInterval` is `Extract`ed from `MediaCondition`, matching how the comparison arm is derived. The media evaluator had no way to answer a feature outside a comparison tuple — its numeric features were resolved by a `let left` switch inside `testComparison` — so that resolution is now `getMediaFeatureValue`, which the comparison arm and the interval arm share. `testComparison` reads better for it: the value it compares is fetched, not accumulated through `break`s. Two `Boolean` return annotations became `boolean` on the way past. They are the boxed object type, and both are on functions this change restructures. Tests: a nine-case table over the primitive varying which side of each bound the value falls on, with strict and non-strict operators paired so the two are never interchangeable, plus four unanswerable cases — an unmeasurable feature and an unresolved bound are no answer, not no bound. Runtime tables on both at-rules against a 600x200 box, with the measured value placed exactly on each bound open and closed. The compiler-plane table pins the tuple layout the evaluation depends on. Mutation-proved. Restoring `return false` in the container evaluator fails 5 cases and in the media evaluator 4, disjointly; assembling the two halves the other way round fails 13 across both planes, including four of the primitive's own cases; swapping start and end in the compiler's interval emit fails the pins and the container runtime together. This also dissolves the last identical-bodied switch arms under `src/native/conditions`: an AST sweep for clauses with textually identical bodies now reports none there. --- .../compiler/container-query.test.ts | 21 +++++ src/__tests__/native/compare.test.ts | 77 ++++++++++++++++++- .../native/container-queries.test.tsx | 34 ++++++++ src/__tests__/native/media-query.test.tsx | 36 +++++++++ src/native/conditions/compare.ts | 43 ++++++++++- src/native/conditions/container-query.ts | 7 +- src/native/conditions/media-query.ts | 56 +++++++++----- 7 files changed, 251 insertions(+), 23 deletions(-) diff --git a/src/__tests__/compiler/container-query.test.ts b/src/__tests__/compiler/container-query.test.ts index 4a035ef3..69281e90 100644 --- a/src/__tests__/compiler/container-query.test.ts +++ b/src/__tests__/compiler/container-query.test.ts @@ -96,3 +96,24 @@ test("a container query is only attached to rules inside it", () => { [{ m: [">=", "width", 400] }], ]); }); + +describe("interval (range pair) conditions", () => { + /** + * The emitted tuple is `["[]", name, start, startOperator, end, + * endOperator]`, and it reads in CSS source order: `start startOperator + * name endOperator end`. The runtime evaluates it in that order, so the + * two operators are pinned separately from the two bounds — swapping either + * pair reads as a valid interval and means something else. + */ + const cases: [condition: string, query: ContainerQuery][] = [ + ["(400px < width < 800px)", { m: ["[]", "width", 400, "<", 800, "<"] }], + ["(400px <= width <= 800px)", { m: ["[]", "width", 400, "<=", 800, "<="] }], + ["(800px > width > 400px)", { m: ["[]", "width", 800, ">", 400, ">"] }], + ["(400px < height < 800px)", { m: ["[]", "height", 400, "<", 800, "<"] }], + ["(400px <= width < 800px)", { m: ["[]", "width", 400, "<=", 800, "<"] }], + ]; + + test.each(cases)("@container %s", (condition, query) => { + expect(compileContainerQueries(condition)).toStrictEqual([query]); + }); +}); diff --git a/src/__tests__/native/compare.test.ts b/src/__tests__/native/compare.test.ts index b21952dd..541b39a2 100644 --- a/src/__tests__/native/compare.test.ts +++ b/src/__tests__/native/compare.test.ts @@ -1,6 +1,13 @@ -import type { MediaFeatureComparison } from "react-native-css/compiler"; +import type { + MediaFeatureComparison, + StyleDescriptor, +} from "react-native-css/compiler"; -import { compareMediaFeature } from "../../native/conditions/compare"; +import { + compareMediaFeature, + testMediaFeatureInterval, + type MediaInterval, +} from "../../native/conditions/compare"; /** * The full cross product of every comparison operator against every ordering @@ -81,3 +88,69 @@ test.each(cases)( expect(compareMediaFeature(operator, left, right)).toBe(result); }, ); + +describe("testMediaFeatureInterval", () => { + /** + * The two halves of an interval are asymmetric — the start bound is compared + * against the measured value and the value against the end bound — so a + * table that only varies the value cannot tell a correct implementation from + * one that assembled the halves the other way round. These cases vary which + * side of each bound the value falls on, and pair a strict operator with a + * non-strict one so the two are never interchangeable. + */ + const cases: [ + label: string, + condition: MediaInterval, + value: number, + matches: boolean, + ][] = [ + ["inside", ["[]", "width", 400, "<", 800, "<"], 600, true], + ["below the start bound", ["[]", "width", 400, "<", 800, "<"], 300, false], + ["above the end bound", ["[]", "width", 400, "<", 800, "<"], 900, false], + ["on an open start bound", ["[]", "width", 400, "<", 800, "<"], 400, false], + [ + "on a closed start bound", + ["[]", "width", 400, "<=", 800, "<"], + 400, + true, + ], + ["on an open end bound", ["[]", "width", 400, "<", 800, "<"], 800, false], + ["on a closed end bound", ["[]", "width", 400, "<", 800, "<="], 800, true], + // Written in the other direction: `800px > width > 400px`. + ["descending, inside", ["[]", "width", 800, ">", 400, ">"], 600, true], + ["descending, outside", ["[]", "width", 800, ">", 400, ">"], 300, false], + ]; + + test.each(cases)("%s", (_label, condition, value, matches) => { + expect(testMediaFeatureInterval(condition, value)).toBe(matches); + }); + + /** + * A feature the evaluator could not measure, and a bound the compiler could + * not resolve, are both "no answer" rather than "no bound". + */ + const unanswerable: [ + label: string, + condition: MediaInterval, + value: unknown, + ][] = [ + ["an unmeasurable feature", ["[]", "width", 400, "<", 800, "<"], undefined], + [ + "a non-numeric feature value", + ["[]", "orientation", 400, "<", 800, "<"], + "landscape", + ], + [ + "an unresolved start bound", + ["[]", "width", undefined, "<", 800, "<"], + 600, + ], + ["an unresolved end bound", ["[]", "width", 400, "<", undefined, "<"], 600], + ]; + + test.each(unanswerable)("%s never matches", (_label, condition, value) => { + expect(testMediaFeatureInterval(condition, value as StyleDescriptor)).toBe( + false, + ); + }); +}); diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index 642d81a2..db9f3689 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -260,6 +260,40 @@ describe("aspect ratio", () => { ); }); +describe("interval (range pair) conditions", () => { + /** + * A 600x200 container, so both bounds of an interval on either axis can be + * placed on either side of the measured value. Each bound is exercised open + * and closed, because an interval is two comparisons and getting one of them + * wrong still looks like an interval. + */ + const cases: [condition: string, matches: boolean][] = [ + ["(400px < width < 800px)", true], + ["(400px < width < 500px)", false], + ["(700px < width < 800px)", false], + // The measured width sits exactly on a bound: open excludes it, closed + // includes it, at both ends. + ["(600px < width < 800px)", false], + ["(600px <= width < 800px)", true], + ["(400px < width < 600px)", false], + ["(400px < width <= 600px)", true], + // The same interval written in the other direction. + ["(800px > width > 400px)", true], + ["(500px > width > 400px)", false], + ["(100px < height < 300px)", true], + ["(100px < height < 200px)", false], + ]; + + test.each(cases)( + "@container %s against a 600x200 container matches: %s", + (condition, matches) => { + expect( + containerQueryMatches(condition, { width: 600, height: 200 }), + ).toBe(matches); + }, + ); +}); + describe("a condition the compiler cannot evaluate", () => { /** * A `@container` block the compiler cannot compile a condition for must not diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index f0bce445..e8b737c9 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -239,6 +239,42 @@ describe("aspect-ratio", () => { ); }); +describe("interval (range pair) conditions", () => { + /** + * A 600x200 viewport, so both bounds of an interval on either axis can be + * placed on either side of the measured value. + */ + const cases: [prelude: string, matches: boolean][] = [ + ["(400px < width < 800px)", true], + ["(400px < width < 500px)", false], + ["(600px < width < 800px)", false], + ["(600px <= width < 800px)", true], + ["(800px > width > 400px)", true], + ["(100px < height < 300px)", true], + ["(100px < height < 200px)", false], + ]; + + test.each(cases)( + "@media %s against a 600x200 viewport matches: %s", + (prelude, matches) => { + registerCSS(` +@media ${prelude} { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 600, height: 200 }); + }); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual( + matches ? { color: "#f00" } : undefined, + ); + }, + ); +}); + describe("a condition the compiler cannot evaluate", () => { /** * A `@media` block the compiler cannot compile a condition for must not diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts index 5c24d6f6..b4140622 100644 --- a/src/native/conditions/compare.ts +++ b/src/native/conditions/compare.ts @@ -1,4 +1,14 @@ -import type { MediaFeatureComparison } from "react-native-css/compiler"; +import type { + MediaCondition, + MediaFeatureComparison, + StyleDescriptor, +} from "react-native-css/compiler"; + +/** + * The interval arm of {@link MediaCondition}, derived from the union rather + * than restated so it cannot drift from the compiler's output. + */ +export type MediaInterval = Extract; /** * Evaluates a single CSS range comparison. @@ -30,3 +40,34 @@ export function compareMediaFeature( return false; } } + +/** + * Evaluates a CSS range pair — `(400px < width < 800px)` and the three other + * ways to write two bounds around one feature. + * + * The compiler emits the pair in source order, so the two comparisons read the + * way they were written: the start bound is on the left of its operator and + * the measured value on the right, and the end bound the other way round. + * Both call sites share this one destructuring, because an interval whose + * halves are assembled in the wrong order is still a well-formed interval and + * says something else. + */ +export function testMediaFeatureInterval( + condition: MediaInterval, + value: StyleDescriptor, +): boolean { + const [, , start, startOperator, end, endOperator] = condition; + + if ( + typeof value !== "number" || + typeof start !== "number" || + typeof end !== "number" + ) { + return false; + } + + 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 07282e3b..db9ffd50 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -17,7 +17,7 @@ import { type Getter, } from "../reactivity"; // import { testAttributes } from "./attributes"; -import { compareMediaFeature } from "./compare"; +import { compareMediaFeature, testMediaFeatureInterval } from "./compare"; import type { RenderGuard } from "./guards"; export const DEFAULT_CONTAINER_NAME = "c:___default___"; @@ -99,7 +99,10 @@ function testContainerMediaCondition( case "!!": return false; case "[]": - return false; + return testMediaFeatureInterval( + condition, + getContainerFeatureValue(condition[1], containerKey, get), + ); case ">": case ">=": case "<": diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 47e648a3..ae890a81 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -7,7 +7,11 @@ import type { } from "react-native-css/compiler"; import { colorScheme, vh, vw, type Getter } from "../reactivity"; -import { compareMediaFeature } from "./compare"; +import { + compareMediaFeature, + testMediaFeatureInterval, + type MediaInterval, +} from "./compare"; /** * The comparison arm of {@link MediaCondition}, derived from the union rather @@ -18,15 +22,22 @@ type MediaComparison = Extract< [MediaFeatureComparison, ...unknown[]] >; +/** The feature name a comparison or an interval condition is written against. */ +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 "&": @@ -47,7 +58,7 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { } } -function testComparison(mediaQuery: MediaComparison, get: Getter): Boolean { +function testComparison(mediaQuery: MediaComparison, get: Getter): boolean { const value = mediaQuery[2]; switch (mediaQuery[1]) { @@ -78,25 +89,34 @@ function testComparison(mediaQuery: MediaComparison, get: Getter): Boolean { return false; } - let left: number | undefined; - const right = value; + const left = getMediaFeatureValue(mediaQuery[1], get); - switch (mediaQuery[1]) { + if (left === undefined) { + return false; + } + + return compareMediaFeature(mediaQuery[0], left, value); +} + +/** + * The features a range or interval condition can be written against — the + * numeric ones. A feature this cannot answer has nothing to compare, so both + * arms treat it as no match rather than guessing a value for it. + */ +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 "aspect-ratio": - left = get(vw) / get(vh); - break; + return get(vw) / get(vh); case "resolution": - left = PixelRatio.get(); - break; + return PixelRatio.get(); default: - return false; + return undefined; } - - return compareMediaFeature(mediaQuery[0], left, right); } From 3bacba9a9905906273a8b18deab1c93b1163f236 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 21:57:35 +0300 Subject: [PATCH 7/8] test: cover every defect on both planes from one shared census MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of what this branch actually observes, done by reintroducing each defect and counting the cases that went red — first against the branch head, then against this commit. Three code paths were reddened by nothing, and a fourth by a single case. reintroduced defect head now a `height` range compiled as `width` (@media) 0 7 an interval's bounds swapped in the emit (@media) 0 5 the runtime drops its Appearance subscription - 1 the viewport `height` feature answered with `vw` 1 12 `>=`, `<` and `<=` all `left > right` 38 65 `>=` compiled as `>` 8 13 a `height` range compiled as `width` (@container) 4 8 `containerHeightFamily` reads the width axis 16 19 a value import of VAR_SYMBOL 1 2 …plus a value re-export from the compiler entry 1 3 `@media all` compiled as "did not compile" 2 6 a comma list dropped for one refused branch 1 2 an interval's bounds swapped (@container) 5 5 the media evaluator's interval arm returns false 4 4 the `` arm removed 17 17 The first four rows are the finding. `@media` had no compiler-plane table for either the comparison operators or the interval layout, so the whole at-rule compiled unobserved while `@container` was pinned twice over; and one primitive now decides a range comparison for both, which is precisely the place a divergence between them would land unseen. The viewport `height` feature was reachable only through an interval case, on an axis this branch rewrote. And the compiler-plane isolation scan reads source shape, so nothing asserted the fact it rests on — that evaluating the runtime installs two listeners on the host. `src/__tests__/native/runtime-boot.test.ts` is the native half of that isolation invariant: load a module into a fresh registry and count what it attached to `Dimensions` and `Appearance`. It reaches further than the scan, which only sees direct specifiers out of `src/compiler/` — a path into the runtime through a third directory is invisible there and caught here. Measured before writing it, because the jest transform decides whether the plane can carry the defect at all: `babel-preset-expo` does NOT elide an import whose only specifier is used in a type position, so the pre-fix source emits `require("../native/reactivity")` under jest exactly as it does in `dist`. Had it elided, the test would have passed for a reason unrelated to the fix. The operator tables are generated from one census in `src/__tests__/_media-features.ts` rather than listed four times: the five operators, the two size features, the `min-`/`max-` spellings, and what each operator MEANS. The meaning is written out, never computed — deriving it from the code under test would make every table agree with a wrong operator — and it is shared so the primitive's own table and the four rendered ones cannot disagree. Each consumer asserts the census reached it, since an empty one generates no cases and leaves every suite green. The file is underscore- prefixed, which is what `testPathIgnorePatterns` already excludes. `@container` has no counterpart to the `@media all` cases, and that is a type rather than a gap: `CompiledContainerCondition` `Exclude`s the `always` state, so a container prelude with no condition is unrepresentable. Tests: 1325, up from 1225. The three failures are the pre-existing Windows `babel-plugin-tester` output mismatches, unchanged. --- src/__tests__/_media-features.ts | 155 ++++++++++++++++++ .../compiler/container-query.test.ts | 42 +++-- src/__tests__/compiler/media-query.test.ts | 101 +++++++++--- src/__tests__/native/compare.test.ts | 140 +++++++++------- .../native/container-queries.test.tsx | 126 +++++++------- src/__tests__/native/media-query.test.tsx | 143 ++++++++++++++++ src/__tests__/native/runtime-boot.test.ts | 92 +++++++++++ 7 files changed, 650 insertions(+), 149 deletions(-) create mode 100644 src/__tests__/_media-features.ts create mode 100644 src/__tests__/native/runtime-boot.test.ts diff --git a/src/__tests__/_media-features.ts b/src/__tests__/_media-features.ts new file mode 100644 index 00000000..310a18aa --- /dev/null +++ b/src/__tests__/_media-features.ts @@ -0,0 +1,155 @@ +import type { MediaFeatureComparison } from "react-native-css/compiler"; + +/** + * The range vocabulary `@media` and `@container` share: the five comparison + * operators, the two size features, and what each operator means. + * + * It is shared rather than restated per suite because one meaning has to hold + * across the primitive, both evaluators and both at-rules. A second copy of a + * five-armed operator table is the exact shape of the defect these tests + * guard: two hand-written switches over the same five operators, differing by + * one character, one of them wrong. + * + * This is not a test file — `testPathIgnorePatterns` skips a path segment + * starting with an underscore. + */ + +/** + * Where the measured value sits relative to the threshold the condition is + * written against. Every range comparison is decided by this and nothing else, + * so it is the dimension a table has to vary — and it is the dimension a + * copy-pasted operator arm hides in, because any two operators agree on at + * least one third of it. + */ +export type Ordering = + | "measured < threshold" + | "measured === threshold" + | "measured > threshold"; + +export const ORDERINGS: Ordering[] = [ + "measured < threshold", + "measured === threshold", + "measured > threshold", +]; + +/** + * What each comparison operator means, written out rather than computed. + * + * This is the specification every table is measured against. Deriving it from + * the code under test would make each table agree with whatever that code + * does, including a wrong operator — so it is literal, and it is the one place + * the semantics are stated. + * + * Typed as a total `Record`, so an operator added to `MediaFeatureComparison` + * is a compile error here rather than a silently uncovered arm. + */ +export const COMPARISON_MATCHES: Record< + MediaFeatureComparison, + Record +> = { + "=": { + "measured < threshold": false, + "measured === threshold": true, + "measured > threshold": false, + }, + ">": { + "measured < threshold": false, + "measured === threshold": false, + "measured > threshold": true, + }, + ">=": { + "measured < threshold": false, + "measured === threshold": true, + "measured > threshold": true, + }, + "<": { + "measured < threshold": true, + "measured === threshold": false, + "measured > threshold": false, + }, + "<=": { + "measured < threshold": true, + "measured === threshold": true, + "measured > threshold": false, + }, +}; + +export const COMPARISON_OPERATORS: MediaFeatureComparison[] = [ + "=", + ">", + ">=", + "<", + "<=", +]; + +/** + * The `min-`/`max-` prefixed spelling of the two operators that have one. + * + * lightningcss normalises `(min-width: 400px)` into a `>=` range condition, so + * the prefixed form is not a separate feature — it is the same condition + * written a second way, and it has to compile to the same tuple and evaluate + * to the same verdict. It is also the spelling almost every author writes, so + * an operator defect reaches users through this row first. + */ +export const RANGE_PREFIX: Partial< + Record +> = { + ">=": "min", + "<=": "max", +}; + +/** + * The two size features a range condition is written against. Both at-rules + * accept both, and each has its own measurement — reading one axis off the + * other is a defect no single-axis table can see. + */ +export const SIZE_FEATURES = ["width", "height"] as const; + +export type SizeFeature = (typeof SIZE_FEATURES)[number]; + +export interface SizeComparison { + /** The operator the runtime is handed, whatever spelling the CSS used. */ + operator: MediaFeatureComparison; + feature: SizeFeature; + /** `range` is `(width >= 400px)`; `prefixed` is `(min-width: 400px)`. */ + spelling: "range" | "prefixed"; + /** The condition as written inside the query's parentheses. */ + condition: (threshold: number) => string; + /** Test-name fragment, e.g. `width >=` or `min-width:`. */ + label: string; +} + +/** + * Every way to write a size range condition: each operator on each axis, plus + * the prefixed spelling of the two operators that have one. + */ +export function sizeComparisons(): SizeComparison[] { + return SIZE_FEATURES.flatMap((feature) => { + return COMPARISON_OPERATORS.flatMap((operator): SizeComparison[] => { + const prefix = RANGE_PREFIX[operator]; + + const range: SizeComparison = { + operator, + feature, + spelling: "range", + condition: (threshold) => `(${feature} ${operator} ${threshold}px)`, + label: `${feature} ${operator}`, + }; + + if (!prefix) { + return [range]; + } + + return [ + range, + { + operator, + feature, + spelling: "prefixed", + condition: (threshold) => `(${prefix}-${feature}: ${threshold}px)`, + label: `${prefix}-${feature}:`, + }, + ]; + }); + }); +} diff --git a/src/__tests__/compiler/container-query.test.ts b/src/__tests__/compiler/container-query.test.ts index 69281e90..c31a400b 100644 --- a/src/__tests__/compiler/container-query.test.ts +++ b/src/__tests__/compiler/container-query.test.ts @@ -1,5 +1,7 @@ import { compile, type ContainerQuery } from "react-native-css/compiler"; +import { sizeComparisons } from "../_media-features"; + /** * Returns the container queries the compiler attached to `.child`. * @@ -25,22 +27,36 @@ function compileContainerQueries(condition: string): ContainerQuery[] { describe("size feature comparisons", () => { /** + * Every comparison operator on every size axis, in both spellings. + * * lightningcss normalises the `min-`/`max-` prefixes into range conditions, - * so the runtime only ever sees the five comparison operators. Every one of - * them has to survive compilation with its own identity — a container query - * evaluator can only be as correct as the operator it is handed. + * so the runtime only ever sees the five operators. Every one of them has to + * survive compilation with its own identity on each axis — an evaluator can + * only be as correct as the operator and the feature name it is handed, and + * a table listing a subset of the cross product cannot say which of the two + * a defect landed on. + * + * Generated from the shared census rather than listed, so an operator added + * to `MediaFeatureComparison` is covered on both axes without an edit here. */ + const cases: [condition: string, query: ContainerQuery][] = + sizeComparisons().map((row) => { + const query: ContainerQuery = { m: [row.operator, row.feature, 400] }; + return [row.condition(400), query]; + }); + + test("the table covers the whole census", () => { + expect(cases).toHaveLength(sizeComparisons().length); + expect(cases.length).toBeGreaterThan(0); + }); + + test.each(cases)("@container %s", (condition, query) => { + expect(compileContainerQueries(condition)).toStrictEqual([query]); + }); +}); + +describe("other size features", () => { const cases: [condition: string, query: ContainerQuery][] = [ - ["(width > 400px)", { m: [">", "width", 400] }], - ["(width >= 400px)", { m: [">=", "width", 400] }], - ["(min-width: 400px)", { m: [">=", "width", 400] }], - ["(width < 400px)", { m: ["<", "width", 400] }], - ["(width <= 400px)", { m: ["<=", "width", 400] }], - ["(max-width: 400px)", { m: ["<=", "width", 400] }], - ["(width = 400px)", { m: ["=", "width", 400] }], - ["(height > 400px)", { m: [">", "height", 400] }], - ["(min-height: 400px)", { m: [">=", "height", 400] }], - ["(max-height: 400px)", { m: ["<=", "height", 400] }], ["(orientation: landscape)", { m: ["=", "orientation", "landscape"] }], ["(orientation: portrait)", { m: ["=", "orientation", "portrait"] }], // A `` is carried to the runtime as the number it denotes, which is diff --git a/src/__tests__/compiler/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index fe002327..8ff5cd6f 100644 --- a/src/__tests__/compiler/media-query.test.ts +++ b/src/__tests__/compiler/media-query.test.ts @@ -1,4 +1,28 @@ -import { compile } from "react-native-css/compiler"; +import { compile, type MediaCondition } from "react-native-css/compiler"; + +import { sizeComparisons } from "../_media-features"; + +/** + * Returns the media conditions the compiler attached to `.my-class`. + * + * The rest of the rule (declarations, specificity, extracted variables) is not + * the subject of these tests, so reading just `m` keeps them from failing on + * an unrelated change to how declarations are emitted. + */ +function compileMediaConditions(prelude: string): MediaCondition[] { + const stylesheet = compile(` + @media ${prelude} { + .my-class { color: red; } + } + `).stylesheet(); + + const rules = + stylesheet.s?.flatMap(([className, ruleSet]) => { + return className === "my-class" ? ruleSet : []; + }) ?? []; + + return rules.flatMap((rule) => rule.m ?? []); +} describe.skip("platform media queries", () => { test("android", () => { @@ -86,35 +110,72 @@ test("@media (hover: hover)", () => { }); }); -describe("aspect-ratio", () => { +describe("size feature comparisons", () => { /** - * Returns the media conditions the compiler attached to `.my-class`. + * Every comparison operator on every size axis, in both spellings — the same + * census the `@container` compiler table and both runtime tables are built + * from. + * + * `@media` and `@container` share one `MediaCondition` vocabulary and one + * runtime primitive, so an operator that compiles differently between them + * is a divergence with nowhere to be caught downstream. Both at-rules are + * held to the identical table for that reason. */ - function compileMediaConditions(prelude: string): unknown { - const stylesheet = compile(` - @media ${prelude} { - .my-class { color: red; } - } - `).stylesheet(); - - return stylesheet.s?.flatMap(([className, ruleSet]) => { - return className === "my-class" ? ruleSet.map((rule) => rule.m) : []; + const cases: [prelude: string, condition: MediaCondition][] = + sizeComparisons().map((row) => { + const condition: MediaCondition = [row.operator, row.feature, 400]; + return [row.condition(400), condition]; }); - } + test("the table covers the whole census", () => { + expect(cases).toHaveLength(sizeComparisons().length); + expect(cases.length).toBeGreaterThan(0); + }); + + test.each(cases)("@media %s", (prelude, condition) => { + expect(compileMediaConditions(prelude)).toStrictEqual([condition]); + }); +}); + +describe("aspect-ratio", () => { /** * `` is a media feature value like any other, so the same parse * serves `@media` and `@container`. A bare number is a ratio too — `1` is * `1/1`. */ - const cases: [prelude: string, conditions: unknown][] = [ - ["(aspect-ratio > 1)", [[[">", "aspect-ratio", 1]]]], - ["(aspect-ratio: 2/1)", [[["=", "aspect-ratio", 2]]]], - ["(min-aspect-ratio: 16/9)", [[[">=", "aspect-ratio", 16 / 9]]]], - ["(max-aspect-ratio: 16/9)", [[["<=", "aspect-ratio", 16 / 9]]]], + const cases: [prelude: string, condition: MediaCondition][] = [ + ["(aspect-ratio > 1)", [">", "aspect-ratio", 1]], + ["(aspect-ratio: 2/1)", ["=", "aspect-ratio", 2]], + ["(min-aspect-ratio: 16/9)", [">=", "aspect-ratio", 16 / 9]], + ["(max-aspect-ratio: 16/9)", ["<=", "aspect-ratio", 16 / 9]], + ]; + + test.each(cases)("@media %s", (prelude, condition) => { + expect(compileMediaConditions(prelude)).toStrictEqual([condition]); + }); +}); + +describe("interval (range pair) conditions", () => { + /** + * The emitted tuple is `["[]", name, start, startOperator, end, + * endOperator]`, and it reads in CSS source order: `start startOperator + * name endOperator end`. The runtime evaluates it in that order, so the two + * operators are pinned separately from the two bounds — swapping either pair + * reads as a valid interval and means something else. + * + * The `@container` compiler suite pins the same layout. One evaluator now + * serves both at-rules, so a divergence in what either one emits reaches a + * shared consumer that cannot tell them apart. + */ + const cases: [prelude: string, condition: MediaCondition][] = [ + ["(400px < width < 800px)", ["[]", "width", 400, "<", 800, "<"]], + ["(400px <= width <= 800px)", ["[]", "width", 400, "<=", 800, "<="]], + ["(800px > width > 400px)", ["[]", "width", 800, ">", 400, ">"]], + ["(400px < height < 800px)", ["[]", "height", 400, "<", 800, "<"]], + ["(400px <= width < 800px)", ["[]", "width", 400, "<=", 800, "<"]], ]; - test.each(cases)("@media %s", (prelude, conditions) => { - expect(compileMediaConditions(prelude)).toStrictEqual(conditions); + test.each(cases)("@media %s", (prelude, condition) => { + expect(compileMediaConditions(prelude)).toStrictEqual([condition]); }); }); diff --git a/src/__tests__/native/compare.test.ts b/src/__tests__/native/compare.test.ts index 541b39a2..3ca9d598 100644 --- a/src/__tests__/native/compare.test.ts +++ b/src/__tests__/native/compare.test.ts @@ -1,8 +1,14 @@ -import type { - MediaFeatureComparison, - StyleDescriptor, -} from "react-native-css/compiler"; +import type { StyleDescriptor } from "react-native-css/compiler"; +import { + COMPARISON_MATCHES, + COMPARISON_OPERATORS, + ORDERINGS, + RANGE_PREFIX, + SIZE_FEATURES, + sizeComparisons, + type Ordering, +} from "../_media-features"; import { compareMediaFeature, testMediaFeatureInterval, @@ -14,78 +20,92 @@ import { * of its two operands. A copy-pasted switch arm is only visible when both are * varied: `>=` and `>` agree on two thirds of this table, and the third they * disagree on is the one CSS authors write `min-width` for. + * + * The verdicts come from the shared census, which is also what the rendered + * `@media` and `@container` tables are measured against — so the primitive and + * the two at-rules cannot disagree about what an operator means. */ -type Ordering = "left < right" | "left === right" | "left > right"; - -const operands: Record = { - "left < right": [100, 200], - "left === right": [200, 200], - "left > right": [300, 200], +const operands: Record = { + "measured < threshold": [100, 200], + "measured === threshold": [200, 200], + "measured > threshold": [300, 200], }; -/** - * Typed as a total `Record`, so an operator added to `MediaFeatureComparison` - * is a compile error here rather than a silently uncovered arm. - */ -const expected: Record> = { - "=": { - "left < right": false, - "left === right": true, - "left > right": false, - }, - ">": { - "left < right": false, - "left === right": false, - "left > right": true, - }, - ">=": { - "left < right": false, - "left === right": true, - "left > right": true, - }, - "<": { - "left < right": true, - "left === right": false, - "left > right": false, - }, - "<=": { - "left < right": true, - "left === right": true, - "left > right": false, - }, -}; - -const operators: MediaFeatureComparison[] = ["=", ">", ">=", "<", "<="]; -const orderings: Ordering[] = [ - "left < right", - "left === right", - "left > right", -]; - -const cases = operators.flatMap((operator) => { - return orderings.map((ordering) => { - const [left, right] = operands[ordering]; +const cases = COMPARISON_OPERATORS.flatMap((operator) => { + return ORDERINGS.map((ordering) => { + const [measured, threshold] = operands[ordering]; return [ operator, ordering, - left, - right, - expected[operator][ordering], + measured, + threshold, + COMPARISON_MATCHES[operator][ordering], ] as const; }); }); test("the table covers every operator against every ordering", () => { - expect([...operators].sort()).toStrictEqual(Object.keys(expected).sort()); - expect([...orderings].sort()).toStrictEqual(Object.keys(operands).sort()); - expect(cases).toHaveLength(operators.length * orderings.length); + expect([...COMPARISON_OPERATORS].sort()).toStrictEqual( + Object.keys(COMPARISON_MATCHES).sort(), + ); + expect([...ORDERINGS].sort()).toStrictEqual(Object.keys(operands).sort()); + expect(cases).toHaveLength(COMPARISON_OPERATORS.length * ORDERINGS.length); expect(cases.length).toBeGreaterThan(0); }); +describe("the shared range-condition census", () => { + /** + * `sizeComparisons()` generates the tables in every suite that renders a + * range condition. An empty or partial census is a silent no-op there — the + * `test.each` produces fewer cases and every suite stays green — so its + * completeness is asserted once, here, where the operator census lives. + */ + const rows = sizeComparisons(); + + test("every operator appears on every size feature", () => { + expect(rows.length).toBeGreaterThan(0); + + expect( + rows + .filter((row) => row.spelling === "range") + .map((row) => `${row.feature} ${row.operator}`) + .sort(), + ).toStrictEqual( + SIZE_FEATURES.flatMap((feature) => { + return COMPARISON_OPERATORS.map((operator) => `${feature} ${operator}`); + }).sort(), + ); + }); + + test("every prefixed spelling appears on every size feature", () => { + expect( + rows + .filter((row) => row.spelling === "prefixed") + .map((row) => `${row.feature} ${row.operator}`) + .sort(), + ).toStrictEqual( + SIZE_FEATURES.flatMap((feature) => { + return Object.keys(RANGE_PREFIX).map((operator) => { + return `${feature} ${operator}`; + }); + }).sort(), + ); + }); + + test("a condition is written the way CSS spells it", () => { + const conditions = rows.map((row) => row.condition(400)); + + expect(conditions).toContain("(width >= 400px)"); + expect(conditions).toContain("(min-width: 400px)"); + expect(conditions).toContain("(height <= 400px)"); + expect(conditions).toContain("(max-height: 400px)"); + }); +}); + test.each(cases)( "%s with %s: compareMediaFeature(_, %d, %d) === %s", - (operator, _ordering, left, right, result) => { - expect(compareMediaFeature(operator, left, right)).toBe(result); + (operator, _ordering, measured, threshold, result) => { + expect(compareMediaFeature(operator, measured, threshold)).toBe(result); }, ); diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index db9f3689..5abc2d3f 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -2,6 +2,14 @@ import { fireEvent, render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS } from "react-native-css/jest"; +import { + COMPARISON_MATCHES, + ORDERINGS, + sizeComparisons, + type Ordering, + type SizeFeature, +} from "../_media-features"; + const parentID = "parent"; const childID = "child"; @@ -162,70 +170,76 @@ function containerQueryMatches( return child.props.style.color === "#00f"; } -describe("width comparisons", () => { +/** + * One container for every size comparison, laid out so the two axes hold + * different numbers — a feature answered off the wrong axis then produces a + * wrong verdict rather than the right one by coincidence. + */ +const CONTAINER = { width: 400, height: 200 }; + +/** + * A threshold on each side of the measured value, and one exactly on it, per + * axis. The two axes draw from disjoint sets of numbers for the same reason + * the container is not square. + */ +const THRESHOLDS: Record> = { + width: { + "measured < threshold": 500, + "measured === threshold": 400, + "measured > threshold": 300, + }, + height: { + "measured < threshold": 250, + "measured === threshold": 200, + "measured > threshold": 150, + }, +}; + +describe("size comparisons", () => { /** - * Every case is measured against the same 400x200 container, so the only - * variable is the comparison operator. `min-`/`max-` prefixes are normalised - * by lightningcss into `>=`/`<=` range conditions, which is why they belong - * in this table rather than in one of their own. + * Every comparison operator, on both axes, in both spellings, with the + * measured value on each side of the threshold and exactly on it. + * + * Two thirds of this table is where a copy-pasted operator arm hides — two + * of the five operators always agree somewhere, and `>=` and `>` differ only + * on the row an author writes `min-width` for. The verdicts come from the + * shared census, so this table and the primitive's own cannot disagree about + * what an operator means. */ - const cases: [condition: string, matches: boolean][] = [ - ["(width > 300px)", true], - ["(width > 400px)", false], - ["(width >= 400px)", true], - ["(width >= 401px)", false], - ["(min-width: 400px)", true], - ["(min-width: 401px)", false], - ["(width < 500px)", true], - ["(width < 400px)", false], - ["(width <= 400px)", true], - ["(width <= 399px)", false], - ["(max-width: 400px)", true], - ["(max-width: 399px)", false], - ["(width = 400px)", true], - ["(width = 401px)", false], - ]; + const cases: [condition: string, ordering: Ordering, matches: boolean][] = + sizeComparisons().flatMap((row) => { + return ORDERINGS.map( + ( + ordering, + ): [condition: string, ordering: Ordering, matches: boolean] => { + return [ + row.condition(THRESHOLDS[row.feature][ordering]), + ordering, + COMPARISON_MATCHES[row.operator][ordering], + ]; + }, + ); + }); + + test("the table covers the whole census", () => { + expect(cases).toHaveLength(sizeComparisons().length * ORDERINGS.length); + expect(cases.length).toBeGreaterThan(0); + }); test.each(cases)( - "@container %s against a 400x200 container matches: %s", - (condition, matches) => { - expect( - containerQueryMatches(condition, { width: 400, height: 200 }), - ).toBe(matches); + "@container %s (%s) against a 400x200 container matches: %s", + (condition, _ordering, matches) => { + expect(containerQueryMatches(condition, CONTAINER)).toBe(matches); }, ); }); -describe("height comparisons", () => { - /** - * The same 400x200 container. Height is deliberately the smaller of the two - * axes so that a height feature reading the container's width instead is a - * visible failure rather than a coincidence. - */ - const cases: [condition: string, matches: boolean][] = [ - ["(height > 100px)", true], - ["(height > 200px)", false], - ["(height > 300px)", false], - ["(height >= 200px)", true], - ["(min-height: 200px)", true], - ["(min-height: 201px)", false], - ["(height < 300px)", true], - ["(height < 200px)", false], - ["(height <= 200px)", true], - ["(max-height: 300px)", true], - ["(max-height: 199px)", false], - ["(height = 200px)", true], - ["(height = 400px)", false], - ]; - - test.each(cases)( - "@container %s against a 400x200 container matches: %s", - (condition, matches) => { - expect( - containerQueryMatches(condition, { width: 400, height: 200 }), - ).toBe(matches); - }, - ); +test("each size axis is measured on its own axis", () => { + // Stated differentially, so it holds whatever the numbers are: on a + // landscape container the same threshold cannot satisfy both axes, and a + // height feature answered with the container's width would make it. + expect(containerQueryMatches("(width > 300px)", CONTAINER)).toBe(true); + expect(containerQueryMatches("(height > 300px)", CONTAINER)).toBe(false); }); describe("aspect ratio", () => { diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index e8b737c9..3cf96efc 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -5,6 +5,13 @@ import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; import { colorScheme } from "react-native-css/runtime"; +import { + COMPARISON_MATCHES, + ORDERINGS, + sizeComparisons, + type Ordering, + type SizeFeature, +} from "../_media-features"; import { dimensions } from "../../native/reactivity"; jest.mock("react-native", () => { @@ -200,6 +207,142 @@ test("not all", () => { }); }); +/** + * Renders `.my-class` under a viewport of `size` and reports whether the + * `@media ` rule won. + * + * `.my-class` is blue outside the query and red inside it, so the returned + * colour is a direct reading of the condition's verdict, and a class that + * resolved to nothing at all cannot read as a non-match. + */ +function mediaQueryMatches( + prelude: string, + size: { width: number; height: number }, +): boolean { + registerCSS(` +.my-class { color: blue; } + +@media ${prelude} { + .my-class { color: red; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), ...size }); + }); + + render(); + + return screen.getByTestId(testID).props.style.color === "#f00"; +} + +/** + * One viewport for every size comparison, with the two axes holding different + * numbers so a feature answered off the wrong axis produces a wrong verdict + * rather than the right one by coincidence. + */ +const VIEWPORT = { width: 600, height: 400 }; + +/** + * A threshold on each side of the measured value, and one exactly on it, per + * axis. The two axes draw from disjoint sets of numbers for the same reason + * the viewport is not square. + */ +const THRESHOLDS: Record> = { + width: { + "measured < threshold": 700, + "measured === threshold": 600, + "measured > threshold": 500, + }, + height: { + "measured < threshold": 450, + "measured === threshold": 400, + "measured > threshold": 350, + }, +}; + +describe("size comparisons", () => { + /** + * The identical census the `@container` runtime table is built from. + * + * One primitive now decides a range comparison for both at-rules, so the two + * are held to the same table — an operator that means one thing under + * `@media` and another under `@container` is the drift that primitive + * exists to make impossible, and only a shared table can observe it. + */ + const cases: [prelude: string, ordering: Ordering, matches: boolean][] = + sizeComparisons().flatMap((row) => { + return ORDERINGS.map( + (ordering): [prelude: string, ordering: Ordering, matches: boolean] => { + return [ + row.condition(THRESHOLDS[row.feature][ordering]), + ordering, + COMPARISON_MATCHES[row.operator][ordering], + ]; + }, + ); + }); + + test("the table covers the whole census", () => { + expect(cases).toHaveLength(sizeComparisons().length * ORDERINGS.length); + expect(cases.length).toBeGreaterThan(0); + }); + + test.each(cases)( + "@media %s (%s) against a 600x400 viewport matches: %s", + (prelude, _ordering, matches) => { + expect(mediaQueryMatches(prelude, VIEWPORT)).toBe(matches); + }, + ); +}); + +test("each size axis is measured on its own axis", () => { + // Stated differentially, so it holds whatever the numbers are: on a + // landscape viewport the same threshold cannot satisfy both axes. + expect(mediaQueryMatches("(width > 500px)", VIEWPORT)).toBe(true); + expect(mediaQueryMatches("(height > 500px)", VIEWPORT)).toBe(false); +}); + +describe("a block whose condition is absent", () => { + /** + * The other side of the distinction the uncompilable table below pins. These + * preludes carry no condition at all, so the block applies at every size — + * a compiler that read "there is no condition" as "the condition did not + * compile" would drop them instead, and nothing else here would notice. + * + * `not print` reads `not (print and ...)`, which is true on every non-print + * device whatever the rest of the query says, so it applies below its own + * width bound as well as above it. + */ + const cases: [prelude: string, width: number][] = [ + ["all", 300], + ["all", 600], + ["screen", 300], + ["screen", 600], + ["not print and (width > 400px)", 300], + ["not print and (width > 400px)", 600], + ]; + + test.each(cases)("@media %s applies at %dpx wide", (prelude, width) => { + expect(mediaQueryMatches(prelude, { ...VIEWPORT, width })).toBe(true); + }); +}); + +describe("a media query list with one uncompilable branch", () => { + /** + * The branch that did not compile contributes nothing, and the branch that + * did keeps its own condition — the block does not become unconditional + * because one of its queries was refused. + */ + const prelude = "(width > env(safe-area-inset-top)), (width > 400px)"; + + test.each([ + [600, true], + [300, false], + ])("at %dpx wide matches: %s", (width, matches) => { + expect(mediaQueryMatches(prelude, { ...VIEWPORT, width })).toBe(matches); + }); +}); + describe("aspect-ratio", () => { /** * The viewport's aspect ratio is its width over its height, measured off the diff --git a/src/__tests__/native/runtime-boot.test.ts b/src/__tests__/native/runtime-boot.test.ts new file mode 100644 index 00000000..91ae9a00 --- /dev/null +++ b/src/__tests__/native/runtime-boot.test.ts @@ -0,0 +1,92 @@ +/** + * The compiler runs at build time — inside Metro, inside a bundler plugin, + * inside this suite. The native runtime is a different plane, and evaluating + * it is not free: `native/reactivity` subscribes to `Dimensions` and to + * `Appearance` at module scope, so merely importing it installs two listeners + * on the host. + * + * The compiler-plane suite pins the source shape that keeps the two apart — no + * module reference out of `src/compiler/` survives emit into the runtime + * planes. This is the same invariant observed from the other side, at runtime + * and through the whole transitive graph: load the compiler and count what it + * attached to React Native. A path that reaches the runtime through a third + * directory is invisible to a scan of `src/compiler/` and is caught here. + */ + +import type * as ReactNative from "react-native"; + +interface RuntimeListeners { + dimensions: number; + appearance: number; +} + +/** + * `react-native` is a CommonJS module, so a dynamic import of it hands back an + * interop namespace whose `default` is the module object. Reading the named + * exports off the namespace directly yields `undefined` under this transform, + * which is why the fallback exists rather than a straight destructure. + */ +async function importReactNative(): Promise { + const imported = await import("react-native"); + const interop = imported as unknown as { default?: typeof ReactNative }; + + return interop.default ?? imported; +} + +/** + * Runs `load` against a fresh module registry and reports the module-scope + * listeners it left behind. + * + * The spies have to sit on the `react-native` copy the reset registry hands + * out, which is a different object from the one an ordinary top-level import + * of this file would hold. + */ +async function listenersRegisteredBy( + load: () => Promise, +): Promise { + jest.resetModules(); + + const { Appearance, Dimensions } = await importReactNative(); + + const dimensions = jest.spyOn(Dimensions, "addEventListener"); + const appearance = jest.spyOn(Appearance, "addChangeListener"); + + try { + await load(); + + return { + dimensions: dimensions.mock.calls.length, + appearance: appearance.mock.calls.length, + }; + } finally { + dimensions.mockRestore(); + appearance.mockRestore(); + } +} + +test("evaluating the native runtime registers its host listeners", async () => { + // The premise everything below rests on, and the vacuity guard on it: if the + // runtime stopped subscribing at module scope, every "registers nothing" + // assertion would hold for a reason that has nothing to do with isolation. + await expect( + listenersRegisteredBy(() => import("../../native/reactivity")), + ).resolves.toStrictEqual({ dimensions: 1, appearance: 1 }); +}); + +test("the compiler's type module registers none", async () => { + // `compiler.types` declares nothing but types. A value import of the runtime + // in it is not elided — the module reference survives emit and evaluates the + // runtime for a symbol that is only ever used as a type. + await expect( + listenersRegisteredBy(() => import("../../compiler/compiler.types")), + ).resolves.toStrictEqual({ dimensions: 0, appearance: 0 }); +}); + +test("importing the compiler entry registers none", async () => { + // The invariant a consumer actually feels: `react-native-css/compiler` is a + // build-time entry point, and pulling it into a bundle must not drag the + // native runtime along behind it. + await expect( + listenersRegisteredBy(() => import("react-native-css/compiler")), + ).resolves.toStrictEqual({ dimensions: 0, appearance: 0 }); +}); From 387fa75f8b2b7aa53a117b3d1a6f5e93ecfb18dc Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 13:00:14 +0300 Subject: [PATCH 8/8] fix: close the holes the audit found in its own guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reintroducing each defect and counting the reds surfaced four places where the branch guarded less than it claimed, and two sibling defects of the same class as the ones it fixes. Counts here are whole-suite, and exclude the three pre-existing Windows babel-plugin-tester failures. The isolation scan had a hole it could not see ---------------------------------------------- native-runtime-isolation restated its own plane census as ["native", "native-internal"], and the ways into the native plane are not directory names. src/index.ts re-exports runtime, and runtime is runtime.native on the native platform, so `import "react-native-css"` in a compiler source resolves to `index` — not under either directory, and pulling all of both. Measured against the census form: that import, `import "../runtime.native"` and `import "react-native-css/runtime"` each reddened zero cases while producing exactly the dist defect the VAR_SYMBOL fix exists to prevent. So the census is gone and the question is reachability: can the module a specifier resolves to reach native/reactivity, through any number of hops and on any platform. That is the fact the invariant actually rests on — the listeners — rather than a second name for it. All three specifiers now redden the scan, and so does a path through a third directory, which no scan of src/compiler/ alone could see: a value import of the runtime added to src/utilities reddens four cases, the scan among them. Two guards go with it. The detector walks the whole tree rather than the top-level statements, so a require() or a dynamic import() inside a function body is a module reference it can find; and a reachability table states what the check discriminates, so a renamed native/reactivity fails loudly instead of making every answer false. compiler/inheritance.test.ts stays in the scan. It sits in that directory rather than under __tests__, so bob compiles it into dist and the package ships it, which makes it a compiler source like any other. One comparison, not one and a half ---------------------------------- compare.ts claimed an operator has exactly one meaning at runtime, and `=` had two: the container evaluator short-circuited it before reaching the primitive, because a keyword feature like orientation answers a string. Measured: deciding `=` wrongly inside the primitive reddened 8 cases and none of them under @container, whose table holds (width = 400px) rows. The primitive takes StyleDescriptor now and answers `=` itself, since narrowing at the call site is how the second copy got written — an evaluator that must reject a keyword before it can call the primitive ends up deciding equality on its own. Both evaluators hand it whatever the feature answered and nothing else. The same mutation now reddens 14, six under @container, including the orientation rows. The numeric guard travels with it, so testMediaFeatureInterval holds none of its own: an unmeasured value or an unresolved bound fails whichever comparison it is an operand of. That guard was observed by nothing — dropping it reddened zero — because the cases that looked like they pinned it return false either way. "landscape" < 800 is NaN < 800; only a string that COERCES tells the two apart, and 400 < "500" is 400 < 500. Three rows cover it now, one per slot, each mutation-proven. Two sibling defects, same class ------------------------------- inline-size and block-size compile cleanly and answered undefined, so @container (min-inline-size: 400px) matched nothing at any size — on the axis container-type: inline-size names, which is the one most container queries are written against. React Native lays out in one writing mode, so inline is the width axis and block the height axis. Reverting reddens 5. A with a zero denominator has no finite quotient, and (min-aspect-ratio: 1/0) emitted Infinity. The native injection path serialises through JSON.stringify, which writes Infinity and NaN as null, so the condition meant one thing under jest and another on a device. A degenerate ratio is refused instead, which routes it into the same "did not compile" path as every other unresolvable value. Reverting reddens 3. Assertions that could not fail ------------------------------ Five expect(cases).toHaveLength(census.length * ORDERINGS.length) compared a generated table against the generator that made it, so the product held for any census. Deleting "<" from the operator census removes 19 cases across four files and left all five green; only compare.test.ts's comparison against COMPARISON_MATCHES — a total Record over MediaFeatureComparison, whose keys are the union itself — noticed. Every table asserts coverage against that record now, and the same deletion reddens 5 tests in 5 suites. conditional-group-rules's control table asserted only that one rule came out, never which condition it carried, so a block emitted with the wrong condition — or with none — passed it. Each case names the condition now, including the two preludes that legitimately carry none. And the aspect-ratio and interval tables get the label they were missing: a matches: true row and a matches: false row fail under opposite defects, one for a block that stops being emitted and one for a block emitted with nothing to check. Neither half observes the other's direction, which is why both are there and why the size of a table is not its coverage. Corrections to what was claimed ------------------------------- - The audit table's "an interval's bounds swapped in the emit (@media)" row reads 0 -> 5. Measured whole-suite it is 9, of which 5 are the compiler-plane pins and 4 the runtime rows that already existed one commit earlier — so the head column is 4, not 0. Zero is the figure against the base, where an unevaluated interval cannot observe its own layout. Both readings of "swapped bounds" give the same numbers. - That table's counting rule is not constant across its rows: the 5 there counts the compiler plane only, while other rows count the whole suite. The counts above use one rule throughout. - "import VAR_SYMBOL for its type only" says the emitted declaration file is unchanged. It is not: compiler.types.d.ts carries `import type { VAR_SYMBOL }` where it carried `import { VAR_SYMBOL }`, and a declaration emit diffs in exactly that one line and nowhere else. Inert for consumers, but not nothing. - "evaluate interval (range pair) conditions" says it dissolves the last identical-bodied switch arms under src/native/conditions. That holds within each switch and not across the two files: `case "!!": return false` is the same body in both, and it is a gap rather than a decision. Known limits, now stated where the decision is made --------------------------------------------------- A comma-separated media query list is intersected, not unioned. rule.m is a flat array fed from two places with opposite meanings — one entry per comma branch, which CSS unions, and one per enclosing @media block or media-carrying selector, which CSS intersects — and testMediaQuery intersects the whole array. Measured: @media (min-width: 400px), (min-height: 300px) at 600x200 does not match while (min-width: 400px) alone does, and switching the evaluator to .some(...) fixes the list and breaks nesting — @media (min-width: 400px) { @media (min-height: 900px) { ... } } starts matching at 600x200. The existing suite catches neither direction, so the substitution passes it. Two entries of the same shape mean two different things; the emit has to say which, which is a change to what is produced rather than to how it is read. It is left standing and written down at the every(never) decision it sits beside, and at the evaluator a reader would otherwise reach for. The boolean context is unimplemented on both planes: (width) compiles to ["!!", name] and both evaluators answer false, so the query reads as valid and can never match. Answering it needs a truthiness rule per feature. Suite: 1361, up from 1325. Two runs with identical totals, zero suite-load failures. yarn typecheck and yarn lint exit 0. --- src/__tests__/_media-features.ts | 16 ++ .../compiler/conditional-group-rules.test.ts | 71 ++++- .../compiler/container-query.test.ts | 17 +- src/__tests__/compiler/media-query.test.ts | 11 +- .../compiler/native-runtime-isolation.test.ts | 271 ++++++++++++++++-- src/__tests__/native/compare.test.ts | 38 ++- .../native/container-queries.test.tsx | 91 ++++-- src/__tests__/native/media-query.test.tsx | 57 +++- src/compiler/compiler.ts | 24 +- src/compiler/media-query.ts | 13 +- src/native/conditions/compare.ts | 45 ++- src/native/conditions/container-query.ts | 31 +- src/native/conditions/media-query.ts | 29 +- 13 files changed, 600 insertions(+), 114 deletions(-) diff --git a/src/__tests__/_media-features.ts b/src/__tests__/_media-features.ts index 310a18aa..d00f686c 100644 --- a/src/__tests__/_media-features.ts +++ b/src/__tests__/_media-features.ts @@ -12,6 +12,22 @@ import type { MediaFeatureComparison } from "react-native-css/compiler"; * * This is not a test file — `testPathIgnorePatterns` skips a path segment * starting with an underscore. + * + * A note on how the tables built from this are read. A rendered case asserts a + * verdict, and the two verdicts fail under opposite defects: a `matches: true` + * row reddens when a condition stops being answered, because the block is then + * dropped or refused; a `matches: false` row reddens when a condition stops + * being asked, because the block is then emitted with nothing to check and + * applies everywhere. Neither half observes the other's direction, so a table + * of one verdict is half a table however many rows it has — which is why every + * table here carries both, and why the counts of the two are worth keeping + * near each other. + * + * A table's size is also not evidence that it covers anything. Every table is + * generated from this census, so its length is the census's length by + * construction and agrees with a census that lost an operator. Coverage is + * asserted against {@link COMPARISON_MATCHES} instead, whose keys are the + * `MediaFeatureComparison` union itself. */ /** diff --git a/src/__tests__/compiler/conditional-group-rules.test.ts b/src/__tests__/compiler/conditional-group-rules.test.ts index cb4b04ca..3334c769 100644 --- a/src/__tests__/compiler/conditional-group-rules.test.ts +++ b/src/__tests__/compiler/conditional-group-rules.test.ts @@ -41,6 +41,22 @@ const uncompilable: [label: string, css: string][] = [ "a negated media condition the compiler cannot resolve", "@media not (width > env(safe-area-inset-top)) { .child { color: red } }", ], + // A `` stands for its quotient, and a zero denominator has none. + // There is no number a comparison against it could be written as: the + // emitted value would be `Infinity` or `NaN`, which the bundle serialises to + // `null` and the runtime then reads as an unresolved bound anyway. + [ + "a media ratio with no finite quotient", + "@media (min-aspect-ratio: 1/0) { .child { color: red } }", + ], + [ + "a media ratio that is not a number at all", + "@media (min-aspect-ratio: 0/0) { .child { color: red } }", + ], + [ + "a container ratio with no finite quotient", + "@container (min-aspect-ratio: 1/0) { .child { color: red } }", + ], ]; describe("a block whose condition does not compile is not emitted", () => { @@ -56,23 +72,64 @@ describe("a block whose condition does compile is emitted", () => { /** * The control for the table above — without it, a compiler that emitted * nothing at all would pass every case there. + * + * Each case names the condition the rule must carry rather than counting the + * rules, because the two failures being pinned are opposite and a count sees + * only one of them: a block dropped when it should not be, and a block kept + * but stripped of the condition that was the whole point of it. The second + * is the more dangerous, since the declarations then apply everywhere. + * + * An absent condition is therefore stated, not omitted. `@media all` and + * `@media not print and (…)` genuinely carry none — `not print` reads `not + * (print and …)`, true on every non-print device whatever follows — and that + * is exactly the state a condition which failed to compile must not be + * confused with. */ - const cases: [label: string, css: string][] = [ - ["@container", "@container (width > 400px) { .child { color: red } }"], - ["@media", "@media (width > 400px) { .child { color: red } }"], - ["@media all", "@media all { .child { color: red } }"], - ["@media screen", "@media screen { .child { color: red } }"], + const cases: [ + label: string, + css: string, + conditions: Pick, + ][] = [ + [ + "@container", + "@container (width > 400px) { .child { color: red } }", + { m: undefined, cq: [{ m: [">", "width", 400] }] }, + ], + [ + "@media", + "@media (width > 400px) { .child { color: red } }", + { m: [[">", "width", 400]], cq: undefined }, + ], + [ + "@media all", + "@media all { .child { color: red } }", + { m: undefined, cq: undefined }, + ], + [ + "@media screen", + "@media screen { .child { color: red } }", + { m: undefined, cq: undefined }, + ], [ "@media not print", "@media not print and (width > 400px) { .child { color: red } }", + { m: undefined, cq: undefined }, ], [ "a media query list with one uncompilable branch", "@media (width > env(safe-area-inset-top)), (width > 400px) { .child { color: red } }", + { m: [[">", "width", 400]], cq: undefined }, + ], + [ + "a ratio whose quotient is finite", + "@media (min-aspect-ratio: 0/1) { .child { color: red } }", + { m: [[">=", "aspect-ratio", 0]], cq: undefined }, ], ]; - test.each(cases)("%s", (_label, css) => { - expect(compileChildRules(css)).toHaveLength(1); + test.each(cases)("%s", (_label, css, conditions) => { + expect( + compileChildRules(css).map((rule) => ({ m: rule.m, cq: rule.cq })), + ).toStrictEqual([conditions]); }); }); diff --git a/src/__tests__/compiler/container-query.test.ts b/src/__tests__/compiler/container-query.test.ts index c31a400b..63d6696b 100644 --- a/src/__tests__/compiler/container-query.test.ts +++ b/src/__tests__/compiler/container-query.test.ts @@ -1,6 +1,6 @@ import { compile, type ContainerQuery } from "react-native-css/compiler"; -import { sizeComparisons } from "../_media-features"; +import { COMPARISON_MATCHES, sizeComparisons } from "../_media-features"; /** * Returns the container queries the compiler attached to `.child`. @@ -45,9 +45,14 @@ describe("size feature comparisons", () => { return [row.condition(400), query]; }); - test("the table covers the whole census", () => { - expect(cases).toHaveLength(sizeComparisons().length); + test("every operator in the census reaches this table", () => { + // Against `COMPARISON_MATCHES`, whose keys are the operator union itself, + // rather than against the length of the generator these cases came from — + // that product holds for any census, an empty one included. expect(cases.length).toBeGreaterThan(0); + expect(new Set(cases.map(([, query]) => query.m?.[0]))).toStrictEqual( + new Set(Object.keys(COMPARISON_MATCHES)), + ); }); test.each(cases)("@container %s", (condition, query) => { @@ -67,6 +72,12 @@ describe("other size features", () => { ["(aspect-ratio >= 4/3)", { m: [">=", "aspect-ratio", 4 / 3] }], ["(min-aspect-ratio: 16/9)", { m: [">=", "aspect-ratio", 16 / 9] }], ["(max-aspect-ratio: 16/9)", { m: ["<=", "aspect-ratio", 16 / 9] }], + // The logical axes. `inline-size` is the feature `container-type: + // inline-size` names, so it is the one most container queries are written + // against, and it compiles under its own name rather than being folded + // into `width` here. + ["(min-inline-size: 400px)", { m: [">=", "inline-size", 400] }], + ["(max-block-size: 400px)", { m: ["<=", "block-size", 400] }], [ "my-container (min-width: 400px)", { m: [">=", "width", 400], n: "c:my-container" }, diff --git a/src/__tests__/compiler/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index 8ff5cd6f..7204004c 100644 --- a/src/__tests__/compiler/media-query.test.ts +++ b/src/__tests__/compiler/media-query.test.ts @@ -1,6 +1,6 @@ import { compile, type MediaCondition } from "react-native-css/compiler"; -import { sizeComparisons } from "../_media-features"; +import { COMPARISON_MATCHES, sizeComparisons } from "../_media-features"; /** * Returns the media conditions the compiler attached to `.my-class`. @@ -127,9 +127,14 @@ describe("size feature comparisons", () => { return [row.condition(400), condition]; }); - test("the table covers the whole census", () => { - expect(cases).toHaveLength(sizeComparisons().length); + test("every operator in the census reaches this table", () => { + // Against `COMPARISON_MATCHES`, whose keys are the operator union itself, + // rather than against the length of the generator these cases came from — + // that product holds for any census, an empty one included. expect(cases.length).toBeGreaterThan(0); + expect(new Set(cases.map(([, condition]) => condition[0]))).toStrictEqual( + new Set(Object.keys(COMPARISON_MATCHES)), + ); }); test.each(cases)("@media %s", (prelude, condition) => { diff --git a/src/__tests__/compiler/native-runtime-isolation.test.ts b/src/__tests__/compiler/native-runtime-isolation.test.ts index 2b130029..e8c34602 100644 --- a/src/__tests__/compiler/native-runtime-isolation.test.ts +++ b/src/__tests__/compiler/native-runtime-isolation.test.ts @@ -16,7 +16,19 @@ import ts from "typescript"; */ const SOURCE_ROOT = resolve(__dirname, "..", ".."); const COMPILER_ROOT = join(SOURCE_ROOT, "compiler"); -const RUNTIME_PLANES = ["native", "native-internal"]; + +/** + * The module whose evaluation is the cost, stated once and reached through the + * graph rather than named a second time as a directory census. + * + * A list of runtime directories has to be kept in step with every entry point + * that leads into one, and the entry points are exactly what a compiler source + * would write: `react-native-css` re-exports `runtime`, which on the native + * platform is `runtime.native`, which is the whole native plane. None of those + * three module ids sits under a runtime directory, so a directory census reads + * them as unrelated to it while they pull all of it. + */ +const RUNTIME_ROOT = "native/reactivity"; interface RuntimeImport { /** Source file, relative to `src/` and POSIX separated. */ @@ -30,10 +42,10 @@ function toPosix(path: string): string { } /** - * Resolves a module specifier to a path relative to `src/`, or `undefined` for - * an external package. `react-native-css/*` maps onto `src/*` — the alias the - * root tsconfig declares and the one the source uses to cross plane - * boundaries. + * Resolves a module specifier to a module id — a path relative to `src/`, with + * no extension — or `undefined` for an external package. `react-native-css/*` + * maps onto `src/*`, the alias the root tsconfig declares and the one the + * source uses to cross plane boundaries. */ function resolveWithinSource( specifier: string, @@ -55,8 +67,15 @@ function resolveWithinSource( } /** - * Every module specifier a file imports for its runtime value, i.e. every one - * that survives into the emitted JavaScript. + * Every module specifier a file references for its runtime value, i.e. every + * one that survives into the emitted JavaScript. + * + * A `require(...)` or a dynamic `import(...)` inside a function body counts: + * the reference survives emit, and this repo already writes them deliberately + * (`components/index.cts` lazily requires every component). Whether the module + * is evaluated eagerly or on first call is a question for + * `native/runtime-boot.test.ts`, which measures evaluation; this scan asks only + * whether the reference is there. */ function findEmittedSpecifiers(sourceText: string, fileName: string): string[] { const sourceFile = ts.createSourceFile( @@ -68,31 +87,53 @@ function findEmittedSpecifiers(sourceText: string, fileName: string): string[] { const specifiers: string[] = []; - for (const statement of sourceFile.statements) { + const read = (node: ts.Node): void => { let moduleSpecifier: ts.Expression | undefined; - if (ts.isImportDeclaration(statement)) { + if (ts.isImportDeclaration(node)) { // `type` is the only phase that elides the module reference. `defer` // still evaluates it, just later. - if (statement.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword) { - continue; + if (node.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword) { + return; } - moduleSpecifier = statement.moduleSpecifier; - } else if (ts.isExportDeclaration(statement)) { - if (statement.isTypeOnly) { - continue; + moduleSpecifier = node.moduleSpecifier; + } else if (ts.isExportDeclaration(node)) { + if (node.isTypeOnly) { + return; + } + moduleSpecifier = node.moduleSpecifier; + } else if (ts.isCallExpression(node)) { + const isRequire = + ts.isIdentifier(node.expression) && node.expression.text === "require"; + const isDynamicImport = + node.expression.kind === ts.SyntaxKind.ImportKeyword; + + if (isRequire || isDynamicImport) { + moduleSpecifier = node.arguments[0]; } - moduleSpecifier = statement.moduleSpecifier; } if (moduleSpecifier && ts.isStringLiteral(moduleSpecifier)) { specifiers.push(moduleSpecifier.text); } - } + + ts.forEachChild(node, read); + }; + + ts.forEachChild(sourceFile, read); return specifiers; } +/** + * The module id a file answers to, i.e. its path with the extension and any + * platform suffix removed. `runtime.native.ts` answers to `runtime`, because + * that is the specifier a bundler resolves it through on native. + */ +function moduleIdOf(relativePath: string): string { + return relativePath.replace(/(\.(native|web|ios|android))?\.[cm]?tsx?$/, ""); +} + function listSourceFiles(directory: string): string[] { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name); @@ -101,21 +142,120 @@ function listSourceFiles(directory: string): string[] { return listSourceFiles(path); } - return /\.tsx?$/.test(entry.name) ? [path] : []; + // A declaration file emits nothing, so it has no module reference to find. + return /\.[cm]?tsx?$/.test(entry.name) && !entry.name.endsWith(".d.ts") + ? [path] + : []; }); } +/** + * Every file each module id resolves to. + * + * A platform-suffixed file is registered under both its bare id and its + * literal path, because both are written: `./runtime` picks whichever variant + * the platform has, and `./runtime.native` names one directly. An id with + * several variants keeps all of them — the question this suite asks is whether + * a specifier can reach the runtime on ANY platform, and answering it for the + * platform that happens to be running is how `runtime.native` stayed invisible. + */ +function indexSourceFiles(files: string[]): Map { + const byId = new Map(); + + const register = (id: string, file: string): void => { + const existing = byId.get(id); + + if (existing) { + existing.push(file); + } else { + byId.set(id, [file]); + } + }; + + for (const file of files) { + const relativePath = toPosix(relative(SOURCE_ROOT, file)); + const id = moduleIdOf(relativePath); + const literal = relativePath.replace(/\.[cm]?tsx?$/, ""); + + register(id, file); + + if (literal !== id) { + register(literal, file); + } + } + + return byId; +} + +const sourceFiles = listSourceFiles(SOURCE_ROOT); +const filesById = indexSourceFiles(sourceFiles); + +const specifiersByFile = new Map( + sourceFiles.map((file): [string, string[]] => { + return [file, findEmittedSpecifiers(readFileSync(file, "utf8"), file)]; + }), +); + +/** The files a module id resolves to, directory `index` included. */ +function filesFor(moduleId: string): string[] { + return filesById.get(moduleId) ?? filesById.get(`${moduleId}/index`) ?? []; +} + +/** + * Whether evaluating this module id can reach {@link RUNTIME_ROOT}, through any + * number of hops and on any platform. + * + * The transitive walk is the point: a check that reads the specifier a compiler + * file wrote and asks whether it names a runtime directory cannot see a path + * through a third directory, nor one through the package's own entry point. + */ +function reachesRuntime(moduleId: string): boolean { + const seen = new Set(); + let frontier = [moduleId]; + + while (frontier.length > 0) { + const next: string[] = []; + + for (const id of frontier) { + if (seen.has(id)) { + continue; + } + seen.add(id); + + if (id === RUNTIME_ROOT) { + return true; + } + + for (const file of filesFor(id)) { + const relativePath = toPosix(relative(SOURCE_ROOT, file)); + + if (moduleIdOf(relativePath) === RUNTIME_ROOT) { + return true; + } + + for (const specifier of specifiersByFile.get(file) ?? []) { + const target = resolveWithinSource(specifier, file); + + if (target !== undefined) { + next.push(target); + } + } + } + } + + frontier = next; + } + + return false; +} + function findRuntimeImports(files: string[]): RuntimeImport[] { return files.flatMap((file) => { - return findEmittedSpecifiers(readFileSync(file, "utf8"), file).flatMap( + return (specifiersByFile.get(file) ?? []).flatMap( (specifier): RuntimeImport[] => { const target = resolveWithinSource(specifier, file); - const crossesPlanes = RUNTIME_PLANES.some((plane) => { - return target === plane || target?.startsWith(`${plane}/`); - }); - - return crossesPlanes + return target !== undefined && reachesRuntime(target) ? [{ from: toPosix(relative(SOURCE_ROOT, file)), specifier }] : []; }, @@ -137,6 +277,23 @@ describe("the emitted-specifier detector", () => { // evaluated. This is exactly the shape the invariant below exists for. ["inline type specifiers", `import { type A } from "./a";`, ["./a"]], ["a local export", `export const a = 1;`, []], + // A reference inside a function body survives emit too, so the scan has to + // walk past the top-level statements to find it. + [ + "a require inside a function", + `export function a() { return require("./a"); }`, + ["./a"], + ], + [ + "a dynamic import inside a function", + `export async function a() { return import("./a"); }`, + ["./a"], + ], + [ + "a require with a computed specifier", + `export function a(name: string) { return require(name); }`, + [], + ], ]; test.each(cases)("%s emits %j", (_description, source, emitted) => { @@ -144,6 +301,68 @@ describe("the emitted-specifier detector", () => { }); }); +describe("the module graph", () => { + /** + * The graph decides the invariant below, so an empty or mis-resolving one + * would pass it by finding nothing. These guards are what make the scan's + * silence mean something. + */ + test("the scan reaches the whole source tree", () => { + const scanned = sourceFiles.map((file) => { + return toPosix(relative(SOURCE_ROOT, file)); + }); + + expect(scanned).toContain("index.ts"); + expect(scanned).toContain("runtime.ts"); + expect(scanned).toContain("runtime.native.ts"); + expect(scanned).toContain("native/reactivity.ts"); + expect(scanned.length).toBeGreaterThan(100); + }); + + test("the runtime root resolves to a file", () => { + // Rename or move `native/reactivity` and every reachability answer below + // silently becomes `false`, which is the one way this suite could pass by + // measuring nothing. + expect(filesFor(RUNTIME_ROOT)).toHaveLength(1); + }); + + test("a platform-suffixed module answers to both of its specifiers", () => { + expect(filesFor("runtime")).toHaveLength(2); + expect(filesFor("runtime.native")).toHaveLength(1); + }); + + /** + * What the invariant discriminates, stated as a table rather than left to the + * one negative assertion below. Every id on the true side is something a + * compiler source could plausibly write, and each one is a way into the whole + * native plane. + */ + const reachability: [moduleId: string, reaches: boolean][] = [ + // The package's own entry points. `index` re-exports `runtime`, and + // `runtime` is `runtime.native` on the native platform. + ["index", true], + ["runtime", true], + ["runtime.native", true], + ["native", true], + ["native/reactivity", true], + ["native-internal", true], + ["components", true], + // The build-time and web planes, which is what this directory is. + ["compiler", false], + ["web", false], + ["babel", false], + ["metro", false], + ["utilities", false], + ]; + + test.each(reachability)( + "%s reaches the native runtime: %s", + (id, reaches) => { + expect(reachesRuntime(id)).toBe(reaches); + }, + ); +}); + describe("compiler sources", () => { const files = listSourceFiles(COMPILER_ROOT); @@ -153,6 +372,10 @@ describe("compiler sources", () => { expect(scanned).toContain("compiler/compiler.types.ts"); expect(scanned).toContain("compiler/compiler.ts"); expect(scanned).toContain("compiler/index.ts"); + // `inheritance.test.ts` sits in this directory rather than under + // `__tests__`, so bob compiles it into `dist` and the package ships it. It + // is scanned for that reason, not by oversight. + expect(scanned).toContain("compiler/inheritance.test.ts"); expect(scanned.length).toBeGreaterThan(10); }); diff --git a/src/__tests__/native/compare.test.ts b/src/__tests__/native/compare.test.ts index 3ca9d598..82e4f128 100644 --- a/src/__tests__/native/compare.test.ts +++ b/src/__tests__/native/compare.test.ts @@ -44,12 +44,23 @@ const cases = COMPARISON_OPERATORS.flatMap((operator) => { }); }); +/** + * `COMPARISON_MATCHES` is a total `Record` over `MediaFeatureComparison`, so + * its keys are the union itself and comparing the census against them is the + * one assertion in this file that an operator cannot go missing from. Every + * other table in the suite is generated from `COMPARISON_OPERATORS`, which + * makes this the link the rest of them hang off: drop an operator here and + * nineteen cases stop being generated across four files, all of them silently. + * + * Comparing `cases.length` against the product of the two censuses would not + * catch it — `cases` is built by mapping over exactly those two, so the length + * is the product whatever they contain, zero included. + */ test("the table covers every operator against every ordering", () => { expect([...COMPARISON_OPERATORS].sort()).toStrictEqual( Object.keys(COMPARISON_MATCHES).sort(), ); expect([...ORDERINGS].sort()).toStrictEqual(Object.keys(operands).sort()); - expect(cases).toHaveLength(COMPARISON_OPERATORS.length * ORDERINGS.length); expect(cases.length).toBeGreaterThan(0); }); @@ -148,6 +159,16 @@ describe("testMediaFeatureInterval", () => { /** * A feature the evaluator could not measure, and a bound the compiler could * not resolve, are both "no answer" rather than "no bound". + * + * All three slots are typed `StyleDescriptor`, so a string is inside the + * declared domain of each, and `compareMediaFeature`'s numeric guard is what + * keeps one out of an arithmetic comparison. Which row observes that guard is + * not obvious: a string that does not look like a number is refused by the + * comparison itself — `"landscape" < 800` is `NaN < 800` — so the first four + * rows hold whether the guard is there or not, and only a string that + * COERCES can tell the two apart. The three numeric-string rows are the ones + * that do, because `400 < "500"` is `400 < 500` and an unguarded interval + * then matches against a value it never measured. */ const unanswerable: [ label: string, @@ -166,6 +187,21 @@ describe("testMediaFeatureInterval", () => { 600, ], ["an unresolved end bound", ["[]", "width", 400, "<", undefined, "<"], 600], + [ + "a feature value that is a numeric string", + ["[]", "width", 400, "<", 800, "<"], + "500", + ], + [ + "a start bound that is a numeric string", + ["[]", "width", "400", "<", 800, "<"], + 600, + ], + [ + "an end bound that is a numeric string", + ["[]", "width", 400, "<", "800", "<"], + 600, + ], ]; test.each(unanswerable)("%s never matches", (_label, condition, value) => { diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx index 5abc2d3f..d5ae3ea8 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react-native"; +import type { MediaFeatureComparison } from "react-native-css/compiler"; import { View } from "react-native-css/components/View"; import { registerCSS } from "react-native-css/jest"; @@ -206,24 +207,39 @@ describe("size comparisons", () => { * shared census, so this table and the primitive's own cannot disagree about * what an operator means. */ - const cases: [condition: string, ordering: Ordering, matches: boolean][] = - sizeComparisons().flatMap((row) => { - return ORDERINGS.map( - ( + const cases: [ + condition: string, + ordering: Ordering, + matches: boolean, + operator: MediaFeatureComparison, + ][] = sizeComparisons().flatMap((row) => { + return ORDERINGS.map( + ( + ordering, + ): [ + condition: string, + ordering: Ordering, + matches: boolean, + operator: MediaFeatureComparison, + ] => { + return [ + row.condition(THRESHOLDS[row.feature][ordering]), ordering, - ): [condition: string, ordering: Ordering, matches: boolean] => { - return [ - row.condition(THRESHOLDS[row.feature][ordering]), - ordering, - COMPARISON_MATCHES[row.operator][ordering], - ]; - }, - ); - }); - - test("the table covers the whole census", () => { - expect(cases).toHaveLength(sizeComparisons().length * ORDERINGS.length); + COMPARISON_MATCHES[row.operator][ordering], + row.operator, + ]; + }, + ); + }); + + test("every operator in the census reaches this table", () => { + // Against `COMPARISON_MATCHES`, whose keys are the operator union itself, + // rather than against the length of the generator these cases came from — + // that product holds for any census, an empty one included. expect(cases.length).toBeGreaterThan(0); + expect(new Set(cases.map(([, , , operator]) => operator))).toStrictEqual( + new Set(Object.keys(COMPARISON_MATCHES)), + ); }); test.each(cases)( @@ -242,11 +258,49 @@ test("each size axis is measured on its own axis", () => { expect(containerQueryMatches("(height > 300px)", CONTAINER)).toBe(false); }); +describe("logical size features", () => { + /** + * `inline-size` and `block-size` are the axes under React Native's single + * writing mode, so they are the physical ones: inline is horizontal, block + * vertical. `container-type: inline-size` names the first of them, which + * makes `(min-inline-size: …)` the most ordinary container query there is. + * + * Stated differentially as well as absolutely: on a landscape container one + * threshold cannot satisfy both axes, so an axis answered off the other one + * cannot pass this table by picking convenient numbers. + */ + const cases: [condition: string, matches: boolean][] = [ + ["(min-inline-size: 400px)", true], + ["(min-inline-size: 500px)", false], + ["(max-inline-size: 400px)", true], + ["(inline-size > 300px)", true], + ["(min-block-size: 200px)", true], + ["(min-block-size: 300px)", false], + ["(block-size > 300px)", false], + ["(400px < inline-size < 800px)", false], + ["(300px < inline-size < 800px)", true], + ]; + + test.each(cases)( + "@container %s against a 400x200 container matches: %s", + (condition, matches) => { + expect(containerQueryMatches(condition, CONTAINER)).toBe(matches); + }, + ); +}); + describe("aspect ratio", () => { /** * A container's aspect ratio is its width over its height, so every case * names the container it is measured against — the 400x200 landscape one is * exactly 2, the 200x400 portrait one exactly 0.5, and 300x300 exactly 1. + * + * The two verdicts are not interchangeable here. Reintroduce the defect this + * table exists for — an `aspect-ratio` value the compiler will not resolve — + * and only the `matches: true` rows redden, because the block is refused and + * never reaches the runtime. The `matches: false` rows are what catches the + * opposite failure, a block kept but emitted with no condition at all, which + * is what an unresolved value produces wherever it is not refused. */ const cases: [ condition: string, @@ -280,6 +334,11 @@ describe("interval (range pair) conditions", () => { * placed on either side of the measured value. Each bound is exercised open * and closed, because an interval is two comparisons and getting one of them * wrong still looks like an interval. + * + * As in the aspect-ratio table, the two verdicts observe opposite failures: + * an interval arm that stops answering reddens only the `matches: true` + * rows, and one that answers everything reddens only the `matches: false` + * ones. */ const cases: [condition: string, matches: boolean][] = [ ["(400px < width < 800px)", true], diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 3cf96efc..6c75a2b8 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -1,6 +1,7 @@ import { PixelRatio } from "react-native"; import { act, render, screen } from "@testing-library/react-native"; +import type { MediaFeatureComparison } 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"; @@ -269,22 +270,39 @@ describe("size comparisons", () => { * `@media` and another under `@container` is the drift that primitive * exists to make impossible, and only a shared table can observe it. */ - const cases: [prelude: string, ordering: Ordering, matches: boolean][] = - sizeComparisons().flatMap((row) => { - return ORDERINGS.map( - (ordering): [prelude: string, ordering: Ordering, matches: boolean] => { - return [ - row.condition(THRESHOLDS[row.feature][ordering]), - ordering, - COMPARISON_MATCHES[row.operator][ordering], - ]; - }, - ); - }); + const cases: [ + prelude: string, + ordering: Ordering, + matches: boolean, + operator: MediaFeatureComparison, + ][] = sizeComparisons().flatMap((row) => { + return ORDERINGS.map( + ( + ordering, + ): [ + prelude: string, + ordering: Ordering, + matches: boolean, + operator: MediaFeatureComparison, + ] => { + return [ + row.condition(THRESHOLDS[row.feature][ordering]), + ordering, + COMPARISON_MATCHES[row.operator][ordering], + row.operator, + ]; + }, + ); + }); - test("the table covers the whole census", () => { - expect(cases).toHaveLength(sizeComparisons().length * ORDERINGS.length); + test("every operator in the census reaches this table", () => { + // Against `COMPARISON_MATCHES`, whose keys are the operator union itself, + // rather than against the length of the generator these cases came from — + // that product holds for any census, an empty one included. expect(cases.length).toBeGreaterThan(0); + expect(new Set(cases.map(([, , , operator]) => operator))).toStrictEqual( + new Set(Object.keys(COMPARISON_MATCHES)), + ); }); test.each(cases)( @@ -347,6 +365,12 @@ describe("aspect-ratio", () => { /** * The viewport's aspect ratio is its width over its height, measured off the * same two observables `width` and `height` already read. + * + * The two verdicts are not interchangeable. Reintroduce the defect this + * table exists for — an `aspect-ratio` value the compiler will not resolve — + * and only the `matches: true` rows redden, because the block is refused and + * never reaches the runtime. The `matches: false` rows are what catches the + * opposite failure, a block emitted with no condition at all. */ const cases: [ prelude: string, @@ -386,6 +410,11 @@ describe("interval (range pair) conditions", () => { /** * A 600x200 viewport, so both bounds of an interval on either axis can be * placed on either side of the measured value. + * + * As in the aspect-ratio table, the two verdicts observe opposite failures: + * an interval arm that stops answering reddens only the `matches: true` + * rows, and one that answers everything reddens only the `matches: false` + * ones. */ const cases: [prelude: string, matches: boolean][] = [ ["(400px < width < 800px)", true], diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index aad5782d..f001f84a 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -366,10 +366,26 @@ function extractMedia( const compiled = media.map((m) => parseMediaQuery(m, builder)); - // A comma-separated media query list is a union, so a branch that cannot - // match contributes nothing while the others still apply. When no branch can - // match, neither can the block, and its rules must not be emitted at all — - // emitting them with no media query applies them everywhere instead. + // A branch that cannot match contributes nothing, and when no branch can + // match, neither can the block: its rules must not be emitted at all, since + // emitting them with no media query applies them everywhere instead. That + // holds however the surviving branches are combined, so this decision does + // not rest on the divergence below. + // + // How they ARE combined is where native parts from CSS, and it parts here + // rather than in the evaluator. `rule.m` is a flat array fed from two places + // with opposite meanings — one entry per comma branch, which CSS unions, and + // one per enclosing `@media` block or media-carrying selector, which CSS + // intersects — and `testMediaQuery` intersects the whole array. Nesting is + // therefore right and a comma list is not: `@media (min-width: 400px), + // (min-height: 300px)` matches only where both hold. Two entries of the same + // shape mean two different things, so no change to the evaluator can fix one + // without breaking the other; the emit has to say which it is, by carrying a + // list of two or more as a single `["|", conditions]`, and by emitting no + // condition at all when a branch is `always` — `@media all, (…)` is + // unconditional. That is a change to what is emitted rather than to how a + // condition is evaluated, so it stands as a known limit here rather than as + // a half-fix in the evaluator. if (compiled.every(({ type }) => type === "never")) { return; } diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 4d086f34..db3d9c40 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -169,11 +169,20 @@ export function parseMediaFeatureValue( value.value satisfies never; return undefined; } - case "ratio": + case "ratio": { // A `` is a pair of numbers standing for their quotient, and the // quotient is what both runtimes derive from their two axes. A bare // number parses as a ratio too, so `1` arrives here as `[1, 1]`. - return value.value[0] / value.value[1]; + const quotient = value.value[0] / value.value[1]; + + // A degenerate ratio — `1/0`, `0/0` — has no finite quotient, so there + // is no bound for a comparison to mean anything against. It is refused, + // which is what turns the block into one that did not compile and drops + // it. Emitting the quotient instead ships a number the bundle cannot + // carry: `JSON.stringify` writes `Infinity` and `NaN` as `null`, so the + // condition would mean one thing under jest and another on a device. + return Number.isFinite(quotient) ? quotient : undefined; + } case "env": } diff --git a/src/native/conditions/compare.ts b/src/native/conditions/compare.ts index b4140622..6ae719e3 100644 --- a/src/native/conditions/compare.ts +++ b/src/native/conditions/compare.ts @@ -11,22 +11,41 @@ import type { export type MediaInterval = Extract; /** - * Evaluates a single CSS range comparison. + * Evaluates a single CSS comparison against whatever the feature answered. * * Media queries and container queries share the `MediaFeatureComparison` * vocabulary, so they share this one implementation of it: an operator has * exactly one meaning at runtime, and the two evaluators cannot drift apart. - * A second hand-written copy of the switch is the defect this prevents — the - * arms differ by a single character, so a wrong one reads as correct. + * A second hand-written copy of an arm is the defect this prevents — the arms + * differ by a single character, so a wrong one reads as correct. + * + * Both operands are `StyleDescriptor` rather than `number`, because that is + * what a feature answers and because narrowing at the call site is how the + * second copy gets written: an evaluator that has to reject a keyword before + * it can call this ends up deciding `=` itself. */ export function compareMediaFeature( operator: MediaFeatureComparison, - left: number, - right: number, + left: StyleDescriptor, + right: StyleDescriptor, ): boolean { + // `=` is the one operator with a meaning off the number line — `orientation` + // answers `"landscape"`, and equality is the only comparison that says + // anything about a keyword. A feature the evaluator could not measure + // answers `undefined`, which equals nothing, not even another unmeasured + // feature. + if (operator === "=") { + return left !== undefined && left === right; + } + + // The remaining four are arithmetic, so a value that is not a number has + // nothing to compare. Coercion is the trap: `400 < "500"` is `400 < 500`, + // which answers a query about a feature that was never measured. + if (typeof left !== "number" || typeof right !== "number") { + return false; + } + switch (operator) { - case "=": - return left === right; case ">": return left > right; case ">=": @@ -51,6 +70,10 @@ export function compareMediaFeature( * Both call sites share this one destructuring, because an interval whose * halves are assembled in the wrong order is still a well-formed interval and * says something else. + * + * An interval is two comparisons and nothing more, so it holds no numeric + * guard of its own: an unmeasured value or an unresolved bound fails whichever + * comparison it is an operand of. */ export function testMediaFeatureInterval( condition: MediaInterval, @@ -58,14 +81,6 @@ export function testMediaFeatureInterval( ): boolean { const [, , start, startOperator, end, endOperator] = condition; - if ( - typeof value !== "number" || - typeof start !== "number" || - typeof end !== "number" - ) { - return false; - } - 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 db9ffd50..f8f9f8a3 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -96,6 +96,11 @@ function testContainerMediaCondition( return condition[1].some((query) => { return testContainerMediaCondition(query, containerKey, get); }); + // `@container (width)` asks whether the feature is present and non-zero. + // Answering it is unimplemented rather than decided: the boolean context + // has its own truthiness rule per feature, and `false` here is a container + // query that reads as valid and can never match. The media evaluator holds + // the same gap. case "!!": return false; case "[]": @@ -107,20 +112,12 @@ function testContainerMediaCondition( 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; - } - - return compareMediaFeature(condition[0], left, right); - } + case "=": + return compareMediaFeature( + condition[0], + getContainerFeatureValue(condition[1], containerKey, get), + condition[2], + ); default: condition satisfies never; return false; @@ -146,8 +143,14 @@ function getContainerFeatureValue( const width = get(containerWidthFamily(containerKey)); const height = get(containerHeightFamily(containerKey)); return width > height ? "landscape" : "portrait"; + // React Native lays out in one writing mode, so the logical axes are the + // physical ones: inline is horizontal and block is vertical. `inline-size` + // is also the axis `container-type: inline-size` names, which makes it the + // feature most container queries are written against. case "inline-size": + return get(containerWidthFamily(containerKey)); case "block-size": + return get(containerHeightFamily(containerKey)); default: return; } diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index ae890a81..467daadf 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -25,12 +25,25 @@ type MediaComparison = Extract< /** The feature name a comparison or an interval condition is written against. */ type MediaFeatureName = MediaComparison[1] | MediaInterval[1]; +/** + * `rule.m` carries one condition per enclosing `@media` block and one per + * media-carrying selector, which CSS intersects, alongside one per comma + * branch, which CSS unions. Intersecting is right for the first two and wrong + * for the third, and the two are indistinguishable once they are in the array, + * so `.some(...)` here would only move the defect onto nesting. The compiler is + * where a list has to be marked as one — see `extractMedia`. + */ export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); } function test(mediaQuery: MediaCondition, get: Getter): boolean { switch (mediaQuery[0]) { + // `@media (width)` asks whether the feature is present and non-zero. + // Answering it is unimplemented rather than decided: the boolean context + // has its own truthiness rule per feature, and `false` here is a media + // query that reads as valid and can never match. The container evaluator + // holds the same gap. case "!!": return false; case "[]": @@ -85,17 +98,11 @@ function testComparison(mediaQuery: MediaComparison, get: Getter): boolean { return value === "landscape" ? get(vh) < get(vw) : get(vh) >= get(vw); } - if (typeof value !== "number") { - return false; - } - - const left = getMediaFeatureValue(mediaQuery[1], get); - - if (left === undefined) { - return false; - } - - return compareMediaFeature(mediaQuery[0], left, value); + return compareMediaFeature( + mediaQuery[0], + getMediaFeatureValue(mediaQuery[1], get), + value, + ); } /**