From 8f1b4438676629a55527d393e3d927fd13ccabc3 Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Mon, 17 Aug 2026 15:23:23 -0500 Subject: [PATCH 1/9] Snackbar: Restart timeout when notice is recreated --- packages/components/src/snackbar/list.tsx | 15 ++++++- .../components/src/snackbar/test/list.tsx | 39 ++++++++++++++++++- ...aboration-notification-preferences.spec.ts | 4 +- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/components/src/snackbar/list.tsx b/packages/components/src/snackbar/list.tsx index 2779eecf39dcfa..14798b03bb419b 100644 --- a/packages/components/src/snackbar/list.tsx +++ b/packages/components/src/snackbar/list.tsx @@ -56,11 +56,19 @@ export function SnackbarList( { onRemove, }: WordPressComponentProps< SnackbarListProps, 'div' > ) { const listRef = useRef< HTMLDivElement >( null ); + const noticeGenerations = useRef( new Map< string, number >() ); const isReducedMotion = useReducedMotion(); className = clsx( 'components-snackbar-list', className ); const removeNotice = - ( notice: SnackbarListProps[ 'notices' ][ number ] ) => () => + ( notice: SnackbarListProps[ 'notices' ][ number ] ) => () => { + // A notice may be recreated with the same ID before React commits its + // removal. Remount the Snackbar in that case so its timeout restarts. + noticeGenerations.current.set( + notice.id, + ( noticeGenerations.current.get( notice.id ) ?? 0 ) + 1 + ); onRemove?.( notice.id ); + }; return (
{ afterEach( () => { jest.resetAllMocks(); + jest.useRealTimers(); } ); it( 'should get focus after a snackbar is dismissed', async () => { @@ -36,4 +38,39 @@ describe( 'SnackbarList', () => { expect( screen.getByTestId( 'snackbar-list' ) ).toHaveFocus(); } ); + + it( 'should restart auto-dismissal when a notice is replaced with the same ID', async () => { + jest.useFakeTimers(); + const onRemove = jest.fn(); + const notice = { + id: 'ID_1', + content: 'A collaborator joined.', + }; + + function RecreatedNotice() { + const [ notices, setNotices ] = useState( [ notice ] ); + + return ( + { + onRemove( id ); + setNotices( [ { ...notice } ] ); + } } + /> + ); + } + + render( ); + + await act( async () => jest.advanceTimersByTime( 6000 ) ); + expect( onRemove ).toHaveBeenCalledTimes( 1 ); + + expect( screen.getByTestId( 'snackbar' ) ).toHaveTextContent( + notice.content + ); + + await act( async () => jest.advanceTimersByTime( 6000 ) ); + expect( onRemove ).toHaveBeenCalledTimes( 2 ); + } ); } ); diff --git a/test/e2e/specs/editor/collaboration/collaboration-notification-preferences.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-notification-preferences.spec.ts index 8a0e728700d593..51dafae3fc6348 100644 --- a/test/e2e/specs/editor/collaboration/collaboration-notification-preferences.spec.ts +++ b/test/e2e/specs/editor/collaboration/collaboration-notification-preferences.spec.ts @@ -52,9 +52,11 @@ test.describe( 'Collaboration - Notification preferences', () => { // Scope to the last snackbar so a stray/earlier snackbar can't // cause a Playwright strict-mode violation on this locator. - await expect( page.getByTestId( 'snackbar' ).last() ).toContainText( + const joinNotice = page.getByTestId( 'snackbar' ).last(); + await expect( joinNotice ).toContainText( `${ SECOND_USER_DISPLAY_NAME } has joined the post.`, { timeout: 10000 } ); + await expect( joinNotice ).toBeHidden( { timeout: 10000 } ); } ); } ); From e0cb0a30e592a94e755e9086dfc97f45ea18c97c Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Mon, 17 Aug 2026 15:28:33 -0500 Subject: [PATCH 2/9] Components: Add snackbar fix changelog --- packages/components/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index dc82702b1599f0..27692fda8153f0 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -36,6 +36,7 @@ - `DuotonePicker`: Stop the duotone bar offering to move its control points. It announced that arrow keys and dragging change the gradient position, but a duotone is two colors with no positions, so the move was discarded. `CustomGradientBar` gains a `disablePositioning` prop for this ([#81850](https://github.com/WordPress/gutenberg/pull/81850)). - `Modal`: Prevent an Escape key press that dismisses the modal from propagating to underlying overlays. ([#81785](https://github.com/WordPress/gutenberg/pull/81785)) - `BoxControl`: Update the opposite side when ALT is held on the left or right input, which each updated themselves instead ([#81530](https://github.com/WordPress/gutenberg/pull/81530)). +- `SnackbarList`: Restart the auto-dismiss timer when a notice is recreated with the same ID during removal ([#81764](https://github.com/WordPress/gutenberg/pull/81764)). - `InputControl`: Vertically center the value of date and time inputs in Safari ([#81361](https://github.com/WordPress/gutenberg/pull/81361)). - `InputControl`: Hide the value-like placeholder Safari renders in empty date and time inputs (e.g. `12:30`), which was indistinguishable from a committed value ([#81991](https://github.com/WordPress/gutenberg/pull/81991)). - `ControlWithError`: Re-read the target's validation message when an `invalid` event is received, so a message that changed without a re-render in between (e.g. a programmatic value change followed by a synthetic `invalid` event) is not revealed stale or empty. While a `validating` custom validity is pending, the message is left untouched so the pending indicator keeps showing ([#81440](https://github.com/WordPress/gutenberg/pull/81440)). From d09f8634e162958512468aad9c5ef8530d9955a5 Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Wed, 26 Aug 2026 11:04:46 -0500 Subject: [PATCH 3/9] Snackbar: Restart timeout without remounting --- packages/components/CHANGELOG.md | 2 +- packages/components/src/snackbar/index.tsx | 38 +++++++++++++++---- packages/components/src/snackbar/list.tsx | 15 +------- .../components/src/snackbar/test/index.tsx | 22 ++++++++++- 4 files changed, 54 insertions(+), 23 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 27692fda8153f0..50b005e59228b4 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -36,7 +36,7 @@ - `DuotonePicker`: Stop the duotone bar offering to move its control points. It announced that arrow keys and dragging change the gradient position, but a duotone is two colors with no positions, so the move was discarded. `CustomGradientBar` gains a `disablePositioning` prop for this ([#81850](https://github.com/WordPress/gutenberg/pull/81850)). - `Modal`: Prevent an Escape key press that dismisses the modal from propagating to underlying overlays. ([#81785](https://github.com/WordPress/gutenberg/pull/81785)) - `BoxControl`: Update the opposite side when ALT is held on the left or right input, which each updated themselves instead ([#81530](https://github.com/WordPress/gutenberg/pull/81530)). -- `SnackbarList`: Restart the auto-dismiss timer when a notice is recreated with the same ID during removal ([#81764](https://github.com/WordPress/gutenberg/pull/81764)). +- `Snackbar`: Restart the auto-dismiss timer when a notice is recreated with the same ID during removal ([#81764](https://github.com/WordPress/gutenberg/pull/81764)). - `InputControl`: Vertically center the value of date and time inputs in Safari ([#81361](https://github.com/WordPress/gutenberg/pull/81361)). - `InputControl`: Hide the value-like placeholder Safari renders in empty date and time inputs (e.g. `12:30`), which was indistinguishable from a committed value ([#81991](https://github.com/WordPress/gutenberg/pull/81991)). - `ControlWithError`: Re-read the target's validation message when an `invalid` event is received, so a message that changed without a re-render in between (e.g. a programmatic value change followed by a synthetic `invalid` event) is not revealed stale or empty. While a `validating` custom validity is pending, the message is left untouched so the pending indicator keeps showing ([#81440](https://github.com/WordPress/gutenberg/pull/81440)). diff --git a/packages/components/src/snackbar/index.tsx b/packages/components/src/snackbar/index.tsx index b3aa0cc4595bc9..9e8c657f6941b2 100644 --- a/packages/components/src/snackbar/index.tsx +++ b/packages/components/src/snackbar/index.tsx @@ -91,17 +91,41 @@ function UnforwardedSnackbar( callbacksRef.current = { onDismiss, onRemove }; } ); + const timeoutRef = useRef< ReturnType< typeof setTimeout > | undefined >( + undefined + ); + + // Check after every render so that a snackbar which survives its removal + // gets a fresh timeout without resetting an active timeout on rerenders. useEffect( () => { - // Only set up the timeout dismiss if we're not explicitly dismissing. - const timeoutHandle = setTimeout( () => { - if ( ! explicitDismiss ) { - callbacksRef.current.onDismiss?.(); - callbacksRef.current.onRemove?.(); + if ( explicitDismiss ) { + if ( timeoutRef.current !== undefined ) { + clearTimeout( timeoutRef.current ); + timeoutRef.current = undefined; } + return; + } + + if ( timeoutRef.current !== undefined ) { + return; + } + + timeoutRef.current = setTimeout( () => { + timeoutRef.current = undefined; + callbacksRef.current.onDismiss?.(); + callbacksRef.current.onRemove?.(); }, NOTICE_TIMEOUT ); + } ); - return () => clearTimeout( timeoutHandle ); - }, [ explicitDismiss ] ); + useEffect( + () => () => { + if ( timeoutRef.current !== undefined ) { + clearTimeout( timeoutRef.current ); + timeoutRef.current = undefined; + } + }, + [] + ); const classes = clsx( className, 'components-snackbar', { 'components-snackbar-explicit-dismiss': !! explicitDismiss, diff --git a/packages/components/src/snackbar/list.tsx b/packages/components/src/snackbar/list.tsx index 14798b03bb419b..2779eecf39dcfa 100644 --- a/packages/components/src/snackbar/list.tsx +++ b/packages/components/src/snackbar/list.tsx @@ -56,19 +56,11 @@ export function SnackbarList( { onRemove, }: WordPressComponentProps< SnackbarListProps, 'div' > ) { const listRef = useRef< HTMLDivElement >( null ); - const noticeGenerations = useRef( new Map< string, number >() ); const isReducedMotion = useReducedMotion(); className = clsx( 'components-snackbar-list', className ); const removeNotice = - ( notice: SnackbarListProps[ 'notices' ][ number ] ) => () => { - // A notice may be recreated with the same ID before React commits its - // removal. Remount the Snackbar in that case so its timeout restarts. - noticeGenerations.current.set( - notice.id, - ( noticeGenerations.current.get( notice.id ) ?? 0 ) + 1 - ); + ( notice: SnackbarListProps[ 'notices' ][ number ] ) => () => onRemove?.( notice.id ); - }; return (
{ mockedSpeak.mockReset(); } ); + afterEach( () => { + jest.useRealTimers(); + } ); + it( 'should render correctly', () => { render( Message ); @@ -44,6 +48,22 @@ describe( 'Snackbar', () => { expect( icon ).toBeVisible(); } ); + it( 'should not restart auto-dismissal after an unrelated rerender', async () => { + jest.useFakeTimers(); + const removeNotice = jest.fn(); + const { rerender } = render( + removeNotice() }>Message + ); + + await act( async () => jest.advanceTimersByTime( 5000 ) ); + rerender( + removeNotice() }>Message + ); + await act( async () => jest.advanceTimersByTime( 1000 ) ); + + expect( removeNotice ).toHaveBeenCalledTimes( 1 ); + } ); + it( 'should be dismissible by clicking the snackbar', async () => { const onRemove = jest.fn(); const onDismiss = jest.fn(); From 9d50dc6d6e89fa71354e95fedb97ce7bdeeddb2c Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Thu, 27 Aug 2026 08:40:29 -0500 Subject: [PATCH 4/9] Snackbar: Restart timeout when presence is restored --- packages/components/src/snackbar/index.tsx | 34 ++++--------------- .../components/src/snackbar/test/list.tsx | 22 ++++++++++-- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/packages/components/src/snackbar/index.tsx b/packages/components/src/snackbar/index.tsx index 9e8c657f6941b2..33d834a459cef7 100644 --- a/packages/components/src/snackbar/index.tsx +++ b/packages/components/src/snackbar/index.tsx @@ -1,5 +1,6 @@ import type { ForwardedRef, KeyboardEvent, MouseEvent } from 'react'; import clsx from 'clsx'; +import { useIsPresent } from 'framer-motion'; import { speak } from '@wordpress/a11y'; import { useEffect, @@ -57,6 +58,8 @@ function UnforwardedSnackbar( }: WordPressComponentProps< SnackbarProps, 'div' >, ref: ForwardedRef< any > ) { + const isPresent = useIsPresent(); + function dismissMe( event: KeyboardEvent | MouseEvent ) { if ( event && event.preventDefault ) { event.preventDefault(); @@ -91,41 +94,18 @@ function UnforwardedSnackbar( callbacksRef.current = { onDismiss, onRemove }; } ); - const timeoutRef = useRef< ReturnType< typeof setTimeout > | undefined >( - undefined - ); - - // Check after every render so that a snackbar which survives its removal - // gets a fresh timeout without resetting an active timeout on rerenders. useEffect( () => { - if ( explicitDismiss ) { - if ( timeoutRef.current !== undefined ) { - clearTimeout( timeoutRef.current ); - timeoutRef.current = undefined; - } - return; - } - - if ( timeoutRef.current !== undefined ) { + if ( explicitDismiss || ! isPresent ) { return; } - timeoutRef.current = setTimeout( () => { - timeoutRef.current = undefined; + const timeoutHandle = setTimeout( () => { callbacksRef.current.onDismiss?.(); callbacksRef.current.onRemove?.(); }, NOTICE_TIMEOUT ); - } ); - useEffect( - () => () => { - if ( timeoutRef.current !== undefined ) { - clearTimeout( timeoutRef.current ); - timeoutRef.current = undefined; - } - }, - [] - ); + return () => clearTimeout( timeoutHandle ); + }, [ explicitDismiss, isPresent ] ); const classes = clsx( className, 'components-snackbar', { 'components-snackbar-explicit-dismiss': !! explicitDismiss, diff --git a/packages/components/src/snackbar/test/list.tsx b/packages/components/src/snackbar/test/list.tsx index 807f0572f167f5..f18c8e6b0455f7 100644 --- a/packages/components/src/snackbar/test/list.tsx +++ b/packages/components/src/snackbar/test/list.tsx @@ -1,6 +1,6 @@ import { act, render, screen } from '@testing-library/react'; import { click } from '@ariakit/test'; -import { useState } from '@wordpress/element'; +import { useEffect, useState } from '@wordpress/element'; import SnackbarList from '../list'; window.scrollTo = jest.fn(); @@ -49,13 +49,28 @@ describe( 'SnackbarList', () => { function RecreatedNotice() { const [ notices, setNotices ] = useState( [ notice ] ); + const [ shouldRecreate, setShouldRecreate ] = useState( false ); + + useEffect( () => { + if ( ! shouldRecreate ) { + return; + } + + const timeoutHandle = setTimeout( () => { + setNotices( [ { ...notice } ] ); + setShouldRecreate( false ); + }, 50 ); + + return () => clearTimeout( timeoutHandle ); + }, [ shouldRecreate ] ); return ( { onRemove( id ); - setNotices( [ { ...notice } ] ); + setNotices( [] ); + setShouldRecreate( true ); } } /> ); @@ -66,6 +81,9 @@ describe( 'SnackbarList', () => { await act( async () => jest.advanceTimersByTime( 6000 ) ); expect( onRemove ).toHaveBeenCalledTimes( 1 ); + // Recreate the notice before its 100ms exit animation completes. + await act( async () => jest.advanceTimersByTime( 50 ) ); + expect( screen.getByTestId( 'snackbar' ) ).toHaveTextContent( notice.content ); From 749ef7bbd467d96503badbbdc11f1ff07ebd453d Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Thu, 27 Aug 2026 10:43:21 -0500 Subject: [PATCH 5/9] Snackbar: Exercise exit animation in dismissal test --- packages/components/src/snackbar/test/list.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/components/src/snackbar/test/list.tsx b/packages/components/src/snackbar/test/list.tsx index f18c8e6b0455f7..c08ec481cee798 100644 --- a/packages/components/src/snackbar/test/list.tsx +++ b/packages/components/src/snackbar/test/list.tsx @@ -3,6 +3,11 @@ import { click } from '@ariakit/test'; import { useEffect, useState } from '@wordpress/element'; import SnackbarList from '../list'; +jest.mock( '@wordpress/compose', () => ( { + ...jest.requireActual( '@wordpress/compose' ), + useReducedMotion: () => false, +} ) ); + window.scrollTo = jest.fn(); describe( 'SnackbarList', () => { From 44ed471966739c7d60fab5c9236b2605199e96f0 Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Thu, 27 Aug 2026 11:01:49 -0500 Subject: [PATCH 6/9] E2E: Track collaboration snackbar by message --- .../collaboration-notification-preferences.spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/e2e/specs/editor/collaboration/collaboration-notification-preferences.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-notification-preferences.spec.ts index 51dafae3fc6348..a3e9be07494240 100644 --- a/test/e2e/specs/editor/collaboration/collaboration-notification-preferences.spec.ts +++ b/test/e2e/specs/editor/collaboration/collaboration-notification-preferences.spec.ts @@ -50,13 +50,13 @@ test.describe( 'Collaboration - Notification preferences', () => { // the admin editor show the join notice. await collaborationUtils.openCollaborativeSession( post.id ); - // Scope to the last snackbar so a stray/earlier snackbar can't - // cause a Playwright strict-mode violation on this locator. - const joinNotice = page.getByTestId( 'snackbar' ).last(); - await expect( joinNotice ).toContainText( - `${ SECOND_USER_DISPLAY_NAME } has joined the post.`, - { timeout: 10000 } - ); + const joinNoticeText = `${ SECOND_USER_DISPLAY_NAME } has joined the post.`; + const joinNotice = page + .getByTestId( 'snackbar' ) + .filter( { hasText: joinNoticeText } ); + await expect( joinNotice ).toContainText( joinNoticeText, { + timeout: 10000, + } ); await expect( joinNotice ).toBeHidden( { timeout: 10000 } ); } ); } ); From 3873d127a6f91789774681edc9b65879d88a1b1a Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Thu, 27 Aug 2026 11:02:03 -0500 Subject: [PATCH 7/9] Components: Restore Snackbar changelog entry --- packages/components/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 5682c3a05efb21..9c6dbfe48f1f0b 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -5,6 +5,7 @@ ### Enhancements - `Popover`: Widen `offset` to also accept an object with separate main and cross axis offsets. The same applies to `BorderBoxControl`'s `popoverOffset` prop ([#82060](https://github.com/WordPress/gutenberg/pull/82060)). +- `Snackbar`: Restart the auto-dismiss timer when a notice is recreated with the same ID during removal ([#81764](https://github.com/WordPress/gutenberg/pull/81764)). ## 40.0.0 (2026-08-26) @@ -42,7 +43,6 @@ - `DuotonePicker`: Stop the duotone bar offering to move its control points. It announced that arrow keys and dragging change the gradient position, but a duotone is two colors with no positions, so the move was discarded. `CustomGradientBar` gains a `disablePositioning` prop for this ([#81850](https://github.com/WordPress/gutenberg/pull/81850)). - `Modal`: Prevent an Escape key press that dismisses the modal from propagating to underlying overlays. ([#81785](https://github.com/WordPress/gutenberg/pull/81785)) - `BoxControl`: Update the opposite side when ALT is held on the left or right input, which each updated themselves instead ([#81530](https://github.com/WordPress/gutenberg/pull/81530)). -- `Snackbar`: Restart the auto-dismiss timer when a notice is recreated with the same ID during removal ([#81764](https://github.com/WordPress/gutenberg/pull/81764)). - `InputControl`: Vertically center the value of date and time inputs in Safari ([#81361](https://github.com/WordPress/gutenberg/pull/81361)). - `InputControl`: Hide the value-like placeholder Safari renders in empty date and time inputs (e.g. `12:30`), which was indistinguishable from a committed value ([#81991](https://github.com/WordPress/gutenberg/pull/81991)). - `ControlWithError`: Re-read the target's validation message when an `invalid` event is received, so a message that changed without a re-render in between (e.g. a programmatic value change followed by a synthetic `invalid` event) is not revealed stale or empty. While a `validating` custom validity is pending, the message is left untouched so the pending indicator keeps showing ([#81440](https://github.com/WordPress/gutenberg/pull/81440)). From 8f4b9ef0e7e09b0a0ed5108a7f0403f69ee8a5af Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Thu, 27 Aug 2026 13:11:17 -0500 Subject: [PATCH 8/9] Components: Classify snackbar fix as a bug fix --- packages/components/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 9c6dbfe48f1f0b..5847e7a9bc0ff6 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -5,6 +5,9 @@ ### Enhancements - `Popover`: Widen `offset` to also accept an object with separate main and cross axis offsets. The same applies to `BorderBoxControl`'s `popoverOffset` prop ([#82060](https://github.com/WordPress/gutenberg/pull/82060)). + +### Bug Fixes + - `Snackbar`: Restart the auto-dismiss timer when a notice is recreated with the same ID during removal ([#81764](https://github.com/WordPress/gutenberg/pull/81764)). ## 40.0.0 (2026-08-26) From eb7635c2688ccc410bea241541b2613ca0a891f3 Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Thu, 27 Aug 2026 13:14:57 -0500 Subject: [PATCH 9/9] Snackbar: Test explicit dismissal timeout --- packages/components/src/snackbar/test/index.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/components/src/snackbar/test/index.tsx b/packages/components/src/snackbar/test/index.tsx index a90cbe5e997438..0af1c1e1c3b725 100644 --- a/packages/components/src/snackbar/test/index.tsx +++ b/packages/components/src/snackbar/test/index.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { click } from '@ariakit/test'; import { speak } from '@wordpress/a11y'; import { SVG, Path } from '@wordpress/primitives'; @@ -89,6 +89,7 @@ describe( 'Snackbar', () => { } ); it( 'should not be dismissible by clicking the snackbar when the `explicitDismiss` prop is set to `true`', async () => { + jest.useFakeTimers(); const onRemove = jest.fn(); const onDismiss = jest.fn(); @@ -113,7 +114,12 @@ describe( 'Snackbar', () => { 'components-snackbar-explicit-dismiss' ); - await click( snackbar ); + fireEvent.click( snackbar ); + + expect( onRemove ).not.toHaveBeenCalled(); + expect( onDismiss ).not.toHaveBeenCalled(); + + await act( async () => jest.advanceTimersByTime( 6000 ) ); expect( onRemove ).not.toHaveBeenCalled(); expect( onDismiss ).not.toHaveBeenCalled();