Skip to content

fix: The displayed error messages are not announced once they appear.#84516

Merged
mollfpr merged 18 commits into
Expensify:mainfrom
Krishna2323:krishna2323/issue/74866
Mar 23, 2026
Merged

fix: The displayed error messages are not announced once they appear.#84516
mollfpr merged 18 commits into
Expensify:mainfrom
Krishna2323:krishna2323/issue/74866

Conversation

@Krishna2323

@Krishna2323 Krishna2323 commented Mar 8, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

Fixed Issues

$ #74866
PROPOSAL:

Tests

  • Same as QA Steps

  • Verify that no errors appear in the JS console

Offline tests

  • Same as QA Steps

QA Steps

  1. Using Chrome + JAWS, open the URL - https://new.expensify.com/
  2. Navigate to the 'Account' and press Enter > Navigate to the 'Settings - Save the world' and press Enter
  3. Tab to the 'I know a teacher' and press Enter
  4. Leave the fields empty or add invalid input
  5. Tab to the 'Let's do this' button and press Enter
  6. Observe the screen reader announcement

Other Occurrences:

  1. On Settings - Save the world - Intro your school principal
  2. On Settings - Security - Close account
  3. On Settings - Security - Two-factor authentication
  4. On Settings - Security - Two-factor authentication (step 2 of 2)
  5. On Settings - Profile - Display name
  6. On Settings - Profile - DOB
  7. On Settings - Profile - Phone number
  8. On Settings - Profile - Legal name
  9. On Settings - Profile - Address
  10. On Workspaces - Duplicate Workspaces
  11. On Workspaces - Overview - Workspace Name
  12. On Workspace - Categories - Add Category
  13. On Workspace - Expensify Card - Bank info
  14. On Workspace - Create Workspace - Confirm Workspace
  15. On Create Report - Add payment card
  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
    • MacOS: Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
android_native.mp4
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
web_chrome.mp4
web_chrome_jaws.mp4
web_chrome_nvda.mp4

Signed-off-by: krishna2323 <belivethatkg@gmail.com>
@melvin-bot

melvin-bot Bot commented Mar 8, 2026

Copy link
Copy Markdown

Hey! I see that you made changes to our Form component. Make sure to update the docs in FORMS.md accordingly. Cheers!

@codecov

codecov Bot commented Mar 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/components/Form/FormWrapper.tsx 83.33% <100.00%> (+0.98%) ⬆️
src/components/FormAlertWrapper.tsx 88.88% <100.00%> (+1.38%) ⬆️
src/components/FormHelpMessage.tsx 88.88% <100.00%> (+3.17%) ⬆️
...rc/hooks/useAccessibilityAnnouncement/index.ios.ts 100.00% <100.00%> (ø)
src/components/Form/FormContext.tsx 25.00% <50.00%> (+25.00%) ⬆️
src/components/Form/FormProvider.tsx 61.39% <94.73%> (+3.01%) ⬆️
...hooks/useAccessibilityAnnouncement/index.native.ts 0.00% <0.00%> (ø)
... and 12 files with indirect coverage changes

@Krishna2323

Copy link
Copy Markdown
Contributor Author

@rushatgabhane @dannymcclain, I'm a bit confused about the expected behaviour? Should the error message be announced every time we click the submit the form? Currently the error message is announced when it appears for the first time.

This PR

fix.mp4

Main

Monosnap.screencast.2026-03-09.18-05-50.mp4

@Krishna2323

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

We were unable to download your code in a timely manner.
ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@rushatgabhane

Copy link
Copy Markdown
Member

@MelvinBot review

@MelvinBot

Copy link
Copy Markdown
Contributor

Code Review

PR Summary: This PR fixes WCAG 4.1.3 compliance by ensuring form validation error messages are announced by screen readers. It introduces a two-phase DOM update pattern (unmount → 100ms delay → remount) for a hidden role="alert" element, driven by an errorAnnouncementKey counter in FormContext that increments on each form submission.

Overall: The approach is sound. The two-phase DOM update is a well-known technique for forcing screen reader re-announcements, and the separation of the visual error message from the hidden announcement element is clean. The errorAnnouncementKey mechanism correctly handles re-submissions. There are two issues I'd flag below.


Issue 1: iOS native hook won't re-announce on re-submit

The useFormHelpMessageAccessibilityAnnouncement.ios.ts hook depends on [message, shouldAnnounceError] but not on errorAnnouncementKey. On iOS native, when a user re-submits with the same validation error, the hook won't re-fire because:

  1. message hasn't changed → effect doesn't re-run
  2. Even if it did, previousAnnouncedMessageRef.current === message → returns early

This means the re-announcement behavior added by this PR only works on web/Android via the DOM-based alert element, but not on iOS native where AccessibilityInfo.announceForAccessibility is the announcement mechanism.

Suggestion: Pass errorAnnouncementKey to the iOS hook and use it to bypass the dedup check:

function useFormHelpMessageAccessibilityAnnouncement(
    message: string | ReactNode,
    shouldAnnounceError: boolean,
    errorAnnouncementKey: number,
) {
    // ...
    useEffect(() => {
        // ... existing logic, but remove the previousAnnouncedMessageRef dedup
        // since errorAnnouncementKey already controls re-trigger timing
    }, [message, shouldAnnounceError, errorAnnouncementKey]);
}

And at line 98 of FormHelpMessage.tsx:

useFormHelpMessageAccessibilityAnnouncement(errorAnnouncementText, shouldAnnounceError, errorAnnouncementKey);

This also fixes a secondary issue: the hook currently receives message (raw prop) instead of errorAnnouncementText. If shouldRenderMessageAsHTML is ever true, the hook would announce raw HTML tags. Passing errorAnnouncementText (which is already run through Parser.htmlToText) solves both problems.


Issue 2: Minor — submitAndAnnounce can double-increment the key

When there are pre-existing errors:

  1. submitAndAnnounce sees !isEmptyObject(errors) → increments key
  2. submit() runs → validation fails → increments key again

The key jumps by 2. This is harmless (React batches both updates, effect fires once), but it's worth adding a brief comment or simplifying. One option: remove the increment from inside submit() entirely and let submitAndAnnounce be the sole source of key increments, since it wraps submit() and is now the only onSubmit handler.


Other observations (not blocking)

  • ESLint full check is failing but it's a pre-existing issue (react-hooks/set-state-in-effect in an approval workflow file), unrelated to this PR. The "Changed files ESLint check" passes.
  • Standalone FormHelpMessage usage (outside FormProvider) still works correctly — errorAnnouncementKey defaults to 0, initial error announcements work, only re-announcement on re-submit is form-specific (which is appropriate).
  • The shouldRenderMessageAsHTML pass-through from FormAlertWrapper to FormHelpMessage is a good fix — it was missing before, meaning HTML error messages weren't being properly rendered.
  • The DotIndicatorMessage.tsx change adding role alongside accessibilityRole is correct — React Native Web maps role directly to the HTML role attribute, while accessibilityRole goes through a mapping layer.

@Krishna2323

Copy link
Copy Markdown
Contributor Author

Will add the recordings for other platforms tomorrow:

Monosnap.screencast.2026-03-13.23-11-36.mp4

@Krishna2323

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

…4866

Made-with: Cursor

# Conflicts:
#	src/components/FormHelpMessage.tsx
@Krishna2323

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@Krishna2323 Krishna2323 force-pushed the krishna2323/issue/74866 branch from 08d3e8a to b33d0f5 Compare March 16, 2026 05:50
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
@Krishna2323

Copy link
Copy Markdown
Contributor Author

@MelvinBot the current implementation works well with NVDA and JAWS but isn't working with VoiceOver. The error message is not announced when they appear for the first time when the input is blurred. Pressing submit button correctly announces the error message. Please investigate VoiceOver issue.

@MelvinBot

Copy link
Copy Markdown
Contributor

VoiceOver Investigation: Error Messages Not Announced on Blur

Summary

VoiceOver on macOS doesn't announce input-level error messages when they first appear on blur. This is caused by two interacting issues: (1) the hidden role="alert" announcement element is dynamically injected into the DOM rather than being pre-registered, and (2) the hiddenElementOutsideOfWindow style uses opacity: 0, which may cause Safari to deprioritize the element in the accessibility tree. Meanwhile, submit works because it uses a key-based remount pattern that creates a brand-new DOM node, which reliably fires the role="alert" system alert event.

Root Cause Analysis

On blur — the code path that doesn't work:

  1. FormProvider.onBlur (line 425-456 of FormProvider.tsx) validates and calls setErrors(), but never increments errorAnnouncementKey.
  2. BaseTextInput renders FormHelpMessage with nativeID={helpMessageTextID} but without shouldReannounceOnSubmit.
  3. In FormHelpMessage, because nativeID is set and shouldAnnounceError is true, shouldUseSeparateWebLiveAnnouncement becomes true on web.
  4. This causes the visible <Text> to have no role="alert" and no accessibilityLiveRegion (both set to undefined).
  5. Instead, a hidden <Text> element is rendered (lines 148-154) with role="alert" and accessibilityLiveRegion="assertive", using hiddenElementOutsideOfWindow styling (position: absolute; top: -10000; opacity: 0).
  6. VoiceOver on macOS/Safari does not reliably announce this element.

