From 416a8200b4a889490a50c96db99de653a5eeda72 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Fri, 27 Feb 2026 13:46:12 +0100 Subject: [PATCH 1/4] fix: Improve live region behavior for short-lived content updates --- .../__tests__/live-region.test.tsx | 45 ++++++++++++++++++- src/live-region/controller.ts | 23 +++++++--- src/live-region/internal.tsx | 8 ++-- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/live-region/__tests__/live-region.test.tsx b/src/live-region/__tests__/live-region.test.tsx index a7637783dc..fa8144cd77 100644 --- a/src/live-region/__tests__/live-region.test.tsx +++ b/src/live-region/__tests__/live-region.test.tsx @@ -11,11 +11,12 @@ import InternalLiveRegion, { import styles from '../../../lib/components/live-region/test-classes/styles.css.js'; const renderLiveRegion = async (jsx: React.ReactElement) => { - const { container } = render(jsx); + const { container, rerender } = render(jsx); await waitFor(() => expect(document.querySelector('[aria-live]')).toBeTruthy()); jest.runAllTimers(); return { + rerender, source: container.querySelector(`.${styles.root}`), politeRegion: document.querySelector('[aria-live=polite]')!, assertiveRegion: document.querySelector('[aria-live=assertive]')!, @@ -130,6 +131,48 @@ describe('LiveRegion', () => { ref.current?.reannounce(); expect(politeRegion).toHaveTextContent('Announcement'); }); + + it('updates message after delay period', async () => { + jest.useFakeTimers(); + const { politeRegion, rerender } = await renderLiveRegion( + + ); + // renderLiveRegion automatically advances all timers + expect(politeRegion).toHaveTextContent('Announcement'); + rerender( + + ); + expect(politeRegion).toHaveTextContent('Announcement'); + jest.runAllTimers(); + expect(politeRegion).toHaveTextContent('Second announcement'); + }); + + it('re-announces the message if the content changed between debounce periods', async () => { + jest.useFakeTimers(); + const { politeRegion, rerender } = await renderLiveRegion( + + ); + expect(politeRegion).toHaveTextContent('Announcement'); + rerender( + + ); + rerender( + + ); + jest.runAllTimers(); + // Note the period to force re-announcement. + expect(politeRegion).toHaveTextContent('Announcement.'); + }); }); describe('text extractor', () => { diff --git a/src/live-region/controller.ts b/src/live-region/controller.ts index 4276cf690f..d203631b13 100644 --- a/src/live-region/controller.ts +++ b/src/live-region/controller.ts @@ -18,11 +18,12 @@ export class LiveRegionController { * During internal unit testing, you can import this and explicitly set it to * 0 to make the live region update the DOM without waiting for a timer. */ - public static defaultDelay = 2; + public static defaultDelay = 1; private _element: HTMLElement; private _timeoutId: number | undefined; private _lastAnnouncement: string | undefined; + private _contentChangedSinceLastAnnouncement = false; private _addedTerminalPeriod = false; private _nextAnnouncement = ''; @@ -48,16 +49,25 @@ export class LiveRegionController { } } - announce({ message, forceReannounce = false }: { message?: string; delay?: number; forceReannounce?: boolean }) { + announce({ message, forceReannounce = false }: { message?: string; forceReannounce?: boolean }) { if (!message) { return; } - this._nextAnnouncement = message.trim(); + const trimmedMessage = message.trim(); + // If the message before and after the throttle period is the same, we shouldn't + // announce anything. But if the component was rerendered with different content + // in the meantime, it's an indication that state changed enough that the same + // message should be reannounced. + if (trimmedMessage !== this._nextAnnouncement) { + this._contentChangedSinceLastAnnouncement = true; + } + this._nextAnnouncement = trimmedMessage; + + // If the delay is 0, just skip the timeout shenanigans and update the + // element synchronously. Great for tests. if (this.delay === 0 || forceReannounce) { - // If the delay is 0, just skip the timeout shenanigans and update the - // element synchronously. Great for tests. return this._updateElement(forceReannounce); } @@ -73,7 +83,7 @@ export class LiveRegionController { // we assign the source text content as a single node. this._element.textContent = this._nextAnnouncement; this._addedTerminalPeriod = false; - } else if (forceReannounce) { + } else if (forceReannounce || this._contentChangedSinceLastAnnouncement) { // A (generally) safe way of forcing re-announcements is toggling the // terminal period. If we only keep adding periods, it's going to be // eventually interpreted as an ellipsis. @@ -85,6 +95,7 @@ export class LiveRegionController { this._lastAnnouncement = this._nextAnnouncement; // Reset the state for the next announcement. + this._contentChangedSinceLastAnnouncement = false; this._timeoutId = undefined; } } diff --git a/src/live-region/internal.tsx b/src/live-region/internal.tsx index 90b077a229..9fc77d21a8 100644 --- a/src/live-region/internal.tsx +++ b/src/live-region/internal.tsx @@ -76,13 +76,13 @@ export default React.forwardRef(function InternalLiveRegion( // server. const liveRegionControllerRef = useRef(); useEffect(() => { - const liveRegionController = new LiveRegionController(assertive ? 'assertive' : 'polite'); + const liveRegionController = new LiveRegionController(assertive ? 'assertive' : 'polite', delay); liveRegionControllerRef.current = liveRegionController; return () => { liveRegionController.destroy(); liveRegionControllerRef.current = undefined; }; - }, [assertive]); + }, [assertive, delay]); const getContent = () => { if (sources) { @@ -96,12 +96,12 @@ export default React.forwardRef(function InternalLiveRegion( // Call the controller on every render. The controller will deduplicate the // message against the previous announcement internally. useEffect(() => { - liveRegionControllerRef.current?.announce({ message: getContent(), delay }); + liveRegionControllerRef.current?.announce({ message: getContent() }); }); useImperativeHandle(ref, () => ({ reannounce() { - liveRegionControllerRef.current?.announce({ message: getContent(), delay, forceReannounce: true }); + liveRegionControllerRef.current?.announce({ message: getContent(), forceReannounce: true }); }, })); From 244484f8ff1dc89cc9e4348555db27c8b38d3477 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 27 Feb 2026 14:08:34 +0100 Subject: [PATCH 2/4] Add demo page --- pages/button/loading-announcements.page.tsx | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 pages/button/loading-announcements.page.tsx diff --git a/pages/button/loading-announcements.page.tsx b/pages/button/loading-announcements.page.tsx new file mode 100644 index 0000000000..b72718c52f --- /dev/null +++ b/pages/button/loading-announcements.page.tsx @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useState } from 'react'; + +import { Input, LiveRegion } from '~components'; +import Button from '~components/button'; + +export default function ButtonIntegrationPage() { + const [loadingState, setLoadingState] = useState<'initial' | 'loading' | 'success'>('initial'); + const [loadingTime, setLoadingTime] = useState(2000); + const [lastReloadTime, setLastReloadTime] = useState(null); + useEffect(() => { + if (loadingState === 'loading') { + const timer = setTimeout(() => { + setLoadingState('success'); + setLastReloadTime(new Date()); + }, loadingTime); + return () => clearTimeout(timer); + } + }, [loadingState, loadingTime]); + return ( +
+

Button with loading text and result

+ setLoadingTime(parseInt(e.detail.value) || 0)} /> + Click to reload: +
+ ); +} From cb663358b16446fb18e057907984868720eea7f0 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Fri, 27 Feb 2026 16:04:01 +0100 Subject: [PATCH 3/4] Handle empty/missing messages correctly. --- pages/button/loading-announcements.page.tsx | 1 - src/live-region/controller.ts | 13 +++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pages/button/loading-announcements.page.tsx b/pages/button/loading-announcements.page.tsx index b72718c52f..ce9c878c29 100644 --- a/pages/button/loading-announcements.page.tsx +++ b/pages/button/loading-announcements.page.tsx @@ -30,7 +30,6 @@ export default function ButtonIntegrationPage() { loadingText="Loading" onClick={() => setLoadingState('loading')} /> - {loadingState === 'success' && (
diff --git a/src/live-region/controller.ts b/src/live-region/controller.ts index d203631b13..8a4e978da9 100644 --- a/src/live-region/controller.ts +++ b/src/live-region/controller.ts @@ -50,21 +50,22 @@ export class LiveRegionController { } announce({ message, forceReannounce = false }: { message?: string; forceReannounce?: boolean }) { - if (!message) { - return; - } - - const trimmedMessage = message.trim(); + const trimmedMessage = message?.trim() ?? ''; // If the message before and after the throttle period is the same, we shouldn't // announce anything. But if the component was rerendered with different content // in the meantime, it's an indication that state changed enough that the same // message should be reannounced. - if (trimmedMessage !== this._nextAnnouncement) { + if (trimmedMessage !== this._lastAnnouncement) { this._contentChangedSinceLastAnnouncement = true; } this._nextAnnouncement = trimmedMessage; + // We're done with internal state updates. If there's nothing to actually announce, bail. + if (!message) { + return; + } + // If the delay is 0, just skip the timeout shenanigans and update the // element synchronously. Great for tests. if (this.delay === 0 || forceReannounce) { From 5f1bf0411258ee0432856c5240104683d62edd8b Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Wed, 4 Mar 2026 21:38:15 +0100 Subject: [PATCH 4/4] Add label to input on test page. --- pages/button/loading-announcements.page.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pages/button/loading-announcements.page.tsx b/pages/button/loading-announcements.page.tsx index ce9c878c29..5a87ca7b23 100644 --- a/pages/button/loading-announcements.page.tsx +++ b/pages/button/loading-announcements.page.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useEffect, useState } from 'react'; -import { Input, LiveRegion } from '~components'; +import { FormField, Input, LiveRegion } from '~components'; import Button from '~components/button'; export default function ButtonIntegrationPage() { @@ -21,8 +21,14 @@ export default function ButtonIntegrationPage() { return (

Button with loading text and result

- setLoadingTime(parseInt(e.detail.value) || 0)} /> - Click to reload: + + setLoadingTime(parseInt(e.detail.value) || 0)} + /> + + Trigger reload: