LiveAnnouncer: announce({ 'aria-labelledby' }) leaves a dangling reference when the announced element unmounts within the 7 s window
- Dominant language
- TypeScript
- Stars
- 15.9k
- Forks
- 1.6k
- Avg merge
- 3d 9m
- Merged PRs (30d)
- 59
Description
### Provide a general summary of the issue here
`react-aria-components` `Button` (1.19.0) calls `announce({ 'aria-labelledby': buttonId }, 'assertive')` from its effect when `isPending` changes while the button is focused (`dist/private/Button.mjs`). `@react-aria/live-announcer` inserts `
A submit button that unmounts on submit — a common pattern (the form is replaced by its result) — leaves the live region referencing an id that no longer exists for those seven seconds. axe-core then reports `role-img-alt` (impact: serious) on the page: *"aria-labelledby attribute references elements that do not exist or references elements that are empty"*.
### 🤔 Expected Behavior?
No dangling reference after the announced element unmounts. Suggestion: snapshot the label text at announce time (resolve `aria-labelledby` to a string when `announce()` is called) rather than inserting a node that references ids which may unmount inside the timeout window.
### 😯 Current Behavior
For up to 7 s after the button unmounts, `[data-live-announcer] [role="img"]` carries an `aria-labelledby` whose target is gone. Screen readers resolve the accessible name at insertion, so this is an **audit false positive rather than a user-facing defect** — but it forces consumers to exclude `[data-live-announcer]` from accessibility scans of any post-submit state.
### 💁 Possible Solution
Resolve the referenced element's text content inside `announce()` and set `textContent` on the inserted node, keeping `role="img"` + `aria-labelledby` only while the target is connected (or dropping that branch entirely).
### 🔦 Context
We run axe on the page after a form submit. The only violation on that state is this node; we now scope one exclusion to `[data-live-announcer]` with the mechanism documented beside it.
### 🖥️ Steps to Reproduce
Minimal (jsdom / React Testing Library):
```tsx
const { rerender, unmount, getByRole } = render(Send);
const button = getByRole("button");
act(() => button.focus());
rerender(Send);
const ghost = document.querySelector('[data-live-announcer] [role="img"][aria-labelledby]');
const target = ghost.getAttribute("aria-labelledby");
// document.getElementById(target) === button ✔
unmount();
// ghost is still in the document; document.getElementById(target) === null ✘
```
In a browser: focus a pending-capable `Button`, flip `isPending`, unmount it, run axe within 7 s.
### Version
react-aria-components 1.19.0 (react-aria `private/live-announcer/LiveAnnouncer.mjs`)
### What browsers are you seeing the problem on?
Chrome (Playwright, mobile-chromium); the behaviour is DOM-level and browser-independent.
### If other, please specify.
_No response_
### What operating system are you using?
macOS / Linux CI
We carry a unit test that reproduces exactly this sequence and will detect when it changes, so a fix here will be noticed and our scan exclusion retired on the release that ships it.
Contributor guide
Research direction
Start in @react-aria/live-announcer's private/live-announcer/LiveAnnouncer.mjs and compare the announce path with dist/private/Button.mjs. Run the unit test that reproduces focusing, changing isPending, and unmounting the Button. Done means the live-region node has no dangling reference after unmount, and the axe violation no longer occurs within the seven-second window.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- playwright, react, typescript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100