On submit — the code path that works:

  1. FormProvider.submit() increments errorAnnouncementKey (line 258).
  2. The shouldReannounceOnSubmit element (lines 155-163) renders with key={reannounce-${errorAnnouncementKey}}, forcing React to create a brand-new DOM node each time.
  3. role="alert" fires an immediate system alert event when a new element with that role appears in the DOM, which VoiceOver reliably detects.

Why VoiceOver misses the blur announcement:

According to accessibility research and MDN documentation, VoiceOver + Safari has documented issues with dynamically injected live regions. The most reliable pattern is:

Keep the live region container always in the DOM (empty from initial render), and only update its text content when the error appears.

The current code conditionally renders the hidden alert element, which means it appears in the DOM simultaneously with its text content. VoiceOver may not treat this as a "content change" in a live region it was monitoring, because it wasn't monitoring anything (the element didn't exist). While role="alert" has special behavior (fires an immediate event when added to DOM), this behavior is inconsistent across Safari versions.

Additionally, combining role="alert" (which implicitly sets aria-live="assertive") with explicit accessibilityLiveRegion="assertive" on the same element (line 150-151) can cause unpredictable behavior on VoiceOver.

Recommended Fix

Option A: Always-in-DOM pattern (most reliable)

Instead of conditionally rendering the hidden alert <Text>, always render it and only change its text content:

// In FormHelpMessage, instead of:
{shouldUseSeparateWebLiveAnnouncement && (
    <Text role={CONST.ROLE.ALERT} style={styles.hiddenElementOutsideOfWindow}>
        {message}
    </Text>
)}

// Use:
<Text role={CONST.ROLE.ALERT} style={styles.hiddenElementOutsideOfWindow}>
    {shouldUseSeparateWebLiveAnnouncement ? message : ''}
</Text>

However, this requires FormHelpMessage itself to always be in the DOM. Currently BaseTextInput conditionally renders FormHelpMessage ({!!inputHelpText && <FormHelpMessage .../>}), so the container still mounts fresh. To truly fix this, the live region container should exist at the BaseTextInput level (always rendered, even when empty).

Option B: Key-based fresh DOM node (simpler change)

Use the same key-based pattern that makes submit work. Add a local announcement key in FormHelpMessage that increments when a new error appears:

const [localAnnouncementKey, setLocalAnnouncementKey] = useState(0);
useEffect(() => {
    if (shouldUseSeparateWebLiveAnnouncement && errorAnnouncementText) {
        setLocalAnnouncementKey(prev => prev + 1);
    }
}, [shouldUseSeparateWebLiveAnnouncement, errorAnnouncementText]);

{shouldUseSeparateWebLiveAnnouncement && localAnnouncementKey > 0 && (
    <Text
        key={`blur-announce-${localAnnouncementKey}`}
        style={styles.hiddenElementOutsideOfWindow}
        role={CONST.ROLE.ALERT}
    >
        {message}
    </Text>
)}

This forces a new DOM node each time an error appears, which reliably triggers role="alert"'s system alert event.

Option C: Remove duplicate ARIA attributes

Regardless of which option above is chosen, the hidden element at lines 148-154 should use either role="alert" or accessibilityLiveRegion="assertive", not both. role="alert" already implies aria-live="assertive" per the ARIA spec, and combining both can cause double announcements on VoiceOver.

