Skip to content

Add AccessibilityInfo.getPreferences() and a longer iOS timeout while Switch Control runs - #59318

Open
camilopires wants to merge 9 commits into
react:mainfrom
camilopires:a11y/accessibility-preferences
Open

camilopires wants to merge 9 commits into
react:mainfrom
camilopires:a11y/accessibility-preferences

Conversation

@camilopires

@camilopires camilopires commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

Following the discussion in react-native-community/discussions-and-proposals#1026, this adds one snapshot for accessibility settings that change how an app should draw its own controls. It replaces the one-flag-per-setting PRs #59178, #59179 and #59180.

React Native apps mostly draw their own switches and buttons with Pressable and View, so the system can't adapt them to these settings as it does UISwitch and system buttons. The app has to read the setting itself.

type AccessibilityPreferences = Readonly<{
  prefersSwitchLabels: boolean;       // iOS On/Off Labels
  prefersNonColorIndicators: boolean; // iOS Differentiate Without Color
}>;
AccessibilityInfo.getPreferences(): Promise<AccessibilityPreferences>;
AccessibilityInfo.addEventListener('preferencesChanged', (preferences) => …);
  • Field names say what the user needs, not which platform setting it is. prefersCrossFadeTransitions already does this. A platform with no such setting reports false, so apps don't need platform checks. A future setting becomes a new field, not a new method and event.
  • The type 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 they're given rather than build one.
  • preferencesChanged carries the whole new snapshot, and fires only when a field changes. Natively, iOS sends it as accessibilityPreferencesChanged, so it can't clash with a preferencesChanged device event from another module on the shared RCTDeviceEventEmitter; the JS event name stays preferencesChanged.
  • On Android, neither setting exists. getPreferences() resolves both fields to false without a native call, and preferencesChanged doesn't subscribe, like the other iOS-only events.
  • A missing native method rejects. If NativeAccessibilityManagerIOS.getCurrentPreferences is missing (new JS on an older binary), getPreferences() rejects with an error naming the method, as isShowBordersEnabled does, rather than resolving a snapshot that looks like "every setting off".
  • The native snapshot is normalised. RCTAccessibilityManager builds every snapshot from the two known keys, boxing each value from a BOOL, so the keys always match AccessibilityPreferences and the values are always CFBoolean. 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.

Split: an earlier version of this PR also made getRecommendedTimeoutMillis() return a longer timeout on iOS while Switch Control is running. That changes an existing API, so it's now a separate PR, #59453, and this one leaves getRecommendedTimeoutMillis() as it is on main.

Scope: Show Borders (#59177) has already landed as isShowBordersEnabled(), and the Android contrast level (#59181) is in review, so neither is folded into the snapshot here. #59176 (Bold Text on Android) and #59453 (the Switch Control timeout) are separate, and whichever lands second needs a small rebase:

Changelog:

[IOS] [ADDED] - AccessibilityInfo.getPreferences() and preferencesChanged (On/Off Labels and Differentiate Without Color)

Test Plan:

  • yarn jest packages/react-native/Libraries/Components/AccessibilityInfo: 49 tests pass. They cover getPreferences on both platforms (including an older iOS binary without the native method), that iOS getRecommendedTimeoutMillis still resolves originalTimeout (the iOS native module has no such method), and the event wiring: the native accessibilityPreferencesChanged event reaches preferencesChanged listeners, and a bare preferencesChanged device event doesn't.
  • yarn flow-check: 0 errors
  • yarn lint: passes
  • yarn build-types --validate: API snapshot up to date
  • yarn test-generated-typescript and yarn test-typescript-legacy: pass
  • yarn format-check-cpp, and Prettier on the changed JS and TS: pass
  • C++ API snapshots regenerated with Doxygen 1.16.1 and Python 3.12, and validated.
  • New RCTAccessibilityManagerTests in RNTesterUnitTests: 9 tests pass on an iPhone 18 Pro simulator (iOS 27.0, Xcode 27.1). Each module instance gets its own settings reader (RCTAccessibilityManagerSetPreferencesReader, in +Internal.h), backed by state the test changes before posting the real UIKit notification. They check the system values at startup (exactly the two keys, CFBoolean values), that installing a reader sends no event, that each setting's change sends exactly one event even when the notification is posted twice, that a notification without a change sends nothing, that two instances keep their own readers, that reader values such as @1 reach the event as CFBoolean, that a reader returning nil logs exactly one warning and gives the system values (starting from the opposite values, so a stale cache can't pass), and that resetting the reader to nil gives the system values. The event tests assert the native name accessibilityPreferencesChanged. Removing the On/Off Labels observer makes 3 of them fail; sending the old event name and dropping the warning makes 4 fail.

On an iPhone 18 Pro simulator running iOS 27.0, on the current branch, I switched each setting in the Settings app with a throwaway XCUITest (not part of this PR). Then I read RNTester's Accessibility → "display options" rows. RNTester stayed running, so the "Last preferencesChanged" row could only update through the event.

Step Last preferencesChanged getPreferences() afterwards
Before none switch labels off, non-color indicators off
On/Off Labels on switch labels on, non-color indicators off
On/Off Labels off switch labels off, non-color indicators off switch labels off, non-color indicators off
Differentiate Without Colour on switch labels off, non-color indicators on
Differentiate Without Colour off switch labels off, non-color indicators off switch labels off, non-color indicators off

Android (from an earlier run; the Android path, which resolves both fields to false without a native call, hasn't changed since): RNTester on an API 36 emulator shows "Preferences: switch labels off, non-color indicators off." and "Last preferencesChanged: none.", with no errors.

Not verified: a SwiftPM build, because this PR doesn't change linking or Package.swift.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 21, 2026
@github-actions

Copy link
Copy Markdown

Warning

JavaScript API change detected

This PR commits an update to ReactNativeApi.d.ts, indicating a change to React Native's public JavaScript API.

  • Please include a clear changelog message.
  • This change will be subject to additional review.

This change was flagged as: POTENTIALLY_BREAKING

@cipolleschi

Copy link
Copy Markdown
Contributor

there are several conflicts in this PR. Can you rebase on top of main please?

@camilopires
camilopires force-pushed the a11y/accessibility-preferences branch from 0d680aa to 55e57de Compare October 24, 2026 00:08
@camilopires

Copy link
Copy Markdown
Contributor Author

Rebased on main and updated the C++ API snapshots. getPreferences() now also rejects on iOS when the native method is missing, matching isShowBordersEnabled.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Visual regression

No visual differences at 977368f4a5a5.

camilopires and others added 2 commits October 24, 2026 14:43
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 <noreply@anthropic.com>
Add a row to Accessibility's display options that shows getPreferences()
and the last preferencesChanged event. A rejected read shows
"Unavailable: <message>" 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 <noreply@anthropic.com>
@camilopires
camilopires force-pushed the a11y/accessibility-preferences branch from 55e57de to 9559cb6 Compare October 24, 2026 13:44
camilopires and others added 7 commits October 24, 2026 14:59
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…t test

The test asserted on the Android mock, so it couldn't fail on iOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@camilopires

Copy link
Copy Markdown
Contributor Author

Updated after a review pass:

  • The Switch Control timeout is split out into Return a longer recommended timeout on iOS while Switch Control is running #59453, because it changes an existing API (getRecommendedTimeoutMillis on iOS) and deserves its own review. This PR now only adds getPreferences() and preferencesChanged, and leaves getRecommendedTimeoutMillis as it is on main.
  • AccessibilityPreferences is now read-only (Readonly<…> in Flow, readonly fields in TypeScript).
  • On iOS the native event is sent as accessibilityPreferencesChanged, so it can't clash with a preferencesChanged device event from another module. The public event name is still preferencesChanged.
  • The native module always sends exactly the two keys as booleans, and the tests use a per-instance settings reader instead of a process-wide one.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants