Repository navigation
feat(native): evaluate prefers-reduced-motion so motion-reduce / motion-safe work on native - #388
YevheniiKotyrlo wants to merge 1 commit into
Conversation
Device evidence — before / afterUNFIXED — Both rows paint the same — the media feature evaluates false on every device, so neither variant fires. FIXED — Exactly one row is green. Which one tracks the OS reduce-motion setting shown above it.
Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. Recording — reduce motion turning on
|
| result | |
|---|---|
main (eda2b286) |
TypeError: Cannot read properties of undefined (reading 'set'): nothing is exported under that name |
| this PR | the card's style is { color: "#020202" } after set("reduce"), still after the device's setting changes, and { color: "#000" } after set(null) |
Run as written, with the class registered and a View rendered, through the repo's jest on Windows with Node 26.
The evaluator's cost — main against this PR
testMediaQuery over one condition, read through a subscribing effect as a render reads it: the median of 21 samples of 1,000,000 evaluations, after 200,000 to warm up, two runs on each side.
| condition | main 2421ea77 |
this PR |
|---|---|---|
(min-width: 640px) |
13.78 ns, 13.99 ns | 13.93 ns, 13.64 ns |
(prefers-color-scheme: dark) |
13.53 ns, 13.45 ns | 13.33 ns, 13.51 ns |
(prefers-reduced-motion: reduce) |
7.82 ns, 7.88 ns | 13.49 ns, 13.25 ns |
The first two differ across the sides by less than two runs of one side differ, at most 0.29 ns. The reduced-motion condition now reads the preference as the colour-scheme one reads the scheme, where main read nothing and answered false. Windows 11 on an AMD Ryzen 9 9950X3D, Node 26.12.1, through the repo's jest.
|
Outdated: the pairs are re-shot on The original note@danstepanov — I've added before/after device captures to 19 of my 26 open PRs here. Each pair is the same device in the same run, differing only in whether the patch is applied, so the difference should be quick to eyeball. The other seven (#390, #410, #427, #428, #429, #430, #432) have nothing visual to show — they're build, typing and resolution fixes. No rush at all, and happy to rebase or split any of them if that makes them easier to take. |
0757e09 to
5987763
Compare
…on-safe work on native The native runtime evaluated prefers-color-scheme but not prefers-reduced-motion, so Tailwind's motion-reduce: and motion-safe: variants compiled and then never applied on a device. devicePreference() keeps a user preference as the device reports it apart from the app's override, which holds until it is cleared. The reduced-motion preference is built on it: the device's answer comes from AccessibilityInfo.isReduceMotionEnabled() and follows reduceMotionChanged from import, as colorScheme follows Appearance. React Native reads the setting only through a callback, so the preference is unknown until it answers, and a reduced-motion query does not match before then. The evaluator compares the prefers-reduced-motion keyword, and (prefers-reduced-motion) alone matches reduce. reducedMotion joins colorScheme in the API: get() returns the preference, and set() overrides it until set(null). On web get() reads matchMedia on each call and set() changes only what get() returns. The jest helper's reset clears an override and puts the device back to AccessibilityInfo's answer.
8951326 to
48eb155
Compare