Additional Notes

  • The hiddenElementOutsideOfWindow style uses opacity: 0, which some sources suggest may cause Safari to deprioritize the element in its accessibility tree. The standard screen-reader-only pattern uses clip: rect(0, 0, 0, 0); overflow: hidden; width: 1px; height: 1px without opacity: 0.
  • The useAccessibilityAnnouncement hook is a no-op on web (index.ts), so all web accessibility announcements rely entirely on DOM-based role="alert" / aria-live.
  • errorAnnouncementKey is only incremented in submit() and submitAndAnnounce(), never on blur. All key-based and fallback announcement mechanisms (including FormWrapper's fallback at line 232) require errorAnnouncementKey > 0.

Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
@Krishna2323 Krishna2323 marked this pull request as ready for review March 17, 2026 12:35
@Krishna2323 Krishna2323 requested review from a team as code owners March 17, 2026 12:35
@aimane-chnaif

Copy link
Copy Markdown
Contributor

Please fix conflict

…4866

Made-with: Cursor

# Conflicts:
#	src/hooks/useAccessibilityAnnouncement/index.ios.ts
#	src/hooks/useAccessibilityAnnouncement/index.native.ts
#	src/hooks/useAccessibilityAnnouncement/index.ts
@Krishna2323

Copy link
Copy Markdown
Contributor Author

@aimane-chnaif conflicts resolved.

@aimane-chnaif

aimane-chnaif commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
    • MacOS: Desktop
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
Android: mWeb Chrome
iOS: HybridApp
iOS: mWeb Safari
MacOS: Chrome / Safari Screenshot 2026-03-18 at 9 57 22 pm Screenshot 2026-03-18 at 9 58 02 pm Screenshot 2026-03-18 at 9 59 15 pm Screenshot 2026-03-18 at 10 00 25 pm Screenshot 2026-03-18 at 10 00 51 pm

@MelvinBot

Copy link
Copy Markdown
Contributor

Code Review (Updated)

The PR has been significantly reworked since my previous review. Both issues I flagged have been addressed. Here's my review of the current state.

All CI checks pass.


Summary of the approach

The PR adds three complementary announcement mechanisms:

  1. Web re-announcement: A hidden <Text role="alert"> element keyed by errorAnnouncementKey. When the key changes (on re-submit), React remounts the element, which triggers screen reader re-announcement.
  2. Native re-announcement: The useAccessibilityAnnouncement hooks now accept an announcementKey option. When the key changes, the hook bypasses its dedup check and re-fires AccessibilityInfo.announceForAccessibility.
  3. Fallback for hidden alerts: When shouldHideFixErrorsAlert is true (general error banner hidden), field-level errors are announced via FormWrapper (web) and FormProvider's useAccessibilityAnnouncement (native).

The shouldReannounceOnSubmit prop on FormHelpMessage cleanly separates re-announcement behavior — only FormAlertWrapper opts in, while standalone usages retain their original behavior.


What looks good

  • iOS/Android re-announcement addressed: The announcementKey support in both index.ios.ts and index.native.ts properly bypasses the previousAnnouncedMessageRef dedup when the key changes. This was the main issue from my previous review.
  • Plain text for HTML messages: errorAnnouncementText uses Parser.htmlToText so screen readers never receive raw HTML tags.
  • Backward compatible: Standalone FormHelpMessage usages (outside FormProvider) still work — getErrorAnnouncementKey defaults to () => 0, initial announcements work as before, and shouldReannounceOnSubmit defaults to false.
  • shouldRenderMessageAsHTML now properly passed from FormAlertWrapper to FormHelpMessage.
  • DotIndicatorMessage change adding role alongside accessibilityRole is correct for React Native Web.

Minor observations (non-blocking)

  1. submitAndAnnounce double-increment: When there are pre-existing errors, submitAndAnnounce increments the key, then submit() validation fails and increments again. The key jumps by 2. This is harmless (React batches both updates), but a comment noting it's intentional would help future readers.

  2. Getter functions in context: getErrorAnnouncementKey and getFallbackAnnouncementMessage are exposed as useCallback getters rather than direct values. Since useCallback recreates the function reference when deps change (triggering context consumers to re-render anyway), this doesn't provide a rendering optimization over direct values. It works correctly, though — just a style observation.

  3. consistent-return eslint-disable in index.ios.ts: The added // eslint-disable-next-line consistent-return is needed because early returns don't return cleanup functions while the main path does. Standard React pattern — fine to suppress.


Overall this looks solid. The layered approach (web DOM-based + native AccessibilityInfo + fallback for hidden alerts) covers all platforms comprehensively.

Comment thread src/components/Form/FormWrapper.tsx Outdated
Comment on lines +234 to +241
{isWeb && !!fallbackAnnouncementMessage && errorAnnouncementKey > 0 && (
<Text
key={`fallback-announce-${errorAnnouncementKey}`}
style={styles.hiddenElementOutsideOfWindow}
role={CONST.ROLE.ALERT}
>
{fallbackAnnouncementMessage}
</Text>

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.

As useAccessibilityAnnouncement hook supports web, can we deprecate hidden Text?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The web hook doesn't support re-announcement -- it only announces once per message. The keyed hidden Text forces a fresh DOM node on each re-submit which reliably triggers re-announcement. Same as #84516 (comment)

Comment thread src/components/DotIndicatorMessage.tsx Outdated
// eslint-disable-next-line react/no-array-index-key
key={index}
style={[StyleUtils.getDotIndicatorTextStyles(isErrorMessage), textStyles]}
role={isErrorMessage ? CONST.ROLE.ALERT : undefined}

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.

What does this fix?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, this is redundant since accessibilityRole already maps to role on web. Removed.

{isWeb && shouldReannounceOnSubmit && hasError && errorAnnouncementKey > 0 && (
<Text
key={`reannounce-${errorAnnouncementKey}`}
style={styles.hiddenElementOutsideOfWindow}

@aimane-chnaif aimane-chnaif Mar 18, 2026

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.

Same deprecation concern here.

Btw why did we need hidden Text in both places? here in FormHelpMessage and in FormWrapper.

FormWrapper contains FormHelpMessage as child.

FormWrapper > FormAlertWithSubmitButton > FormAlertWrapper > FormHelpMessage

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The web hook can't replace these because it doesn't support announcementKey for re-announcements.

  • FormHelpMessage's is for re-announcing the general form alert ('Please fix the errors') on re-submit.
  • FormWrapper's is a fallback for forms with only field-level errors (no general alert) -- it announces the first field error on submit.

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.

The web hook can't replace these because it doesn't support announcementKey for re-announcements.

Why can't introduce announcementKey like you did for native?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Expected Result

When validation errors occur, the error message should be automatically announced by screen readers and user should receive immediate auditory feedback about the error and its location

  • Focus or programmatic notification should help users understand what needs to be corrected

I’ve already spent a significant amount of time on this PR and gone beyond the original scope. I implemented a re-announcement feature, which turned out to be much more complex than the expected behavior.

Introducing announcementKey would require reworking and re-testing everything again, especially since getting this to work consistently across JAWS, NVDA, and VoiceOver was already quite challenging.

@rushatgabhane Could you please check if it’s possible to increase the bounty for this, given that the work has gone beyond the original scope? I can take care of updating the hook if that works.

@rushatgabhane rushatgabhane Mar 18, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please check if it’s possible to increase the bounty for this, given that the work has gone beyond the original scope

an expensify employee would decide that. but yeah if the difficulty is more than expected, you can ask for more and give an explanation when payment is due

const {getErrorAnnouncementKey, getFallbackAnnouncementMessage} = useContext(FormContext);
const errorAnnouncementKey = getErrorAnnouncementKey();
const fallbackAnnouncementMessage = getFallbackAnnouncementMessage();
const isWeb = getPlatform() === CONST.PLATFORM.WEB;

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.

This is against our cross platform philosophy but we'll fix as followup by deprecating hidden Text in favor of web hook implementation.

@aimane-chnaif

aimane-chnaif commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

@Krishna2323 TS error. Seems unrelated though

Comment thread src/components/Form/FormProvider.tsx Outdated
return '';
}, [errors]);

useAccessibilityAnnouncement(firstFieldErrorMessage, !isGeneralAlertVisible && !!firstFieldErrorMessage && errorAnnouncementKey > 0, {

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.

Do you have test case where first field error message is announced?

I am seeing general error message for all the cases listed in QA Steps

Image

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.

Nvm I found cases. i.e. task title input page

const {translate} = useLocalize();
const {isOffline} = useNetwork();

const defaultFixErrorsMessage = `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`;

@aimane-chnaif aimane-chnaif Mar 18, 2026

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.

This is against our translation guideline
https://github.com/Expensify/App/blob/main/contributingGuides/philosophies/INTERNATIONALIZATION.md#--string-concatenation-should-not-be-used-for-translations

Let's not block on this since it's existing on main but should be migrated as followup.

<Text style={[styles.formError, styles.mb0]}>
{`${translate('common.please')} `}
<TextLink
style={styles.label}
onPress={onFixTheErrorsLinkPressed}
>
{translate('common.fixTheErrors')}
</TextLink>
{` ${translate('common.inTheFormBeforeContinuing')}.`}
</Text>

@melvin-bot

melvin-bot Bot commented Mar 19, 2026

Copy link
Copy Markdown

We did not find an internal engineer to review this PR, trying to assign a random engineer to #74866 as well as to this PR... Please reach out for help on Slack if no one gets assigned!

@melvin-bot melvin-bot Bot requested a review from mollfpr March 19, 2026 06:53

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5652ba6087

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/components/Form/FormProvider.tsx
Comment thread src/components/Form/FormProvider.tsx Outdated
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
@rushatgabhane

rushatgabhane commented Mar 19, 2026

Copy link
Copy Markdown
Member

@Krishna2323 please merge main. looks like there is some unrelated failing test

@mollfpr

mollfpr commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

@Krishna2323 We have a conflict, could you resolve it? Thanks!

Signed-off-by: krishna2323 <belivethatkg@gmail.com>
@mollfpr mollfpr merged commit 55c7917 into Expensify:main Mar 23, 2026
31 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🚧 @mollfpr has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/Julesssss in version: 9.3.43-3 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants