diff --git a/src/__tests__/_media-features.ts b/src/__tests__/_media-features.ts new file mode 100644 index 00000000..d00f686c --- /dev/null +++ b/src/__tests__/_media-features.ts @@ -0,0 +1,171 @@ +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. + * + * 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. + */ + +/** + * 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/conditional-group-rules.test.ts b/src/__tests__/compiler/conditional-group-rules.test.ts new file mode 100644 index 00000000..3334c769 --- /dev/null +++ b/src/__tests__/compiler/conditional-group-rules.test.ts @@ -0,0 +1,135 @@ +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 } }", + ], + // 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", () => { + 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. + * + * 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, + 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, 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 new file mode 100644 index 00000000..63d6696b --- /dev/null +++ b/src/__tests__/compiler/container-query.test.ts @@ -0,0 +1,146 @@ +import { compile, type ContainerQuery } from "react-native-css/compiler"; + +import { COMPARISON_MATCHES, sizeComparisons } from "../_media-features"; + +/** + * 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", () => { + /** + * 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 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("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) => { + expect(compileContainerQueries(condition)).toStrictEqual([query]); + }); +}); + +describe("other size features", () => { + const cases: [condition: string, query: ContainerQuery][] = [ + ["(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] }], + // 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" }, + ], + ]; + + test.each(cases)("@container %s", (condition, query) => { + expect(compileContainerQueries(condition)).toStrictEqual([query]); + }); +}); + +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 { + 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] }], + ]); +}); + +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__/compiler/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index 760ede29..7204004c 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 { COMPARISON_MATCHES, 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", () => { @@ -85,3 +109,78 @@ test("@media (hover: hover)", () => { ], }); }); + +describe("size feature comparisons", () => { + /** + * 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. + */ + const cases: [prelude: string, condition: MediaCondition][] = + sizeComparisons().map((row) => { + const condition: MediaCondition = [row.operator, row.feature, 400]; + return [row.condition(400), condition]; + }); + + 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) => { + 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, 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, condition) => { + expect(compileMediaConditions(prelude)).toStrictEqual([condition]); + }); +}); 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..e8c34602 --- /dev/null +++ b/src/__tests__/compiler/native-runtime-isolation.test.ts @@ -0,0 +1,394 @@ +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"); + +/** + * 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. */ + 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 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, + 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 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( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + ); + + const specifiers: string[] = []; + + const read = (node: ts.Node): void => { + let moduleSpecifier: ts.Expression | undefined; + + if (ts.isImportDeclaration(node)) { + // `type` is the only phase that elides the module reference. `defer` + // still evaluates it, just later. + if (node.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword) { + return; + } + 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]; + } + } + + 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); + + if (entry.isDirectory()) { + return listSourceFiles(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 (specifiersByFile.get(file) ?? []).flatMap( + (specifier): RuntimeImport[] => { + const target = resolveWithinSource(specifier, file); + + return target !== undefined && reachesRuntime(target) + ? [{ 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;`, []], + // 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) => { + expect(findEmittedSpecifiers(source, "probe.ts")).toStrictEqual(emitted); + }); +}); + +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); + + 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"); + // `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); + }); + + 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/__tests__/native/compare.test.ts b/src/__tests__/native/compare.test.ts new file mode 100644 index 00000000..82e4f128 --- /dev/null +++ b/src/__tests__/native/compare.test.ts @@ -0,0 +1,212 @@ +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, + type MediaInterval, +} 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. + * + * 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. + */ +const operands: Record = { + "measured < threshold": [100, 200], + "measured === threshold": [200, 200], + "measured > threshold": [300, 200], +}; + +const cases = COMPARISON_OPERATORS.flatMap((operator) => { + return ORDERINGS.map((ordering) => { + const [measured, threshold] = operands[ordering]; + return [ + operator, + ordering, + measured, + threshold, + COMPARISON_MATCHES[operator][ordering], + ] as const; + }); +}); + +/** + * `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.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, measured, threshold, result) => { + expect(compareMediaFeature(operator, measured, threshold)).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". + * + * 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, + 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], + [ + "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) => { + 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 3ea60394..d5ae3ea8 100644 --- a/src/__tests__/native/container-queries.test.tsx +++ b/src/__tests__/native/container-queries.test.tsx @@ -1,7 +1,16 @@ 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"; +import { + COMPARISON_MATCHES, + ORDERINGS, + sizeComparisons, + type Ordering, + type SizeFeature, +} from "../_media-features"; + const parentID = "parent"; const childID = "child"; @@ -113,3 +122,292 @@ test("container query width", () => { color: "#00f", }); }); + +/** + * Renders `.child` inside a container laid out at `width` x `height`, and + * 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, + { 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"; +} + +/** + * 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 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, + 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, + 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)( + "@container %s (%s) against a 400x200 container matches: %s", + (condition, _ordering, matches) => { + expect(containerQueryMatches(condition, CONTAINER)).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("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, + 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("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. + * + * 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], + ["(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 + * 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], + // 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/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 020b4aad..6c75a2b8 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -1,10 +1,18 @@ 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"; +import { + COMPARISON_MATCHES, + ORDERINGS, + sizeComparisons, + type Ordering, + type SizeFeature, +} from "../_media-features"; import { dimensions } from "../../native/reactivity"; jest.mock("react-native", () => { @@ -200,6 +208,272 @@ 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, + 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("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)( + "@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 + * 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, + 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("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], + ["(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 + * 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/__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 }); +}); 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..f001f84a 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -364,8 +364,36 @@ function extractMedia( return; } - for (const m of media) { - parseMediaQuery(m, builder); + const compiled = media.map((m) => parseMediaQuery(m, builder)); + + // 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; + } + + 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 +414,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/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; 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..db3d9c40 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( @@ -163,7 +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]`. + 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 new file mode 100644 index 00000000..6ae719e3 --- /dev/null +++ b/src/native/conditions/compare.ts @@ -0,0 +1,88 @@ +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 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 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: 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 "<": + return left < right; + case "<=": + return left <= right; + default: + operator satisfies never; + 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. + * + * 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, + value: StyleDescriptor, +): boolean { + const [, , start, startOperator, end, endOperator] = condition; + + return ( + compareMediaFeature(startOperator, start, value) && + compareMediaFeature(endOperator, value, end) + ); +} diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index ac546c9a..f8f9f8a3 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -17,6 +17,7 @@ import { type Getter, } from "../reactivity"; // import { testAttributes } from "./attributes"; +import { compareMediaFeature, testMediaFeatureInterval } from "./compare"; import type { RenderGuard } from "./guards"; export const DEFAULT_CONTAINER_NAME = "c:___default___"; @@ -95,40 +96,28 @@ 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 "[]": - return false; + return testMediaFeatureInterval( + condition, + getContainerFeatureValue(condition[1], containerKey, get), + ); case ">": case ">=": case "<": case "<=": - case "=": { - const left = getContainerFeatureValue(condition[1], containerKey, get); - const right = condition[2]; - - if (condition[0] === "=") { - return left === right; - } - - if (typeof left !== "number" || typeof right !== "number") { - return false; - } - - switch (condition[0]) { - case ">": - return left > right; - case ">=": - return left > right; - case "<": - return left > right; - case "<=": - return left > right; - default: - condition[0] satisfies never; - return false; - } - } + case "=": + return compareMediaFeature( + condition[0], + getContainerFeatureValue(condition[1], containerKey, get), + condition[2], + ); default: condition satisfies never; return false; @@ -154,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 75cd9006..467daadf 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,19 +1,56 @@ /* eslint-disable */ import { I18nManager, PixelRatio, Platform } from "react-native"; -import type { MediaCondition } from "react-native-css/compiler"; +import type { + MediaCondition, + MediaFeatureComparison, +} from "react-native-css/compiler"; import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { + compareMediaFeature, + testMediaFeatureInterval, + type MediaInterval, +} from "./compare"; +/** + * 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[]] +>; + +/** 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 { +function test(mediaQuery: MediaCondition, get: Getter): boolean { switch (mediaQuery[0]) { - case "[]": + // `@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 "[]": + return testMediaFeatureInterval( + mediaQuery, + getMediaFeatureValue(mediaQuery[1], get), + ); case "!": return !test(mediaQuery[1], get); case "&": @@ -34,7 +71,7 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { } } -function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { +function testComparison(mediaQuery: MediaComparison, get: Getter): boolean { const value = mediaQuery[2]; switch (mediaQuery[1]) { @@ -61,39 +98,32 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { return value === "landscape" ? get(vh) < get(vw) : get(vh) >= get(vw); } - if (typeof value !== "number") { - return false; - } - - let left: number | undefined; - const right = value; + return compareMediaFeature( + mediaQuery[0], + getMediaFeatureValue(mediaQuery[1], get), + value, + ); +} - switch (mediaQuery[1]) { +/** + * 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": + return get(vw) / get(vh); case "resolution": - left = PixelRatio.get(); - break; - default: - return false; - } - - switch (mediaQuery[0]) { - case "=": - return left === right; - case ">": - return left > right; - case ">=": - return left >= right; - case "<": - return left < right; - case "<=": - return left <= right; + return PixelRatio.get(); default: - return false; + return undefined; } } 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; }); });