Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c0028b1
fix(native): evaluate range pairs through one shared comparison
YevheniiKotyrlo Oct 11, 2026
a05beba
fix: answer a condition the runtime cannot decide with unknown, not f…
YevheniiKotyrlo Oct 11, 2026
981e205
fix(compiler): leave a discrete feature unknown when its keyword is n…
YevheniiKotyrlo Oct 20, 2026
437238a
fix(compiler): fold a media feature's keyword to lowercase before com…
YevheniiKotyrlo Oct 20, 2026
a2ab922
refactor(native): drop the min- and max- feature arms the compiler ne…
YevheniiKotyrlo Oct 20, 2026
eeb79d1
test(native): select no container for a range pair with an unresolvab…
YevheniiKotyrlo Oct 20, 2026
e1233af
refactor(native): lint the media evaluator with no file-wide disable
YevheniiKotyrlo Oct 20, 2026
d21d3ce
fix: keep an unresolvable term unknown, prune what no device can matc…
YevheniiKotyrlo Oct 20, 2026
551a78d
fix(jest): reset the hover signal through the package's internal entr…
YevheniiKotyrlo Oct 20, 2026
e6efb30
fix: measure pointer and any-pointer, and make a media feature holdin…
YevheniiKotyrlo Oct 20, 2026
6527b4e
refactor(native): match any-pointer through the shared comparison, an…
YevheniiKotyrlo Oct 21, 2026
fbd7bb8
fix: read an unlisted condition as unknown and a TV or Mac Catalyst i…
YevheniiKotyrlo Oct 21, 2026
01c2f5b
refactor: keep the feature parsers private, and read a condition with…
YevheniiKotyrlo Oct 21, 2026
ccff882
fix(native): answer pointer and hover from the primary input, as MQ5 …
YevheniiKotyrlo Oct 21, 2026
781496d
test: assert the absent colour where toHaveStyle(undefined) asserted …
YevheniiKotyrlo Oct 21, 2026
0b2fbbe
perf(native): evaluate a condition without allocating, and measure -w…
YevheniiKotyrlo Oct 21, 2026
328c8da
perf(native): read a condition's tuple by index on the evaluation path
YevheniiKotyrlo Oct 21, 2026
8836326
fix(compiler): match a media type in any case, as a keyword already does
YevheniiKotyrlo Oct 21, 2026
f560c39
fix(compiler): leave a feature holding a negative resolution unknown
YevheniiKotyrlo Oct 22, 2026
a147973
fix(compiler): read a media query's em and rem against the font size …
YevheniiKotyrlo Oct 16, 2026
9c0fbca
refactor(native): read the default font size from DEFAULT_REM
YevheniiKotyrlo Oct 16, 2026
206c6cc
fix(compiler): read a media query's em against the root font size, in…
YevheniiKotyrlo Oct 22, 2026
567c206
Merge branch 'main' into fix/media-initial-font-size
YevheniiKotyrlo Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions src/__tests__/compiler/condition-semantics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type {
ContainerCondition as CSSContainerCondition,
MediaCondition as CSSMediaCondition,
} from "lightningcss";
import { compile, type StyleRule } from "react-native-css/compiler";

import { parseContainerCondition } from "../../compiler/container-query";
import { parseMediaQueryCondition } from "../../compiler/media-query";
import { StylesheetBuilder } from "../../compiler/stylesheet";

function compileChildConditions(css: string): Pick<StyleRule, "m" | "cq">[] {
return (compile(css).stylesheet().s ?? []).flatMap(([className, rules]) =>
className === "child" ? rules.map(({ m, cq }) => ({ m, cq })) : [],
);
}

// CSS Conditional 5 §5.4: an unknown size feature or an unresolvable value selects no container
test.each([
"@container (fictional-feature > 10px)",
"@container (fictional-feature > 10px) or (width > 400px)",
"@container not (fictional-feature > 10px)",
"@container (width > env(safe-area-inset-top))",
"@container (width > env(safe-area-inset-top)) or (width > 400px)",
])("%s emits no block", (prelude) => {
expect(
compileChildConditions(`${prelude} { .child { color: red } }`),
).toStrictEqual([]);
});

// An unknown keeps an `and` or a `not` unknown (MQ5 §3.1), so a condition no device can make true compiles to no rule
test.each([
"@container style(--foo: bar)",
"@container not style(--foo: bar)",
"@container style(--foo: bar) and (width > 400px)",
"@media (width > env(safe-area-inset-top))",
"@media not (width: env(safe-area-inset-top))",
"@media (orientation: bogus)",
"@media not (orientation: bogus)",
"@media not ((width > 100px) or (orientation: bogus))",
"@media not (env(safe-area-inset-top) < width < env(safe-area-inset-bottom))",
"@media not (env(safe-area-inset-top) < width < 800px)",
"@media not (400px < width < env(safe-area-inset-bottom))",
"@media not ((env(safe-area-inset-top) < width < 800px) or (width < 0px))",
"@media (min-resolution: -1dpi)",
"@media (max-resolution: -1dpi)",
"@media not (max-resolution: -1dpi)",
"@media (resolution: -1dppx)",
"@media not (resolution: -1dppx)",
"@media (resolution > -1dppx)",
"@media not (resolution < -1x)",
"@media not (-1dppx < resolution < 1dppx)",
"@media (min-resolution: -1dpcm)",
])("%s, which no device can make true, emits no block", (prelude) => {
expect(
compileChildConditions(`${prelude} { .child { color: red } }`),
).toStrictEqual([]);
});

test.each<[string, Pick<StyleRule, "m" | "cq">]>([
[
"@container style(--foo: bar) or (width > 400px)",
{ m: undefined, cq: [{ m: ["|", [["?"], [">", "width", 400]]] }] },
],
[
"@media (width > env(safe-area-inset-top)) or (width > 400px)",
{ m: [["|", [["?"], [">", "width", 400]]]], cq: undefined },
],
[
"@media (min-resolution: -1dppx) or (width > 400px)",
{ m: [["|", [["?"], [">", "width", 400]]]], cq: undefined },
],
[
"@container (width > 10ch) or (width > 100px)",
{
m: undefined,
cq: [
{
m: [
"|",
[
[">", "width", null],
[">", "width", 100],
],
],
},
],
},
],
[
"@media not (400px < width < 800px)",
{ m: [["!", ["[]", "width", 400, "<", 800, "<"]]], cq: undefined },
],
[
"@container (width > 10em)",
{ m: undefined, cq: [{ m: [">", "width", [{}, "em", 10, 1]] }] },
],
[
"@media (constructor: x)",
{ m: [["=", "constructor", "x"]], cq: undefined },
],
["@media (grid: 0)", { m: [["=", "grid", 0]], cq: undefined }],
["@media (grid: 1)", { m: [["=", "grid", 1]], cq: undefined }],
])("%s compiles to %j", (prelude, conditions) => {
expect(
compileChildConditions(`${prelude} { .child { color: red } }`),
).toStrictEqual([conditions]);
});

test("a media query list keeps the branch a device can match beside one none can", () => {
expect(
compileChildConditions(
"@media (width > env(safe-area-inset-top)), (width > 400px) { .child { color: red } }",
),
).toStrictEqual([{ m: [[">", "width", 400]], cq: undefined }]);
});

// lightningcss 1.33's error recovery emits <general-enclosed> as a condition the 1.30 types do not list, which MQ5 §3.1 leaves unknown
const UNLISTED_CONDITIONS = [
["condition", { type: "unknown", value: [] }],
["operator", { type: "operation", operator: "xor", conditions: [] }],
] as const;

test.each(UNLISTED_CONDITIONS)(
"a media %s lightningcss's types do not list compiles to an unknown term",
(_, unlisted) => {
const condition: unknown = unlisted;

expect(
parseMediaQueryCondition(
condition as CSSMediaCondition,
new StylesheetBuilder({}),
),
).toStrictEqual(["?"]);
},
);

test.each(UNLISTED_CONDITIONS)(
"a container %s lightningcss's types do not list selects no container",
(_, unlisted) => {
const condition: unknown = unlisted;

expect(
parseContainerCondition(
condition as CSSContainerCondition,
new StylesheetBuilder({}),
),
).toBeUndefined();
},
);
141 changes: 141 additions & 0 deletions src/__tests__/native/compare.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type {
MediaFeatureComparison,
MediaFeatureOperand,
} from "react-native-css/compiler";

import {
compareMediaFeature,
evaluateCondition,
isTruthyFeatureValue,
testMediaFeatureInterval,
type FeatureReader,
type MeasuredFeatureValue,
type MediaFeatureName,
type MediaInterval,
} from "../../native/conditions/compare";
import { UNKNOWN, type Truth } from "../../native/conditions/kleene";

test.each([
[">", [false, false, true]],
[">=", [false, true, true]],
["<", [true, false, false]],
["<=", [true, true, false]],
["=", [false, true, false]],
] as const)("%s compares 39, 40 and 41 against 40", (operator, expected) => {
expect(
[39, 40, 41].map((value) => compareMediaFeature(operator, value, 40)),
).toStrictEqual(expected);
});

const operands: [
label: string,
operator: MediaFeatureComparison,
left: MediaFeatureOperand | undefined,
right: MediaFeatureOperand,
truth: Truth,
][] = [
["an unmeasured feature", ">", undefined, 400, UNKNOWN],
["an unresolved operand", ">", 400, null, UNKNOWN],
["an operand only an element resolves", ">", 400, [{}, "em", 10, 1], UNKNOWN],
["a keyword ordered against a number", ">", "landscape", 400, UNKNOWN],
["an equal keyword", "=", "landscape", "landscape", true],
["a different keyword", "=", "portrait", "landscape", false],
];

test.each(operands)("%s", (_label, operator, left, right, truth) => {
expect(compareMediaFeature(operator, left, right)).toBe(truth);
});

const intervals: [
label: string,
condition: MediaInterval,
value: MeasuredFeatureValue,
truth: Truth,
][] = [
["inside", ["[]", "width", 400, "<", 800, "<"], 600, true],
["below", ["[]", "width", 400, "<", 800, "<"], 300, false],
["above", ["[]", "width", 400, "<", 800, "<"], 900, false],
["on an open bound", ["[]", "width", 400, "<", 800, "<"], 400, false],
["on a closed bound", ["[]", "width", 400, "<=", 800, "<"], 400, true],
["written descending", ["[]", "width", 800, ">", 400, ">"], 600, true],
["unmeasured", ["[]", "width", 400, "<", 800, "<"], undefined, UNKNOWN],
[
"with an unresolved bound",
["[]", "width", null, "<", 800, "<"],
600,
UNKNOWN,
],
["false on one bound", ["[]", "width", 700, "<", null, "<"], 600, false],
];

test.each(intervals)("an interval %s", (_label, condition, value, truth) => {
expect(testMediaFeatureInterval(condition, value)).toBe(truth);
});

test.each([
[400, true],
[0, false],
[Number.NaN, false],
["hover", true],
["none", false],
["no-preference", false],
[undefined, UNKNOWN],
] as const)("%p in a boolean context is %p", (value, truth) => {
expect(isTruthyFeatureValue(value)).toBe(truth);
});

const recordingReader = (
values: Readonly<Partial<Record<MediaFeatureName, MeasuredFeatureValue>>>,
read: MediaFeatureName[],
): FeatureReader<undefined> => ({
read: (name) => {
read.push(name);
return values[name];
},
compare: ([operator, name, value]) => {
read.push(name);
return compareMediaFeature(operator, values[name], value);
},
});

test("an empty conjunction is true and an empty disjunction false", () => {
const reader = recordingReader({}, []);

expect(evaluateCondition(["&", []], reader, undefined)).toBe(true);
expect(evaluateCondition(["|", []], reader, undefined)).toBe(false);
});

test("a decided term stops the evaluation, and an unknown one does not", () => {
const read: MediaFeatureName[] = [];
const reader = recordingReader({ width: 400, height: 300 }, read);

expect(
evaluateCondition(
[
"&",
[
["=", "scan", "progressive"],
[">", "width", 500],
[">", "height", 100],
],
],
reader,
undefined,
),
).toBe(false);
expect(
evaluateCondition(
[
"|",
[
["=", "scan", "progressive"],
[">", "width", 300],
[">", "height", 100],
],
],
reader,
undefined,
),
).toBe(true);
expect(read).toStrictEqual(["scan", "width", "scan", "width"]);
});
Loading