From cc6efa47b6285ebb4763a5dd620d8cb3d574ef83 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 6 Oct 2026 14:17:17 +0300 Subject: [PATCH 1/7] feat(native): evaluate prefers-reduced-motion so motion-reduce / motion-safe work on native --- src/__tests__/compiler/compiler.test.tsx | 63 +++++++ src/__tests__/native/media-query.test.tsx | 155 +++++++++++++++++- .../native/reduce-motion-init.test.ts | 89 ++++++++++ src/native/conditions/media-query.ts | 16 +- src/native/reactivity.ts | 34 ++++ 5 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/native/reduce-motion-init.test.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..e4c62545 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -232,6 +232,69 @@ test("light-dark()", () => { }); }); +test("prefers-reduced-motion", () => { + // `motion-reduce:` compiles to `reduce`, `motion-safe:` to `no-preference`, + // and a bare `@media (prefers-reduced-motion)` to the boolean (`!!`) form. + // The native runtime evaluates these against the reduceMotion observable + // (see native/media-query.test.tsx). + expect( + compile( + `@media (prefers-reduced-motion: reduce) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + s: [2, 1], + m: [["=", "prefers-reduced-motion", "reduce"]], + d: [{ opacity: 0 }], + }, + ], + ], + ], + }); + + expect( + compile( + `@media (prefers-reduced-motion: no-preference) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + s: [2, 1], + m: [["=", "prefers-reduced-motion", "no-preference"]], + d: [{ opacity: 0 }], + }, + ], + ], + ], + }); + + expect( + compile( + `@media (prefers-reduced-motion) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + s: [2, 1], + m: [["!!", "prefers-reduced-motion"]], + d: [{ opacity: 0 }], + }, + ], + ], + ], + }); +}); + test("media query nested in rules", () => { const compiled = compile(` .my-class { diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 020b4aad..e4a4ab99 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -5,7 +5,7 @@ import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; import { colorScheme } from "react-native-css/runtime"; -import { dimensions } from "../../native/reactivity"; +import { dimensions, reduceMotion } from "../../native/reactivity"; jest.mock("react-native", () => { const RN = jest.requireActual("react-native"); @@ -283,3 +283,156 @@ describe("max-resolution", () => { expect(component.props.style).toStrictEqual(undefined); }); }); + +// Outside the describe below, whose beforeEach overwrites the value before any +// assertion can see it. The documented cold-start default is motion ENABLED: the +// getter is async with no synchronous counterpart, so the first paint answers from +// this, and seeding true would suppress motion-safe: styling for every user. +test("reduceMotion defaults to false before AccessibilityInfo answers", () => { + expect(reduceMotion.get()).toBe(false); +}); + +describe("prefers-reduced-motion", () => { + // reduceMotion and colorScheme are module-global observables; reset them so + // each test starts from a known state (motion enabled, light scheme). + beforeEach(() => { + act(() => { + reduceMotion.set(false); + colorScheme.set("light"); + }); + }); + + test("reduce (motion-reduce:) — applies only when reduce motion is enabled", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion: reduce) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Default: motion enabled → the reduce rule does not apply. + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + // Reactive both ways — toggling the OS flag off restores the base style. + act(() => { + reduceMotion.set(false); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("no-preference (motion-safe:) — applies only when reduce motion is disabled", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion: no-preference) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Default: motion enabled → no-preference matches. + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("composes with prefers-color-scheme via `and`", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion: reduce) and (prefers-color-scheme: dark) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + // Only reduce motion — the rule still needs dark. + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + // Both conditions now hold. + act(() => { + colorScheme.set("dark"); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("negation — not (prefers-reduced-motion: reduce)", () => { + registerCSS(` +.my-class { color: blue; } + +@media not all and (prefers-reduced-motion: reduce) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Motion enabled → not(reduce) is true → the rule applies. + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("bare boolean — @media (prefers-reduced-motion) is equivalent to reduce", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Bare boolean form matches when reduce motion is enabled (CSS: bare ≡ reduce). + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("an unrecognised value never matches", () => { + // The compiler emits ["=", name, value] for any value, with no allowlist, so + // this condition is reachable. MQ5 makes an unknown value false — a two-way + // branch on `no-preference` would alias everything else to `reduce`. + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion: bogus-value) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); +}); diff --git a/src/__tests__/native/reduce-motion-init.test.ts b/src/__tests__/native/reduce-motion-init.test.ts new file mode 100644 index 00000000..17f1e8e4 --- /dev/null +++ b/src/__tests__/native/reduce-motion-init.test.ts @@ -0,0 +1,89 @@ +// src/native/reactivity.ts is the root of every media feature — colorScheme, vw/vh, +// containers and reduceMotion all live there. Seeding reduceMotion reaches out to +// AccessibilityInfo at module scope, so a host without a native AccessibilityInfo +// must not take the whole module down with it. +// +// A Proxy over requireActual rather than a spread: spreading react-native eagerly +// triggers its lazy DevMenu getter and throws. +const noopSubscription = { remove: () => undefined }; + +const withAccessibilityInfo = + (accessibilityInfo: unknown) => (): Record => { + const actual = jest.requireActual>("react-native"); + + return new Proxy(actual, { + get: (target, property): unknown => + property === "AccessibilityInfo" + ? accessibilityInfo + : Reflect.get(target, property), + }); + }; + +describe("an AccessibilityInfo without isReduceMotionEnabled", () => { + test("does not stop the reactivity module importing", () => { + jest.resetModules(); + jest.doMock( + "react-native", + withAccessibilityInfo({ addEventListener: () => noopSubscription }), + ); + + const reactivity = jest.requireActual< + typeof import("../../native/reactivity") + >("../../native/reactivity"); + + expect(reactivity.reduceMotion.get()).toBe(false); + expect(reactivity.colorScheme).toBeDefined(); + expect(reactivity.vw).toBeDefined(); + }); +}); + +describe("an AccessibilityInfo whose getter rejects", () => { + test("leaves the safe default in place and does not reject unhandled", async () => { + jest.resetModules(); + jest.doMock( + "react-native", + withAccessibilityInfo({ + isReduceMotionEnabled: () => + Promise.reject(new Error("NativeAccessibilityManager unavailable")), + addEventListener: () => noopSubscription, + }), + ); + + const reactivity = jest.requireActual< + typeof import("../../native/reactivity") + >("../../native/reactivity"); + await Promise.resolve(); + + expect(reactivity.reduceMotion.get()).toBe(false); + }); +}); + +describe("a working AccessibilityInfo", () => { + test("seeds reduceMotion from it and stays live on the change event", async () => { + jest.resetModules(); + let changed: ((enabled: boolean) => void) | undefined; + jest.doMock( + "react-native", + withAccessibilityInfo({ + isReduceMotionEnabled: () => Promise.resolve(true), + addEventListener: (event: string, listener: (v: boolean) => void) => { + if (event === "reduceMotionChanged") changed = listener; + return noopSubscription; + }, + }), + ); + + const reactivity = jest.requireActual< + typeof import("../../native/reactivity") + >("../../native/reactivity"); + await Promise.resolve(); + + // The seed is what connects the observable to the OS at all; without it the + // flag is a value the library only ever writes to itself + expect(reactivity.reduceMotion.get()).toBe(true); + + expect(changed).toBeDefined(); + changed?.(false); + expect(reactivity.reduceMotion.get()).toBe(false); + }); +}); diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index afd51250..d5225269 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -3,7 +3,7 @@ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; -import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { colorScheme, reduceMotion, vh, vw, type Getter } from "../reactivity"; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); @@ -12,8 +12,13 @@ export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { function test(mediaQuery: MediaCondition, get: Getter): Boolean { switch (mediaQuery[0]) { case "[]": - case "!!": return false; + case "!!": + // A bare boolean media feature, e.g. `@media (prefers-reduced-motion)`. + // Per CSS, bare `(prefers-reduced-motion)` is equivalent to `reduce`. + return mediaQuery[1] === "prefers-reduced-motion" + ? get(reduceMotion) + : false; case "!": return !test(mediaQuery[1], get); case "&": @@ -47,6 +52,13 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { case "prefers-color-scheme": { return value === get(colorScheme); } + case "prefers-reduced-motion": { + // `motion-reduce:` compiles to `reduce`, `motion-safe:` to + // `no-preference`. An equality test rather than a two-way branch, so an + // unrecognised value is false as MQ5 requires, instead of aliasing to + // `reduce`. + return value === (get(reduceMotion) ? "reduce" : "no-preference"); + } case "display-mode": return value === "native" || Platform.OS === value; case "min-width": diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 296988a6..7360b22c 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -1,6 +1,7 @@ /* eslint-disable */ import { createContext } from "react"; import { + AccessibilityInfo, Appearance, Dimensions, type ColorSchemeName, @@ -233,6 +234,39 @@ export const colorScheme = observable( ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); +/** Reduce Motion ************************************************************/ + +// Mirror the Color Scheme wiring above — but AccessibilityInfo has no +// synchronous getter (Appearance.getColorScheme() does), so this can't be +// seeded synchronously. It starts `false` (motion enabled — the safe default), +// flips when isReduceMotionEnabled() resolves (a brief, unavoidable cold-start +// window), and stays live via reduceMotionChanged. iOS drives this directly; on +// Android there is no distinct setting, so AccessibilityInfoModule reads +// Settings.Global.TRANSITION_ANIMATION_SCALE and reports true when it is 0 +// (react-native #31221 — which is about that reading disagreeing with the +// "Remove animations" toggle). +export const reduceMotion = observable(false); + +// Guarded because this module is the root of every media feature. Without a +// native AccessibilityInfo the getter is absent or rejects, and an unguarded +// call throws at module scope — taking colorScheme, vw/vh and containers down +// with it, none of which have anything to do with motion. +try { + AccessibilityInfo.isReduceMotionEnabled() + .then((enabled) => reduceMotion.set(enabled)) + .catch(() => undefined); +} catch { + // Leave the safe default in place. +} + +try { + AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled) => + reduceMotion.set(enabled), + ); +} catch { + // Without the listener the flag stays at whatever the seed resolved to. +} + /** Containers ****************************************************************/ export type ContainerContextValue = Record< From 4f4cd69fe471c50d38ae43028144ac9419f06fd4 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 16:11:26 +0300 Subject: [PATCH 2/7] fix(compiler): read a media feature's keyword value case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A keyword is ASCII case-insensitive (css-values-4 §2.5), and lightningcss hands an unknown feature's ident through as authored, so `(prefers-color-scheme: DARK)` and `(prefers-reduced-motion: REDUCE)` matched nothing. The compiler lowercases an ident value. The native suite checks every row of a browser table: twelve queries under each motion preference and colour scheme, as Chromium, Firefox and WebKit evaluate them. --- src/__tests__/native/media-query.test.tsx | 67 +++++++++++++++++++++++ src/compiler/media-query.ts | 4 +- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index e4a4ab99..9567b5d0 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -436,3 +436,70 @@ describe("prefers-reduced-motion", () => { expect(component.props.style).toStrictEqual({ color: "#00f" }); }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 agree on every row: [query, reduce/light, reduce/dark, no-preference/light, no-preference/dark] +const BROWSER_MEDIA_ROWS = [ + ["(prefers-reduced-motion: reduce)", true, true, false, false], + ["(prefers-reduced-motion: no-preference)", false, false, true, true], + ["(prefers-reduced-motion)", true, true, false, false], + ["not (prefers-reduced-motion)", false, false, true, true], + ["(prefers-reduced-motion: bogus)", false, false, false, false], + ["not all and (prefers-reduced-motion: reduce)", false, false, true, true], + [ + "(prefers-reduced-motion: reduce) and (min-width: 1px)", + true, + true, + false, + false, + ], + [ + "(prefers-reduced-motion: reduce), (prefers-color-scheme: dark)", + true, + true, + false, + true, + ], + ["not (prefers-reduced-motion: reduce)", false, false, true, true], + ["(prefers-reduced-motion: REDUCE)", true, true, false, false], + ["(prefers-color-scheme: DARK)", false, true, false, true], + ["(prefers-color-scheme: dark)", false, true, false, true], +] as const; + +const MEDIA_STATES = [ + ["reduce", "light"], + ["reduce", "dark"], + ["no-preference", "light"], + ["no-preference", "dark"], +] as const; + +describe.each(BROWSER_MEDIA_ROWS)( + "@media %s matches as browsers match it", + (query, ...matches) => { + afterEach(() => { + act(() => { + reduceMotion.set(false); + colorScheme.set("light"); + }); + }); + + test.each( + MEDIA_STATES.map( + ([motion, scheme], index) => [motion, scheme, matches[index]] as const, + ), + )("under %s motion in the %s scheme: %s", (motion, scheme, expected) => { + act(() => { + reduceMotion.set(motion === "reduce"); + colorScheme.set(scheme); + }); + registerCSS( + `.my-class { color: #000000; } @media ${query} { .my-class { color: #010101; } }`, + ); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: expected ? "#010101" : "#000", + }); + }); + }, +); diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 1408adc0..0aa76156 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -145,8 +145,10 @@ export function parseMediaFeatureValue( builder: StylesheetBuilder, ): StyleDescriptor { switch (value.type) { - case "boolean": case "ident": + // A keyword is ASCII case-insensitive (css-values-4 §2.5), so `DARK` names the scheme `dark` does + return value.value.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); + case "boolean": case "integer": case "number": return value.value; From f950d7fce728fde7a97589f925c3fff4b8bbd91e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 16:11:36 +0300 Subject: [PATCH 3/7] refactor(native): state the reduced-motion constraints in one line each --- src/native/conditions/media-query.ts | 8 ++------ src/native/reactivity.ts | 19 ++++--------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index d5225269..d29a422e 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -14,8 +14,7 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { case "[]": return false; case "!!": - // A bare boolean media feature, e.g. `@media (prefers-reduced-motion)`. - // Per CSS, bare `(prefers-reduced-motion)` is equivalent to `reduce`. + // MQ5: a feature in a boolean context is true unless its value is `no-preference` return mediaQuery[1] === "prefers-reduced-motion" ? get(reduceMotion) : false; @@ -53,10 +52,7 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { return value === get(colorScheme); } case "prefers-reduced-motion": { - // `motion-reduce:` compiles to `reduce`, `motion-safe:` to - // `no-preference`. An equality test rather than a two-way branch, so an - // unrecognised value is false as MQ5 requires, instead of aliasing to - // `reduce`. + // An unknown value matches nothing (MQ5), so this is an equality, not a two-way branch return value === (get(reduceMotion) ? "reduce" : "no-preference"); } case "display-mode": diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 7360b22c..5d2b6715 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -236,27 +236,16 @@ Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); /** Reduce Motion ************************************************************/ -// Mirror the Color Scheme wiring above — but AccessibilityInfo has no -// synchronous getter (Appearance.getColorScheme() does), so this can't be -// seeded synchronously. It starts `false` (motion enabled — the safe default), -// flips when isReduceMotionEnabled() resolves (a brief, unavoidable cold-start -// window), and stays live via reduceMotionChanged. iOS drives this directly; on -// Android there is no distinct setting, so AccessibilityInfoModule reads -// Settings.Global.TRANSITION_ANIMATION_SCALE and reports true when it is 0 -// (react-native #31221 — which is about that reading disagreeing with the -// "Remove animations" toggle). +// AccessibilityInfo answers asynchronously, so motion counts as enabled until it does; Android reports its animator scale (react-native#31221) export const reduceMotion = observable(false); -// Guarded because this module is the root of every media feature. Without a -// native AccessibilityInfo the getter is absent or rejects, and an unguarded -// call throws at module scope — taking colorScheme, vw/vh and containers down -// with it, none of which have anything to do with motion. +// Every media feature imports this module, so a platform without AccessibilityInfo must not throw here try { AccessibilityInfo.isReduceMotionEnabled() .then((enabled) => reduceMotion.set(enabled)) .catch(() => undefined); } catch { - // Leave the safe default in place. + // Motion stays enabled } try { @@ -264,7 +253,7 @@ try { reduceMotion.set(enabled), ); } catch { - // Without the listener the flag stays at whatever the seed resolved to. + // The flag keeps the value the seed resolved to } /** Containers ****************************************************************/ From aec778cee454b1a6e4694e5b90b49264a7e9861e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 17:04:12 +0300 Subject: [PATCH 4/7] fix(native): evaluate a media feature in a boolean context as MQ4 does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A feature in a boolean context is true unless its value is zero, `none` or one the feature defines as false (MQ4 §2.4.2), so `(prefers-color-scheme)`, `(orientation)`, `(width)`, `(height)`, `(resolution)` and `(display-mode)` match in browsers. The runtime answered every one of them false. The browser table in the native suite gains a row for each. --- src/__tests__/native/media-query.test.tsx | 8 ++++++++ src/native/conditions/media-query.ts | 24 +++++++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 9567b5d0..45e03f71 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -463,6 +463,14 @@ const BROWSER_MEDIA_ROWS = [ ["(prefers-reduced-motion: REDUCE)", true, true, false, false], ["(prefers-color-scheme: DARK)", false, true, false, true], ["(prefers-color-scheme: dark)", false, true, false, true], + ["(prefers-color-scheme)", true, true, true, true], + ["not (prefers-color-scheme)", false, false, false, false], + ["(orientation)", true, true, true, true], + ["(width)", true, true, true, true], + ["(height)", true, true, true, true], + ["(resolution)", true, true, true, true], + ["(display-mode)", true, true, true, true], + ["(unknown-feature)", false, false, false, false], ] as const; const MEDIA_STATES = [ diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index d29a422e..9bc13f0a 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -14,10 +14,7 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { case "[]": return false; case "!!": - // MQ5: a feature in a boolean context is true unless its value is `no-preference` - return mediaQuery[1] === "prefers-reduced-motion" - ? get(reduceMotion) - : false; + return testBooleanContext(mediaQuery[1], get); case "!": return !test(mediaQuery[1], get); case "&": @@ -38,6 +35,25 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { } } +// MQ4 §2.4.2: a feature in a boolean context is true unless its value is zero, `none` or one the feature defines as false +function testBooleanContext(feature: string, get: Getter): boolean { + switch (feature) { + case "prefers-reduced-motion": + return get(reduceMotion); + case "width": + return get(vw) !== 0; + case "height": + return get(vh) !== 0; + case "prefers-color-scheme": + case "orientation": + case "resolution": + case "display-mode": + return true; + default: + return false; + } +} + function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { const value = mediaQuery[2]; From 87e6064a0a4c57c0d3645690d6cd5de7363603c9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 17:15:12 +0300 Subject: [PATCH 5/7] refactor(native): leave the boolean context of other features to #426 #426 answers every media feature in a boolean context from its current value. This branch keeps the one arm its own feature needs. --- src/__tests__/native/media-query.test.tsx | 8 -------- src/native/conditions/media-query.ts | 24 ++++------------------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 45e03f71..9567b5d0 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -463,14 +463,6 @@ const BROWSER_MEDIA_ROWS = [ ["(prefers-reduced-motion: REDUCE)", true, true, false, false], ["(prefers-color-scheme: DARK)", false, true, false, true], ["(prefers-color-scheme: dark)", false, true, false, true], - ["(prefers-color-scheme)", true, true, true, true], - ["not (prefers-color-scheme)", false, false, false, false], - ["(orientation)", true, true, true, true], - ["(width)", true, true, true, true], - ["(height)", true, true, true, true], - ["(resolution)", true, true, true, true], - ["(display-mode)", true, true, true, true], - ["(unknown-feature)", false, false, false, false], ] as const; const MEDIA_STATES = [ diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 9bc13f0a..d29a422e 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -14,7 +14,10 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { case "[]": return false; case "!!": - return testBooleanContext(mediaQuery[1], get); + // MQ5: a feature in a boolean context is true unless its value is `no-preference` + return mediaQuery[1] === "prefers-reduced-motion" + ? get(reduceMotion) + : false; case "!": return !test(mediaQuery[1], get); case "&": @@ -35,25 +38,6 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { } } -// MQ4 §2.4.2: a feature in a boolean context is true unless its value is zero, `none` or one the feature defines as false -function testBooleanContext(feature: string, get: Getter): boolean { - switch (feature) { - case "prefers-reduced-motion": - return get(reduceMotion); - case "width": - return get(vw) !== 0; - case "height": - return get(vh) !== 0; - case "prefers-color-scheme": - case "orientation": - case "resolution": - case "display-mode": - return true; - default: - return false; - } -} - function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { const value = mediaQuery[2]; From a08470cd0c3a488a6caeec71ce0da850c6b17501 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 17:15:50 +0300 Subject: [PATCH 6/7] test(native): an unknown media feature never matches in a boolean context --- src/__tests__/native/media-query.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 9567b5d0..9f5841ae 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -463,6 +463,7 @@ const BROWSER_MEDIA_ROWS = [ ["(prefers-reduced-motion: REDUCE)", true, true, false, false], ["(prefers-color-scheme: DARK)", false, true, false, true], ["(prefers-color-scheme: dark)", false, true, false, true], + ["(unknown-feature)", false, false, false, false], ] as const; const MEDIA_STATES = [ From 598776332a10a6a7ab2effd1b5b91681f6544cab Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 17:51:02 +0300 Subject: [PATCH 7/7] =?UTF-8?q?docs(compiler):=20cite=20css-values-4=20?= =?UTF-8?q?=C2=A74.1=20for=20keyword=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/compiler/media-query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 0aa76156..858a22e3 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -146,7 +146,7 @@ export function parseMediaFeatureValue( ): StyleDescriptor { switch (value.type) { case "ident": - // A keyword is ASCII case-insensitive (css-values-4 §2.5), so `DARK` names the scheme `dark` does + // A keyword is ASCII case-insensitive (css-values-4 §4.1), so `DARK` names the scheme `dark` does return value.value.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); case "boolean": case "integer":