Skip to content

fix: debounce list update announcements to prevent race conditions during rapid filtering#7745

Open
mustafajw07 wants to merge 5 commits intoprimer:mainfrom
mustafajw07:feature/7611-A11y
Open

fix: debounce list update announcements to prevent race conditions during rapid filtering#7745
mustafajw07 wants to merge 5 commits intoprimer:mainfrom
mustafajw07:feature/7611-A11y

Conversation

@mustafajw07
Copy link
Copy Markdown

Closes #7611

Overview

This PR fixes an accessibility issue where screen reader announcements were firing excessively due to a lack of debouncing during rapid list filtering. By wrapping the announceListUpdates logic in useAnnouncements with a setTimeout and providing a cleanup function, list announcements are now properly debounced, preventing race conditions. This ensures users using assistive technologies aren't overwhelmed with stale or conflicting announcements when typing quickly in a SelectPanel or FilteredActionList.

Changelog

New

(None)

Changed

  • Updated useAnnouncements to debounce screen reader announcements natively by wrapping the list updates in a setTimeout.
  • Added a clearTimeout cleanup function inside of announceListUpdates to clear pending timeout effects.

Removed

(None)

Rollout strategy

  • Patch release
  • Minor release
  • Major release; if selected, include a written rollout or migration plan
  • None; if selected, include a brief description as to why

Testing & Reviewing

  1. Open Storybook and go to a component that uses useAnnouncements (e.g. FilteredActionList or SelectPanel).
  2. Turn on your screen reader (e.g., VoiceOver).
  3. Type rapidly into the filter input. Verify that the screen reader correctly announces the final updated list state without queueing and announcing every intermediate state.

Merge checklist

@mustafajw07 mustafajw07 requested a review from a team as a code owner April 10, 2026 11:39
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 10, 2026

🦋 Changeset detected

Latest commit: 5fa2176

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Updates FilteredActionList/SelectPanel screen reader announcements to avoid excessive, competing list update announcements during rapid filtering by introducing debouncing in useAnnouncements.

Changes:

  • Debounces announceListUpdates in useAnnouncements using setTimeout with effect cleanup.
  • Adds a changeset for a patch release describing the accessibility fix.
Show a summary per file
File Description
packages/react/src/FilteredActionList/useAnnouncements.tsx Wrapes list update announcements in a timeout and adds cleanup to reduce rapid-fire announcements.
.changeset/use-announcements.md Patch changeset documenting the debounced list announcements behavior change.

Copilot's findings

Comments suppressed due to low confidence (3)

packages/react/src/FilteredActionList/useAnnouncements.tsx:142

  • The cleanup only clears the setTimeout, but in the active-descendant branch a requestAnimationFrame callback is scheduled after the timeout fires. If the effect re-runs/unmounts after the timeout callback executes but before the rAF runs, the stale rAF can still announce outdated state. Consider tracking the rAF id (and calling cancelAnimationFrame in cleanup) or using a cancelled flag checked in both callbacks.
          // give @primer/behaviors a moment to update active-descendant
          window.requestAnimationFrame(() => {
            const activeItem = getItemWithActiveDescendant(listContainerRef, items)
            if (!activeItem) return
            const {index, text, selected} = activeItem

            const announcementText = [
              `List updated`,
              `Focused item: ${text}`,
              `${selected ? 'selected' : 'not selected'}`,
              `${index + 1} of ${items.length}`,
            ].join(', ')

            announce(announcementText, {
              delayMs,
              from: liveRegion ? liveRegion : undefined, // announce will create a liveRegion if it doesn't find one
            })
          })
        }

packages/react/src/FilteredActionList/useAnnouncements.tsx:113

  • message is optional, but this branch interpolates message?.title/message?.description into a template string. If message is undefined, screen readers will announce "undefined. undefined" when items.length === 0 && !loading. Please guard on message being present (or provide a fallback string) before announcing.
        if (items.length === 0 && !loading) {
          announce(`${message?.title}. ${message?.description}`, {delayMs})
          return

packages/react/src/FilteredActionList/useAnnouncements.tsx:145

  • This PR introduces debouncing semantics for list update announcements, but existing tests only assert the final message after typing. Please add a test that simulates rapid filtering (e.g., multiple quick type calls or fake timers) and asserts that intermediate announcements are not emitted (only the final state is announced).
      const timeoutId = window.setTimeout(() => {
        liveRegion?.clear() // clear previous announcements

        if (items.length === 0 && !loading) {
          announce(`${message?.title}. ${message?.description}`, {delayMs})
          return
        }

        if (usingRovingTabindex) {
          const announcementText = `${items.length} item${items.length > 1 ? 's' : ''} available, ${selectedItems} selected.`

          announce(announcementText, {
            delayMs,
            from: liveRegion ? liveRegion : undefined,
          })
        } else {
          // give @primer/behaviors a moment to update active-descendant
          window.requestAnimationFrame(() => {
            const activeItem = getItemWithActiveDescendant(listContainerRef, items)
            if (!activeItem) return
            const {index, text, selected} = activeItem

            const announcementText = [
              `List updated`,
              `Focused item: ${text}`,
              `${selected ? 'selected' : 'not selected'}`,
              `${index + 1} of ${items.length}`,
            ].join(', ')

            announce(announcementText, {
              delayMs,
              from: liveRegion ? liveRegion : undefined, // announce will create a liveRegion if it doesn't find one
            })
          })
        }
      }, delayMs)

      return () => window.clearTimeout(timeoutId)
  • Files reviewed: 2/2 changed files
  • Comments generated: 1

Comment on lines +108 to +114
const timeoutId = window.setTimeout(() => {
liveRegion?.clear() // clear previous announcements

if (items.length === 0 && !loading) {
announce(`${message?.title}. ${message?.description}`, {delayMs})
return
}
if (items.length === 0 && !loading) {
announce(`${message?.title}. ${message?.description}`, {delayMs})
return
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

delayMs is already passed to announce(...) to intentionally delay announcements (see file-level comment). Using the same delayMs as the setTimeout debounce means list updates are delayed twice (timeout + announce delay), likely ~1000ms instead of the previous 500ms. Consider separating debounce timing from the announcement delay (e.g., debounceMs for the outer timeout, and keep delayMs only for announce, or vice-versa).

This issue also appears in the following locations of the same file:

  • line 108
  • line 111
  • line 124

Copilot uses AI. Check for mistakes.
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.

I think this makes sense, perhaps we should remove the delay for this function in lieu of the added debounce

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated the changes in pr, please validate

Comment on lines +108 to +114
const timeoutId = window.setTimeout(() => {
liveRegion?.clear() // clear previous announcements

if (items.length === 0 && !loading) {
announce(`${message?.title}. ${message?.description}`, {delayMs})
return
}
if (items.length === 0 && !loading) {
announce(`${message?.title}. ${message?.description}`, {delayMs})
return
}
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.

I think this makes sense, perhaps we should remove the delay for this function in lieu of the added debounce

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.

[A11y] Add debounce to screen reader announcements for SelectPanel

3 participants