From cc6efa47b6285ebb4763a5dd620d8cb3d574ef83 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 6 Oct 2026 14:17:17 +0300 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] =?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/21] 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/21] 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/21] 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/21] 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/21] 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"; From aa9ee2b15e6155f41ef0c2b2b4311e3aedc810da Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 14:13:53 +0300 Subject: [PATCH 13/21] fix: hold a reducedMotion override until set(null), and let a web override reach what scripts read --- src/__tests__/native/media-query.test.tsx | 60 ++++++++++++++++++- .../native/reduce-motion-init.test.ts | 12 ++-- src/__tests__/web/reduced-motion.test.ts | 52 +++++++++++----- src/native/reactivity.ts | 58 ++++++++++-------- src/runtime.types.ts | 2 +- src/web/api.tsx | 22 +++++-- 6 files changed, 153 insertions(+), 53 deletions(-) diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 782cff98..66b0c8d8 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -293,7 +293,36 @@ describe("a reduced-motion preference AccessibilityInfo has not answered", () => }); afterEach(() => { - deviceReducedMotion.set(answered); + act(() => { + deviceReducedMotion.set(answered); + }); + }); + + test("matches a negated query", () => { + registerCSS( + `.my-class { color: #000000; } @media not (prefers-reduced-motion: reduce) { .my-class { color: #010101; } }`, + ); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#010101", + }); + }); + + test("a mounted view takes the device's answer when it arrives", () => { + registerCSS(` + .my-class { color: #000000; } + @media (prefers-reduced-motion: reduce) { .my-class { color: #020202; } }`); + + render(); + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "#000" }); + + act(() => { + deviceReducedMotion.set("reduce"); + }); + expect(component.props.style).toStrictEqual({ color: "#020202" }); }); test.each([ @@ -339,6 +368,35 @@ describe("a reduced-motion preference AccessibilityInfo has not answered", () => }); }); +test("reducedMotion.set holds through a change of the device's setting, and set(null) follows the device again", () => { + registerCSS(` +.my-class { color: #000000; } +@media (prefers-reduced-motion: reduce) { .my-class { color: #020202; } }`); + const answered = deviceReducedMotion.get(); + + render(); + const component = screen.getByTestId(testID); + + try { + act(() => { + reducedMotion.set("no-preference"); + deviceReducedMotion.set("reduce"); + }); + expect(reducedMotion.get()).toBe("no-preference"); + expect(component.props.style).toStrictEqual({ color: "#000" }); + + act(() => { + reducedMotion.set(null); + }); + expect(reducedMotion.get()).toBe("reduce"); + expect(component.props.style).toStrictEqual({ color: "#020202" }); + } finally { + act(() => { + deviceReducedMotion.set(answered); + }); + } +}); + test("reducedMotion.set overrides the device's preference, and set(null) follows the device again at once", () => { registerCSS(` .my-class { color: #000000; } diff --git a/src/__tests__/native/reduce-motion-init.test.ts b/src/__tests__/native/reduce-motion-init.test.ts index c4984b69..f1e27a61 100644 --- a/src/__tests__/native/reduce-motion-init.test.ts +++ b/src/__tests__/native/reduce-motion-init.test.ts @@ -108,30 +108,30 @@ describe("the reduced-motion preference", () => { expect(reducedMotion.get()).toBe("no-preference"); }); - test("is no-preference where AccessibilityInfo has no change event, without failing the import", async () => { + test("takes AccessibilityInfo's answer where it has no change event, without failing the import", async () => { const { reducedMotion } = importReactivityWith({ isReduceMotionEnabled: () => Promise.resolve(true), }); await settle(); - expect(reducedMotion.get()).toBe("no-preference"); + expect(reducedMotion.get()).toBe("reduce"); }); - test("an override stands over the device's answer until the device's setting changes", async () => { + test("an override holds through the device's changes until it is set to undefined", async () => { const { accessibilityInfo, answer, change } = controlledAccessibilityInfo(); const { reducedMotion } = importReactivityWith(accessibilityInfo); answer(true); await settle(); reducedMotion.set("no-preference"); + change(false); + change(true); 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"); + expect(reducedMotion.get()).toBe("no-preference"); }); }); diff --git a/src/__tests__/web/reduced-motion.test.ts b/src/__tests__/web/reduced-motion.test.ts index 5dde99f6..c264cfdb 100644 --- a/src/__tests__/web/reduced-motion.test.ts +++ b/src/__tests__/web/reduced-motion.test.ts @@ -1,28 +1,50 @@ import { reducedMotion } from "../../web/api"; +function withMatchMedia(value: unknown, run: () => void) { + Object.defineProperty(window, "matchMedia", { configurable: true, value }); + try { + run(); + } finally { + Reflect.deleteProperty(window, "matchMedia"); + reducedMotion.set(null); + } +} + test.each([ [true, "reduce"], [false, "no-preference"], ] as const)( - "reducedMotion reads %s from matchMedia as %s, and leaves it to the browser", + "reducedMotion reads %s from matchMedia as %s", (matches, preference) => { - Object.defineProperty(window, "matchMedia", { - configurable: true, - value: (query: string) => ({ + withMatchMedia( + (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"); - } + () => { + expect(reducedMotion.get()).toBe(preference); + }, + ); }, ); -test("reducedMotion has no preference to read without matchMedia", () => { - expect(reducedMotion.get()).toBeUndefined(); +test("an override changes what scripts read until set(null) reads matchMedia again", () => { + withMatchMedia( + () => ({ matches: true }), + () => { + reducedMotion.set("no-preference"); + expect(reducedMotion.get()).toBe("no-preference"); + + reducedMotion.set(null); + expect(reducedMotion.get()).toBe("reduce"); + }, + ); +}); + +test.each([ + ["no matchMedia", undefined], + ["a matchMedia that is no function", {}], +])("reducedMotion has no preference to read with %s", (_, value) => { + withMatchMedia(value, () => { + expect(reducedMotion.get()).toBeUndefined(); + }); }); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 4de863df..52ab3917 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -236,43 +236,49 @@ export const colorScheme = observable( ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); +/** Device Preferences ********************************************************/ + +// A user preference as the device reports it, unknown until it answers, and as the app overrides it until set(undefined), the shape MQ5 §13's requestOverride() and clearOverride() give the web +export function devicePreference(reported?: Value) { + const device = observable(reported); + const preference = observable( + (read, override) => override ?? read(device), + ); + return { device, preference } as const; +} + /** Reduced Motion ************************************************************/ // 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); -} +export const { device: deviceReducedMotion, preference: reducedMotion } = + devicePreference(); -function seedDeviceReducedMotion(isReduceMotionEnabled: boolean) { - // A change event that arrived first is newer than what the getter read - if (deviceReducedMotion.get() === undefined) { - deviceReducedMotion.set(isReduceMotionEnabled ? "reduce" : "no-preference"); - } +function toReducedMotionPreference( + isReduceMotionEnabled: boolean, +): ReducedMotionPreference { + return isReduceMotionEnabled ? "reduce" : "no-preference"; } -async function readDeviceReducedMotion(): Promise { +// A test double may leave out the change event, and the read below still answers +if (typeof AccessibilityInfo.addEventListener === "function") { AccessibilityInfo.addEventListener( "reduceMotionChanged", - followDeviceReducedMotion, + (isReduceMotionEnabled: boolean) => { + deviceReducedMotion.set(toReducedMotionPreference(isReduceMotionEnabled)); + }, ); - return await AccessibilityInfo.isReduceMotionEnabled(); } -// 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); -}); +// React Native rejects where the platform has no accessibility module, and a test double may leave out the call: either way the user has stated no preference to it +void Promise.resolve() + .then(() => AccessibilityInfo.isReduceMotionEnabled()) + .then(toReducedMotionPreference, () => "no-preference" as const) + .then((answer) => { + // A change event that arrived first is newer than the answer + if (deviceReducedMotion.get() === undefined) { + deviceReducedMotion.set(answer); + } + }); /** Containers ****************************************************************/ diff --git a/src/runtime.types.ts b/src/runtime.types.ts index f0224372..5037a0c0 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -155,6 +155,6 @@ export type ReducedMotionPreference = "no-preference" | "reduce"; export interface ReducedMotion { get: () => ReducedMotionPreference | undefined; - /** `null` follows the device's setting again */ + /** Overrides the device's setting until `null`, which follows the device again */ set: (value: ReducedMotionPreference | null) => void; } diff --git a/src/web/api.tsx b/src/web/api.tsx index b2b932f0..ca945d1b 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -14,7 +14,11 @@ import type { StyledOptions, } from "react-native-css"; -import type { ReactComponent, Styled } from "../runtime.types"; +import type { + ReactComponent, + ReducedMotionPreference, + Styled, +} from "../runtime.types"; import { assignStyle } from "./assign-style"; const defaultMapping: StyledConfiguration> = { @@ -73,10 +77,19 @@ export const colorScheme: ColorScheme = { }, }; +let reducedMotionOverride: ReducedMotionPreference | undefined; + export const reducedMotion: ReducedMotion = { get() { + if (reducedMotionOverride !== undefined) { + return reducedMotionOverride; + } + // Server rendering has no window, and React Native's jest environment a window without matchMedia - if (typeof window === "undefined" || !("matchMedia" in window)) { + if ( + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) { return undefined; } @@ -84,8 +97,9 @@ export const reducedMotion: ReducedMotion = { ? "reduce" : "no-preference"; }, - set() { - // The browser evaluates prefers-reduced-motion in the stylesheet itself, so a script cannot override it + // The browser evaluates the stylesheet's @media rules itself, so an override reaches what scripts read and not the CSS, which MQ5 §13's requestOverride() will reach once a browser ships it + set(value) { + reducedMotionOverride = value ?? undefined; }, }; From 66aa22e45f9ef88efbb557332c409eac8bdf508c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 14:20:30 +0300 Subject: [PATCH 14/21] refactor(native): start a device preference unknown, and drop the case that pinned a known limit --- src/__tests__/native/media-query.test.tsx | 12 ------------ src/native/reactivity.ts | 4 ++-- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 66b0c8d8..c297fc22 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -298,18 +298,6 @@ describe("a reduced-motion preference AccessibilityInfo has not answered", () => }); }); - test("matches a negated query", () => { - registerCSS( - `.my-class { color: #000000; } @media not (prefers-reduced-motion: reduce) { .my-class { color: #010101; } }`, - ); - - render(); - - expect(screen.getByTestId(testID).props.style).toStrictEqual({ - color: "#010101", - }); - }); - test("a mounted view takes the device's answer when it arrives", () => { registerCSS(` .my-class { color: #000000; } diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 52ab3917..844cdd5f 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -239,8 +239,8 @@ Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); /** Device Preferences ********************************************************/ // A user preference as the device reports it, unknown until it answers, and as the app overrides it until set(undefined), the shape MQ5 §13's requestOverride() and clearOverride() give the web -export function devicePreference(reported?: Value) { - const device = observable(reported); +export function devicePreference() { + const device = observable(undefined); const preference = observable( (read, override) => override ?? read(device), ); From 359ab87962d975d3925335d6a00533e051c27608 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 14:32:06 +0300 Subject: [PATCH 15/21] fix(web): keep a reducedMotion override to the browser, as a server renders every visitor --- src/__tests__/web/reduced-motion.test.ts | 14 ++++++++++++++ src/web/api.tsx | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/__tests__/web/reduced-motion.test.ts b/src/__tests__/web/reduced-motion.test.ts index c264cfdb..83116095 100644 --- a/src/__tests__/web/reduced-motion.test.ts +++ b/src/__tests__/web/reduced-motion.test.ts @@ -48,3 +48,17 @@ test.each([ expect(reducedMotion.get()).toBeUndefined(); }); }); + +test("a server keeps no override, since it renders every visitor's page in one process", () => { + const browserWindow: unknown = globalThis.window; + Reflect.deleteProperty(globalThis, "window"); + + try { + reducedMotion.set("reduce"); + expect(reducedMotion.get()).toBeUndefined(); + } finally { + Reflect.set(globalThis, "window", browserWindow); + } + + expect(reducedMotion.get()).toBeUndefined(); +}); diff --git a/src/web/api.tsx b/src/web/api.tsx index ca945d1b..549f2816 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -99,7 +99,10 @@ export const reducedMotion: ReducedMotion = { }, // The browser evaluates the stylesheet's @media rules itself, so an override reaches what scripts read and not the CSS, which MQ5 §13's requestOverride() will reach once a browser ships it set(value) { - reducedMotionOverride = value ?? undefined; + // A server renders every visitor's page in one process, so only a browser keeps an override + if (typeof window !== "undefined") { + reducedMotionOverride = value ?? undefined; + } }, }; From 7e4c03607549fe418ec38e2a3129de0af3f90da4 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 14:35:39 +0300 Subject: [PATCH 16/21] refactor(web): hold the reducedMotion override in a browserPreference helper --- src/web/api.tsx | 21 ++++----------------- src/web/browser-preference.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 17 deletions(-) create mode 100644 src/web/browser-preference.ts diff --git a/src/web/api.tsx b/src/web/api.tsx index 549f2816..d8235d79 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -20,6 +20,7 @@ import type { Styled, } from "../runtime.types"; import { assignStyle } from "./assign-style"; +import { browserPreference } from "./browser-preference"; const defaultMapping: StyledConfiguration> = { className: "style", @@ -77,14 +78,8 @@ export const colorScheme: ColorScheme = { }, }; -let reducedMotionOverride: ReducedMotionPreference | undefined; - -export const reducedMotion: ReducedMotion = { - get() { - if (reducedMotionOverride !== undefined) { - return reducedMotionOverride; - } - +export const reducedMotion: ReducedMotion = + browserPreference(() => { // Server rendering has no window, and React Native's jest environment a window without matchMedia if ( typeof window === "undefined" || @@ -96,15 +91,7 @@ export const reducedMotion: ReducedMotion = { return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "reduce" : "no-preference"; - }, - // The browser evaluates the stylesheet's @media rules itself, so an override reaches what scripts read and not the CSS, which MQ5 §13's requestOverride() will reach once a browser ships it - set(value) { - // A server renders every visitor's page in one process, so only a browser keeps an override - if (typeof window !== "undefined") { - reducedMotionOverride = value ?? undefined; - } - }, -}; + }); /** * @deprecated Use `` instead. diff --git a/src/web/browser-preference.ts b/src/web/browser-preference.ts new file mode 100644 index 00000000..c0670fad --- /dev/null +++ b/src/web/browser-preference.ts @@ -0,0 +1,19 @@ +// The browser evaluates the stylesheet's @media rules itself, so an override reaches what scripts read and not the CSS, which MQ5 §13's requestOverride() will reach once a browser ships it +export function browserPreference( + readBrowser: () => Value | undefined, +): { + readonly get: () => Value | undefined; + readonly set: (value: Value | null) => void; +} { + let override: Value | undefined; + + return { + get: () => override ?? readBrowser(), + set: (value) => { + // A server renders every visitor's page in one process, so only a browser keeps an override + if (typeof window !== "undefined") { + override = value ?? undefined; + } + }, + }; +} From 0baba446b245580ef54776cedb09b7d5a1795369 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 17:18:32 +0300 Subject: [PATCH 17/21] fix: restore the device's answer between tests, and import without an AccessibilityInfo --- src/__tests__/compiler/compiler.test.tsx | 5 +++++ src/__tests__/native/media-query.test.tsx | 10 ++++++++++ src/__tests__/native/reduce-motion-init.test.ts | 11 ++++++++++- src/__tests__/web/reduced-motion.test.ts | 11 +++++++++++ src/jest/index.ts | 12 +++++++++++- src/native-internal/index.ts | 2 +- src/native/reactivity.ts | 14 +++++++++++--- src/runtime.types.ts | 2 +- src/web/api.tsx | 17 ++++++++++++++--- src/web/browser-preference.ts | 2 +- 10 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index aa8c9e8a..a9654832 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -247,6 +247,11 @@ test.each([ ["=", "prefers-reduced-motion", "reduce"], ], ["(prefers-color-scheme: DARK)", ["=", "prefers-color-scheme", "dark"]], + // css-values-4 §4.1 folds ASCII only, so a Kelvin sign is no K + [ + "(prefers-color-scheme: dar\u212A)", + ["=", "prefers-color-scheme", "dar\u212A"], + ], ["IOS", ["=", "platform", "ios"]], ["(grid: 1)", ["=", "grid", true]], ["(color: 8)", ["=", "color", 8]], diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index c297fc22..2d4848c8 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -418,6 +418,16 @@ describe("the react-native-css/jest reset", () => { test("starts the next test from the device's preference", () => { expect(reducedMotion.get()).toBe("no-preference"); }); + + test("lets a test change the device's answer", () => { + deviceReducedMotion.set("reduce"); + + expect(reducedMotion.get()).toBe("reduce"); + }); + + test("starts the next test from the device's first answer", () => { + 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 f1e27a61..0e858df3 100644 --- a/src/__tests__/native/reduce-motion-init.test.ts +++ b/src/__tests__/native/reduce-motion-init.test.ts @@ -8,7 +8,9 @@ interface AccessibilityInfoDouble { ) => { readonly remove: () => void }; } -function importReactivityWith(accessibilityInfo: AccessibilityInfoDouble) { +function importReactivityWith( + accessibilityInfo: AccessibilityInfoDouble | undefined, +) { 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", () => { @@ -108,6 +110,13 @@ describe("the reduced-motion preference", () => { expect(reducedMotion.get()).toBe("no-preference"); }); + test("is no-preference where react-native has no AccessibilityInfo, without failing the import", async () => { + const { reducedMotion } = importReactivityWith(undefined); + await settle(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); + test("takes AccessibilityInfo's answer where it has no change event, without failing the import", async () => { const { reducedMotion } = importReactivityWith({ isReduceMotionEnabled: () => Promise.resolve(true), diff --git a/src/__tests__/web/reduced-motion.test.ts b/src/__tests__/web/reduced-motion.test.ts index 83116095..d0fb698c 100644 --- a/src/__tests__/web/reduced-motion.test.ts +++ b/src/__tests__/web/reduced-motion.test.ts @@ -62,3 +62,14 @@ test("a server keeps no override, since it renders every visitor's page in one p expect(reducedMotion.get()).toBeUndefined(); }); + +test("reducedMotion parses its query once for each matchMedia", () => { + const matchMedia = jest.fn(() => ({ matches: true })); + + withMatchMedia(matchMedia, () => { + expect(reducedMotion.get()).toBe("reduce"); + expect(reducedMotion.get()).toBe("reduce"); + }); + + expect(matchMedia).toHaveBeenCalledTimes(1); +}); diff --git a/src/jest/index.ts b/src/jest/index.ts index 5cdeca28..17b17e25 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -8,7 +8,10 @@ import { reducedMotion, StyleCollection, } from "react-native-css/native"; -import { dimensions } from "react-native-css/native-internal"; +import { + deviceReducedMotion, + dimensions, +} from "react-native-css/native-internal"; declare global { /* eslint-disable @typescript-eslint/no-namespace */ @@ -21,12 +24,19 @@ declare global { export const testID = "react-native-css"; +// A test may fire React Native's reduceMotionChanged, so each test starts from the answer the file's first test began with +let deviceReducedMotionAtStart: + | { readonly answer: ReturnType } + | undefined; + // 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); + deviceReducedMotionAtStart ??= { answer: deviceReducedMotion.get() }; + deviceReducedMotion.set(deviceReducedMotionAtStart.answer); reducedMotion.set(null); }); diff --git a/src/native-internal/index.ts b/src/native-internal/index.ts index cee72f4e..f5831aad 100644 --- a/src/native-internal/index.ts +++ b/src/native-internal/index.ts @@ -1,4 +1,4 @@ export * from "./root"; export * from "./style-collection"; export * from "./variables"; -export { dimensions } from "../native/reactivity"; +export { deviceReducedMotion, dimensions } from "../native/reactivity"; diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 844cdd5f..38586d8f 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -259,9 +259,15 @@ function toReducedMotionPreference( return isReduceMotionEnabled ? "reduce" : "no-preference"; } -// A test double may leave out the change event, and the read below still answers -if (typeof AccessibilityInfo.addEventListener === "function") { - AccessibilityInfo.addEventListener( +// A consumer's test double may leave out AccessibilityInfo or its change event, and the read below still answers +function followReduceMotionChanges( + accessibilityInfo: Partial | undefined, +): void { + if (typeof accessibilityInfo?.addEventListener !== "function") { + return; + } + + accessibilityInfo.addEventListener( "reduceMotionChanged", (isReduceMotionEnabled: boolean) => { deviceReducedMotion.set(toReducedMotionPreference(isReduceMotionEnabled)); @@ -269,6 +275,8 @@ if (typeof AccessibilityInfo.addEventListener === "function") { ); } +followReduceMotionChanges(AccessibilityInfo); + // React Native rejects where the platform has no accessibility module, and a test double may leave out the call: either way the user has stated no preference to it void Promise.resolve() .then(() => AccessibilityInfo.isReduceMotionEnabled()) diff --git a/src/runtime.types.ts b/src/runtime.types.ts index 5037a0c0..03f1e0c8 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -155,6 +155,6 @@ export type ReducedMotionPreference = "no-preference" | "reduce"; export interface ReducedMotion { get: () => ReducedMotionPreference | undefined; - /** Overrides the device's setting until `null`, which follows the device again */ + /** Overrides the device's setting until `null`, which follows the device again; on web it changes only what `get()` returns */ set: (value: ReducedMotionPreference | null) => void; } diff --git a/src/web/api.tsx b/src/web/api.tsx index d8235d79..4d2b73ae 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -78,6 +78,12 @@ export const colorScheme: ColorScheme = { }, }; +// A MediaQueryList keeps its matches current, so each matchMedia parses the query once +const reduceMotionQueries = new WeakMap< + typeof window.matchMedia, + MediaQueryList +>(); + export const reducedMotion: ReducedMotion = browserPreference(() => { // Server rendering has no window, and React Native's jest environment a window without matchMedia @@ -88,9 +94,14 @@ export const reducedMotion: ReducedMotion = return undefined; } - return window.matchMedia("(prefers-reduced-motion: reduce)").matches - ? "reduce" - : "no-preference"; + const matchMedia = window.matchMedia; + let query = reduceMotionQueries.get(matchMedia); + if (query === undefined) { + query = matchMedia.call(window, "(prefers-reduced-motion: reduce)"); + reduceMotionQueries.set(matchMedia, query); + } + + return query.matches ? "reduce" : "no-preference"; }); /** diff --git a/src/web/browser-preference.ts b/src/web/browser-preference.ts index c0670fad..ba2b458f 100644 --- a/src/web/browser-preference.ts +++ b/src/web/browser-preference.ts @@ -1,4 +1,4 @@ -// The browser evaluates the stylesheet's @media rules itself, so an override reaches what scripts read and not the CSS, which MQ5 §13's requestOverride() will reach once a browser ships it +// The browser evaluates the stylesheet's @media rules and matchMedia() itself, so an override changes only what get() returns, where MQ5 §13's requestOverride() would reach both once a browser ships it export function browserPreference( readBrowser: () => Value | undefined, ): { From 99fd91cf375ac30b3eb933dff4dd5bc20d3eb2e5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 22 Oct 2026 00:45:44 +0300 Subject: [PATCH 18/21] fix: reset to AccessibilityInfo's answer even when it arrives late, and read matchMedia on each get reactivity keeps the device's answer apart from the events a test fires, and the jest helper's reset, now a function its tests call, restores it, so an answer that arrives after the reset still gets through. The web preference asks matchMedia on each read, which a test double may change. The case folding leaves this PR: #426 folds a keyword the same way, and a media type with it. --- src/__tests__/compiler/compiler.test.tsx | 32 ------------------- src/__tests__/native/media-query.test.tsx | 17 ++++------ .../native/reduce-motion-init.test.ts | 18 +++++++++++ src/__tests__/web/reduced-motion.test.ts | 11 ++++--- src/compiler/media-query.ts | 8 ++--- src/jest/index.ts | 30 +++-------------- src/jest/reset.ts | 22 +++++++++++++ src/native-internal/index.ts | 6 +++- src/native/conditions/media-query.ts | 2 +- src/native/reactivity.ts | 10 +++++- src/utilities/ascii-case.ts | 4 --- src/web/api.tsx | 18 +++-------- 12 files changed, 78 insertions(+), 100 deletions(-) create mode 100644 src/jest/reset.ts delete mode 100644 src/utilities/ascii-case.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index a9654832..892c1db9 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -242,17 +242,6 @@ test.each([ ["=", "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"]], - // css-values-4 §4.1 folds ASCII only, so a Kelvin sign is no K - [ - "(prefers-color-scheme: dar\u212A)", - ["=", "prefers-color-scheme", "dar\u212A"], - ], - ["IOS", ["=", "platform", "ios"]], ["(grid: 1)", ["=", "grid", true]], ["(color: 8)", ["=", "color", 8]], ["(-webkit-device-pixel-ratio: 2)", ["=", "-webkit-device-pixel-ratio", 2]], @@ -346,27 +335,6 @@ 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 2d4848c8..e9260e90 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -5,6 +5,7 @@ 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 { resetBetweenTests } from "../../jest/reset"; import { deviceReducedMotion, dimensions } from "../../native/reactivity"; jest.mock("react-native", () => { @@ -409,23 +410,21 @@ test("reducedMotion.set overrides the device's preference, and set(null) follows }); describe("the react-native-css/jest reset", () => { - test("lets a test override the preference", () => { + test("clears a test's override", () => { reducedMotion.set("reduce"); - expect(reducedMotion.get()).toBe("reduce"); - }); - test("starts the next test from the device's preference", () => { + resetBetweenTests(); + expect(reducedMotion.get()).toBe("no-preference"); }); - test("lets a test change the device's answer", () => { + test("puts a device a test changed back to AccessibilityInfo's answer", () => { deviceReducedMotion.set("reduce"); - expect(reducedMotion.get()).toBe("reduce"); - }); - test("starts the next test from the device's first answer", () => { + resetBetweenTests(); + expect(reducedMotion.get()).toBe("no-preference"); }); }); @@ -460,8 +459,6 @@ const BROWSER_MEDIA_ROWS = [ 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], ["(unknown-feature)", false, false, false, false], ] as const; diff --git a/src/__tests__/native/reduce-motion-init.test.ts b/src/__tests__/native/reduce-motion-init.test.ts index 0e858df3..70d2ce8a 100644 --- a/src/__tests__/native/reduce-motion-init.test.ts +++ b/src/__tests__/native/reduce-motion-init.test.ts @@ -59,6 +59,24 @@ function controlledAccessibilityInfo() { } describe("the reduced-motion preference", () => { + test("lets an answer that arrives after the jest helper's reset through, and is reset to it once a test changes it", async () => { + const { accessibilityInfo, answer, change } = controlledAccessibilityInfo(); + const { reducedMotion } = importReactivityWith(accessibilityInfo); + const { resetBetweenTests } = + jest.requireActual("../../jest/reset"); + + resetBetweenTests(); + answer(true); + await settle(); + expect(reducedMotion.get()).toBe("reduce"); + + change(false); + expect(reducedMotion.get()).toBe("no-preference"); + + resetBetweenTests(); + expect(reducedMotion.get()).toBe("reduce"); + }); + test("is unknown until AccessibilityInfo answers", () => { const { accessibilityInfo } = controlledAccessibilityInfo(); diff --git a/src/__tests__/web/reduced-motion.test.ts b/src/__tests__/web/reduced-motion.test.ts index d0fb698c..8d8d1e41 100644 --- a/src/__tests__/web/reduced-motion.test.ts +++ b/src/__tests__/web/reduced-motion.test.ts @@ -63,13 +63,14 @@ test("a server keeps no override, since it renders every visitor's page in one p expect(reducedMotion.get()).toBeUndefined(); }); -test("reducedMotion parses its query once for each matchMedia", () => { - const matchMedia = jest.fn(() => ({ matches: true })); +test("reducedMotion reads a matchMedia double's new answer", () => { + const matchMedia = jest.fn(() => ({ matches: false })); withMatchMedia(matchMedia, () => { - expect(reducedMotion.get()).toBe("reduce"); + expect(reducedMotion.get()).toBe("no-preference"); + + matchMedia.mockImplementation(() => ({ matches: true })); + expect(reducedMotion.get()).toBe("reduce"); }); - - expect(matchMedia).toHaveBeenCalledTimes(1); }); diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 66558a07..1408adc0 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -8,7 +8,6 @@ import type { QueryFeatureFor_MediaFeatureId, } from "lightningcss"; -import { asciiLowerCase } from "../utilities/ascii-case"; import type { MediaCondition, MediaFeatureComparison, @@ -33,8 +32,7 @@ export function parseMediaQuery( // These all/screen are not conditions, they always apply if (query.mediaType !== "all" && query.mediaType !== "screen") { - // Media types are ASCII case-insensitive (MQ4 §3), and lightningcss lowercases only all, print and screen - platformCondition = ["=", "platform", asciiLowerCase(query.mediaType)]; + platformCondition = ["=", "platform", query.mediaType]; } } @@ -147,10 +145,8 @@ export function parseMediaFeatureValue( builder: StylesheetBuilder, ): StyleDescriptor { switch (value.type) { - case "ident": - // Keywords are ASCII case-insensitive (css-values-4 §4.1) - return asciiLowerCase(value.value); case "boolean": + case "ident": case "integer": case "number": return value.value; diff --git a/src/jest/index.ts b/src/jest/index.ts index 17b17e25..57aa5a3d 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -1,17 +1,9 @@ -import { Appearance, Dimensions } from "react-native"; - import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; -import { - colorScheme, - reducedMotion, - StyleCollection, -} from "react-native-css/native"; -import { - deviceReducedMotion, - dimensions, -} from "react-native-css/native-internal"; +import { StyleCollection } from "react-native-css/native"; + +import { resetBetweenTests } from "./reset"; declare global { /* eslint-disable @typescript-eslint/no-namespace */ @@ -24,21 +16,7 @@ declare global { export const testID = "react-native-css"; -// A test may fire React Native's reduceMotionChanged, so each test starts from the answer the file's first test began with -let deviceReducedMotionAtStart: - | { readonly answer: ReturnType } - | undefined; - -// 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); - deviceReducedMotionAtStart ??= { answer: deviceReducedMotion.get() }; - deviceReducedMotion.set(deviceReducedMotionAtStart.answer); - reducedMotion.set(null); -}); +beforeEach(resetBetweenTests); const debugDefault = Boolean( process.env.REACT_NATIVE_CSS_TEST_DEBUG && diff --git a/src/jest/reset.ts b/src/jest/reset.ts new file mode 100644 index 00000000..1a803c82 --- /dev/null +++ b/src/jest/reset.ts @@ -0,0 +1,22 @@ +import { Appearance, Dimensions } from "react-native"; + +import { + colorScheme, + reducedMotion, + StyleCollection, +} from "react-native-css/native"; +import { + deviceReducedMotion, + deviceReducedMotionAnswer, + dimensions, +} from "react-native-css/native-internal"; + +// The components a consumer's suite renders read the package's own build, so the reset reaches them through the package's entry; a test may fire React Native's reduceMotionChanged, so the device goes back to AccessibilityInfo's answer, or waits for it +export function resetBetweenTests(): void { + StyleCollection.styles.clear(); + dimensions.set(Dimensions.get("window")); + Appearance.setColorScheme("unspecified"); + colorScheme.set(null); + deviceReducedMotion.set(deviceReducedMotionAnswer.get()); + reducedMotion.set(null); +} diff --git a/src/native-internal/index.ts b/src/native-internal/index.ts index f5831aad..df7389aa 100644 --- a/src/native-internal/index.ts +++ b/src/native-internal/index.ts @@ -1,4 +1,8 @@ export * from "./root"; export * from "./style-collection"; export * from "./variables"; -export { deviceReducedMotion, dimensions } from "../native/reactivity"; +export { + deviceReducedMotion, + deviceReducedMotionAnswer, + dimensions, +} from "../native/reactivity"; diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index e62fb5b6..bab0e350 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -13,7 +13,7 @@ function test(mediaQuery: MediaCondition, get: Getter): boolean { case "[]": return false; case "!!": - // (prefers-reduced-motion) alone matches every value but no-preference (MQ5 §2.4.2) + // (prefers-reduced-motion) alone matches reduce, its one value besides no-preference (MQ5 §2.4.2), and nothing before the device answers return ( mediaQuery[1] === "prefers-reduced-motion" && get(reducedMotion) === "reduce" diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 38586d8f..ea3d9ad0 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -259,9 +259,16 @@ function toReducedMotionPreference( return isReduceMotionEnabled ? "reduce" : "no-preference"; } +// The answer AccessibilityInfo's read gave, which the jest helper restores between tests; a change event after it is newer +export const deviceReducedMotionAnswer = observable< + ReducedMotionPreference | undefined +>(undefined); + // A consumer's test double may leave out AccessibilityInfo or its change event, and the read below still answers function followReduceMotionChanges( - accessibilityInfo: Partial | undefined, + accessibilityInfo: + | Readonly>> + | undefined, ): void { if (typeof accessibilityInfo?.addEventListener !== "function") { return; @@ -282,6 +289,7 @@ void Promise.resolve() .then(() => AccessibilityInfo.isReduceMotionEnabled()) .then(toReducedMotionPreference, () => "no-preference" as const) .then((answer) => { + deviceReducedMotionAnswer.set(answer); // A change event that arrived first is newer than the answer if (deviceReducedMotion.get() === undefined) { deviceReducedMotion.set(answer); diff --git a/src/utilities/ascii-case.ts b/src/utilities/ascii-case.ts deleted file mode 100644 index 748b92c5..00000000 --- a/src/utilities/ascii-case.ts +++ /dev/null @@ -1,4 +0,0 @@ -// 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()); -} diff --git a/src/web/api.tsx b/src/web/api.tsx index 4d2b73ae..1b61a5c2 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -78,12 +78,6 @@ export const colorScheme: ColorScheme = { }, }; -// A MediaQueryList keeps its matches current, so each matchMedia parses the query once -const reduceMotionQueries = new WeakMap< - typeof window.matchMedia, - MediaQueryList ->(); - export const reducedMotion: ReducedMotion = browserPreference(() => { // Server rendering has no window, and React Native's jest environment a window without matchMedia @@ -94,14 +88,10 @@ export const reducedMotion: ReducedMotion = return undefined; } - const matchMedia = window.matchMedia; - let query = reduceMotionQueries.get(matchMedia); - if (query === undefined) { - query = matchMedia.call(window, "(prefers-reduced-motion: reduce)"); - reduceMotionQueries.set(matchMedia, query); - } - - return query.matches ? "reduce" : "no-preference"; + // Scripts read this, never a render, so each read asks matchMedia again, whose answer a test's double may change + return window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "reduce" + : "no-preference"; }); /** From 7f75f6a4300adcf861517be2fd762288c2a3a47b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 22 Oct 2026 00:49:13 +0300 Subject: [PATCH 19/21] fix(web): answer a matchMedia stub that returns no list as no matchMedia does --- src/__tests__/web/reduced-motion.test.ts | 1 + src/web/api.tsx | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/__tests__/web/reduced-motion.test.ts b/src/__tests__/web/reduced-motion.test.ts index 8d8d1e41..ce4091c6 100644 --- a/src/__tests__/web/reduced-motion.test.ts +++ b/src/__tests__/web/reduced-motion.test.ts @@ -43,6 +43,7 @@ test("an override changes what scripts read until set(null) reads matchMedia aga test.each([ ["no matchMedia", undefined], ["a matchMedia that is no function", {}], + ["a matchMedia stub that returns nothing", () => undefined], ])("reducedMotion has no preference to read with %s", (_, value) => { withMatchMedia(value, () => { expect(reducedMotion.get()).toBeUndefined(); diff --git a/src/web/api.tsx b/src/web/api.tsx index 1b61a5c2..7687298c 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -88,10 +88,17 @@ export const reducedMotion: ReducedMotion = return undefined; } - // Scripts read this, never a render, so each read asks matchMedia again, whose answer a test's double may change - return window.matchMedia("(prefers-reduced-motion: reduce)").matches - ? "reduce" - : "no-preference"; + // Scripts read this, never a render, so each read asks matchMedia again, whose answer a test's double may change; a test's stub may return no list at all + const matchMedia: ( + this: Window, + query: string, + ) => MediaQueryList | undefined = window.matchMedia; + const query = matchMedia.call(window, "(prefers-reduced-motion: reduce)"); + if (query === undefined) { + return undefined; + } + + return query.matches ? "reduce" : "no-preference"; }); /** From ee620e769e8fa71b2b9b6d785084c503c4f41191 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 22 Oct 2026 00:53:11 +0300 Subject: [PATCH 20/21] test: give the jest helper's reset a file of its own --- src/__tests__/native/jest-reset.test.ts | 24 +++++++++++++++++++++++ src/__tests__/native/media-query.test.tsx | 21 -------------------- 2 files changed, 24 insertions(+), 21 deletions(-) create mode 100644 src/__tests__/native/jest-reset.test.ts diff --git a/src/__tests__/native/jest-reset.test.ts b/src/__tests__/native/jest-reset.test.ts new file mode 100644 index 00000000..342ac9b1 --- /dev/null +++ b/src/__tests__/native/jest-reset.test.ts @@ -0,0 +1,24 @@ +import { reducedMotion } from "react-native-css/runtime"; + +import { resetBetweenTests } from "../../jest/reset"; +import { deviceReducedMotion } from "../../native/reactivity"; + +describe("the react-native-css/jest reset", () => { + test("clears a test's override", () => { + reducedMotion.set("reduce"); + expect(reducedMotion.get()).toBe("reduce"); + + resetBetweenTests(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); + + test("puts a device a test changed back to AccessibilityInfo's answer", () => { + deviceReducedMotion.set("reduce"); + expect(reducedMotion.get()).toBe("reduce"); + + resetBetweenTests(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); +}); diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index e9260e90..8485bace 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -5,7 +5,6 @@ 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 { resetBetweenTests } from "../../jest/reset"; import { deviceReducedMotion, dimensions } from "../../native/reactivity"; jest.mock("react-native", () => { @@ -409,26 +408,6 @@ test("reducedMotion.set overrides the device's preference, and set(null) follows expect(component.props.style).toStrictEqual({ color: "#000" }); }); -describe("the react-native-css/jest reset", () => { - test("clears a test's override", () => { - reducedMotion.set("reduce"); - expect(reducedMotion.get()).toBe("reduce"); - - resetBetweenTests(); - - expect(reducedMotion.get()).toBe("no-preference"); - }); - - test("puts a device a test changed back to AccessibilityInfo's answer", () => { - deviceReducedMotion.set("reduce"); - expect(reducedMotion.get()).toBe("reduce"); - - resetBetweenTests(); - - 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] const BROWSER_MEDIA_ROWS = [ ["(prefers-reduced-motion: reduce)", true, true, false, false], From 8951326c76bb7447c955c41fb63044ec1241ddc2 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 22 Oct 2026 02:08:11 +0300 Subject: [PATCH 21/21] test(compiler): keep to the reduced-motion queries this change compiles --- src/__tests__/compiler/compiler.test.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 892c1db9..f97671b2 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -242,9 +242,6 @@ test.each([ ["=", "prefers-reduced-motion", "no-preference"], ], ["(prefers-reduced-motion)", ["!!", "prefers-reduced-motion"]], - ["(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 ${query} { .my-class { opacity: 0 } }`).stylesheet(),