From f036503c212fbe0bf4a4c839c6490555e63783b4 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 14:53:58 +0300 Subject: [PATCH 1/3] feat(native): evaluate prefers-reduced-motion on native Only prefers-color-scheme was wired into the native media-query evaluator (via Appearance). prefers-reduced-motion had no observable and no evaluator case, so `motion-reduce:` / `motion-safe:` (and any @media (prefers-reduced-motion) rule) compiled cleanly and then resolved to false on every device -- a silent no-op off the web. Mirror the colorScheme wiring: a reduceMotion observable seeded from AccessibilityInfo.isReduceMotionEnabled() and kept live via the reduceMotionChanged event, plus a prefers-reduced-motion case in testComparison handling reduce and no-preference. Also handle the bare `@media (prefers-reduced-motion)` boolean form (per CSS, bare is equivalent to reduce). Adds runtime tests (reduce, no-preference, composition, negation, bare) mirroring the existing color-scheme test, plus a compiler test asserting the emitted media condition. --- src/__tests__/compiler/compiler.test.tsx | 63 +++++++++++ src/__tests__/native/media-query.test.tsx | 125 +++++++++++++++++++++- src/native/conditions/media-query.ts | 15 ++- src/native/reactivity.ts | 17 +++ 4 files changed, 217 insertions(+), 3 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 7a9fbea8..59d3b1f6 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -324,6 +324,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..c79177e1 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,126 @@ describe("max-resolution", () => { expect(component.props.style).toStrictEqual(undefined); }); }); + +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" }); + }); +}); diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 75cd9006..c34a5910 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,12 @@ 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`. Mirror the prefers-color-scheme wiring, reading the + // live OS flag from the AccessibilityInfo-backed reduceMotion observable. + return value === "no-preference" ? !get(reduceMotion) : get(reduceMotion); + } 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 0824edeb..5fada05e 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, @@ -221,6 +222,22 @@ export const colorScheme = observable( ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); +/** Reduce Motion ************************************************************/ + +// Mirror the Color Scheme wiring above. Appearance.getColorScheme() is +// synchronous, but AccessibilityInfo has no synchronous getter, so the +// observable seeds `false` (motion enabled — the safe default) and flips +// when isReduceMotionEnabled() resolves, then stays live via the +// reduceMotionChanged event. iOS drives this directly; on Android the OS +// surface is the animation duration scale (react-native #31221). +export const reduceMotion = observable(false); +AccessibilityInfo.isReduceMotionEnabled().then((enabled) => + reduceMotion.set(enabled), +); +AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled) => + reduceMotion.set(enabled), +); + /** Containers ****************************************************************/ export type ContainerContextValue = Record; From 06d3ead687560147b1da6974275f80220953fdda Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 15:11:28 +0300 Subject: [PATCH 2/3] docs(native): clarify the reduce-motion cold-start seed Note that AccessibilityInfo has no synchronous getter (unlike Appearance.getColorScheme()), so the observable seeds `false` and flips when the async read resolves -- a brief, inherent cold-start window. Comment-only; no behaviour change. --- src/native/reactivity.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 5fada05e..d129c032 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -224,12 +224,13 @@ Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); /** Reduce Motion ************************************************************/ -// Mirror the Color Scheme wiring above. Appearance.getColorScheme() is -// synchronous, but AccessibilityInfo has no synchronous getter, so the -// observable seeds `false` (motion enabled — the safe default) and flips -// when isReduceMotionEnabled() resolves, then stays live via the -// reduceMotionChanged event. iOS drives this directly; on Android the OS -// surface is the animation duration scale (react-native #31221). +// 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 the OS surface is the animation duration scale (react-native +// #31221). The one-shot seed read is intentionally fire-and-forget. export const reduceMotion = observable(false); AccessibilityInfo.isReduceMotionEnabled().then((enabled) => reduceMotion.set(enabled), From 0757e09eac5552620f62712dbed210193a77d5ff Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 22:15:23 +0300 Subject: [PATCH 3/3] fix(native): guard the AccessibilityInfo seed and compare the value exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the original, both measured. The seed reached out to AccessibilityInfo unguarded at module scope. This module is the root of every media feature, so on a host without a native AccessibilityInfo the missing getter took colorScheme, vw/vh and containers down with it at import — and a rejecting getter killed the process outright, since nothing attached a catch. Both now guarded. The comparison arm was a two-way branch on `no-preference`, so it answered `get(reduceMotion)` for every other value — `(prefers-reduced-motion: anything)` matched whenever the flag was on. The compiler emits `["=", name, value]` with no allowlist, so that condition is reachable. An equality test makes an unrecognised value false, as MQ5 requires. Three tests cover what nothing did: that the observable is wired to AccessibilityInfo at all — deleting either the seed or the listener was green before — that the cold-start default survives its own beforeEach, and that an absent or rejecting getter leaves the module importable. The Android comment named the wrong setting. AccessibilityInfoModule reads Settings.Global.TRANSITION_ANIMATION_SCALE, not the animator duration scale; the cited react-native#31221 is precisely about the two disagreeing. --- src/__tests__/native/media-query.test.tsx | 30 +++++++ .../native/reduce-motion-init.test.ts | 83 +++++++++++++++++++ src/native/conditions/media-query.ts | 7 +- src/native/reactivity.ts | 34 ++++++-- 4 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/native/reduce-motion-init.test.ts diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index c79177e1..e4a4ab99 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -284,6 +284,14 @@ describe("max-resolution", () => { }); }); +// 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). @@ -405,4 +413,26 @@ describe("prefers-reduced-motion", () => { }); 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..1de0a74a --- /dev/null +++ b/src/__tests__/native/reduce-motion-init.test.ts @@ -0,0 +1,83 @@ +// 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", async () => { + jest.resetModules(); + jest.doMock( + "react-native", + withAccessibilityInfo({ addEventListener: () => noopSubscription }), + ); + + const reactivity = await import("../../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 = await import("../../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 = await import("../../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 c34a5910..2685602a 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -54,9 +54,10 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { } case "prefers-reduced-motion": { // `motion-reduce:` compiles to `reduce`, `motion-safe:` to - // `no-preference`. Mirror the prefers-color-scheme wiring, reading the - // live OS flag from the AccessibilityInfo-backed reduceMotion observable. - return value === "no-preference" ? !get(reduceMotion) : get(reduceMotion); + // `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; diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index d129c032..df7d535c 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -228,16 +228,32 @@ Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); // 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 the OS surface is the animation duration scale (react-native -// #31221). The one-shot seed read is intentionally fire-and-forget. +// 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); -AccessibilityInfo.isReduceMotionEnabled().then((enabled) => - reduceMotion.set(enabled), -); -AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled) => - reduceMotion.set(enabled), -); + +// 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 ****************************************************************/