[iOS][Accessibility][Critical?] #35774: accessibility state race condition - #54131

Open
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement
Open

[iOS][Accessibility][Critical?] #35774: accessibility state race condition #54131
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement

Conversation

@maxencehenneron

@maxencehenneronmaxencehenneron commented Oct 10, 2025

Copy link
Copy Markdown
Contributor

Refactor accessibility actions to fix a race condition on iOS. Fixes#35774

Summary:

I know you will most likely not like my fix, but there currently is a race condition that creates issues for our most vulnerable users.

Any accessibility action performed on an element on iOS are out of sync, from custom actions, taps, magic taps, increment, decrement or escape.

This is because the VoiceOver on iOS will immediately read the text once an action is called as it expects the function to update accessibilityValue / accessibilityState. The problem is we're emitting an asynchronous event to update this value so there is currently no easy way to fix this.

What I found is working is using this experimental_flushSync function to update the state synchronously. This is obviously bad for performance but i'd argue that for the current context (vision impairment) having 60fps when running an action (that doesn't happen often) is less important than having wrong vocal directions.

Changelog:

[iOS] [Fixed] - Updated accessibilityIncrement, accessibilityDecrement, accessibilityActivate, accessibilityPerformEscape, accessibilityPerformMagicTap to synchronously update the state when the action is run
[iOS] [Fixed] - Add missing accessibility props to TouchableOpacity

Test Plan:

Before:

ScreenRecording_10-10-2025.16-23-24_1.mov

After:
https://github.com/user-attachments/assets/5e31e2de-63e9-4234-bb4c-d46dfe1cabcf

Refactor accessibilityIncrement and accessibilityDecrement to fix a race condition on iOS.
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Hi @maxencehenneron!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@maxencehenneronmaxencehenneron changed the title Fix #35774: adjustable increment/decrement race conditionFix #35774: a11y "adjustable" increment/decrement action race conditionOct 10, 2025
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-clameta-claBot 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 10, 2025
@facebook-github-botfacebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 10, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This is actually worse than I thought:

We have the same issue with custom actions (my last commit fixes it). Meaning running a custom action that updates the accessibilityState (for example "selected", "expanded") will announce the previous state so any vision-impaired user would think the action was not performed and run it again. That time it would announce "selected" when it's actually not selected anymore.

I want to stress that accessibility is a legal requirement in Europe and the US and required by many legal frameworks.

@maxencehenneronmaxencehenneron changed the title Fix #35774: a11y "adjustable" increment/decrement action race condition#35774: accessibility state race condition Oct 14, 2025
@maxencehenneronmaxencehenneron changed the title #35774: accessibility state race condition [iOS][Accessibility][Critical?] #35774: accessibility state race condition Oct 14, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This PR can now be reviewed

@efoken

Copy link
Copy Markdown

Great, finally 👍

I wonder why this was never fixed before, because we have this issue at IBM iX for many of our clients apps and as a11y is a legal requirement in the EU we getting more and more trouble with this issue 😅

Even if this uses experimental code, it seems to be the easiest fix.

@maxencehenneron

maxencehenneron commented Oct 15, 2025

Copy link
Copy Markdown
ContributorAuthor

Small note on my PR:

You have to implement onAccessibilityTap for the standard action (double tap) to work. Only implementing "onPress" will result in the wrong state being announced.
I think this is fine. We do not want to synchronously flush onPress and explicitly adding onAccessibilityTap makes sense.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

If anyone wants to reliably reproduce this issue, add the following code to the rn-tester playground:

functionPlayground(){const[counter,setCounter]=React.useState(0);// Simulate some heavy computationfor(leti=0;i<10000000;i++);return(<Viewstyle={styles.container}><PressableaccessibleaccessibilityValue={{text: `Counter is ${counter}`}}onAccessibilityTap={()=>setCounter(counter+1)}><RNTesterTextaccessible={false}>The counter is: {counter}</RNTesterText></Pressable></View>);}

Without my fix, it reads the wrong value. With my fix, the correct value is read.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

@javache Sorry for the ping but I believe my accessibility PRs got overlooked and this bug is kinda critical for any production app. Would you be able to forward my PRs to the appropriate person at Meta? Thank you

@javache

Copy link
Copy Markdown
Contributor

Thanks for the detailed write-up here.

You're right that using experimental_flushSync raises some eyebrows. Not only can it hurt performance, it currently can be unpredictable to when it will actually get executed (we've seen React code not using transitions or a badly configured FlatList taking 1s+), which will make the user believe the app is unresponsive. Worse, if the JS thread is currently trying to synchronously access the UI thread, the app will deadlock (see feature flag enableMainQueueCoordinatorOnIOS)

One thing which would help reduce the risk here is that we first add a feature flag for this so we can do a test of this internally at Meta before changing this behaviour by default.

cc @lunaleaps@yungsters@sammy-SC on using experimental_flushSync here.

Comment on lines +311 to +313
onAccessibilityTap={this.props.onAccessibilityTap}
onAccessibilityEscape={this.props.onAccessibilityEscape}
onMagicTap={this.props.onMagicTap}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to these changes? Can it be a separate PR?

{
if (_eventEmitter && _props->onAccessibilityTap) {
_eventEmitter->onAccessibilityTap();
auto emitter = std::static_pointer_cast<const ViewEventEmitter>(_eventEmitter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: prefer a static_cast of *_eventEmitter

meta-codesyncBot pushed a commit that referenced this pull request Dec 9, 2025
Summary:
This is my PR 2 out of 3 to improve accessibility on iOS. If you're reading this, please also have a look at #54131, this is a critical race condition bug that basically makes accessibility extremely hard to handle on large apps.
Fix for #53496: on the new architecture, any accessibility label is ignored and only the "name" is read by VoiceOver. We want to use name as an "action id", and label as the translatable value. While android has a id/label system for actions, iOS doesn't so my implementation, inspired by the old paper architecture, maps labels to name. Native iOS will only see the labels but the JS side will properly receive names when an action is executed. I believe this is the correct way of handling this. Another thing we could do is just deprecate "label" and expect name to be in the correct language, but I do not like that.
## Changelog:
[IOS][FIXED] - Accessibility actions labels are not read by VoiceOver
Pull Request resolved: #54286
Test Plan:
Open RN-Tester, go to Accessibility -> Accessibility Action Examples, enable VoiceOver and select "This view supports many actions". Now scroll up fast to select the actions.
On the current implementation you will hear VoiceOver say "copy", then "cut", then "paste". It now says "cut label", "copy label", "paste label".
As a reference, here's how this view is implemented:
```jsx
<View
accessible={true}
accessibilityActions={[
{name: 'cut', label: 'cut label'},
{name: 'copy', label: 'copy label'},
{name: 'paste', label: 'paste label'},
]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'cut':
Alert.alert('Alert', 'cut action success');
break;
case 'copy':
Alert.alert('Alert', 'copy action success');
break;
case 'paste':
Alert.alert('Alert', 'paste action success');
break;
}
}}>
<RNTesterText>This view supports many actions.</RNTesterText>
</View>
```
Reviewed By: cipolleschi
Differential Revision: D87766483
Pulled By: javache
fbshipit-source-id: a979f0126efce4b87c6d823519d512229fb4118e
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

I have some time to dedicate to this PR this month, what can I do to unblock it? Should I update the code to add a feature-flag?
Do you have any other idea on how this problem could be resolved? I understand it would significantly slow down the app on huge lists but I doubt there's a way around it. It might be something we have to accept. This would only affect the app while using the a11y controls, which are meant to be used by vision-impaired users that will likely not perceive stuttering.

@react-native-bot

Copy link
Copy Markdown
Collaborator

This PR is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 7 days.

@react-native-botreact-native-bot added Stale There has been a lack of activity on this issue and it may be closed soon. and removed Stale There has been a lack of activity on this issue and it may be closed soon. labels Jul 5, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.Shared with MetaApplied 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.

iOS Accessibility Value Out of Sync

5 participants

@maxencehenneron@efoken@javache@react-native-bot@facebook-github-bot
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[iOS][Accessibility][Critical?] #35774: accessibility state race condition - #54131

Open
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement
Open

[iOS][Accessibility][Critical?] #35774: accessibility state race condition #54131
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement

Conversation

@maxencehenneron

@maxencehenneronmaxencehenneron commented Oct 10, 2025

Copy link
Copy Markdown
Contributor

Refactor accessibility actions to fix a race condition on iOS. Fixes#35774

Summary:

I know you will most likely not like my fix, but there currently is a race condition that creates issues for our most vulnerable users.

Any accessibility action performed on an element on iOS are out of sync, from custom actions, taps, magic taps, increment, decrement or escape.

This is because the VoiceOver on iOS will immediately read the text once an action is called as it expects the function to update accessibilityValue / accessibilityState. The problem is we're emitting an asynchronous event to update this value so there is currently no easy way to fix this.

What I found is working is using this experimental_flushSync function to update the state synchronously. This is obviously bad for performance but i'd argue that for the current context (vision impairment) having 60fps when running an action (that doesn't happen often) is less important than having wrong vocal directions.

Changelog:

[iOS] [Fixed] - Updated accessibilityIncrement, accessibilityDecrement, accessibilityActivate, accessibilityPerformEscape, accessibilityPerformMagicTap to synchronously update the state when the action is run
[iOS] [Fixed] - Add missing accessibility props to TouchableOpacity

Test Plan:

Before:

ScreenRecording_10-10-2025.16-23-24_1.mov

After:
https://github.com/user-attachments/assets/5e31e2de-63e9-4234-bb4c-d46dfe1cabcf

Refactor accessibilityIncrement and accessibilityDecrement to fix a race condition on iOS.
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Hi @maxencehenneron!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@maxencehenneronmaxencehenneron changed the title Fix #35774: adjustable increment/decrement race conditionFix #35774: a11y "adjustable" increment/decrement action race conditionOct 10, 2025
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-clameta-claBot 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 10, 2025
@facebook-github-botfacebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 10, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This is actually worse than I thought:

We have the same issue with custom actions (my last commit fixes it). Meaning running a custom action that updates the accessibilityState (for example "selected", "expanded") will announce the previous state so any vision-impaired user would think the action was not performed and run it again. That time it would announce "selected" when it's actually not selected anymore.

I want to stress that accessibility is a legal requirement in Europe and the US and required by many legal frameworks.

@maxencehenneronmaxencehenneron changed the title Fix #35774: a11y "adjustable" increment/decrement action race condition#35774: accessibility state race condition Oct 14, 2025
@maxencehenneronmaxencehenneron changed the title #35774: accessibility state race condition [iOS][Accessibility][Critical?] #35774: accessibility state race condition Oct 14, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This PR can now be reviewed

@efoken

Copy link
Copy Markdown

Great, finally 👍

I wonder why this was never fixed before, because we have this issue at IBM iX for many of our clients apps and as a11y is a legal requirement in the EU we getting more and more trouble with this issue 😅

Even if this uses experimental code, it seems to be the easiest fix.

@maxencehenneron

maxencehenneron commented Oct 15, 2025

Copy link
Copy Markdown
ContributorAuthor

Small note on my PR:

You have to implement onAccessibilityTap for the standard action (double tap) to work. Only implementing "onPress" will result in the wrong state being announced.
I think this is fine. We do not want to synchronously flush onPress and explicitly adding onAccessibilityTap makes sense.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

If anyone wants to reliably reproduce this issue, add the following code to the rn-tester playground:

functionPlayground(){const[counter,setCounter]=React.useState(0);// Simulate some heavy computationfor(leti=0;i<10000000;i++);return(<Viewstyle={styles.container}><PressableaccessibleaccessibilityValue={{text: `Counter is ${counter}`}}onAccessibilityTap={()=>setCounter(counter+1)}><RNTesterTextaccessible={false}>The counter is: {counter}</RNTesterText></Pressable></View>);}

Without my fix, it reads the wrong value. With my fix, the correct value is read.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

@javache Sorry for the ping but I believe my accessibility PRs got overlooked and this bug is kinda critical for any production app. Would you be able to forward my PRs to the appropriate person at Meta? Thank you

@javache

Copy link
Copy Markdown
Contributor

Thanks for the detailed write-up here.

You're right that using experimental_flushSync raises some eyebrows. Not only can it hurt performance, it currently can be unpredictable to when it will actually get executed (we've seen React code not using transitions or a badly configured FlatList taking 1s+), which will make the user believe the app is unresponsive. Worse, if the JS thread is currently trying to synchronously access the UI thread, the app will deadlock (see feature flag enableMainQueueCoordinatorOnIOS)

One thing which would help reduce the risk here is that we first add a feature flag for this so we can do a test of this internally at Meta before changing this behaviour by default.

cc @lunaleaps@yungsters@sammy-SC on using experimental_flushSync here.

Comment on lines +311 to +313
onAccessibilityTap={this.props.onAccessibilityTap}
onAccessibilityEscape={this.props.onAccessibilityEscape}
onMagicTap={this.props.onMagicTap}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to these changes? Can it be a separate PR?

{
if (_eventEmitter && _props->onAccessibilityTap) {
_eventEmitter->onAccessibilityTap();
auto emitter = std::static_pointer_cast<const ViewEventEmitter>(_eventEmitter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: prefer a static_cast of *_eventEmitter

meta-codesyncBot pushed a commit that referenced this pull request Dec 9, 2025
Summary:
This is my PR 2 out of 3 to improve accessibility on iOS. If you're reading this, please also have a look at #54131, this is a critical race condition bug that basically makes accessibility extremely hard to handle on large apps.
Fix for #53496: on the new architecture, any accessibility label is ignored and only the "name" is read by VoiceOver. We want to use name as an "action id", and label as the translatable value. While android has a id/label system for actions, iOS doesn't so my implementation, inspired by the old paper architecture, maps labels to name. Native iOS will only see the labels but the JS side will properly receive names when an action is executed. I believe this is the correct way of handling this. Another thing we could do is just deprecate "label" and expect name to be in the correct language, but I do not like that.
## Changelog:
[IOS][FIXED] - Accessibility actions labels are not read by VoiceOver
Pull Request resolved: #54286
Test Plan:
Open RN-Tester, go to Accessibility -> Accessibility Action Examples, enable VoiceOver and select "This view supports many actions". Now scroll up fast to select the actions.
On the current implementation you will hear VoiceOver say "copy", then "cut", then "paste". It now says "cut label", "copy label", "paste label".
As a reference, here's how this view is implemented:
```jsx
<View
accessible={true}
accessibilityActions={[
{name: 'cut', label: 'cut label'},
{name: 'copy', label: 'copy label'},
{name: 'paste', label: 'paste label'},
]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'cut':
Alert.alert('Alert', 'cut action success');
break;
case 'copy':
Alert.alert('Alert', 'copy action success');
break;
case 'paste':
Alert.alert('Alert', 'paste action success');
break;
}
}}>
<RNTesterText>This view supports many actions.</RNTesterText>
</View>
```
Reviewed By: cipolleschi
Differential Revision: D87766483
Pulled By: javache
fbshipit-source-id: a979f0126efce4b87c6d823519d512229fb4118e
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

I have some time to dedicate to this PR this month, what can I do to unblock it? Should I update the code to add a feature-flag?
Do you have any other idea on how this problem could be resolved? I understand it would significantly slow down the app on huge lists but I doubt there's a way around it. It might be something we have to accept. This would only affect the app while using the a11y controls, which are meant to be used by vision-impaired users that will likely not perceive stuttering.

@react-native-bot

Copy link
Copy Markdown
Collaborator

This PR is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 7 days.

@react-native-botreact-native-bot added Stale There has been a lack of activity on this issue and it may be closed soon. and removed Stale There has been a lack of activity on this issue and it may be closed soon. labels Jul 5, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.Shared with MetaApplied 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.

iOS Accessibility Value Out of Sync

5 participants

@maxencehenneron@efoken@javache@react-native-bot@facebook-github-bot
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[iOS][Accessibility][Critical?] #35774: accessibility state race condition - #54131

Open
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement
Open

[iOS][Accessibility][Critical?] #35774: accessibility state race condition #54131
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement

Conversation

@maxencehenneron

@maxencehenneronmaxencehenneron commented Oct 10, 2025

Copy link
Copy Markdown
Contributor

Refactor accessibility actions to fix a race condition on iOS. Fixes#35774

Summary:

I know you will most likely not like my fix, but there currently is a race condition that creates issues for our most vulnerable users.

Any accessibility action performed on an element on iOS are out of sync, from custom actions, taps, magic taps, increment, decrement or escape.

This is because the VoiceOver on iOS will immediately read the text once an action is called as it expects the function to update accessibilityValue / accessibilityState. The problem is we're emitting an asynchronous event to update this value so there is currently no easy way to fix this.

What I found is working is using this experimental_flushSync function to update the state synchronously. This is obviously bad for performance but i'd argue that for the current context (vision impairment) having 60fps when running an action (that doesn't happen often) is less important than having wrong vocal directions.

Changelog:

[iOS] [Fixed] - Updated accessibilityIncrement, accessibilityDecrement, accessibilityActivate, accessibilityPerformEscape, accessibilityPerformMagicTap to synchronously update the state when the action is run
[iOS] [Fixed] - Add missing accessibility props to TouchableOpacity

Test Plan:

Before:

ScreenRecording_10-10-2025.16-23-24_1.mov

After:
https://github.com/user-attachments/assets/5e31e2de-63e9-4234-bb4c-d46dfe1cabcf

Refactor accessibilityIncrement and accessibilityDecrement to fix a race condition on iOS.
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Hi @maxencehenneron!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@maxencehenneronmaxencehenneron changed the title Fix #35774: adjustable increment/decrement race conditionFix #35774: a11y "adjustable" increment/decrement action race conditionOct 10, 2025
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-clameta-claBot 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 10, 2025
@facebook-github-botfacebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 10, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This is actually worse than I thought:

We have the same issue with custom actions (my last commit fixes it). Meaning running a custom action that updates the accessibilityState (for example "selected", "expanded") will announce the previous state so any vision-impaired user would think the action was not performed and run it again. That time it would announce "selected" when it's actually not selected anymore.

I want to stress that accessibility is a legal requirement in Europe and the US and required by many legal frameworks.

@maxencehenneronmaxencehenneron changed the title Fix #35774: a11y "adjustable" increment/decrement action race condition#35774: accessibility state race condition Oct 14, 2025
@maxencehenneronmaxencehenneron changed the title #35774: accessibility state race condition [iOS][Accessibility][Critical?] #35774: accessibility state race condition Oct 14, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This PR can now be reviewed

@efoken

Copy link
Copy Markdown

Great, finally 👍

I wonder why this was never fixed before, because we have this issue at IBM iX for many of our clients apps and as a11y is a legal requirement in the EU we getting more and more trouble with this issue 😅

Even if this uses experimental code, it seems to be the easiest fix.

@maxencehenneron

maxencehenneron commented Oct 15, 2025

Copy link
Copy Markdown
ContributorAuthor

Small note on my PR:

You have to implement onAccessibilityTap for the standard action (double tap) to work. Only implementing "onPress" will result in the wrong state being announced.
I think this is fine. We do not want to synchronously flush onPress and explicitly adding onAccessibilityTap makes sense.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

If anyone wants to reliably reproduce this issue, add the following code to the rn-tester playground:

functionPlayground(){const[counter,setCounter]=React.useState(0);// Simulate some heavy computationfor(leti=0;i<10000000;i++);return(<Viewstyle={styles.container}><PressableaccessibleaccessibilityValue={{text: `Counter is ${counter}`}}onAccessibilityTap={()=>setCounter(counter+1)}><RNTesterTextaccessible={false}>The counter is: {counter}</RNTesterText></Pressable></View>);}

Without my fix, it reads the wrong value. With my fix, the correct value is read.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

@javache Sorry for the ping but I believe my accessibility PRs got overlooked and this bug is kinda critical for any production app. Would you be able to forward my PRs to the appropriate person at Meta? Thank you

@javache

Copy link
Copy Markdown
Contributor

Thanks for the detailed write-up here.

You're right that using experimental_flushSync raises some eyebrows. Not only can it hurt performance, it currently can be unpredictable to when it will actually get executed (we've seen React code not using transitions or a badly configured FlatList taking 1s+), which will make the user believe the app is unresponsive. Worse, if the JS thread is currently trying to synchronously access the UI thread, the app will deadlock (see feature flag enableMainQueueCoordinatorOnIOS)

One thing which would help reduce the risk here is that we first add a feature flag for this so we can do a test of this internally at Meta before changing this behaviour by default.

cc @lunaleaps@yungsters@sammy-SC on using experimental_flushSync here.

Comment on lines +311 to +313
onAccessibilityTap={this.props.onAccessibilityTap}
onAccessibilityEscape={this.props.onAccessibilityEscape}
onMagicTap={this.props.onMagicTap}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to these changes? Can it be a separate PR?

{
if (_eventEmitter && _props->onAccessibilityTap) {
_eventEmitter->onAccessibilityTap();
auto emitter = std::static_pointer_cast<const ViewEventEmitter>(_eventEmitter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: prefer a static_cast of *_eventEmitter

meta-codesyncBot pushed a commit that referenced this pull request Dec 9, 2025
Summary:
This is my PR 2 out of 3 to improve accessibility on iOS. If you're reading this, please also have a look at #54131, this is a critical race condition bug that basically makes accessibility extremely hard to handle on large apps.
Fix for #53496: on the new architecture, any accessibility label is ignored and only the "name" is read by VoiceOver. We want to use name as an "action id", and label as the translatable value. While android has a id/label system for actions, iOS doesn't so my implementation, inspired by the old paper architecture, maps labels to name. Native iOS will only see the labels but the JS side will properly receive names when an action is executed. I believe this is the correct way of handling this. Another thing we could do is just deprecate "label" and expect name to be in the correct language, but I do not like that.
## Changelog:
[IOS][FIXED] - Accessibility actions labels are not read by VoiceOver
Pull Request resolved: #54286
Test Plan:
Open RN-Tester, go to Accessibility -> Accessibility Action Examples, enable VoiceOver and select "This view supports many actions". Now scroll up fast to select the actions.
On the current implementation you will hear VoiceOver say "copy", then "cut", then "paste". It now says "cut label", "copy label", "paste label".
As a reference, here's how this view is implemented:
```jsx
<View
accessible={true}
accessibilityActions={[
{name: 'cut', label: 'cut label'},
{name: 'copy', label: 'copy label'},
{name: 'paste', label: 'paste label'},
]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'cut':
Alert.alert('Alert', 'cut action success');
break;
case 'copy':
Alert.alert('Alert', 'copy action success');
break;
case 'paste':
Alert.alert('Alert', 'paste action success');
break;
}
}}>
<RNTesterText>This view supports many actions.</RNTesterText>
</View>
```
Reviewed By: cipolleschi
Differential Revision: D87766483
Pulled By: javache
fbshipit-source-id: a979f0126efce4b87c6d823519d512229fb4118e
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

I have some time to dedicate to this PR this month, what can I do to unblock it? Should I update the code to add a feature-flag?
Do you have any other idea on how this problem could be resolved? I understand it would significantly slow down the app on huge lists but I doubt there's a way around it. It might be something we have to accept. This would only affect the app while using the a11y controls, which are meant to be used by vision-impaired users that will likely not perceive stuttering.

@react-native-bot

Copy link
Copy Markdown
Collaborator

This PR is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 7 days.

@react-native-botreact-native-bot added Stale There has been a lack of activity on this issue and it may be closed soon. and removed Stale There has been a lack of activity on this issue and it may be closed soon. labels Jul 5, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.Shared with MetaApplied 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.

iOS Accessibility Value Out of Sync

5 participants

@maxencehenneron@efoken@javache@react-native-bot@facebook-github-bot
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[iOS][Accessibility][Critical?] #35774: accessibility state race condition - #54131

Open
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement
Open

[iOS][Accessibility][Critical?] #35774: accessibility state race condition #54131
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement

Conversation

@maxencehenneron

@maxencehenneronmaxencehenneron commented Oct 10, 2025

Copy link
Copy Markdown
Contributor

Refactor accessibility actions to fix a race condition on iOS. Fixes#35774

Summary:

I know you will most likely not like my fix, but there currently is a race condition that creates issues for our most vulnerable users.

Any accessibility action performed on an element on iOS are out of sync, from custom actions, taps, magic taps, increment, decrement or escape.

This is because the VoiceOver on iOS will immediately read the text once an action is called as it expects the function to update accessibilityValue / accessibilityState. The problem is we're emitting an asynchronous event to update this value so there is currently no easy way to fix this.

What I found is working is using this experimental_flushSync function to update the state synchronously. This is obviously bad for performance but i'd argue that for the current context (vision impairment) having 60fps when running an action (that doesn't happen often) is less important than having wrong vocal directions.

Changelog:

[iOS] [Fixed] - Updated accessibilityIncrement, accessibilityDecrement, accessibilityActivate, accessibilityPerformEscape, accessibilityPerformMagicTap to synchronously update the state when the action is run
[iOS] [Fixed] - Add missing accessibility props to TouchableOpacity

Test Plan:

Before:

ScreenRecording_10-10-2025.16-23-24_1.mov

After:
https://github.com/user-attachments/assets/5e31e2de-63e9-4234-bb4c-d46dfe1cabcf

Refactor accessibilityIncrement and accessibilityDecrement to fix a race condition on iOS.
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Hi @maxencehenneron!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@maxencehenneronmaxencehenneron changed the title Fix #35774: adjustable increment/decrement race conditionFix #35774: a11y "adjustable" increment/decrement action race conditionOct 10, 2025
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-clameta-claBot 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 10, 2025
@facebook-github-botfacebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 10, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This is actually worse than I thought:

We have the same issue with custom actions (my last commit fixes it). Meaning running a custom action that updates the accessibilityState (for example "selected", "expanded") will announce the previous state so any vision-impaired user would think the action was not performed and run it again. That time it would announce "selected" when it's actually not selected anymore.

I want to stress that accessibility is a legal requirement in Europe and the US and required by many legal frameworks.

@maxencehenneronmaxencehenneron changed the title Fix #35774: a11y "adjustable" increment/decrement action race condition#35774: accessibility state race condition Oct 14, 2025
@maxencehenneronmaxencehenneron changed the title #35774: accessibility state race condition [iOS][Accessibility][Critical?] #35774: accessibility state race condition Oct 14, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This PR can now be reviewed

@efoken

Copy link
Copy Markdown

Great, finally 👍

I wonder why this was never fixed before, because we have this issue at IBM iX for many of our clients apps and as a11y is a legal requirement in the EU we getting more and more trouble with this issue 😅

Even if this uses experimental code, it seems to be the easiest fix.

@maxencehenneron

maxencehenneron commented Oct 15, 2025

Copy link
Copy Markdown
ContributorAuthor

Small note on my PR:

You have to implement onAccessibilityTap for the standard action (double tap) to work. Only implementing "onPress" will result in the wrong state being announced.
I think this is fine. We do not want to synchronously flush onPress and explicitly adding onAccessibilityTap makes sense.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

If anyone wants to reliably reproduce this issue, add the following code to the rn-tester playground:

functionPlayground(){const[counter,setCounter]=React.useState(0);// Simulate some heavy computationfor(leti=0;i<10000000;i++);return(<Viewstyle={styles.container}><PressableaccessibleaccessibilityValue={{text: `Counter is ${counter}`}}onAccessibilityTap={()=>setCounter(counter+1)}><RNTesterTextaccessible={false}>The counter is: {counter}</RNTesterText></Pressable></View>);}

Without my fix, it reads the wrong value. With my fix, the correct value is read.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

@javache Sorry for the ping but I believe my accessibility PRs got overlooked and this bug is kinda critical for any production app. Would you be able to forward my PRs to the appropriate person at Meta? Thank you

@javache

Copy link
Copy Markdown
Contributor

Thanks for the detailed write-up here.

You're right that using experimental_flushSync raises some eyebrows. Not only can it hurt performance, it currently can be unpredictable to when it will actually get executed (we've seen React code not using transitions or a badly configured FlatList taking 1s+), which will make the user believe the app is unresponsive. Worse, if the JS thread is currently trying to synchronously access the UI thread, the app will deadlock (see feature flag enableMainQueueCoordinatorOnIOS)

One thing which would help reduce the risk here is that we first add a feature flag for this so we can do a test of this internally at Meta before changing this behaviour by default.

cc @lunaleaps@yungsters@sammy-SC on using experimental_flushSync here.

Comment on lines +311 to +313
onAccessibilityTap={this.props.onAccessibilityTap}
onAccessibilityEscape={this.props.onAccessibilityEscape}
onMagicTap={this.props.onMagicTap}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to these changes? Can it be a separate PR?

{
if (_eventEmitter && _props->onAccessibilityTap) {
_eventEmitter->onAccessibilityTap();
auto emitter = std::static_pointer_cast<const ViewEventEmitter>(_eventEmitter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: prefer a static_cast of *_eventEmitter

meta-codesyncBot pushed a commit that referenced this pull request Dec 9, 2025
Summary:
This is my PR 2 out of 3 to improve accessibility on iOS. If you're reading this, please also have a look at #54131, this is a critical race condition bug that basically makes accessibility extremely hard to handle on large apps.
Fix for #53496: on the new architecture, any accessibility label is ignored and only the "name" is read by VoiceOver. We want to use name as an "action id", and label as the translatable value. While android has a id/label system for actions, iOS doesn't so my implementation, inspired by the old paper architecture, maps labels to name. Native iOS will only see the labels but the JS side will properly receive names when an action is executed. I believe this is the correct way of handling this. Another thing we could do is just deprecate "label" and expect name to be in the correct language, but I do not like that.
## Changelog:
[IOS][FIXED] - Accessibility actions labels are not read by VoiceOver
Pull Request resolved: #54286
Test Plan:
Open RN-Tester, go to Accessibility -> Accessibility Action Examples, enable VoiceOver and select "This view supports many actions". Now scroll up fast to select the actions.
On the current implementation you will hear VoiceOver say "copy", then "cut", then "paste". It now says "cut label", "copy label", "paste label".
As a reference, here's how this view is implemented:
```jsx
<View
accessible={true}
accessibilityActions={[
{name: 'cut', label: 'cut label'},
{name: 'copy', label: 'copy label'},
{name: 'paste', label: 'paste label'},
]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'cut':
Alert.alert('Alert', 'cut action success');
break;
case 'copy':
Alert.alert('Alert', 'copy action success');
break;
case 'paste':
Alert.alert('Alert', 'paste action success');
break;
}
}}>
<RNTesterText>This view supports many actions.</RNTesterText>
</View>
```
Reviewed By: cipolleschi
Differential Revision: D87766483
Pulled By: javache
fbshipit-source-id: a979f0126efce4b87c6d823519d512229fb4118e
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

I have some time to dedicate to this PR this month, what can I do to unblock it? Should I update the code to add a feature-flag?
Do you have any other idea on how this problem could be resolved? I understand it would significantly slow down the app on huge lists but I doubt there's a way around it. It might be something we have to accept. This would only affect the app while using the a11y controls, which are meant to be used by vision-impaired users that will likely not perceive stuttering.

@react-native-bot

Copy link
Copy Markdown
Collaborator

This PR is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 7 days.

@react-native-botreact-native-bot added Stale There has been a lack of activity on this issue and it may be closed soon. and removed Stale There has been a lack of activity on this issue and it may be closed soon. labels Jul 5, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.Shared with MetaApplied 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.

iOS Accessibility Value Out of Sync

5 participants

@maxencehenneron@efoken@javache@react-native-bot@facebook-github-bot
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[iOS][Accessibility][Critical?] #35774: accessibility state race condition - #54131

Open
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement
Open

[iOS][Accessibility][Critical?] #35774: accessibility state race condition #54131
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement

Conversation

@maxencehenneron

@maxencehenneronmaxencehenneron commented Oct 10, 2025

Copy link
Copy Markdown
Contributor

Refactor accessibility actions to fix a race condition on iOS. Fixes#35774

Summary:

I know you will most likely not like my fix, but there currently is a race condition that creates issues for our most vulnerable users.

Any accessibility action performed on an element on iOS are out of sync, from custom actions, taps, magic taps, increment, decrement or escape.

This is because the VoiceOver on iOS will immediately read the text once an action is called as it expects the function to update accessibilityValue / accessibilityState. The problem is we're emitting an asynchronous event to update this value so there is currently no easy way to fix this.

What I found is working is using this experimental_flushSync function to update the state synchronously. This is obviously bad for performance but i'd argue that for the current context (vision impairment) having 60fps when running an action (that doesn't happen often) is less important than having wrong vocal directions.

Changelog:

[iOS] [Fixed] - Updated accessibilityIncrement, accessibilityDecrement, accessibilityActivate, accessibilityPerformEscape, accessibilityPerformMagicTap to synchronously update the state when the action is run
[iOS] [Fixed] - Add missing accessibility props to TouchableOpacity

Test Plan:

Before:

ScreenRecording_10-10-2025.16-23-24_1.mov

After:
https://github.com/user-attachments/assets/5e31e2de-63e9-4234-bb4c-d46dfe1cabcf

Refactor accessibilityIncrement and accessibilityDecrement to fix a race condition on iOS.
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Hi @maxencehenneron!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@maxencehenneronmaxencehenneron changed the title Fix #35774: adjustable increment/decrement race conditionFix #35774: a11y "adjustable" increment/decrement action race conditionOct 10, 2025
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-clameta-claBot 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 10, 2025
@facebook-github-botfacebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 10, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This is actually worse than I thought:

We have the same issue with custom actions (my last commit fixes it). Meaning running a custom action that updates the accessibilityState (for example "selected", "expanded") will announce the previous state so any vision-impaired user would think the action was not performed and run it again. That time it would announce "selected" when it's actually not selected anymore.

I want to stress that accessibility is a legal requirement in Europe and the US and required by many legal frameworks.

@maxencehenneronmaxencehenneron changed the title Fix #35774: a11y "adjustable" increment/decrement action race condition#35774: accessibility state race condition Oct 14, 2025
@maxencehenneronmaxencehenneron changed the title #35774: accessibility state race condition [iOS][Accessibility][Critical?] #35774: accessibility state race condition Oct 14, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This PR can now be reviewed

@efoken

Copy link
Copy Markdown

Great, finally 👍

I wonder why this was never fixed before, because we have this issue at IBM iX for many of our clients apps and as a11y is a legal requirement in the EU we getting more and more trouble with this issue 😅

Even if this uses experimental code, it seems to be the easiest fix.

@maxencehenneron

maxencehenneron commented Oct 15, 2025

Copy link
Copy Markdown
ContributorAuthor

Small note on my PR:

You have to implement onAccessibilityTap for the standard action (double tap) to work. Only implementing "onPress" will result in the wrong state being announced.
I think this is fine. We do not want to synchronously flush onPress and explicitly adding onAccessibilityTap makes sense.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

If anyone wants to reliably reproduce this issue, add the following code to the rn-tester playground:

functionPlayground(){const[counter,setCounter]=React.useState(0);// Simulate some heavy computationfor(leti=0;i<10000000;i++);return(<Viewstyle={styles.container}><PressableaccessibleaccessibilityValue={{text: `Counter is ${counter}`}}onAccessibilityTap={()=>setCounter(counter+1)}><RNTesterTextaccessible={false}>The counter is: {counter}</RNTesterText></Pressable></View>);}

Without my fix, it reads the wrong value. With my fix, the correct value is read.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

@javache Sorry for the ping but I believe my accessibility PRs got overlooked and this bug is kinda critical for any production app. Would you be able to forward my PRs to the appropriate person at Meta? Thank you

@javache

Copy link
Copy Markdown
Contributor

Thanks for the detailed write-up here.

You're right that using experimental_flushSync raises some eyebrows. Not only can it hurt performance, it currently can be unpredictable to when it will actually get executed (we've seen React code not using transitions or a badly configured FlatList taking 1s+), which will make the user believe the app is unresponsive. Worse, if the JS thread is currently trying to synchronously access the UI thread, the app will deadlock (see feature flag enableMainQueueCoordinatorOnIOS)

One thing which would help reduce the risk here is that we first add a feature flag for this so we can do a test of this internally at Meta before changing this behaviour by default.

cc @lunaleaps@yungsters@sammy-SC on using experimental_flushSync here.

Comment on lines +311 to +313
onAccessibilityTap={this.props.onAccessibilityTap}
onAccessibilityEscape={this.props.onAccessibilityEscape}
onMagicTap={this.props.onMagicTap}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to these changes? Can it be a separate PR?

{
if (_eventEmitter && _props->onAccessibilityTap) {
_eventEmitter->onAccessibilityTap();
auto emitter = std::static_pointer_cast<const ViewEventEmitter>(_eventEmitter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: prefer a static_cast of *_eventEmitter

meta-codesyncBot pushed a commit that referenced this pull request Dec 9, 2025
Summary:
This is my PR 2 out of 3 to improve accessibility on iOS. If you're reading this, please also have a look at #54131, this is a critical race condition bug that basically makes accessibility extremely hard to handle on large apps.
Fix for #53496: on the new architecture, any accessibility label is ignored and only the "name" is read by VoiceOver. We want to use name as an "action id", and label as the translatable value. While android has a id/label system for actions, iOS doesn't so my implementation, inspired by the old paper architecture, maps labels to name. Native iOS will only see the labels but the JS side will properly receive names when an action is executed. I believe this is the correct way of handling this. Another thing we could do is just deprecate "label" and expect name to be in the correct language, but I do not like that.
## Changelog:
[IOS][FIXED] - Accessibility actions labels are not read by VoiceOver
Pull Request resolved: #54286
Test Plan:
Open RN-Tester, go to Accessibility -> Accessibility Action Examples, enable VoiceOver and select "This view supports many actions". Now scroll up fast to select the actions.
On the current implementation you will hear VoiceOver say "copy", then "cut", then "paste". It now says "cut label", "copy label", "paste label".
As a reference, here's how this view is implemented:
```jsx
<View
accessible={true}
accessibilityActions={[
{name: 'cut', label: 'cut label'},
{name: 'copy', label: 'copy label'},
{name: 'paste', label: 'paste label'},
]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'cut':
Alert.alert('Alert', 'cut action success');
break;
case 'copy':
Alert.alert('Alert', 'copy action success');
break;
case 'paste':
Alert.alert('Alert', 'paste action success');
break;
}
}}>
<RNTesterText>This view supports many actions.</RNTesterText>
</View>
```
Reviewed By: cipolleschi
Differential Revision: D87766483
Pulled By: javache
fbshipit-source-id: a979f0126efce4b87c6d823519d512229fb4118e
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

I have some time to dedicate to this PR this month, what can I do to unblock it? Should I update the code to add a feature-flag?
Do you have any other idea on how this problem could be resolved? I understand it would significantly slow down the app on huge lists but I doubt there's a way around it. It might be something we have to accept. This would only affect the app while using the a11y controls, which are meant to be used by vision-impaired users that will likely not perceive stuttering.

@react-native-bot

Copy link
Copy Markdown
Collaborator

This PR is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 7 days.

@react-native-botreact-native-bot added Stale There has been a lack of activity on this issue and it may be closed soon. and removed Stale There has been a lack of activity on this issue and it may be closed soon. labels Jul 5, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.Shared with MetaApplied 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.

iOS Accessibility Value Out of Sync

5 participants

@maxencehenneron@efoken@javache@react-native-bot@facebook-github-bot
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[iOS][Accessibility][Critical?] #35774: accessibility state race condition - #54131

Open
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement
Open

[iOS][Accessibility][Critical?] #35774: accessibility state race condition #54131
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement

Conversation

@maxencehenneron

@maxencehenneronmaxencehenneron commented Oct 10, 2025

Copy link
Copy Markdown
Contributor

Refactor accessibility actions to fix a race condition on iOS. Fixes#35774

Summary:

I know you will most likely not like my fix, but there currently is a race condition that creates issues for our most vulnerable users.

Any accessibility action performed on an element on iOS are out of sync, from custom actions, taps, magic taps, increment, decrement or escape.

This is because the VoiceOver on iOS will immediately read the text once an action is called as it expects the function to update accessibilityValue / accessibilityState. The problem is we're emitting an asynchronous event to update this value so there is currently no easy way to fix this.

What I found is working is using this experimental_flushSync function to update the state synchronously. This is obviously bad for performance but i'd argue that for the current context (vision impairment) having 60fps when running an action (that doesn't happen often) is less important than having wrong vocal directions.

Changelog:

[iOS] [Fixed] - Updated accessibilityIncrement, accessibilityDecrement, accessibilityActivate, accessibilityPerformEscape, accessibilityPerformMagicTap to synchronously update the state when the action is run
[iOS] [Fixed] - Add missing accessibility props to TouchableOpacity

Test Plan:

Before:

ScreenRecording_10-10-2025.16-23-24_1.mov

After:
https://github.com/user-attachments/assets/5e31e2de-63e9-4234-bb4c-d46dfe1cabcf

Refactor accessibilityIncrement and accessibilityDecrement to fix a race condition on iOS.
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Hi @maxencehenneron!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@maxencehenneronmaxencehenneron changed the title Fix #35774: adjustable increment/decrement race conditionFix #35774: a11y "adjustable" increment/decrement action race conditionOct 10, 2025
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-clameta-claBot 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 10, 2025
@facebook-github-botfacebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 10, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This is actually worse than I thought:

We have the same issue with custom actions (my last commit fixes it). Meaning running a custom action that updates the accessibilityState (for example "selected", "expanded") will announce the previous state so any vision-impaired user would think the action was not performed and run it again. That time it would announce "selected" when it's actually not selected anymore.

I want to stress that accessibility is a legal requirement in Europe and the US and required by many legal frameworks.

@maxencehenneronmaxencehenneron changed the title Fix #35774: a11y "adjustable" increment/decrement action race condition#35774: accessibility state race condition Oct 14, 2025
@maxencehenneronmaxencehenneron changed the title #35774: accessibility state race condition [iOS][Accessibility][Critical?] #35774: accessibility state race condition Oct 14, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This PR can now be reviewed

@efoken

Copy link
Copy Markdown

Great, finally 👍

I wonder why this was never fixed before, because we have this issue at IBM iX for many of our clients apps and as a11y is a legal requirement in the EU we getting more and more trouble with this issue 😅

Even if this uses experimental code, it seems to be the easiest fix.

@maxencehenneron

maxencehenneron commented Oct 15, 2025

Copy link
Copy Markdown
ContributorAuthor

Small note on my PR:

You have to implement onAccessibilityTap for the standard action (double tap) to work. Only implementing "onPress" will result in the wrong state being announced.
I think this is fine. We do not want to synchronously flush onPress and explicitly adding onAccessibilityTap makes sense.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

If anyone wants to reliably reproduce this issue, add the following code to the rn-tester playground:

functionPlayground(){const[counter,setCounter]=React.useState(0);// Simulate some heavy computationfor(leti=0;i<10000000;i++);return(<Viewstyle={styles.container}><PressableaccessibleaccessibilityValue={{text: `Counter is ${counter}`}}onAccessibilityTap={()=>setCounter(counter+1)}><RNTesterTextaccessible={false}>The counter is: {counter}</RNTesterText></Pressable></View>);}

Without my fix, it reads the wrong value. With my fix, the correct value is read.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

@javache Sorry for the ping but I believe my accessibility PRs got overlooked and this bug is kinda critical for any production app. Would you be able to forward my PRs to the appropriate person at Meta? Thank you

@javache

Copy link
Copy Markdown
Contributor

Thanks for the detailed write-up here.

You're right that using experimental_flushSync raises some eyebrows. Not only can it hurt performance, it currently can be unpredictable to when it will actually get executed (we've seen React code not using transitions or a badly configured FlatList taking 1s+), which will make the user believe the app is unresponsive. Worse, if the JS thread is currently trying to synchronously access the UI thread, the app will deadlock (see feature flag enableMainQueueCoordinatorOnIOS)

One thing which would help reduce the risk here is that we first add a feature flag for this so we can do a test of this internally at Meta before changing this behaviour by default.

cc @lunaleaps@yungsters@sammy-SC on using experimental_flushSync here.

Comment on lines +311 to +313
onAccessibilityTap={this.props.onAccessibilityTap}
onAccessibilityEscape={this.props.onAccessibilityEscape}
onMagicTap={this.props.onMagicTap}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to these changes? Can it be a separate PR?

{
if (_eventEmitter && _props->onAccessibilityTap) {
_eventEmitter->onAccessibilityTap();
auto emitter = std::static_pointer_cast<const ViewEventEmitter>(_eventEmitter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: prefer a static_cast of *_eventEmitter

meta-codesyncBot pushed a commit that referenced this pull request Dec 9, 2025
Summary:
This is my PR 2 out of 3 to improve accessibility on iOS. If you're reading this, please also have a look at #54131, this is a critical race condition bug that basically makes accessibility extremely hard to handle on large apps.
Fix for #53496: on the new architecture, any accessibility label is ignored and only the "name" is read by VoiceOver. We want to use name as an "action id", and label as the translatable value. While android has a id/label system for actions, iOS doesn't so my implementation, inspired by the old paper architecture, maps labels to name. Native iOS will only see the labels but the JS side will properly receive names when an action is executed. I believe this is the correct way of handling this. Another thing we could do is just deprecate "label" and expect name to be in the correct language, but I do not like that.
## Changelog:
[IOS][FIXED] - Accessibility actions labels are not read by VoiceOver
Pull Request resolved: #54286
Test Plan:
Open RN-Tester, go to Accessibility -> Accessibility Action Examples, enable VoiceOver and select "This view supports many actions". Now scroll up fast to select the actions.
On the current implementation you will hear VoiceOver say "copy", then "cut", then "paste". It now says "cut label", "copy label", "paste label".
As a reference, here's how this view is implemented:
```jsx
<View
accessible={true}
accessibilityActions={[
{name: 'cut', label: 'cut label'},
{name: 'copy', label: 'copy label'},
{name: 'paste', label: 'paste label'},
]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'cut':
Alert.alert('Alert', 'cut action success');
break;
case 'copy':
Alert.alert('Alert', 'copy action success');
break;
case 'paste':
Alert.alert('Alert', 'paste action success');
break;
}
}}>
<RNTesterText>This view supports many actions.</RNTesterText>
</View>
```
Reviewed By: cipolleschi
Differential Revision: D87766483
Pulled By: javache
fbshipit-source-id: a979f0126efce4b87c6d823519d512229fb4118e
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

I have some time to dedicate to this PR this month, what can I do to unblock it? Should I update the code to add a feature-flag?
Do you have any other idea on how this problem could be resolved? I understand it would significantly slow down the app on huge lists but I doubt there's a way around it. It might be something we have to accept. This would only affect the app while using the a11y controls, which are meant to be used by vision-impaired users that will likely not perceive stuttering.

@react-native-bot

Copy link
Copy Markdown
Collaborator

This PR is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 7 days.

@react-native-botreact-native-bot added Stale There has been a lack of activity on this issue and it may be closed soon. and removed Stale There has been a lack of activity on this issue and it may be closed soon. labels Jul 5, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.Shared with MetaApplied 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.

iOS Accessibility Value Out of Sync

5 participants

@maxencehenneron@efoken@javache@react-native-bot@facebook-github-bot
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[iOS][Accessibility][Critical?] #35774: accessibility state race condition - #54131

Open
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement
Open

[iOS][Accessibility][Critical?] #35774: accessibility state race condition #54131
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement

Conversation

@maxencehenneron

@maxencehenneronmaxencehenneron commented Oct 10, 2025

Copy link
Copy Markdown
Contributor

Refactor accessibility actions to fix a race condition on iOS. Fixes#35774

Summary:

I know you will most likely not like my fix, but there currently is a race condition that creates issues for our most vulnerable users.

Any accessibility action performed on an element on iOS are out of sync, from custom actions, taps, magic taps, increment, decrement or escape.

This is because the VoiceOver on iOS will immediately read the text once an action is called as it expects the function to update accessibilityValue / accessibilityState. The problem is we're emitting an asynchronous event to update this value so there is currently no easy way to fix this.

What I found is working is using this experimental_flushSync function to update the state synchronously. This is obviously bad for performance but i'd argue that for the current context (vision impairment) having 60fps when running an action (that doesn't happen often) is less important than having wrong vocal directions.

Changelog:

[iOS] [Fixed] - Updated accessibilityIncrement, accessibilityDecrement, accessibilityActivate, accessibilityPerformEscape, accessibilityPerformMagicTap to synchronously update the state when the action is run
[iOS] [Fixed] - Add missing accessibility props to TouchableOpacity

Test Plan:

Before:

ScreenRecording_10-10-2025.16-23-24_1.mov

After:
https://github.com/user-attachments/assets/5e31e2de-63e9-4234-bb4c-d46dfe1cabcf

Refactor accessibilityIncrement and accessibilityDecrement to fix a race condition on iOS.
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Hi @maxencehenneron!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@maxencehenneronmaxencehenneron changed the title Fix #35774: adjustable increment/decrement race conditionFix #35774: a11y "adjustable" increment/decrement action race conditionOct 10, 2025
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-clameta-claBot 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 10, 2025
@facebook-github-botfacebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 10, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This is actually worse than I thought:

We have the same issue with custom actions (my last commit fixes it). Meaning running a custom action that updates the accessibilityState (for example "selected", "expanded") will announce the previous state so any vision-impaired user would think the action was not performed and run it again. That time it would announce "selected" when it's actually not selected anymore.

I want to stress that accessibility is a legal requirement in Europe and the US and required by many legal frameworks.

@maxencehenneronmaxencehenneron changed the title Fix #35774: a11y "adjustable" increment/decrement action race condition#35774: accessibility state race condition Oct 14, 2025
@maxencehenneronmaxencehenneron changed the title #35774: accessibility state race condition [iOS][Accessibility][Critical?] #35774: accessibility state race condition Oct 14, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This PR can now be reviewed

@efoken

Copy link
Copy Markdown

Great, finally 👍

I wonder why this was never fixed before, because we have this issue at IBM iX for many of our clients apps and as a11y is a legal requirement in the EU we getting more and more trouble with this issue 😅

Even if this uses experimental code, it seems to be the easiest fix.

@maxencehenneron

maxencehenneron commented Oct 15, 2025

Copy link
Copy Markdown
ContributorAuthor

Small note on my PR:

You have to implement onAccessibilityTap for the standard action (double tap) to work. Only implementing "onPress" will result in the wrong state being announced.
I think this is fine. We do not want to synchronously flush onPress and explicitly adding onAccessibilityTap makes sense.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

If anyone wants to reliably reproduce this issue, add the following code to the rn-tester playground:

functionPlayground(){const[counter,setCounter]=React.useState(0);// Simulate some heavy computationfor(leti=0;i<10000000;i++);return(<Viewstyle={styles.container}><PressableaccessibleaccessibilityValue={{text: `Counter is ${counter}`}}onAccessibilityTap={()=>setCounter(counter+1)}><RNTesterTextaccessible={false}>The counter is: {counter}</RNTesterText></Pressable></View>);}

Without my fix, it reads the wrong value. With my fix, the correct value is read.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

@javache Sorry for the ping but I believe my accessibility PRs got overlooked and this bug is kinda critical for any production app. Would you be able to forward my PRs to the appropriate person at Meta? Thank you

@javache

Copy link
Copy Markdown
Contributor

Thanks for the detailed write-up here.

You're right that using experimental_flushSync raises some eyebrows. Not only can it hurt performance, it currently can be unpredictable to when it will actually get executed (we've seen React code not using transitions or a badly configured FlatList taking 1s+), which will make the user believe the app is unresponsive. Worse, if the JS thread is currently trying to synchronously access the UI thread, the app will deadlock (see feature flag enableMainQueueCoordinatorOnIOS)

One thing which would help reduce the risk here is that we first add a feature flag for this so we can do a test of this internally at Meta before changing this behaviour by default.

cc @lunaleaps@yungsters@sammy-SC on using experimental_flushSync here.

Comment on lines +311 to +313
onAccessibilityTap={this.props.onAccessibilityTap}
onAccessibilityEscape={this.props.onAccessibilityEscape}
onMagicTap={this.props.onMagicTap}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to these changes? Can it be a separate PR?

{
if (_eventEmitter && _props->onAccessibilityTap) {
_eventEmitter->onAccessibilityTap();
auto emitter = std::static_pointer_cast<const ViewEventEmitter>(_eventEmitter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: prefer a static_cast of *_eventEmitter

meta-codesyncBot pushed a commit that referenced this pull request Dec 9, 2025
Summary:
This is my PR 2 out of 3 to improve accessibility on iOS. If you're reading this, please also have a look at #54131, this is a critical race condition bug that basically makes accessibility extremely hard to handle on large apps.
Fix for #53496: on the new architecture, any accessibility label is ignored and only the "name" is read by VoiceOver. We want to use name as an "action id", and label as the translatable value. While android has a id/label system for actions, iOS doesn't so my implementation, inspired by the old paper architecture, maps labels to name. Native iOS will only see the labels but the JS side will properly receive names when an action is executed. I believe this is the correct way of handling this. Another thing we could do is just deprecate "label" and expect name to be in the correct language, but I do not like that.
## Changelog:
[IOS][FIXED] - Accessibility actions labels are not read by VoiceOver
Pull Request resolved: #54286
Test Plan:
Open RN-Tester, go to Accessibility -> Accessibility Action Examples, enable VoiceOver and select "This view supports many actions". Now scroll up fast to select the actions.
On the current implementation you will hear VoiceOver say "copy", then "cut", then "paste". It now says "cut label", "copy label", "paste label".
As a reference, here's how this view is implemented:
```jsx
<View
accessible={true}
accessibilityActions={[
{name: 'cut', label: 'cut label'},
{name: 'copy', label: 'copy label'},
{name: 'paste', label: 'paste label'},
]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'cut':
Alert.alert('Alert', 'cut action success');
break;
case 'copy':
Alert.alert('Alert', 'copy action success');
break;
case 'paste':
Alert.alert('Alert', 'paste action success');
break;
}
}}>
<RNTesterText>This view supports many actions.</RNTesterText>
</View>
```
Reviewed By: cipolleschi
Differential Revision: D87766483
Pulled By: javache
fbshipit-source-id: a979f0126efce4b87c6d823519d512229fb4118e
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

I have some time to dedicate to this PR this month, what can I do to unblock it? Should I update the code to add a feature-flag?
Do you have any other idea on how this problem could be resolved? I understand it would significantly slow down the app on huge lists but I doubt there's a way around it. It might be something we have to accept. This would only affect the app while using the a11y controls, which are meant to be used by vision-impaired users that will likely not perceive stuttering.

@react-native-bot

Copy link
Copy Markdown
Collaborator

This PR is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 7 days.

@react-native-botreact-native-bot added Stale There has been a lack of activity on this issue and it may be closed soon. and removed Stale There has been a lack of activity on this issue and it may be closed soon. labels Jul 5, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.Shared with MetaApplied 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.

iOS Accessibility Value Out of Sync

5 participants

@maxencehenneron@efoken@javache@react-native-bot@facebook-github-bot
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[iOS][Accessibility][Critical?] #35774: accessibility state race condition - #54131

Open
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement
Open

[iOS][Accessibility][Critical?] #35774: accessibility state race condition #54131
maxencehenneron wants to merge 4 commits into
react:mainfrom
maxencehenneron:fix-a11y-increment-decrement

Conversation

@maxencehenneron

@maxencehenneronmaxencehenneron commented Oct 10, 2025

Copy link
Copy Markdown
Contributor

Refactor accessibility actions to fix a race condition on iOS. Fixes#35774

Summary:

I know you will most likely not like my fix, but there currently is a race condition that creates issues for our most vulnerable users.

Any accessibility action performed on an element on iOS are out of sync, from custom actions, taps, magic taps, increment, decrement or escape.

This is because the VoiceOver on iOS will immediately read the text once an action is called as it expects the function to update accessibilityValue / accessibilityState. The problem is we're emitting an asynchronous event to update this value so there is currently no easy way to fix this.

What I found is working is using this experimental_flushSync function to update the state synchronously. This is obviously bad for performance but i'd argue that for the current context (vision impairment) having 60fps when running an action (that doesn't happen often) is less important than having wrong vocal directions.

Changelog:

[iOS] [Fixed] - Updated accessibilityIncrement, accessibilityDecrement, accessibilityActivate, accessibilityPerformEscape, accessibilityPerformMagicTap to synchronously update the state when the action is run
[iOS] [Fixed] - Add missing accessibility props to TouchableOpacity

Test Plan:

Before:

ScreenRecording_10-10-2025.16-23-24_1.mov

After:
https://github.com/user-attachments/assets/5e31e2de-63e9-4234-bb4c-d46dfe1cabcf

Refactor accessibilityIncrement and accessibilityDecrement to fix a race condition on iOS.
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Hi @maxencehenneron!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@maxencehenneronmaxencehenneron changed the title Fix #35774: adjustable increment/decrement race conditionFix #35774: a11y "adjustable" increment/decrement action race conditionOct 10, 2025
@meta-cla

meta-claBot commented Oct 10, 2025

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-clameta-claBot 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 10, 2025
@facebook-github-botfacebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 10, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This is actually worse than I thought:

We have the same issue with custom actions (my last commit fixes it). Meaning running a custom action that updates the accessibilityState (for example "selected", "expanded") will announce the previous state so any vision-impaired user would think the action was not performed and run it again. That time it would announce "selected" when it's actually not selected anymore.

I want to stress that accessibility is a legal requirement in Europe and the US and required by many legal frameworks.

@maxencehenneronmaxencehenneron changed the title Fix #35774: a11y "adjustable" increment/decrement action race condition#35774: accessibility state race condition Oct 14, 2025
@maxencehenneronmaxencehenneron changed the title #35774: accessibility state race condition [iOS][Accessibility][Critical?] #35774: accessibility state race condition Oct 14, 2025
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

This PR can now be reviewed

@efoken

Copy link
Copy Markdown

Great, finally 👍

I wonder why this was never fixed before, because we have this issue at IBM iX for many of our clients apps and as a11y is a legal requirement in the EU we getting more and more trouble with this issue 😅

Even if this uses experimental code, it seems to be the easiest fix.

@maxencehenneron

maxencehenneron commented Oct 15, 2025

Copy link
Copy Markdown
ContributorAuthor

Small note on my PR:

You have to implement onAccessibilityTap for the standard action (double tap) to work. Only implementing "onPress" will result in the wrong state being announced.
I think this is fine. We do not want to synchronously flush onPress and explicitly adding onAccessibilityTap makes sense.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

If anyone wants to reliably reproduce this issue, add the following code to the rn-tester playground:

functionPlayground(){const[counter,setCounter]=React.useState(0);// Simulate some heavy computationfor(leti=0;i<10000000;i++);return(<Viewstyle={styles.container}><PressableaccessibleaccessibilityValue={{text: `Counter is ${counter}`}}onAccessibilityTap={()=>setCounter(counter+1)}><RNTesterTextaccessible={false}>The counter is: {counter}</RNTesterText></Pressable></View>);}

Without my fix, it reads the wrong value. With my fix, the correct value is read.

@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

@javache Sorry for the ping but I believe my accessibility PRs got overlooked and this bug is kinda critical for any production app. Would you be able to forward my PRs to the appropriate person at Meta? Thank you

@javache

Copy link
Copy Markdown
Contributor

Thanks for the detailed write-up here.

You're right that using experimental_flushSync raises some eyebrows. Not only can it hurt performance, it currently can be unpredictable to when it will actually get executed (we've seen React code not using transitions or a badly configured FlatList taking 1s+), which will make the user believe the app is unresponsive. Worse, if the JS thread is currently trying to synchronously access the UI thread, the app will deadlock (see feature flag enableMainQueueCoordinatorOnIOS)

One thing which would help reduce the risk here is that we first add a feature flag for this so we can do a test of this internally at Meta before changing this behaviour by default.

cc @lunaleaps@yungsters@sammy-SC on using experimental_flushSync here.

Comment on lines +311 to +313
onAccessibilityTap={this.props.onAccessibilityTap}
onAccessibilityEscape={this.props.onAccessibilityEscape}
onMagicTap={this.props.onMagicTap}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to these changes? Can it be a separate PR?

{
if (_eventEmitter && _props->onAccessibilityTap) {
_eventEmitter->onAccessibilityTap();
auto emitter = std::static_pointer_cast<const ViewEventEmitter>(_eventEmitter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: prefer a static_cast of *_eventEmitter

meta-codesyncBot pushed a commit that referenced this pull request Dec 9, 2025
Summary:
This is my PR 2 out of 3 to improve accessibility on iOS. If you're reading this, please also have a look at #54131, this is a critical race condition bug that basically makes accessibility extremely hard to handle on large apps.
Fix for #53496: on the new architecture, any accessibility label is ignored and only the "name" is read by VoiceOver. We want to use name as an "action id", and label as the translatable value. While android has a id/label system for actions, iOS doesn't so my implementation, inspired by the old paper architecture, maps labels to name. Native iOS will only see the labels but the JS side will properly receive names when an action is executed. I believe this is the correct way of handling this. Another thing we could do is just deprecate "label" and expect name to be in the correct language, but I do not like that.
## Changelog:
[IOS][FIXED] - Accessibility actions labels are not read by VoiceOver
Pull Request resolved: #54286
Test Plan:
Open RN-Tester, go to Accessibility -> Accessibility Action Examples, enable VoiceOver and select "This view supports many actions". Now scroll up fast to select the actions.
On the current implementation you will hear VoiceOver say "copy", then "cut", then "paste". It now says "cut label", "copy label", "paste label".
As a reference, here's how this view is implemented:
```jsx
<View
accessible={true}
accessibilityActions={[
{name: 'cut', label: 'cut label'},
{name: 'copy', label: 'copy label'},
{name: 'paste', label: 'paste label'},
]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'cut':
Alert.alert('Alert', 'cut action success');
break;
case 'copy':
Alert.alert('Alert', 'copy action success');
break;
case 'paste':
Alert.alert('Alert', 'paste action success');
break;
}
}}>
<RNTesterText>This view supports many actions.</RNTesterText>
</View>
```
Reviewed By: cipolleschi
Differential Revision: D87766483
Pulled By: javache
fbshipit-source-id: a979f0126efce4b87c6d823519d512229fb4118e
@maxencehenneron

Copy link
Copy Markdown
ContributorAuthor

I have some time to dedicate to this PR this month, what can I do to unblock it? Should I update the code to add a feature-flag?
Do you have any other idea on how this problem could be resolved? I understand it would significantly slow down the app on huge lists but I doubt there's a way around it. It might be something we have to accept. This would only affect the app while using the a11y controls, which are meant to be used by vision-impaired users that will likely not perceive stuttering.

@react-native-bot

Copy link
Copy Markdown
Collaborator

This PR is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 7 days.

@react-native-botreact-native-bot added Stale There has been a lack of activity on this issue and it may be closed soon. and removed Stale There has been a lack of activity on this issue and it may be closed soon. labels Jul 5, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.Shared with MetaApplied 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.

iOS Accessibility Value Out of Sync

5 participants

@maxencehenneron@efoken@javache@react-native-bot@facebook-github-bot