adobe / adobe/react-spectrum

LiveAnnouncer: announce({ 'aria-labelledby' }) leaves a dangling reference when the announced element unmounts within the 7 s window

Open
#10,579 1 comment 0 reactions 0 assignees View on GitHub
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 `

` into its `[data-live-announcer]` log and removes it after `LIVEREGION_TIMEOUT_DELAY` (7000 ms).

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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.