Problem
The native runtime evaluates
prefers-color-schemebut notprefers-reduced-motion. Tailwind compilesmotion-reduce:*to@media (prefers-reduced-motion: reduce)andmotion-safe:*to@media (prefers-reduced-motion: no-preference), so both variants compile and then never apply on a device, whatever its accessibility setting.Solution
reactivity.tsgainsdevicePreference(), which keeps a user preference as the device reports it apart from the app's override, an override holding until it is cleared, the shape MQ5 §13 gives the web'srequestOverride()andclearOverride(). The reduced-motion preference is built on it: the device's answer comes fromAccessibilityInfo.isReduceMotionEnabled()and stays live fromreduceMotionChanged, which the package follows from import as it followsAppearance. React Native reads the setting only through a callback, on iOS and Android, so until it answers there is no preference and a reduced-motion query does not match: a first paint never starts amotion-safe:animation for someone who turned motion off. A change event that arrives before the answer wins over it. Where React Native rejects for want of an accessibility module, or a test double lacksAccessibilityInfoor its getter, the preference isno-preference.prefers-reduced-motion: <value>compares the keyword, asprefers-color-schemedoes, so an unrecognised value never matches;(prefers-reduced-motion)alone matches underreduceonly (MQ5 §2.4.2, §12.1).reducedMotionbesidecolorScheme, as Expose the reduced-motion preference beside colorScheme #507 proposes:get()returns the preference,undefineduntil the device answers;set("reduce" | "no-preference")overrides it through every change of the device's setting;set(null)returns to the device's answer at once. On web the browser evaluates the query itself, soget()readsmatchMediaon each call andset()changes only whatget()returns, throughbrowserPreference(); a server, which renders every visitor's page in one process, keeps no override.react-native-css/jesthelper's reset,resetBetweenTests(), clears an override before each test and puts the device back toAccessibilityInfo's answer,no-preferencewith React Native's mock, even when that answer arrives after the test began. It resets through the package's entries, which a consumer's components resolve to.The opt-out form,
animate-spin motion-reduce:animate-none, animates untilAccessibilityInfoanswers and stops then. On Android with Reanimated before 4.7.0 that stop can leave the view at its last transform (docs/known-issues.md, Reanimated #10507, fixed by #10528).Tests
src/__tests__/native/media-query.test.tsx: before the answer no positive reduced-motion query matches and a mounted view takes the answer when it arrives; a mounted view follows the preference both ways;set()holds through the device's changes andset(null)returns at once; and a table of 31 queries under each motion preference in each colour scheme, as Chromium 156, Firefox 157 and WebKit 27.2 evaluate them: the two variants, the boolean and negated forms, unrecognised and CSS-wide keywords, a feature name in capitals, whitespace and a comment, media types,or, nested negations and an unknown feature beside the preference (MQ5 §2–3).src/__tests__/compiler/compiler.test.tsx: the condition each reduced-motion query of that table compiles to, but for the unrecognised keywords, which fix: evaluate range pairs, and answer a condition the runtime cannot decide with unknown #426 compiles to no rule.src/__tests__/native/reduce-motion-init.test.ts: the answer, a change event before and after it, a rejecting or missing getter, a missingAccessibilityInfoor change event, and an answer after the jest reset; each case waits for every pending callback.src/__tests__/native/jest-reset.test.ts: the reset clears an override and restores a device a test changed.src/__tests__/web/reduced-motion.test.ts:get()readsmatchMediaagain after a double changes its answer, an override holds untilset(null), a server keeps none, and nomatchMediafunction or list means no preference.src/__tests__/native/_reduced-motion-types.ts, checked byyarn typecheckas_styled-types.tsxis:set()takes the two keywords andnullfrom the root, native and web entries and refuses any other value;get()may returnundefined.On
main, 150 of the 204 cases fail and the type test does not typecheck; all pass here, the native files also with every sheet compiledinlineVariables: falseand with every sheet throughJSON.stringifyandJSON.parse, as Metro hands it to a device.Verification
Every
ci.ymljob passes on this head merged intomain: lint, typecheck, the tests (1518 passed), both builds, the web export, nothing unstaged, commitlint, and the iOS dev build on macOS 27.0.1 with Xcode 27.1, where CI pins 26.6.(prefers-reduced-motion: reduce)now reads the preference as(prefers-color-scheme: dark)reads the scheme: 13.3 ns per evaluation againstmain's 7.8 ns, where it read nothing.No earlier issue tracks the missing query; I searched for
reduced motion,prefers-reduced-motion,motion-reduceandmotion-safe.The device pair, a recording of both variants as the setting turns on, the
reducedMotionsnippet and the evaluator's cost are in this comment.Merge order
prefers-reduced-motionfrom that lookup. With both, a negated query whose operand is unknown, an unrecognised keyword or the preference before the answer, matches nothing (MQ5 §3.1), and(prefers-reduced-motion: REDUCE)matches, as in the browsers.prefers-color-schemecase beside this one and makescolorScheme.set()hold until cleared, keeping the scheme's override inAppearance; whichever lands second keeps both.reactivity.tson one global; whichever lands second moves the reduced-motion preference and its subscription there, so a second copy of the package shares it.inherits: falseon registered custom properties #412, fix(types): accept an explicit undefined wherever a consumer writes an optional member #418, fix(types): type CSS custom properties against what both platforms honour #423, fix: own the color scheme union, so the package compiles on react-native 0.81 #429, fix(native): a custom property with no value produces no key #431, fix(native): answer :dir() per element, and compile every spelling of it #459, fix(compiler): read a media query's em and rem against the font size rem inlines #494, fix(web): take the variable name the native variable hooks take #496 and fix(native): measure em against the font size each element computes #498 change lines beside this PR's in the same files.