From 9559cb690f04e23bca49495b197a159401cc83c1 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:43:58 +0100 Subject: [PATCH 1/9] Add AccessibilityInfo.getPreferences() and preferencesChanged Add a preferences snapshot for accessibility settings that change how an app should draw its own controls, named by what the user needs rather than by each platform's setting: prefersSwitchLabels (iOS On/Off Labels) and prefersNonColorIndicators (iOS Differentiate Without Color). Android has neither setting, so it resolves both to false without a native call. preferencesChanged carries the whole new snapshot. AccessibilityPreferences is read-only (Readonly<> in Flow, readonly fields in TypeScript), and its docs say fields will be added over time, so apps should read the object rather than build one. The native spec keeps the fields inline, because a named type would add C++ structs to the API snapshots. Passing resolve as onSuccess lets Flow's exact object types check that the spec matches the public type. When NativeAccessibilityManagerIOS.getCurrentPreferences is missing (new JS on an older binary), getPreferences() rejects with an error naming the method, like isShowBordersEnabled, rather than resolving a snapshot that looks like "every setting off". Adds Jest tests, type tests, a typed jest-preset mock and the API snapshot. The iOS native side follows. Co-Authored-By: Claude Opus 5.5 --- .../jest/mocks/AccessibilityInfo.js | 10 ++- .../AccessibilityInfo/AccessibilityInfo.js | 59 +++++++++++++ ...cessibilityInfo-preferencesChanged-test.js | 82 +++++++++++++++++++ .../__tests__/AccessibilityInfo-test.js | 67 ++++++++++++++- packages/react-native/ReactNativeApi.d.ts | 11 ++- packages/react-native/__typetests__/index.tsx | 14 ++++ packages/react-native/index.js.flow | 1 + .../modules/NativeAccessibilityManager.js | 7 ++ .../AccessibilityInfo/AccessibilityInfo.d.ts | 40 +++++++++ 9 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js diff --git a/packages/jest-preset/jest/mocks/AccessibilityInfo.js b/packages/jest-preset/jest/mocks/AccessibilityInfo.js index d49f63d51ebf..3f8ea17984c7 100644 --- a/packages/jest-preset/jest/mocks/AccessibilityInfo.js +++ b/packages/jest-preset/jest/mocks/AccessibilityInfo.js @@ -4,10 +4,12 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * @flow strict-local * @format */ +import type {AccessibilityPreferences} from 'react-native'; + const AccessibilityInfo = { addEventListener: jest.fn(() => ({ remove: jest.fn(), @@ -20,6 +22,12 @@ const AccessibilityInfo = { isAccessibilityServiceEnabled: jest.fn(() => Promise.resolve(false), ) as JestMockFn<$FlowFixMe, $FlowFixMe>, + getPreferences: jest.fn(() => + Promise.resolve({ + prefersSwitchLabels: false, + prefersNonColorIndicators: false, + }), + ) as JestMockFn<[], Promise>, isShowBordersEnabled: jest.fn(() => Promise.resolve(false)) as JestMockFn< $FlowFixMe, $FlowFixMe, diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index 017c1d3c842e..4775579e6eba 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -18,6 +18,27 @@ import legacySendAccessibilityEvent from './legacySendAccessibilityEvent'; import NativeAccessibilityInfoAndroid from './NativeAccessibilityInfo'; import NativeAccessibilityManagerIOS from './NativeAccessibilityManager'; +/** + * Accessibility settings that change how an app should draw its own controls, + * named by what the user needs rather than by each platform's setting. A field + * is `false` on a platform that has no such setting. + * + * Fields will be added over time, so read the object that `getPreferences()` + * or `preferencesChanged` gives you rather than building one yourself. + */ +export type AccessibilityPreferences = Readonly<{ + // On/Off Labels (iOS): draw I/O marks on custom switches. + prefersSwitchLabels: boolean, + // Differentiate Without Color (iOS): convey state with shapes, icons or text, + // not by color alone. + prefersNonColorIndicators: boolean, +}>; + +const NEUTRAL_PREFERENCES: AccessibilityPreferences = { + prefersSwitchLabels: false, + prefersNonColorIndicators: false, +}; + // Events that are only supported on Android. type AccessibilityEventDefinitionsAndroid = { accessibilityServiceChanged: [boolean], @@ -29,6 +50,7 @@ type AccessibilityEventDefinitionsIOS = { announcementFinished: [{announcement: string, success: boolean}], boldTextChanged: [boolean], darkerSystemColorsChanged: [boolean], + preferencesChanged: [AccessibilityPreferences], reduceTransparencyChanged: [boolean], showBordersChanged: [boolean], }; @@ -69,6 +91,7 @@ const EventNames: Map = ['screenReaderChanged', 'screenReaderChanged'], ['darkerSystemColorsChanged', 'darkerSystemColorsChanged'], ['showBordersChanged', 'showBordersChanged'], + ['preferencesChanged', 'preferencesChanged'], ]); /** @@ -319,6 +342,40 @@ const AccessibilityInfo = { } }, + /** + * Query the accessibility preferences that change how custom controls should + * be drawn (see `AccessibilityPreferences`). + * + * Returns a promise which resolves to an `AccessibilityPreferences` object. + * On Android, which has none of these settings, every field is `false`. + */ + getPreferences(): Promise { + if (Platform.OS === 'android') { + return Promise.resolve({...NEUTRAL_PREFERENCES}); + } else { + return new Promise((resolve, reject) => { + if (NativeAccessibilityManagerIOS?.getCurrentPreferences != null) { + // The fields are written out twice by hand: inline in the native + // module spec (a named type would add C++ structs to the API + // snapshots) and in the TypeScript types. Passing `resolve` as + // `onSuccess` makes Flow's exact object types check that the spec + // matches `AccessibilityPreferences`; `__typetests__` covers the + // TypeScript copy. + NativeAccessibilityManagerIOS.getCurrentPreferences(resolve, reject); + } else { + // A new API, so a missing native method rejects rather than + // resolving a snapshot that looks like "every setting off", as + // `isShowBordersEnabled` does. + reject( + new Error( + 'NativeAccessibilityManagerIOS.getCurrentPreferences is not available', + ), + ); + } + }); + } + }, + /** * Query whether reduced transparency is currently enabled. * @@ -442,6 +499,8 @@ const AccessibilityInfo = { * - `showBordersChanged`: iOS-only event. Fires when the state of the show borders toggle changes * (called Button Shapes before iOS 26.1). * The argument to the event handler is a boolean. + * - `preferencesChanged`: iOS-only event. Fires when any field of `getPreferences()` changes. + * The argument to the event handler is the new `AccessibilityPreferences` object. * * These events are only supported on Android: * diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js new file mode 100644 index 000000000000..2e9282ddcf8f --- /dev/null +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js @@ -0,0 +1,82 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import typeof RCTDeviceEventEmitterType from '../../../EventEmitter/RCTDeviceEventEmitter'; +import typeof AccessibilityInfoType from '../AccessibilityInfo'; + +const invariant = require('invariant'); + +jest.unmock('../AccessibilityInfo'); + +type LoadedModules = { + AccessibilityInfo: AccessibilityInfoType, + RCTDeviceEventEmitter: RCTDeviceEventEmitterType, +}; + +// `AccessibilityInfo` picks its native event names when it is first loaded, +// so each platform needs a fresh module registry. +function loadForPlatform(os: 'android' | 'ios'): LoadedModules { + let modules: ?LoadedModules = null; + jest.isolateModules(() => { + const Platform = require('../../../Utilities/Platform').default; + /* $FlowFixMe[incompatible-type] */ + Platform.OS = os; + modules = { + AccessibilityInfo: require('../AccessibilityInfo').default, + RCTDeviceEventEmitter: + require('../../../EventEmitter/RCTDeviceEventEmitter').default, + }; + }); + invariant(modules != null, 'Expected modules to load'); + return modules; +} + +describe('AccessibilityInfo preferencesChanged', () => { + it('delivers the preferences snapshot on iOS', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'preferencesChanged', + handler, + ); + const preferences = { + prefersSwitchLabels: true, + prefersNonColorIndicators: false, + }; + RCTDeviceEventEmitter.emit('preferencesChanged', preferences); + subscription.remove(); + RCTDeviceEventEmitter.emit('preferencesChanged', { + prefersSwitchLabels: false, + prefersNonColorIndicators: false, + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(preferences); + }); + + it('does not subscribe on Android', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = + loadForPlatform('android'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'preferencesChanged', + handler, + ); + RCTDeviceEventEmitter.emit('preferencesChanged', { + prefersSwitchLabels: true, + prefersNonColorIndicators: true, + }); + subscription.remove(); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js index 380be9fdd394..c74c8c29312e 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -22,6 +22,13 @@ const mockGetCurrentShowBordersState = jest.fn((onSuccess, onError) => const mockGetCurrentBoldTextState = jest.fn((onSuccess, onError) => onSuccess(true), ); +const IOS_PREFERENCES: AccessibilityPreferences = { + prefersSwitchLabels: true, + prefersNonColorIndicators: false, +}; +const mockGetCurrentPreferences = jest.fn((onSuccess, onError) => + onSuccess(IOS_PREFERENCES), +); const mockGetCurrentGrayscaleState = jest.fn((onSuccess, onError) => onSuccess(true), ); @@ -38,6 +45,13 @@ const mockGetCurrentVoiceOverState = jest.fn((onSuccess, onError) => onSuccess(true), ); let mockNativeAccessibilityManagerIOS: { + getCurrentPreferences: JestMockFn< + [ + onSuccess: (preferences: AccessibilityPreferences) => void, + onError: (error: Error) => void, + ], + void, + > | null, getCurrentShowBordersState: JestMockFn< [ onSuccess: (isShowBordersEnabled: boolean) => void, @@ -102,6 +116,7 @@ let mockNativeAccessibilityManagerIOS: { void, > | null, } = { + getCurrentPreferences: mockGetCurrentPreferences, getCurrentShowBordersState: mockGetCurrentShowBordersState, getCurrentPrefersCrossFadeTransitionsState: mockGetCurrentPrefersCrossFadeTransitionsState, @@ -113,7 +128,6 @@ let mockNativeAccessibilityManagerIOS: { getCurrentReduceTransparencyState: mockGetCurrentReduceTransparencyState, getCurrentVoiceOverState: mockGetCurrentVoiceOverState, }; - const ANDROID_RECOMMENDED_TIMEOUT = 6000; const mockIsHighTextContrastEnabled = jest.fn(onSuccess => onSuccess(true)); const mockIsGrayscaleEnabled = jest.fn(onSuccess => onSuccess(true)); @@ -173,6 +187,8 @@ jest.mock('../NativeAccessibilityInfo', () => ({ default: mockNativeAccessibilityInfoAndroid, })); +import type {AccessibilityPreferences} from '../AccessibilityInfo'; + const Platform = require('../../../Utilities/Platform').default; const AccessibilityInfo = require('../AccessibilityInfo').default; const invariant = require('invariant'); @@ -831,7 +847,7 @@ describe('AccessibilityInfo', () => { }); describe('iOS', () => { - it('should return originalTimeout', async () => { + it('should return originalTimeout without calling native', async () => { /* $FlowFixMe[incompatible-type] */ Platform.OS = 'ios'; @@ -839,12 +855,59 @@ describe('AccessibilityInfo', () => { await AccessibilityInfo.getRecommendedTimeoutMillis(5000); expect(recommendedTimeoutMillis).toBe(5000); + expect(mockGetRecommendedTimeoutMillis).not.toHaveBeenCalled(); + }); + }); + }); + + describe('getPreferences', () => { + describe('Android', () => { + it('should return neutral preferences without calling native', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + const preferences = await AccessibilityInfo.getPreferences(); + + expect(preferences).toEqual({ + prefersSwitchLabels: false, + prefersNonColorIndicators: false, + }); + expect(mockGetCurrentPreferences).not.toHaveBeenCalled(); + }); + }); + + describe('iOS', () => { + it('should call getCurrentPreferences if available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + const preferences = await AccessibilityInfo.getPreferences(); + + expect(mockGetCurrentPreferences).toHaveBeenCalled(); + expect(preferences).toEqual(IOS_PREFERENCES); + }); + + it('should throw error if getCurrentPreferences is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + mockNativeAccessibilityManagerIOS.getCurrentPreferences = null; + + const result: unknown = await AccessibilityInfo.getPreferences().catch( + e => e, + ); + + invariant(result instanceof Error, 'Expected getPreferences to reject'); + expect(result.message).toEqual( + 'NativeAccessibilityManagerIOS.getCurrentPreferences is not available', + ); }); }); }); afterEach(() => { mockNativeAccessibilityManagerIOS = { + getCurrentPreferences: mockGetCurrentPreferences, getCurrentShowBordersState: mockGetCurrentShowBordersState, getCurrentPrefersCrossFadeTransitionsState: mockGetCurrentPrefersCrossFadeTransitionsState, diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index c0f8309f0ac5..c7d0a1a92f2a 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -91,6 +91,7 @@ declare const AccessibilityInfo_default: { queue?: boolean }, ): void + getPreferences(): Promise getRecommendedTimeoutMillis(originalTimeout: number): Promise isAccessibilityServiceEnabled(): Promise isBoldTextEnabled(): Promise @@ -974,12 +975,17 @@ declare type AccessibilityEventDefinitionsIOS = { ] boldTextChanged: [boolean] darkerSystemColorsChanged: [boolean] + preferencesChanged: [AccessibilityPreferences] reduceTransparencyChanged: [boolean] showBordersChanged: [boolean] } declare type AccessibilityEventTypes = "click" | "focus" | "viewHoverEnter" | "windowStateChange" declare type AccessibilityInfo = typeof AccessibilityInfo +declare type AccessibilityPreferences = { + readonly prefersNonColorIndicators: boolean + readonly prefersSwitchLabels: boolean +} declare interface AccessibilityProps extends Readonly< AccessibilityPropsAndroid & AccessibilityPropsIOS & AccessibilityPropsCore > {} @@ -5775,7 +5781,8 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a276ec6e AccessibilityActionInfo, // c94545a7 - AccessibilityInfo, // 7d8718ed + AccessibilityInfo, // f03a8242 + AccessibilityPreferences, // 9861c4a0 AccessibilityProps, // 95dbb1d8 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 75e5dce1634c..6b50653a821e 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -23,6 +23,7 @@ For a list of complete Typescript examples: check https://github.com/bgrieder/RN import * as React from 'react'; import { AccessibilityInfo, + AccessibilityPreferences, ActionSheetIOS, Alert, Animated, @@ -1470,6 +1471,19 @@ AccessibilityInfo.isShowBordersEnabled().then(isEnabled => AccessibilityInfo.addEventListener('showBordersChanged', isEnabled => console.log(`AccessibilityInfo.showBordersChanged => ${isEnabled}`), ); +AccessibilityInfo.getPreferences().then( + (preferences: AccessibilityPreferences) => + console.log( + `AccessibilityInfo.getPreferences => ${preferences.prefersSwitchLabels} ${preferences.prefersNonColorIndicators}`, + ), +); +AccessibilityInfo.addEventListener( + 'preferencesChanged', + (preferences: AccessibilityPreferences) => + console.log( + `AccessibilityInfo.preferencesChanged => ${preferences.prefersSwitchLabels}`, + ), +); AccessibilityInfo.isBoldTextEnabled().then(isEnabled => console.log(`AccessibilityInfo.isBoldTextEnabled => ${isEnabled}`), ); diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index 113127b308b2..6c89ba4e1505 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -242,6 +242,7 @@ export {default as VirtualizedSectionList} from './Libraries/Lists/VirtualizedSe // #endregion // #region APIs +export type {AccessibilityPreferences} from './Libraries/Components/AccessibilityInfo/AccessibilityInfo'; export {default as AccessibilityInfo} from './Libraries/Components/AccessibilityInfo/AccessibilityInfo'; export type { diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js index 0dfb462fe674..a574cde67d6d 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js @@ -42,6 +42,13 @@ export interface Spec extends TurboModule { onSuccess: (isShowBordersEnabled: boolean) => void, onError: (error: UnsafeObject) => void, ) => void; + readonly getCurrentPreferences?: ( + onSuccess: (preferences: { + prefersSwitchLabels: boolean, + prefersNonColorIndicators: boolean, + }) => void, + onError: (error: UnsafeObject) => void, + ) => void; readonly getCurrentReduceTransparencyState: ( onSuccess: (isReduceTransparencyEnabled: boolean) => void, onError: (error: UnsafeObject) => void, diff --git a/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts index 6f2ccb478328..e79c6624b19b 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts @@ -47,6 +47,30 @@ type AccessibilityAnnouncementFinishedEventHandler = ( event: AccessibilityAnnouncementFinishedEvent, ) => void; +/** + * Accessibility settings that change how an app should draw its own controls, + * named by what the user needs rather than by each platform's setting. A field + * is `false` on a platform that has no such setting. + * + * Fields will be added over time, so read the object that `getPreferences()` + * or `preferencesChanged` gives you rather than building one yourself. + */ +export interface AccessibilityPreferences { + /** On/Off Labels (iOS): draw I/O marks on custom switches. */ + readonly prefersSwitchLabels: boolean; + /** + * Differentiate Without Color (iOS): convey state with shapes, icons or + * text, not by color alone. + */ + readonly prefersNonColorIndicators: boolean; +} + +type AccessibilityPreferencesEventName = 'preferencesChanged'; // iOS-only Event + +type AccessibilityPreferencesEventHandler = ( + preferences: AccessibilityPreferences, +) => void; + type AccessibilityEventTypes = 'click' | 'focus' | 'viewHoverEnter' | 'windowStateChange'; @@ -95,6 +119,12 @@ export interface AccessibilityInfoStatic { */ isShowBordersEnabled: () => Promise; + /** + * Query the accessibility preferences that change how custom controls + * should be drawn. On Android, every field is `false`. + */ + getPreferences: () => Promise; + /** * Query whether darker system colors is currently enabled. * @@ -151,6 +181,16 @@ export interface AccessibilityInfoStatic { eventName: AccessibilityAnnouncementEventName, handler: AccessibilityAnnouncementFinishedEventHandler, ): EmitterSubscription; + /** + * Fires when any field of `getPreferences()` changes, with the new + * preferences. + * + * @platform ios + */ + addEventListener( + eventName: AccessibilityPreferencesEventName, + handler: AccessibilityPreferencesEventHandler, + ): EmitterSubscription; /** * Set accessibility focus to a react component. From 4dbc901d43a2bc3ca7e62a6596f5fb98ecbd4925 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:44:07 +0100 Subject: [PATCH 2/9] Show accessibility preferences in RNTester Add a row to Accessibility's display options that shows getPreferences() and the last preferencesChanged event. A rejected read shows "Unavailable: " instead of staying on "loading", and a starting read that resolves after an event doesn't overwrite the newer value. Co-Authored-By: Claude Opus 5.5 --- .../Accessibility/AccessibilityExample.js | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index ff2891104139..9334b9562807 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -11,7 +11,11 @@ 'use strict'; import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; -import type {EventSubscription, GestureResponderEvent} from 'react-native'; +import type { + AccessibilityPreferences, + EventSubscription, + GestureResponderEvent, +} from 'react-native'; import RNTesterBlock from '../../components/RNTesterBlock'; import RNTesterText from '../../components/RNTesterText'; @@ -1606,6 +1610,7 @@ class DisplayOptionsStatusExample extends React.Component<{}> { /> )} + ); } @@ -1647,6 +1652,66 @@ function DisplayOptionStatusExample({ ); } +function describePreferences(preferences: AccessibilityPreferences): string { + return ( + `switch labels ${preferences.prefersSwitchLabels ? 'on' : 'off'}, ` + + `non-color indicators ${preferences.prefersNonColorIndicators ? 'on' : 'off'}` + ); +} + +function PreferencesStatusExample(): React.Node { + const [preferences, setPreferences] = + useState(null); + const [error, setError] = useState(null); + const [lastEvent, setLastEvent] = useState(null); + useEffect(() => { + let isMounted = true; + // An event carries newer preferences than a starting read still in flight. + let receivedEvent = false; + const listener = AccessibilityInfo.addEventListener( + 'preferencesChanged', + newPreferences => { + receivedEvent = true; + setPreferences(newPreferences); + setLastEvent(newPreferences); + }, + ); + AccessibilityInfo.getPreferences() + .then(newPreferences => { + if (isMounted && !receivedEvent) { + setPreferences(newPreferences); + } + }) + .catch((e: Error) => { + if (isMounted && !receivedEvent) { + setError(e.message); + } + }); + return function cleanup() { + isMounted = false; + listener.remove(); + }; + }, []); + let status = 'loading'; + if (preferences != null) { + status = describePreferences(preferences); + } else if (error != null) { + status = `Unavailable: ${error}`; + } + return ( + + + {'Preferences: '} + {status}. + + + {'Last preferencesChanged: '} + {lastEvent == null ? 'none' : describePreferences(lastEvent)}. + + + ); +} + function AccessibilityExpandedExample(): React.Node { const [expand, setExpanded] = useState(false); const expandAction = {name: 'expand'}; From 28dbd31040e304970c35c869e3d7b56203861d14 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:59:24 +0100 Subject: [PATCH 3/9] Read accessibility preferences on iOS RCTAccessibilityManager builds the getPreferences() snapshot from On/Off Labels (UIAccessibilityIsOnOffSwitchLabelsEnabled) and Differentiate Without Color (UIAccessibilityShouldDifferentiateWithoutColor), observes both settings' notifications, and sends preferencesChanged with the whole snapshot only when it changes. One normaliser builds every snapshot from the known keys, boxing each value from a BOOL, so the keys always match AccessibilityPreferences and the values are always CFBoolean. Tests can replace how one module instance reads the settings with RCTAccessibilityManagerSetPreferencesReader, in +Internal.h. Setting a reader re-reads the snapshot without sending an event, and nil reads the system again. A reader that returns nil falls back to the system with a warning. Co-Authored-By: Claude Opus 5.5 --- .../RCTAccessibilityManager+Internal.h | 11 +++ .../CoreModules/RCTAccessibilityManager.mm | 80 ++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h b/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h index 6e180007fdca..2f97fd164737 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h @@ -19,6 +19,17 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled( RCTAccessibilityManager *accessibilityManager, BOOL isVoiceOverEnabled); +// Returns the raw settings behind getPreferences(), keyed by the +// AccessibilityPreferences field names. Returning nil reads the system. +typedef NSDictionary *_Nullable (^RCTAccessibilityPreferencesReader)(void); + +// Replaces how this module reads the settings behind getPreferences(), and +// re-reads them without sending preferencesChanged. Pass nil to read the +// system settings again. +void RCTAccessibilityManagerSetPreferencesReader( + RCTAccessibilityManager *accessibilityManager, + RCTAccessibilityPreferencesReader _Nullable preferencesReader); + RCT_EXTERN_C_END NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm index dee5e643c758..e00231c48854 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm @@ -56,7 +56,48 @@ static NSNotificationName RCTShowBordersStatusDidChangeNotification() #pragma clang diagnostic pop } -@implementation RCTAccessibilityManager +static NSDictionary *RCTSystemAccessibilityPreferences(void) +{ + return @{ + @"prefersSwitchLabels" : @(UIAccessibilityIsOnOffSwitchLabelsEnabled()), + @"prefersNonColorIndicators" : @(UIAccessibilityShouldDifferentiateWithoutColor()), + }; +} + +static BOOL RCTAccessibilityPreferenceValue(id value) +{ + return [value respondsToSelector:@selector(boolValue)] && [value boolValue]; +} + +// Builds the AccessibilityInfo.getPreferences() snapshot from `reader`, or from +// the system settings when `reader` is nil. The keys must match the fields of +// AccessibilityPreferences in AccessibilityInfo.js. Only those keys are read, +// and each value is boxed from a BOOL, so the snapshot always has exactly these +// keys and every value is a CFBoolean, whatever the reader returns. +static NSDictionary *RCTCurrentAccessibilityPreferences( + RCTAccessibilityPreferencesReader _Nullable reader) +{ + NSDictionary *raw = nil; + if (reader != nil) { + raw = reader(); + if (raw == nil) { + RCTLogWarn(@"The accessibility preferences reader returned nil, so the system settings are used instead."); + } + } + if (raw == nil) { + raw = RCTSystemAccessibilityPreferences(); + } + return @{ + @"prefersSwitchLabels" : @(RCTAccessibilityPreferenceValue(raw[@"prefersSwitchLabels"])), + @"prefersNonColorIndicators" : @(RCTAccessibilityPreferenceValue(raw[@"prefersNonColorIndicators"])), + }; +} + +@implementation RCTAccessibilityManager { + NSDictionary *_preferences; + // Replaced in tests through RCTAccessibilityManagerSetPreferencesReader. + RCTAccessibilityPreferencesReader _preferencesReader; +} @synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED; @synthesize moduleRegistry = _moduleRegistry; @@ -125,6 +166,17 @@ - (instancetype)init name:RCTShowBordersStatusDidChangeNotification() object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(preferencesStatusDidChange:) + name:UIAccessibilityOnOffSwitchLabelsDidChangeNotification + object:nil]; + + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(preferencesStatusDidChange:) + name:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(voiceVoiceOverStatusDidChange:) name:UIAccessibilityVoiceOverStatusDidChangeNotification @@ -138,6 +190,7 @@ - (instancetype)init _isDarkerSystemColorsEnabled = UIAccessibilityDarkerSystemColorsEnabled(); _isReduceTransparencyEnabled = UIAccessibilityIsReduceTransparencyEnabled(); _isShowBordersEnabled = RCTIsShowBordersEnabled(); + _preferences = RCTCurrentAccessibilityPreferences(nil); _isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); } return self; @@ -255,6 +308,18 @@ - (void)showBordersStatusDidChange:(__unused NSNotification *)notification } } +- (void)preferencesStatusDidChange:(__unused NSNotification *)notification +{ + NSDictionary *newPreferences = RCTCurrentAccessibilityPreferences(_preferencesReader); + if (![_preferences isEqualToDictionary:newPreferences]) { + _preferences = newPreferences; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + [[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"preferencesChanged" body:_preferences]; +#pragma clang diagnostic pop + } +} + - (void)voiceVoiceOverStatusDidChange:(__unused NSNotification *)notification { BOOL isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); @@ -469,6 +534,11 @@ - (void)getCurrentVoiceOverState:(RCTResponseSenderBlock)onSuccess onError:(__un onSuccess(@[ @(_isVoiceOverEnabled) ]); } +- (void)getCurrentPreferences:(RCTResponseSenderBlock)onSuccess onError:(__unused RCTResponseSenderBlock)onError +{ + onSuccess(@[ _preferences ]); +} + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { @@ -484,6 +554,14 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled( [accessibilityManager _setIsVoiceOverEnabled:isVoiceOverEnabled]; } +void RCTAccessibilityManagerSetPreferencesReader( + RCTAccessibilityManager *accessibilityManager, + RCTAccessibilityPreferencesReader _Nullable preferencesReader) +{ + accessibilityManager->_preferencesReader = [preferencesReader copy]; + accessibilityManager->_preferences = RCTCurrentAccessibilityPreferences(accessibilityManager->_preferencesReader); +} + @end @implementation RCTBridgeProxy (RCTAccessibilityManager) From fc21f05f964e40e251911e1f2d2106074c59124a Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:59:35 +0100 Subject: [PATCH 4/9] Test accessibility preferences in RCTAccessibilityManager Add RCTAccessibilityManagerTests to RNTesterUnitTests. The tests install per-instance readers backed by mutable state, change the state, and post the real UIKit notifications, so they cover the observers, the deduplication and getCurrentPreferences rather than a helper. They check the system values at startup, that the setter sends no event, that each setting's change sends exactly one event, that two instances keep their own readers, that reader values reach the event as CFBoolean, and that a nil reader returns the system values. Co-Authored-By: Claude Opus 5.5 --- .../RNTesterPods.xcodeproj/project.pbxproj | 4 + .../RCTAccessibilityManagerTests.m | 308 ++++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 1d336b424136..51ae4cd2bc1b 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ E7DB20DF22B2BAA6005AC45F /* RCTImageLoaderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C222B2BAA4005AC45F /* RCTImageLoaderTests.m */; }; E7DB20E022B2BAA6005AC45F /* RCTMethodArgumentTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C322B2BAA4005AC45F /* RCTMethodArgumentTests.m */; }; E7DB20E222B2BAA6005AC45F /* RCTGzipTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C522B2BAA4005AC45F /* RCTGzipTests.m */; }; + A11E0F0D2EA6000100A11E0F /* RCTAccessibilityManagerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A11E0F0C2EA6000100A11E0F /* RCTAccessibilityManagerTests.m */; }; E7DB20E322B2BAA6005AC45F /* RCTAllocationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C622B2BAA5005AC45F /* RCTAllocationTests.m */; }; E7DB20E422B2BAA6005AC45F /* RCTFormatErrorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C722B2BAA5005AC45F /* RCTFormatErrorTests.m */; }; E7DB20E522B2BAA6005AC45F /* RCTDevMenuTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C822B2BAA5005AC45F /* RCTDevMenuTests.m */; }; @@ -123,6 +124,7 @@ E7DB20C222B2BAA4005AC45F /* RCTImageLoaderTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTImageLoaderTests.m; sourceTree = ""; }; E7DB20C322B2BAA4005AC45F /* RCTMethodArgumentTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTMethodArgumentTests.m; sourceTree = ""; }; E7DB20C522B2BAA4005AC45F /* RCTGzipTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTGzipTests.m; sourceTree = ""; }; + A11E0F0C2EA6000100A11E0F /* RCTAccessibilityManagerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTAccessibilityManagerTests.m; sourceTree = ""; }; E7DB20C622B2BAA5005AC45F /* RCTAllocationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTAllocationTests.m; sourceTree = ""; }; E7DB20C722B2BAA5005AC45F /* RCTFormatErrorTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTFormatErrorTests.m; sourceTree = ""; }; E7DB20C822B2BAA5005AC45F /* RCTDevMenuTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTDevMenuTests.m; sourceTree = ""; }; @@ -333,6 +335,7 @@ E7DB20AF22B2BAA4005AC45F /* RCTFontTests.m */, E7DB20C722B2BAA5005AC45F /* RCTFormatErrorTests.m */, E7DB20C522B2BAA4005AC45F /* RCTGzipTests.m */, + A11E0F0C2EA6000100A11E0F /* RCTAccessibilityManagerTests.m */, E7DB20AE22B2BAA4005AC45F /* RCTImageLoaderHelpers.h */, E7DB20C922B2BAA5005AC45F /* RCTImageLoaderHelpers.m */, E7DB20C222B2BAA4005AC45F /* RCTImageLoaderTests.m */, @@ -760,6 +763,7 @@ F1A0B1C23D4E5F6071829302 /* RCTTurboModuleArrayBufferTests.mm in Sources */, F1A0B1C23D4E5F6071829322 /* RCTTurboModuleStringConversionTests.mm in Sources */, E7DB20E222B2BAA6005AC45F /* RCTGzipTests.m in Sources */, + A11E0F0D2EA6000100A11E0F /* RCTAccessibilityManagerTests.m in Sources */, E7DB20ED22B2BAA6005AC45F /* RCTURLUtilsTests.m in Sources */, E7DB20D322B2BAA6005AC45F /* RCTBlobManagerTests.m in Sources */, E7DB20E322B2BAA6005AC45F /* RCTAllocationTests.m in Sources */, diff --git a/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m new file mode 100644 index 000000000000..d1d49ea22395 --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m @@ -0,0 +1,308 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import + +#import +#import +#import + +// The native module method JS calls, from the generated spec. +@interface RCTAccessibilityManager (RCTAccessibilityManagerTests) + +- (void)getCurrentPreferences:(RCTResponseSenderBlock)onSuccess onError:(RCTResponseSenderBlock)onError; + +@end + +// Records the device events the module sends. +@interface RCTAccessibilityManagerTestsEventDispatcher : NSObject + +@property (nonatomic, strong) NSMutableArray *events; + +- (void)sendDeviceEventWithName:(NSString *)name body:(id)body; + +@end + +@implementation RCTAccessibilityManagerTestsEventDispatcher + +- (instancetype)init +{ + if (self = [super init]) { + _events = [NSMutableArray new]; + } + return self; +} + +- (void)sendDeviceEventWithName:(NSString *)name body:(id)body +{ + [_events addObject:@[ name, body ]]; +} + +@end + +@interface RCTAccessibilityManagerTestsModuleRegistry : RCTModuleRegistry + +@property (nonatomic, strong) RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher; + +@end + +@implementation RCTAccessibilityManagerTestsModuleRegistry + +- (id)moduleForName:(const char *)moduleName +{ + return strcmp(moduleName, "EventDispatcher") == 0 ? _eventDispatcher : nil; +} + +@end + +// Holds the settings a test reader returns, so a test can change them after +// installing the reader. Installing a reader refreshes the module's cache, so +// only a notification can be what sends an event. +@interface RCTAccessibilityManagerTestsSettings : NSObject + +@property (nonatomic, copy) NSDictionary *preferences; + +@end + +@implementation RCTAccessibilityManagerTestsSettings + +@end + +static NSDictionary *RCTTestPreferences( + BOOL prefersSwitchLabels, + BOOL prefersNonColorIndicators) +{ + return @{ + @"prefersSwitchLabels" : @(prefersSwitchLabels), + @"prefersNonColorIndicators" : @(prefersNonColorIndicators), + }; +} + +static BOOL RCTIsCFBoolean(id value) +{ + return value != nil && CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID(); +} + +@interface RCTAccessibilityManagerTests : XCTestCase + +@end + +@implementation RCTAccessibilityManagerTests { + // The module holds its registry weakly, so the test keeps each one alive. + NSMutableArray *_moduleRegistries; +} + +- (void)setUp +{ + [super setUp]; + _moduleRegistries = [NSMutableArray new]; +} + +- (void)tearDown +{ + _moduleRegistries = nil; + [super tearDown]; +} + +- (RCTAccessibilityManager *)managerWithEventDispatcher:(RCTAccessibilityManagerTestsEventDispatcher *)eventDispatcher +{ + RCTAccessibilityManagerTestsModuleRegistry *moduleRegistry = [RCTAccessibilityManagerTestsModuleRegistry new]; + moduleRegistry.eventDispatcher = eventDispatcher; + [_moduleRegistries addObject:moduleRegistry]; + RCTAccessibilityManager *manager = [RCTAccessibilityManager new]; + manager.moduleRegistry = moduleRegistry; + return manager; +} + +- (void)installReaderOverSettings:(RCTAccessibilityManagerTestsSettings *)settings + onManager:(RCTAccessibilityManager *)manager +{ + RCTAccessibilityManagerSetPreferencesReader(manager, ^NSDictionary * { + return settings.preferences; + }); +} + +- (NSDictionary *)currentPreferencesOf:(RCTAccessibilityManager *)manager +{ + __block NSDictionary *preferences = nil; + [manager + getCurrentPreferences:^(NSArray *response) { + preferences = response.firstObject; + } + onError:^(__unused NSArray *response) { + XCTFail(@"getCurrentPreferences failed"); + }]; + return preferences; +} + +- (void)postNotification:(NSNotificationName)name +{ + [[NSNotificationCenter defaultCenter] postNotificationName:name object:nil]; +} + +- (void)testSystemReaderAtStartup +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + + NSDictionary *preferences = [self currentPreferencesOf:manager]; + + XCTAssertEqualObjects( + [NSSet setWithArray:preferences.allKeys], + ([NSSet setWithArray:@[ @"prefersSwitchLabels", @"prefersNonColorIndicators" ]])); + XCTAssertTrue(RCTIsCFBoolean(preferences[@"prefersSwitchLabels"])); + XCTAssertTrue(RCTIsCFBoolean(preferences[@"prefersNonColorIndicators"])); + XCTAssertEqualObjects(preferences[@"prefersSwitchLabels"], @(UIAccessibilityIsOnOffSwitchLabelsEnabled())); + XCTAssertEqualObjects(preferences[@"prefersNonColorIndicators"], @(UIAccessibilityShouldDifferentiateWithoutColor())); + XCTAssertEqual(eventDispatcher.events.count, 0); +} + +- (void)testSetterReplacesThePreferencesWithoutAnEvent +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + + RCTAccessibilityManagerSetPreferencesReader(manager, ^NSDictionary * { + return RCTTestPreferences(YES, YES); + }); + + XCTAssertEqualObjects([self currentPreferencesOf:manager], RCTTestPreferences(YES, YES)); + XCTAssertEqual(eventDispatcher.events.count, 0); +} + +- (void)testOnOffSwitchLabelsChangeSendsOneEvent +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + RCTAccessibilityManagerTestsSettings *settings = [RCTAccessibilityManagerTestsSettings new]; + settings.preferences = RCTTestPreferences(NO, NO); + [self installReaderOverSettings:settings onManager:manager]; + + settings.preferences = RCTTestPreferences(YES, NO); + [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; + [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; + + XCTAssertEqualObjects(eventDispatcher.events, (@[ @[ @"preferencesChanged", RCTTestPreferences(YES, NO) ] ])); + XCTAssertEqualObjects([self currentPreferencesOf:manager], RCTTestPreferences(YES, NO)); +} + +- (void)testDifferentiateWithoutColorChangeSendsOneEvent +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + RCTAccessibilityManagerTestsSettings *settings = [RCTAccessibilityManagerTestsSettings new]; + settings.preferences = RCTTestPreferences(NO, NO); + [self installReaderOverSettings:settings onManager:manager]; + + settings.preferences = RCTTestPreferences(NO, YES); + [self postNotification:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification]; + [self postNotification:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification]; + + XCTAssertEqualObjects(eventDispatcher.events, (@[ @[ @"preferencesChanged", RCTTestPreferences(NO, YES) ] ])); + XCTAssertEqualObjects([self currentPreferencesOf:manager], RCTTestPreferences(NO, YES)); +} + +- (void)testNotificationAfterTheSetterWithTheSameValuesSendsNoEvent +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + RCTAccessibilityManagerTestsSettings *settings = [RCTAccessibilityManagerTestsSettings new]; + settings.preferences = RCTTestPreferences(YES, YES); + [self installReaderOverSettings:settings onManager:manager]; + + [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; + [self postNotification:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification]; + + XCTAssertEqual(eventDispatcher.events.count, 0); +} + +- (void)testSeparateInstancesUseTheirOwnReaders +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcherA = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcherB = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *managerA = [self managerWithEventDispatcher:eventDispatcherA]; + RCTAccessibilityManager *managerB = [self managerWithEventDispatcher:eventDispatcherB]; + XCTAssertNotNil(managerA); + XCTAssertNotNil(managerB); + RCTAccessibilityManagerTestsSettings *settingsA = [RCTAccessibilityManagerTestsSettings new]; + RCTAccessibilityManagerTestsSettings *settingsB = [RCTAccessibilityManagerTestsSettings new]; + settingsA.preferences = RCTTestPreferences(NO, NO); + settingsB.preferences = RCTTestPreferences(NO, NO); + [self installReaderOverSettings:settingsA onManager:managerA]; + [self installReaderOverSettings:settingsB onManager:managerB]; + + settingsA.preferences = RCTTestPreferences(YES, NO); + settingsB.preferences = RCTTestPreferences(NO, YES); + [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; + + XCTAssertEqualObjects(eventDispatcherA.events, (@[ @[ @"preferencesChanged", RCTTestPreferences(YES, NO) ] ])); + XCTAssertEqualObjects(eventDispatcherB.events, (@[ @[ @"preferencesChanged", RCTTestPreferences(NO, YES) ] ])); +} + +- (void)testReaderValuesReachTheEventAsCFBooleans +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + RCTAccessibilityManagerTestsSettings *settings = [RCTAccessibilityManagerTestsSettings new]; + settings.preferences = RCTTestPreferences(NO, NO); + [self installReaderOverSettings:settings onManager:manager]; + + settings.preferences = @{@"prefersSwitchLabels" : @1, @"prefersNonColorIndicators" : @0, @"unknownKey" : @YES}; + [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; + + XCTAssertEqual(eventDispatcher.events.count, 1); + NSDictionary *body = eventDispatcher.events.firstObject[1]; + XCTAssertEqualObjects(body, RCTTestPreferences(YES, NO)); + XCTAssertTrue(RCTIsCFBoolean(body[@"prefersSwitchLabels"])); + XCTAssertTrue(RCTIsCFBoolean(body[@"prefersNonColorIndicators"])); + XCTAssertEqual(body.count, 2); +} + +- (void)testReaderReturningNilFallsBackToTheSystem +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + + RCTAccessibilityManagerSetPreferencesReader(manager, ^NSDictionary * { + return nil; + }); + + XCTAssertEqualObjects( + [self currentPreferencesOf:manager], + RCTTestPreferences( + UIAccessibilityIsOnOffSwitchLabelsEnabled(), UIAccessibilityShouldDifferentiateWithoutColor())); +} + +- (void)testResettingTheReaderReturnsTheSystemValues +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + BOOL systemSwitchLabels = UIAccessibilityIsOnOffSwitchLabelsEnabled(); + BOOL systemNonColorIndicators = UIAccessibilityShouldDifferentiateWithoutColor(); + RCTAccessibilityManagerSetPreferencesReader(manager, ^NSDictionary * { + return RCTTestPreferences(!systemSwitchLabels, !systemNonColorIndicators); + }); + XCTAssertEqualObjects( + [self currentPreferencesOf:manager], RCTTestPreferences(!systemSwitchLabels, !systemNonColorIndicators)); + + RCTAccessibilityManagerSetPreferencesReader(manager, nil); + + XCTAssertEqualObjects( + [self currentPreferencesOf:manager], RCTTestPreferences(systemSwitchLabels, systemNonColorIndicators)); + XCTAssertEqual(eventDispatcher.events.count, 0); +} + +@end From 98ecc1798175393a7137d702dde2ab87b991c3e8 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:04:19 +0100 Subject: [PATCH 5/9] Update C++ API snapshots for getCurrentPreferences The iOS NativeAccessibilityManager spec gains getCurrentPreferences, so the generated spec method appears in the Apple snapshots. Generated with Doxygen 1.16.1 and Python 3.12, as in validate-cxx-api-snapshots.yml. Co-Authored-By: Claude Opus 5.5 --- scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api | 1 + scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api | 1 + 4 files changed, 4 insertions(+) diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 8de2c49759a6..0e078b8bd927 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -2147,6 +2147,7 @@ protocol NativeAccessibilityManagerSpec : public NSObjectRCTBridgeModule, public public virtual void getCurrentDarkerSystemColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentGrayscaleState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentInvertColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); + public virtual void getCurrentPreferences:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentPrefersCrossFadeTransitionsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceMotionState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceTransparencyState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index 6b5e45141f07..6a07a95a76c1 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -2205,6 +2205,7 @@ protocol NativeAccessibilityManagerSpec : public NSObjectRCTBridgeModule, public public virtual void getCurrentDarkerSystemColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentGrayscaleState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentInvertColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); + public virtual void getCurrentPreferences:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentPrefersCrossFadeTransitionsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceMotionState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceTransparencyState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index e8201541b2e1..4d8d1a0e5706 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -2140,6 +2140,7 @@ protocol NativeAccessibilityManagerSpec : public NSObjectRCTBridgeModule, public public virtual void getCurrentDarkerSystemColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentGrayscaleState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentInvertColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); + public virtual void getCurrentPreferences:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentPrefersCrossFadeTransitionsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceMotionState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceTransparencyState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index d3ff97cc59c6..0a787f97bd2e 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -2147,6 +2147,7 @@ protocol NativeAccessibilityManagerSpec : public NSObjectRCTBridgeModule, public public virtual void getCurrentDarkerSystemColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentGrayscaleState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentInvertColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); + public virtual void getCurrentPreferences:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentPrefersCrossFadeTransitionsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceMotionState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceTransparencyState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); From 0f01f05c9647851bfe31bb26c248b69ba2f97d87 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:07:18 +0100 Subject: [PATCH 6/9] Rename the native preferences event to accessibilityPreferencesChanged The iOS module sent preferencesChanged on the shared device event emitter, where another native module could send the same name. The public JS event stays preferencesChanged. Co-Authored-By: Claude Opus 5.5 --- .../AccessibilityInfo/AccessibilityInfo.js | 2 +- ...cessibilityInfo-preferencesChanged-test.js | 24 ++++++++++++++++--- .../RCTAccessibilityManager+Internal.h | 4 ++-- .../CoreModules/RCTAccessibilityManager.mm | 3 ++- .../RCTAccessibilityManagerTests.m | 8 +++---- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index 4775579e6eba..e86f1566c934 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -91,7 +91,7 @@ const EventNames: Map = ['screenReaderChanged', 'screenReaderChanged'], ['darkerSystemColorsChanged', 'darkerSystemColorsChanged'], ['showBordersChanged', 'showBordersChanged'], - ['preferencesChanged', 'preferencesChanged'], + ['preferencesChanged', 'accessibilityPreferencesChanged'], ]); /** diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js index 2e9282ddcf8f..bcea62074508 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js @@ -51,9 +51,9 @@ describe('AccessibilityInfo preferencesChanged', () => { prefersSwitchLabels: true, prefersNonColorIndicators: false, }; - RCTDeviceEventEmitter.emit('preferencesChanged', preferences); + RCTDeviceEventEmitter.emit('accessibilityPreferencesChanged', preferences); subscription.remove(); - RCTDeviceEventEmitter.emit('preferencesChanged', { + RCTDeviceEventEmitter.emit('accessibilityPreferencesChanged', { prefersSwitchLabels: false, prefersNonColorIndicators: false, }); @@ -62,6 +62,24 @@ describe('AccessibilityInfo preferencesChanged', () => { expect(handler).toHaveBeenCalledWith(preferences); }); + it('ignores a bare preferencesChanged device event on iOS', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'preferencesChanged', + handler, + ); + // Another module could emit this name on the shared emitter. + RCTDeviceEventEmitter.emit('preferencesChanged', { + prefersSwitchLabels: true, + prefersNonColorIndicators: true, + }); + subscription.remove(); + + expect(handler).not.toHaveBeenCalled(); + }); + it('does not subscribe on Android', () => { const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('android'); @@ -71,7 +89,7 @@ describe('AccessibilityInfo preferencesChanged', () => { 'preferencesChanged', handler, ); - RCTDeviceEventEmitter.emit('preferencesChanged', { + RCTDeviceEventEmitter.emit('accessibilityPreferencesChanged', { prefersSwitchLabels: true, prefersNonColorIndicators: true, }); diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h b/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h index 2f97fd164737..1c0808fd2f34 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h @@ -24,8 +24,8 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled( typedef NSDictionary *_Nullable (^RCTAccessibilityPreferencesReader)(void); // Replaces how this module reads the settings behind getPreferences(), and -// re-reads them without sending preferencesChanged. Pass nil to read the -// system settings again. +// re-reads them without sending accessibilityPreferencesChanged. Pass nil to +// read the system settings again. void RCTAccessibilityManagerSetPreferencesReader( RCTAccessibilityManager *accessibilityManager, RCTAccessibilityPreferencesReader _Nullable preferencesReader); diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm index e00231c48854..0381620687a8 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm @@ -315,7 +315,8 @@ - (void)preferencesStatusDidChange:(__unused NSNotification *)notification _preferences = newPreferences; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" - [[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"preferencesChanged" body:_preferences]; + [[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"accessibilityPreferencesChanged" + body:_preferences]; #pragma clang diagnostic pop } } diff --git a/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m index d1d49ea22395..83fa048cf002 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m +++ b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m @@ -190,7 +190,7 @@ - (void)testOnOffSwitchLabelsChangeSendsOneEvent [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; - XCTAssertEqualObjects(eventDispatcher.events, (@[ @[ @"preferencesChanged", RCTTestPreferences(YES, NO) ] ])); + XCTAssertEqualObjects(eventDispatcher.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(YES, NO) ] ])); XCTAssertEqualObjects([self currentPreferencesOf:manager], RCTTestPreferences(YES, NO)); } @@ -207,7 +207,7 @@ - (void)testDifferentiateWithoutColorChangeSendsOneEvent [self postNotification:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification]; [self postNotification:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification]; - XCTAssertEqualObjects(eventDispatcher.events, (@[ @[ @"preferencesChanged", RCTTestPreferences(NO, YES) ] ])); + XCTAssertEqualObjects(eventDispatcher.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(NO, YES) ] ])); XCTAssertEqualObjects([self currentPreferencesOf:manager], RCTTestPreferences(NO, YES)); } @@ -245,8 +245,8 @@ - (void)testSeparateInstancesUseTheirOwnReaders settingsB.preferences = RCTTestPreferences(NO, YES); [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; - XCTAssertEqualObjects(eventDispatcherA.events, (@[ @[ @"preferencesChanged", RCTTestPreferences(YES, NO) ] ])); - XCTAssertEqualObjects(eventDispatcherB.events, (@[ @[ @"preferencesChanged", RCTTestPreferences(NO, YES) ] ])); + XCTAssertEqualObjects(eventDispatcherA.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(YES, NO) ] ])); + XCTAssertEqualObjects(eventDispatcherB.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(NO, YES) ] ])); } - (void)testReaderValuesReachTheEventAsCFBooleans From d543c652c72144565f6b88131c15ef6968d51b14 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:07:29 +0100 Subject: [PATCH 7/9] Check the nil-reader warning in RCTAccessibilityManagerTests The test now starts from the opposite of the system values, so a stale cache can't pass, and asserts the module logs exactly one warning. Co-Authored-By: Claude Opus 5.5 --- .../RCTAccessibilityManagerTests.m | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m index 83fa048cf002..48a4b003ba95 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m +++ b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m @@ -11,6 +11,7 @@ #import #import #import +#import // The native module method JS calls, from the generated spec. @interface RCTAccessibilityManager (RCTAccessibilityManagerTests) @@ -105,6 +106,7 @@ - (void)setUp - (void)tearDown { + RCTSetLogFunction(RCTDefaultLogFunction); _moduleRegistries = nil; [super tearDown]; } @@ -190,7 +192,8 @@ - (void)testOnOffSwitchLabelsChangeSendsOneEvent [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; - XCTAssertEqualObjects(eventDispatcher.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(YES, NO) ] ])); + XCTAssertEqualObjects( + eventDispatcher.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(YES, NO) ] ])); XCTAssertEqualObjects([self currentPreferencesOf:manager], RCTTestPreferences(YES, NO)); } @@ -207,7 +210,8 @@ - (void)testDifferentiateWithoutColorChangeSendsOneEvent [self postNotification:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification]; [self postNotification:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification]; - XCTAssertEqualObjects(eventDispatcher.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(NO, YES) ] ])); + XCTAssertEqualObjects( + eventDispatcher.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(NO, YES) ] ])); XCTAssertEqualObjects([self currentPreferencesOf:manager], RCTTestPreferences(NO, YES)); } @@ -245,8 +249,10 @@ - (void)testSeparateInstancesUseTheirOwnReaders settingsB.preferences = RCTTestPreferences(NO, YES); [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; - XCTAssertEqualObjects(eventDispatcherA.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(YES, NO) ] ])); - XCTAssertEqualObjects(eventDispatcherB.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(NO, YES) ] ])); + XCTAssertEqualObjects( + eventDispatcherA.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(YES, NO) ] ])); + XCTAssertEqualObjects( + eventDispatcherB.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(NO, YES) ] ])); } - (void)testReaderValuesReachTheEventAsCFBooleans @@ -274,15 +280,36 @@ - (void)testReaderReturningNilFallsBackToTheSystem RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; XCTAssertNotNil(manager); + BOOL systemSwitchLabels = UIAccessibilityIsOnOffSwitchLabelsEnabled(); + BOOL systemNonColorIndicators = UIAccessibilityShouldDifferentiateWithoutColor(); + // Start from the opposite of the system values, so a cache left unchanged + // would not match the system values below. + RCTAccessibilityManagerSetPreferencesReader(manager, ^NSDictionary * { + return RCTTestPreferences(!systemSwitchLabels, !systemNonColorIndicators); + }); + XCTAssertEqualObjects( + [self currentPreferencesOf:manager], RCTTestPreferences(!systemSwitchLabels, !systemNonColorIndicators)); + NSMutableArray *warnings = [NSMutableArray new]; + RCTSetLogFunction( + ^(RCTLogLevel level, + __unused RCTLogSource source, + __unused NSString *fileName, + __unused NSNumber *lineNumber, + NSString *message) { + if (level == RCTLogLevelWarning) { + [warnings addObject:message]; + } + }); RCTAccessibilityManagerSetPreferencesReader(manager, ^NSDictionary * { return nil; }); + XCTAssertEqual(warnings.count, 1); + XCTAssertTrue([warnings.firstObject containsString:@"reader returned nil"]); XCTAssertEqualObjects( - [self currentPreferencesOf:manager], - RCTTestPreferences( - UIAccessibilityIsOnOffSwitchLabelsEnabled(), UIAccessibilityShouldDifferentiateWithoutColor())); + [self currentPreferencesOf:manager], RCTTestPreferences(systemSwitchLabels, systemNonColorIndicators)); + XCTAssertEqual(eventDispatcher.events.count, 0); } - (void)testResettingTheReaderReturnsTheSystemValues From ebce665e537c2ab5ead83d60d5a7136ac6ceb574 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:07:32 +0100 Subject: [PATCH 8/9] Check the iOS module has no getRecommendedTimeoutMillis in the timeout test The test asserted on the Android mock, so it couldn't fail on iOS. Co-Authored-By: Claude Opus 5.5 --- .../AccessibilityInfo/__tests__/AccessibilityInfo-test.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js index c74c8c29312e..0b951843801b 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -128,6 +128,7 @@ let mockNativeAccessibilityManagerIOS: { getCurrentReduceTransparencyState: mockGetCurrentReduceTransparencyState, getCurrentVoiceOverState: mockGetCurrentVoiceOverState, }; + const ANDROID_RECOMMENDED_TIMEOUT = 6000; const mockIsHighTextContrastEnabled = jest.fn(onSuccess => onSuccess(true)); const mockIsGrayscaleEnabled = jest.fn(onSuccess => onSuccess(true)); @@ -847,15 +848,18 @@ describe('AccessibilityInfo', () => { }); describe('iOS', () => { - it('should return originalTimeout without calling native', async () => { + it('should return originalTimeout, as the iOS module has no getRecommendedTimeoutMillis', async () => { /* $FlowFixMe[incompatible-type] */ Platform.OS = 'ios'; + expect( + jest.requireMock('../NativeAccessibilityManager').default, + ).not.toHaveProperty('getRecommendedTimeoutMillis'); + const recommendedTimeoutMillis = await AccessibilityInfo.getRecommendedTimeoutMillis(5000); expect(recommendedTimeoutMillis).toBe(5000); - expect(mockGetRecommendedTimeoutMillis).not.toHaveBeenCalled(); }); }); }); From 977368f4a5a597c4bb0650a33cde4defcd5e54e1 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:07:34 +0100 Subject: [PATCH 9/9] Clarify the getPreferences comments and document the iOS rejection Co-Authored-By: Claude Opus 5.5 --- .../AccessibilityInfo/AccessibilityInfo.js | 21 +++++++++++-------- .../AccessibilityInfo/AccessibilityInfo.d.ts | 2 ++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index e86f1566c934..2e5112059b60 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -348,6 +348,8 @@ const AccessibilityInfo = { * * Returns a promise which resolves to an `AccessibilityPreferences` object. * On Android, which has none of these settings, every field is `false`. + * On iOS, rejects if the native module or method is missing (for example, + * JS newer than the app binary). */ getPreferences(): Promise { if (Platform.OS === 'android') { @@ -355,17 +357,18 @@ const AccessibilityInfo = { } else { return new Promise((resolve, reject) => { if (NativeAccessibilityManagerIOS?.getCurrentPreferences != null) { - // The fields are written out twice by hand: inline in the native - // module spec (a named type would add C++ structs to the API - // snapshots) and in the TypeScript types. Passing `resolve` as - // `onSuccess` makes Flow's exact object types check that the spec - // matches `AccessibilityPreferences`; `__typetests__` covers the - // TypeScript copy. + // Besides `AccessibilityPreferences`, the fields are written by + // hand inline in the native module spec (a named type would add C++ + // structs to the API snapshots) and in the TypeScript types. + // Passing `resolve` as `onSuccess` makes Flow's exact object types + // check the spec against `AccessibilityPreferences`; + // `__typetests__` covers the TypeScript types. The native keys in + // RCTAccessibilityManager.mm must match too. NativeAccessibilityManagerIOS.getCurrentPreferences(resolve, reject); } else { - // A new API, so a missing native method rejects rather than - // resolving a snapshot that looks like "every setting off", as - // `isShowBordersEnabled` does. + // A new API, so a missing native method rejects, as + // `isShowBordersEnabled` does, rather than resolving a snapshot that + // looks like every setting is off. reject( new Error( 'NativeAccessibilityManagerIOS.getCurrentPreferences is not available', diff --git a/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts index e79c6624b19b..a97c61b6128a 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts @@ -122,6 +122,8 @@ export interface AccessibilityInfoStatic { /** * Query the accessibility preferences that change how custom controls * should be drawn. On Android, every field is `false`. + * On iOS, rejects if the native module or method is missing (for example, + * JS newer than the app binary). */ getPreferences: () => Promise;