From cc6efa47b6285ebb4763a5dd620d8cb3d574ef83 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 6 Oct 2026 14:17:17 +0300 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] =?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": From 4bc30353ffffc5f907703a119b3a72a25d3575df Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 13:19:49 +0300 Subject: [PATCH 08/12] fix(native): keep the reduced-motion preference unknown until AccessibilityInfo answers The preference holds the prefers-reduced-motion keyword, or nothing before the platform answers, so neither motion-safe nor motion-reduce applies to a first paint that guessed. A change event that arrives before the getter's answer is kept, and the jest helper resets the preference between tests. --- src/__tests__/native/media-query.test.tsx | 185 ++++-------------- .../native/reduce-motion-init.test.ts | 141 ++++++------- src/jest/index.ts | 3 +- src/native/conditions/media-query.ts | 19 +- src/native/reactivity.ts | 45 +++-- 5 files changed, 148 insertions(+), 245 deletions(-) diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 9f5841ae..609e0526 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, reduceMotion } from "../../native/reactivity"; +import { dimensions, reducedMotion } from "../../native/reactivity"; jest.mock("react-native", () => { const RN = jest.requireActual("react-native"); @@ -284,157 +284,48 @@ 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). - 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; } -}`); +describe("a reduced-motion preference AccessibilityInfo has not answered", () => { + test.each([ + "(prefers-reduced-motion: reduce)", + "(prefers-reduced-motion: no-preference)", + "(prefers-reduced-motion)", + ])("leaves @media %s unmatched", (query) => { + registerCSS( + `.my-class { color: #000000; } @media ${query} { .my-class { color: #010101; } }`, + ); 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(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#000", }); - 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); +test("a mounted view follows the reduced-motion preference as it changes", () => { + registerCSS(` +.my-class { color: #000000; } +@media (prefers-reduced-motion: no-preference) { .my-class { color: #010101; } } +@media (prefers-reduced-motion: reduce) { .my-class { color: #020202; } }`); - // Motion enabled → not(reduce) is true → the rule applies. - expect(component.props.style).toStrictEqual({ color: "#f00" }); + render(); + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "#000" }); - act(() => { - reduceMotion.set(true); - }); - expect(component.props.style).toStrictEqual({ color: "#00f" }); + act(() => { + reducedMotion.set("no-preference"); }); + expect(component.props.style).toStrictEqual({ color: "#010101" }); - 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" }); + act(() => { + reducedMotion.set("reduce"); }); + expect(component.props.style).toStrictEqual({ color: "#020202" }); - 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" }); + act(() => { + reducedMotion.set("no-preference"); }); + expect(component.props.style).toStrictEqual({ color: "#010101" }); }); // Chromium 153, Firefox 155 and WebKit 26.6 agree on every row: [query, reduce/light, reduce/dark, no-preference/light, no-preference/dark] @@ -452,6 +343,13 @@ const BROWSER_MEDIA_ROWS = [ false, false, ], + [ + "(prefers-reduced-motion: reduce) and (prefers-color-scheme: dark)", + false, + true, + false, + false, + ], [ "(prefers-reduced-motion: reduce), (prefers-color-scheme: dark)", true, @@ -476,20 +374,13 @@ const MEDIA_STATES = [ 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"); + reducedMotion.set(motion); colorScheme.set(scheme); }); registerCSS( diff --git a/src/__tests__/native/reduce-motion-init.test.ts b/src/__tests__/native/reduce-motion-init.test.ts index 17f1e8e4..68ba618d 100644 --- a/src/__tests__/native/reduce-motion-init.test.ts +++ b/src/__tests__/native/reduce-motion-init.test.ts @@ -1,14 +1,17 @@ -// 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 => { +type ReduceMotionListener = (isReduceMotionEnabled: boolean) => void; + +interface AccessibilityInfoDouble { + readonly isReduceMotionEnabled: () => Promise; + readonly addEventListener: ( + event: string, + listener: ReduceMotionListener, + ) => { readonly remove: () => void }; +} + +function importReactivityWith(accessibilityInfo: AccessibilityInfoDouble) { + jest.resetModules(); + // A Proxy over requireActual rather than a spread: spreading react-native eagerly triggers its lazy DevMenu getter and throws + jest.doMock("react-native", () => { const actual = jest.requireActual>("react-native"); return new Proxy(actual, { @@ -17,73 +20,75 @@ const withAccessibilityInfo = ? accessibilityInfo : Reflect.get(target, property), }); + }); + + return jest.requireActual( + "../../native/reactivity", + ); +} + +function controlledAccessibilityInfo() { + const listeners: ReduceMotionListener[] = []; + let answer: (isReduceMotionEnabled: boolean) => void = () => undefined; + const answered = new Promise((resolve) => { + answer = resolve; + }); + + return { + accessibilityInfo: { + isReduceMotionEnabled: () => answered, + addEventListener: (event: string, listener: ReduceMotionListener) => { + if (event === "reduceMotionChanged") listeners.push(listener); + return { remove: () => undefined }; + }, + }, + answer, + change: (isReduceMotionEnabled: boolean) => { + for (const listener of listeners) listener(isReduceMotionEnabled); + }, }; +} + +describe("the reduced-motion preference", () => { + test("is unknown until AccessibilityInfo answers", () => { + const { accessibilityInfo } = controlledAccessibilityInfo(); + + const { reducedMotion } = importReactivityWith(accessibilityInfo); -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(); + expect(reducedMotion.get()).toBeUndefined(); }); -}); -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"); + test("takes AccessibilityInfo's answer, then follows its change event", async () => { + const { accessibilityInfo, answer, change } = controlledAccessibilityInfo(); + const { reducedMotion } = importReactivityWith(accessibilityInfo); + + answer(true); await Promise.resolve(); + expect(reducedMotion.get()).toBe("reduce"); - expect(reactivity.reduceMotion.get()).toBe(false); + change(false); + expect(reducedMotion.get()).toBe("no-preference"); }); -}); -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"); + test("keeps a change event over an answer that resolves after it", async () => { + const { accessibilityInfo, answer, change } = controlledAccessibilityInfo(); + const { reducedMotion } = importReactivityWith(accessibilityInfo); + + change(true); + answer(false); 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(reducedMotion.get()).toBe("reduce"); + }); + + test("is no-preference where AccessibilityInfo rejects, without an unhandled rejection", async () => { + const { reducedMotion } = importReactivityWith({ + isReduceMotionEnabled: () => + Promise.reject(new Error("NativeAccessibilityManager unavailable")), + addEventListener: () => ({ remove: () => undefined }), + }); + await Promise.resolve(); - expect(changed).toBeDefined(); - changed?.(false); - expect(reactivity.reduceMotion.get()).toBe(false); + expect(reducedMotion.get()).toBe("no-preference"); }); }); diff --git a/src/jest/index.ts b/src/jest/index.ts index a8d249f5..b9c48143 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -5,7 +5,7 @@ import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; -import { colorScheme, dimensions } from "../native/reactivity"; +import { colorScheme, dimensions, reducedMotion } from "../native/reactivity"; declare global { /* eslint-disable @typescript-eslint/no-namespace */ @@ -23,6 +23,7 @@ beforeEach(() => { dimensions.set(Dimensions.get("window")); Appearance.setColorScheme("unspecified"); colorScheme.set(null); + reducedMotion.set(undefined); }); const debugDefault = Boolean( diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index d29a422e..e62fb5b6 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,23 +1,23 @@ -/* eslint-disable */ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; -import { colorScheme, reduceMotion, vh, vw, type Getter } from "../reactivity"; +import { colorScheme, reducedMotion, vh, vw, type Getter } from "../reactivity"; 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 "[]": 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; + // (prefers-reduced-motion) alone matches every value but no-preference (MQ5 §2.4.2) + return ( + mediaQuery[1] === "prefers-reduced-motion" && + get(reducedMotion) === "reduce" + ); case "!": return !test(mediaQuery[1], get); case "&": @@ -38,7 +38,7 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { } } -function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { +function testComparison(mediaQuery: MediaCondition, get: Getter): boolean { const value = mediaQuery[2]; switch (mediaQuery[1]) { @@ -52,8 +52,7 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { return value === get(colorScheme); } case "prefers-reduced-motion": { - // An unknown value matches nothing (MQ5), so this is an equality, not a two-way branch - return value === (get(reduceMotion) ? "reduce" : "no-preference"); + return value === get(reducedMotion); } case "display-mode": return value === "native" || Platform.OS === value; diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 5d2b6715..155409c9 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -234,28 +234,35 @@ export const colorScheme = observable( ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); -/** Reduce Motion ************************************************************/ - -// AccessibilityInfo answers asynchronously, so motion counts as enabled until it does; Android reports its animator scale (react-native#31221) -export const reduceMotion = observable(false); - -// 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 { - // Motion stays enabled -} +/** Reduced Motion ************************************************************/ -try { - AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled) => - reduceMotion.set(enabled), - ); -} catch { - // The flag keeps the value the seed resolved to +export type ReducedMotionPreference = "no-preference" | "reduce"; + +// AccessibilityInfo answers asynchronously, and until it does the preference is unknown and matches no query +export const reducedMotion = observable( + undefined, +); + +function setReducedMotion(isReduceMotionEnabled: boolean) { + reducedMotion.set(isReduceMotionEnabled ? "reduce" : "no-preference"); } +AccessibilityInfo.addEventListener("reduceMotionChanged", setReducedMotion); +AccessibilityInfo.isReduceMotionEnabled().then( + (isReduceMotionEnabled) => { + // A change event that arrived first is newer than what the getter read + if (reducedMotion.get() === undefined) { + setReducedMotion(isReduceMotionEnabled); + } + }, + // React Native rejects where the platform has no accessibility module, so the user has stated no preference to it + () => { + if (reducedMotion.get() === undefined) { + reducedMotion.set("no-preference"); + } + }, +); + /** Containers ****************************************************************/ export type ContainerContextValue = Record< From 840b69c0a518594748d2945fdddd6db3bbc50948 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 13:19:57 +0300 Subject: [PATCH 09/12] fix(compiler): fold a custom media type to lowercase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Media types are ASCII case-insensitive (MQ4 §3), and lightningcss lowercases only all, print and screen, so `@media IOS` never matched iOS. --- src/__tests__/compiler/compiler.test.tsx | 80 +++++++----------------- src/compiler/media-query.ts | 8 ++- src/utilities/ascii-case.ts | 4 ++ 3 files changed, 31 insertions(+), 61 deletions(-) create mode 100644 src/utilities/ascii-case.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index e4c62545..aabce573 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -232,66 +232,30 @@ 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). +test.each([ + [ + "(prefers-reduced-motion: reduce)", + ["=", "prefers-reduced-motion", "reduce"], + ], + [ + "(prefers-reduced-motion: no-preference)", + ["=", "prefers-reduced-motion", "no-preference"], + ], + ["(prefers-reduced-motion)", ["!!", "prefers-reduced-motion"]], + [ + "(prefers-reduced-motion: REDUCE)", + ["=", "prefers-reduced-motion", "reduce"], + ], + ["(prefers-color-scheme: DARK)", ["=", "prefers-color-scheme", "dark"]], + ["IOS", ["=", "platform", "ios"]], + ["(grid: 1)", ["=", "grid", true]], + ["(color: 8)", ["=", "color", 8]], + ["(-webkit-device-pixel-ratio: 2)", ["=", "-webkit-device-pixel-ratio", 2]], +])("@media %s compiles to its condition", (query, condition) => { expect( - compile( - `@media (prefers-reduced-motion: reduce) { .my-class { opacity: 0 } }`, - ).stylesheet(), + compile(`@media ${query} { .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 }], - }, - ], - ], - ], + s: [["my-class", [{ s: [2, 1], m: [condition], d: [{ opacity: 0 }] }]]], }); }); diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 858a22e3..66558a07 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -8,6 +8,7 @@ import type { QueryFeatureFor_MediaFeatureId, } from "lightningcss"; +import { asciiLowerCase } from "../utilities/ascii-case"; import type { MediaCondition, MediaFeatureComparison, @@ -32,7 +33,8 @@ export function parseMediaQuery( // These all/screen are not conditions, they always apply if (query.mediaType !== "all" && query.mediaType !== "screen") { - platformCondition = ["=", "platform", query.mediaType]; + // Media types are ASCII case-insensitive (MQ4 §3), and lightningcss lowercases only all, print and screen + platformCondition = ["=", "platform", asciiLowerCase(query.mediaType)]; } } @@ -146,8 +148,8 @@ export function parseMediaFeatureValue( ): StyleDescriptor { switch (value.type) { case "ident": - // 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()); + // Keywords are ASCII case-insensitive (css-values-4 §4.1) + return asciiLowerCase(value.value); case "boolean": case "integer": case "number": diff --git a/src/utilities/ascii-case.ts b/src/utilities/ascii-case.ts new file mode 100644 index 00000000..748b92c5 --- /dev/null +++ b/src/utilities/ascii-case.ts @@ -0,0 +1,4 @@ +// Selectors §6.3 and HTML fold ASCII letters only: toLowerCase would also fold U+212A KELVIN SIGN onto k +export function asciiLowerCase(text: string): string { + return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); +} From 07c38bb2efd5d0b795ad8cdb755f60ee3a0814f9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 18:15:30 +0300 Subject: [PATCH 10/12] feat(native): let a script set the reduced-motion preference, as it sets colorScheme `reducedMotion.get()` returns the preference, `reducedMotion.set()` overrides it until the device's setting next changes, as `colorScheme.set()` does, and `set(null)` follows the device again. On web the browser evaluates the query in the stylesheet, so `get()` reads `matchMedia` and `set()` changes nothing. The jest helper's reset follows the device too, so a suite reaches the preference its AccessibilityInfo mock reports, or the one it sets. The device's preference is read through one path: a getter that throws, as a test double without one does, ends where a rejection does, at no-preference, and one guard keeps a change event that arrived first. A container query's keyword compiles in lowercase, as a media query's does. --- src/__tests__/compiler/compiler.test.tsx | 21 +++++++++ src/__tests__/native/media-query.test.tsx | 47 +++++++++++++++++-- .../native/reduce-motion-init.test.ts | 11 ++++- src/__tests__/web/reduced-motion.test.ts | 28 +++++++++++ src/jest/index.ts | 10 ++-- src/native/api.tsx | 16 +++++++ src/native/reactivity.ts | 44 ++++++++++------- src/runtime.types.ts | 8 ++++ src/web/api.tsx | 17 +++++++ 9 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/web/reduced-motion.test.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index aabce573..aa8c9e8a 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -341,6 +341,27 @@ test("container queries", () => { }); }); +test("a container query keyword compiles in lowercase", () => { + expect( + compile( + `@container (orientation: LANDSCAPE) { .child { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "child", + [ + { + cq: [{ m: ["=", "orientation", "landscape"] }], + d: [{ opacity: 0 }], + s: [2, 1], + }, + ], + ], + ], + }); +}); + test("warnings", () => { const compiled = compile(` .my-class { diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 609e0526..1f589cdf 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -1,11 +1,14 @@ -import { PixelRatio } from "react-native"; +import { AccessibilityInfo, PixelRatio } from "react-native"; import { act, render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; -import { colorScheme } from "react-native-css/runtime"; +import { colorScheme, reducedMotion } from "react-native-css/runtime"; -import { dimensions, reducedMotion } from "../../native/reactivity"; +import { + dimensions, + reducedMotion as reducedMotionObservable, +} from "../../native/reactivity"; jest.mock("react-native", () => { const RN = jest.requireActual("react-native"); @@ -290,6 +293,7 @@ describe("a reduced-motion preference AccessibilityInfo has not answered", () => "(prefers-reduced-motion: no-preference)", "(prefers-reduced-motion)", ])("leaves @media %s unmatched", (query) => { + reducedMotionObservable.set(undefined); registerCSS( `.my-class { color: #000000; } @media ${query} { .my-class { color: #010101; } }`, ); @@ -303,6 +307,7 @@ describe("a reduced-motion preference AccessibilityInfo has not answered", () => }); test("a mounted view follows the reduced-motion preference as it changes", () => { + reducedMotionObservable.set(undefined); registerCSS(` .my-class { color: #000000; } @media (prefers-reduced-motion: no-preference) { .my-class { color: #010101; } } @@ -328,6 +333,42 @@ test("a mounted view follows the reduced-motion preference as it changes", () => expect(component.props.style).toStrictEqual({ color: "#010101" }); }); +test("reducedMotion.set overrides the device's preference, and set(null) follows the device again", async () => { + registerCSS(` +.my-class { color: #000000; } +@media (prefers-reduced-motion: reduce) { .my-class { color: #020202; } }`); + + render(); + const component = screen.getByTestId(testID); + expect(reducedMotion.get()).toBe("no-preference"); + expect(component.props.style).toStrictEqual({ color: "#000" }); + + act(() => { + reducedMotion.set("reduce"); + }); + expect(reducedMotion.get()).toBe("reduce"); + expect(component.props.style).toStrictEqual({ color: "#020202" }); + + await act(async () => { + reducedMotion.set(null); + await Promise.resolve(); + }); + expect(reducedMotion.get()).toBe("no-preference"); + expect(component.props.style).toStrictEqual({ color: "#000" }); +}); + +describe("the react-native-css/jest reset", () => { + beforeAll(() => { + jest + .mocked(AccessibilityInfo.isReduceMotionEnabled) + .mockResolvedValueOnce(true); + }); + + test("starts each test from the preference the device reports", () => { + expect(reducedMotion.get()).toBe("reduce"); + }); +}); + // 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], diff --git a/src/__tests__/native/reduce-motion-init.test.ts b/src/__tests__/native/reduce-motion-init.test.ts index 68ba618d..1f0b9165 100644 --- a/src/__tests__/native/reduce-motion-init.test.ts +++ b/src/__tests__/native/reduce-motion-init.test.ts @@ -1,7 +1,7 @@ type ReduceMotionListener = (isReduceMotionEnabled: boolean) => void; interface AccessibilityInfoDouble { - readonly isReduceMotionEnabled: () => Promise; + readonly isReduceMotionEnabled?: () => Promise; readonly addEventListener: ( event: string, listener: ReduceMotionListener, @@ -91,4 +91,13 @@ describe("the reduced-motion preference", () => { expect(reducedMotion.get()).toBe("no-preference"); }); + + test("is no-preference where AccessibilityInfo has no getter, without failing the import", async () => { + const { reducedMotion } = importReactivityWith({ + addEventListener: () => ({ remove: () => undefined }), + }); + await Promise.resolve(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); }); diff --git a/src/__tests__/web/reduced-motion.test.ts b/src/__tests__/web/reduced-motion.test.ts new file mode 100644 index 00000000..5dde99f6 --- /dev/null +++ b/src/__tests__/web/reduced-motion.test.ts @@ -0,0 +1,28 @@ +import { reducedMotion } from "../../web/api"; + +test.each([ + [true, "reduce"], + [false, "no-preference"], +] as const)( + "reducedMotion reads %s from matchMedia as %s, and leaves it to the browser", + (matches, preference) => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)" && matches, + }), + }); + + try { + expect(reducedMotion.get()).toBe(preference); + reducedMotion.set(matches ? "no-preference" : "reduce"); + expect(reducedMotion.get()).toBe(preference); + } finally { + Reflect.deleteProperty(window, "matchMedia"); + } + }, +); + +test("reducedMotion has no preference to read without matchMedia", () => { + expect(reducedMotion.get()).toBeUndefined(); +}); diff --git a/src/jest/index.ts b/src/jest/index.ts index b9c48143..226f9a91 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -5,7 +5,11 @@ import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; -import { colorScheme, dimensions, reducedMotion } from "../native/reactivity"; +import { + colorScheme, + dimensions, + followSystemReducedMotion, +} from "../native/reactivity"; declare global { /* eslint-disable @typescript-eslint/no-namespace */ @@ -18,12 +22,12 @@ declare global { export const testID = "react-native-css"; -beforeEach(() => { +beforeEach(async () => { StyleCollection.styles.clear(); dimensions.set(Dimensions.get("window")); Appearance.setColorScheme("unspecified"); colorScheme.set(null); - reducedMotion.set(undefined); + await followSystemReducedMotion(); }); const debugDefault = Boolean( diff --git a/src/native/api.tsx b/src/native/api.tsx index 6736f746..e9c9a2fb 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -9,6 +9,7 @@ import type { ColorScheme, Props, ReactComponent, + ReducedMotion, Styled, StyledConfiguration, StyledOptions, @@ -18,6 +19,8 @@ import { usePassthrough } from "./react/usePassthrough"; import { cleanupEffect, colorScheme as colorSchemeObs, + followSystemReducedMotion, + reducedMotion as reducedMotionObs, VAR_SYMBOL, type Effect, type Getter, @@ -76,6 +79,19 @@ export const colorScheme: ColorScheme = { }, }; +export const reducedMotion: ReducedMotion = { + get() { + return reducedMotionObs.get(); + }, + set(value) { + if (value === null) { + void followSystemReducedMotion(); + } else { + reducedMotionObs.set(value); + } + }, +}; + export const useUnstableNativeVariable = useNativeVariable; export const useCssElement = < diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 155409c9..b97fcd59 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -10,6 +10,8 @@ import { import type { StyleDescriptor } from "react-native-css/compiler"; +import type { ReducedMotionPreference } from "../runtime.types"; + export type Effect = { observers: Set>; run(): void; @@ -236,9 +238,7 @@ Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); /** Reduced Motion ************************************************************/ -export type ReducedMotionPreference = "no-preference" | "reduce"; - -// AccessibilityInfo answers asynchronously, and until it does the preference is unknown and matches no query +// AccessibilityInfo answers asynchronously; until it does the preference is unknown, so a query on it does not match and a negated one does export const reducedMotion = observable( undefined, ); @@ -247,21 +247,31 @@ function setReducedMotion(isReduceMotionEnabled: boolean) { reducedMotion.set(isReduceMotionEnabled ? "reduce" : "no-preference"); } +function seedReducedMotion(isReduceMotionEnabled: boolean) { + // A change event that arrived first is newer than what the getter read + if (reducedMotion.get() === undefined) { + setReducedMotion(isReduceMotionEnabled); + } +} + +// React Native rejects where the platform has no accessibility module, and a test double may have no getter at all: either way the user has stated no preference to it +function readReduceMotionEnabled(): Promise { + try { + return Promise.resolve(AccessibilityInfo.isReduceMotionEnabled()); + } catch (error) { + return Promise.reject(error); + } +} + +export function followSystemReducedMotion(): Promise { + reducedMotion.set(undefined); + return readReduceMotionEnabled().then(seedReducedMotion, () => + seedReducedMotion(false), + ); +} + AccessibilityInfo.addEventListener("reduceMotionChanged", setReducedMotion); -AccessibilityInfo.isReduceMotionEnabled().then( - (isReduceMotionEnabled) => { - // A change event that arrived first is newer than what the getter read - if (reducedMotion.get() === undefined) { - setReducedMotion(isReduceMotionEnabled); - } - }, - // React Native rejects where the platform has no accessibility module, so the user has stated no preference to it - () => { - if (reducedMotion.get() === undefined) { - reducedMotion.set("no-preference"); - } - }, -); +followSystemReducedMotion(); /** Containers ****************************************************************/ diff --git a/src/runtime.types.ts b/src/runtime.types.ts index 69cab6ca..f0224372 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -150,3 +150,11 @@ export interface ColorScheme { get: () => ColorSchemeName | null | undefined; set: (value: ColorSchemeName | null | undefined) => void; } + +export type ReducedMotionPreference = "no-preference" | "reduce"; + +export interface ReducedMotion { + get: () => ReducedMotionPreference | undefined; + /** `null` follows the device's setting again */ + set: (value: ReducedMotionPreference | null) => void; +} diff --git a/src/web/api.tsx b/src/web/api.tsx index 28e09ab8..b2b932f0 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -9,6 +9,7 @@ import { Appearance } from "react-native"; import type { ColorScheme, Props, + ReducedMotion, StyledConfiguration, StyledOptions, } from "react-native-css"; @@ -72,6 +73,22 @@ export const colorScheme: ColorScheme = { }, }; +export const reducedMotion: ReducedMotion = { + get() { + // Server rendering has no window, and React Native's jest environment a window without matchMedia + if (typeof window === "undefined" || !("matchMedia" in window)) { + return undefined; + } + + return window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "reduce" + : "no-preference"; + }, + set() { + // The browser evaluates prefers-reduced-motion in the stylesheet itself, so a script cannot override it + }, +}; + /** * @deprecated Use `` instead. */ From a79174f68122dd0e5a6a86ef72f75fc2c9e32426 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 20:01:22 +0300 Subject: [PATCH 11/12] fix(native): keep the device's reduced-motion answer apart from a script's override `reducedMotion.set(null)` emptied the preference and read the device again, so a running app was unknown, and matched every negated query, for the whole native round trip. The device's answer and a script's override are now two values: the override stands over the answer until the device's setting changes, and clearing it returns to the answer at once. The getter and the change event are subscribed in one async step, so a test double without either ends at no-preference, as a rejection does. The jest helper clears the override, and colorScheme's, through the package's own entry, which is what a consumer's components read; it no longer waits on the device before each test. --- src/__tests__/native/media-query.test.tsx | 77 ++++++++++--------- .../native/reduce-motion-init.test.ts | 44 +++++++++-- src/jest/index.ts | 15 ++-- src/native/api.tsx | 7 +- src/native/reactivity.ts | 51 ++++++------ 5 files changed, 115 insertions(+), 79 deletions(-) diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 1f589cdf..782cff98 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -1,14 +1,11 @@ -import { AccessibilityInfo, PixelRatio } from "react-native"; +import { PixelRatio } from "react-native"; import { act, render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; import { colorScheme, reducedMotion } from "react-native-css/runtime"; -import { - dimensions, - reducedMotion as reducedMotionObservable, -} from "../../native/reactivity"; +import { deviceReducedMotion, dimensions } from "../../native/reactivity"; jest.mock("react-native", () => { const RN = jest.requireActual("react-native"); @@ -288,12 +285,22 @@ describe("max-resolution", () => { }); describe("a reduced-motion preference AccessibilityInfo has not answered", () => { + let answered: ReturnType; + + beforeEach(() => { + answered = deviceReducedMotion.get(); + deviceReducedMotion.set(undefined); + }); + + afterEach(() => { + deviceReducedMotion.set(answered); + }); + test.each([ "(prefers-reduced-motion: reduce)", "(prefers-reduced-motion: no-preference)", "(prefers-reduced-motion)", ])("leaves @media %s unmatched", (query) => { - reducedMotionObservable.set(undefined); registerCSS( `.my-class { color: #000000; } @media ${query} { .my-class { color: #010101; } }`, ); @@ -304,36 +311,35 @@ describe("a reduced-motion preference AccessibilityInfo has not answered", () => color: "#000", }); }); -}); -test("a mounted view follows the reduced-motion preference as it changes", () => { - reducedMotionObservable.set(undefined); - registerCSS(` -.my-class { color: #000000; } -@media (prefers-reduced-motion: no-preference) { .my-class { color: #010101; } } -@media (prefers-reduced-motion: reduce) { .my-class { color: #020202; } }`); + test("a mounted view follows the reduced-motion preference as it changes", () => { + registerCSS(` + .my-class { color: #000000; } + @media (prefers-reduced-motion: no-preference) { .my-class { color: #010101; } } + @media (prefers-reduced-motion: reduce) { .my-class { color: #020202; } }`); - render(); - const component = screen.getByTestId(testID); - expect(component.props.style).toStrictEqual({ color: "#000" }); + render(); + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "#000" }); - act(() => { - reducedMotion.set("no-preference"); - }); - expect(component.props.style).toStrictEqual({ color: "#010101" }); + act(() => { + reducedMotion.set("no-preference"); + }); + expect(component.props.style).toStrictEqual({ color: "#010101" }); - act(() => { - reducedMotion.set("reduce"); - }); - expect(component.props.style).toStrictEqual({ color: "#020202" }); + act(() => { + reducedMotion.set("reduce"); + }); + expect(component.props.style).toStrictEqual({ color: "#020202" }); - act(() => { - reducedMotion.set("no-preference"); + act(() => { + reducedMotion.set("no-preference"); + }); + expect(component.props.style).toStrictEqual({ color: "#010101" }); }); - expect(component.props.style).toStrictEqual({ color: "#010101" }); }); -test("reducedMotion.set overrides the device's preference, and set(null) follows the device again", async () => { +test("reducedMotion.set overrides the device's preference, and set(null) follows the device again at once", () => { registerCSS(` .my-class { color: #000000; } @media (prefers-reduced-motion: reduce) { .my-class { color: #020202; } }`); @@ -349,24 +355,23 @@ test("reducedMotion.set overrides the device's preference, and set(null) follows expect(reducedMotion.get()).toBe("reduce"); expect(component.props.style).toStrictEqual({ color: "#020202" }); - await act(async () => { + act(() => { reducedMotion.set(null); - await Promise.resolve(); }); expect(reducedMotion.get()).toBe("no-preference"); expect(component.props.style).toStrictEqual({ color: "#000" }); }); describe("the react-native-css/jest reset", () => { - beforeAll(() => { - jest - .mocked(AccessibilityInfo.isReduceMotionEnabled) - .mockResolvedValueOnce(true); - }); + test("lets a test override the preference", () => { + reducedMotion.set("reduce"); - test("starts each test from the preference the device reports", () => { expect(reducedMotion.get()).toBe("reduce"); }); + + test("starts the next test from the device's preference", () => { + expect(reducedMotion.get()).toBe("no-preference"); + }); }); // Chromium 153, Firefox 155 and WebKit 26.6 agree on every row: [query, reduce/light, reduce/dark, no-preference/light, no-preference/dark] diff --git a/src/__tests__/native/reduce-motion-init.test.ts b/src/__tests__/native/reduce-motion-init.test.ts index 1f0b9165..c4984b69 100644 --- a/src/__tests__/native/reduce-motion-init.test.ts +++ b/src/__tests__/native/reduce-motion-init.test.ts @@ -2,7 +2,7 @@ type ReduceMotionListener = (isReduceMotionEnabled: boolean) => void; interface AccessibilityInfoDouble { readonly isReduceMotionEnabled?: () => Promise; - readonly addEventListener: ( + readonly addEventListener?: ( event: string, listener: ReduceMotionListener, ) => { readonly remove: () => void }; @@ -27,6 +27,13 @@ function importReactivityWith(accessibilityInfo: AccessibilityInfoDouble) { ); } +// Every promise the module chains settles before a macrotask runs +function settle() { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} + function controlledAccessibilityInfo() { const listeners: ReduceMotionListener[] = []; let answer: (isReduceMotionEnabled: boolean) => void = () => undefined; @@ -63,7 +70,7 @@ describe("the reduced-motion preference", () => { const { reducedMotion } = importReactivityWith(accessibilityInfo); answer(true); - await Promise.resolve(); + await settle(); expect(reducedMotion.get()).toBe("reduce"); change(false); @@ -76,7 +83,7 @@ describe("the reduced-motion preference", () => { change(true); answer(false); - await Promise.resolve(); + await settle(); expect(reducedMotion.get()).toBe("reduce"); }); @@ -87,7 +94,7 @@ describe("the reduced-motion preference", () => { Promise.reject(new Error("NativeAccessibilityManager unavailable")), addEventListener: () => ({ remove: () => undefined }), }); - await Promise.resolve(); + await settle(); expect(reducedMotion.get()).toBe("no-preference"); }); @@ -96,8 +103,35 @@ describe("the reduced-motion preference", () => { const { reducedMotion } = importReactivityWith({ addEventListener: () => ({ remove: () => undefined }), }); - await Promise.resolve(); + await settle(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); + + test("is no-preference where AccessibilityInfo has no change event, without failing the import", async () => { + const { reducedMotion } = importReactivityWith({ + isReduceMotionEnabled: () => Promise.resolve(true), + }); + await settle(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); + + test("an override stands over the device's answer until the device's setting changes", async () => { + const { accessibilityInfo, answer, change } = controlledAccessibilityInfo(); + const { reducedMotion } = importReactivityWith(accessibilityInfo); + answer(true); + await settle(); + reducedMotion.set("no-preference"); expect(reducedMotion.get()).toBe("no-preference"); + + reducedMotion.set(undefined); + expect(reducedMotion.get()).toBe("reduce"); + + reducedMotion.set("no-preference"); + change(false); + change(true); + expect(reducedMotion.get()).toBe("reduce"); }); }); diff --git a/src/jest/index.ts b/src/jest/index.ts index 226f9a91..06739be5 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -3,13 +3,13 @@ import { Appearance, Dimensions } from "react-native"; import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; -import { StyleCollection } from "react-native-css/native"; - import { colorScheme, - dimensions, - followSystemReducedMotion, -} from "../native/reactivity"; + reducedMotion, + StyleCollection, +} from "react-native-css/native"; + +import { dimensions } from "../native/reactivity"; declare global { /* eslint-disable @typescript-eslint/no-namespace */ @@ -22,12 +22,13 @@ declare global { export const testID = "react-native-css"; -beforeEach(async () => { +// The components a consumer's suite renders read the package's own build, so the reset reaches them through the package's entry +beforeEach(() => { StyleCollection.styles.clear(); dimensions.set(Dimensions.get("window")); Appearance.setColorScheme("unspecified"); colorScheme.set(null); - await followSystemReducedMotion(); + reducedMotion.set(null); }); const debugDefault = Boolean( diff --git a/src/native/api.tsx b/src/native/api.tsx index e9c9a2fb..edd8d16f 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -19,7 +19,6 @@ import { usePassthrough } from "./react/usePassthrough"; import { cleanupEffect, colorScheme as colorSchemeObs, - followSystemReducedMotion, reducedMotion as reducedMotionObs, VAR_SYMBOL, type Effect, @@ -84,11 +83,7 @@ export const reducedMotion: ReducedMotion = { return reducedMotionObs.get(); }, set(value) { - if (value === null) { - void followSystemReducedMotion(); - } else { - reducedMotionObs.set(value); - } + reducedMotionObs.set(value ?? undefined); }, }; diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index b97fcd59..4de863df 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -238,40 +238,41 @@ Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); /** Reduced Motion ************************************************************/ -// AccessibilityInfo answers asynchronously; until it does the preference is unknown, so a query on it does not match and a negated one does -export const reducedMotion = observable( - undefined, -); - -function setReducedMotion(isReduceMotionEnabled: boolean) { - reducedMotion.set(isReduceMotionEnabled ? "reduce" : "no-preference"); +// AccessibilityInfo answers asynchronously; until it does the device's preference is unknown, so a query on it does not match and a negated one does +export const deviceReducedMotion = observable< + ReducedMotionPreference | undefined +>(undefined); + +// A script's override stands over the device's preference until the device's setting changes, as colorScheme's does +export const reducedMotion = observable< + ReducedMotionPreference | undefined, + ReducedMotionPreference | undefined +>((read, override) => override ?? read(deviceReducedMotion)); + +function followDeviceReducedMotion(isReduceMotionEnabled: boolean) { + deviceReducedMotion.set(isReduceMotionEnabled ? "reduce" : "no-preference"); + reducedMotion.set(undefined); } -function seedReducedMotion(isReduceMotionEnabled: boolean) { +function seedDeviceReducedMotion(isReduceMotionEnabled: boolean) { // A change event that arrived first is newer than what the getter read - if (reducedMotion.get() === undefined) { - setReducedMotion(isReduceMotionEnabled); - } -} - -// React Native rejects where the platform has no accessibility module, and a test double may have no getter at all: either way the user has stated no preference to it -function readReduceMotionEnabled(): Promise { - try { - return Promise.resolve(AccessibilityInfo.isReduceMotionEnabled()); - } catch (error) { - return Promise.reject(error); + if (deviceReducedMotion.get() === undefined) { + deviceReducedMotion.set(isReduceMotionEnabled ? "reduce" : "no-preference"); } } -export function followSystemReducedMotion(): Promise { - reducedMotion.set(undefined); - return readReduceMotionEnabled().then(seedReducedMotion, () => - seedReducedMotion(false), +async function readDeviceReducedMotion(): Promise { + AccessibilityInfo.addEventListener( + "reduceMotionChanged", + followDeviceReducedMotion, ); + return await AccessibilityInfo.isReduceMotionEnabled(); } -AccessibilityInfo.addEventListener("reduceMotionChanged", setReducedMotion); -followSystemReducedMotion(); +// React Native rejects where the platform has no accessibility module, and a test double may lack either call: either way the user has stated no preference to it +readDeviceReducedMotion().then(seedDeviceReducedMotion, () => { + seedDeviceReducedMotion(false); +}); /** Containers ****************************************************************/ From 7027d9913592b6931fb8322f39a38e752fa4c211 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 23:15:38 +0300 Subject: [PATCH 12/12] fix(jest): reset the window through the package's internal entry, as the components read it The jest helper resolves to the package's source in a consumer's suite while the components it renders resolve to the build, so a reset through a relative import missed the window size they read. --- src/jest/index.ts | 3 +-- src/native-internal/index.ts | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jest/index.ts b/src/jest/index.ts index 06739be5..5cdeca28 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -8,8 +8,7 @@ import { reducedMotion, StyleCollection, } from "react-native-css/native"; - -import { dimensions } from "../native/reactivity"; +import { dimensions } from "react-native-css/native-internal"; declare global { /* eslint-disable @typescript-eslint/no-namespace */ diff --git a/src/native-internal/index.ts b/src/native-internal/index.ts index e80c9bd6..cee72f4e 100644 --- a/src/native-internal/index.ts +++ b/src/native-internal/index.ts @@ -1,3 +1,4 @@ export * from "./root"; export * from "./style-collection"; export * from "./variables"; +export { dimensions } from "../native/reactivity";