From 2ab12564203a3fc3947f1cb93f2a8d847ff03f07 Mon Sep 17 00:00:00 2001 From: Mohamed MAACHE Date: Tue, 30 Jun 2026 17:03:41 +0100 Subject: [PATCH 1/2] fix(Snackbar): apply hidden state even when animation reports finished:false under Fabric, #4951 Under the New Architecture (Fabric), `Animated.timing` can fire its `start` callback with `{ finished: false }` even when the hide animation completes naturally (no interruption). The previous code guarded `setHidden(true)` behind `if (finished)`, so the Snackbar stayed mounted forever on Fabric after `visible` became false. The fix removes the guard and calls `setHidden(true)` unconditionally in the animation callback, mirroring the show-path fix from PR #4447 which addressed the same class of Fabric noise for `handleOnVisible`. The sole trade-off is a mid-hide interrupt: if `visible` flips back to `true` while the hide animation is running, `setHidden(true)` fires, but the subsequent render immediately calls `setHidden(false)` via `handleOnVisible`, causing at most a one-frame visual snap, strictly better than a permanently stuck Snackbar that no state change can clear. Adds a regression test that mocks `Animated.timing` to deliver `{ finished: false }` and asserts the component unmounts cleanly. --- src/components/Snackbar.tsx | 11 ++-- src/components/__tests__/Snackbar.test.tsx | 60 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/components/Snackbar.tsx b/src/components/Snackbar.tsx index 04502eb7de..f9bab3a6fe 100644 --- a/src/components/Snackbar.tsx +++ b/src/components/Snackbar.tsx @@ -199,14 +199,17 @@ const Snackbar = ({ clearTimeout(hideTimeout.current); } + // Under the New Architecture (Fabric), the animation callback can fire + // with `finished: false` even when the animation completes naturally. + // Guarding `setHidden(true)` on `finished` causes the Snackbar to stay + // mounted after `visible` becomes false on Fabric (issue #4951). + // Mirror the show-path fix from PR #4447: call setHidden unconditionally. Animated.timing(opacity, { toValue: 0, duration: 100 * scale, useNativeDriver: true, - }).start(({ finished }) => { - if (finished) { - setHidden(true); - } + }).start(() => { + setHidden(true); }); }); diff --git a/src/components/__tests__/Snackbar.test.tsx b/src/components/__tests__/Snackbar.test.tsx index 16b118da09..d12906aa16 100644 --- a/src/components/__tests__/Snackbar.test.tsx +++ b/src/components/__tests__/Snackbar.test.tsx @@ -93,6 +93,66 @@ it('renders snackbar with View & Text as a child', async () => { expect(tree).toMatchSnapshot(); }); +// Regression test for https://github.com/callstack/react-native-paper/issues/4951 +// Under the New Architecture (Fabric), Animated.timing fires its callback with +// `finished: false` even when the animation completes naturally. The hide path +// in handleOnHidden gated `setHidden(true)` on `if (finished)`, so the Snackbar +// stayed mounted forever. The fix removes that guard (mirrors the show-path fix +// from PR #4447). +it('unmounts after visible becomes false even when the hide animation reports finished:false (Fabric new arch)', async () => { + // Arrange: mount visible Snackbar and settle the show animation. + const view = await render( + + Snackbar content + + ); + + await act(() => { + jest.advanceTimersByTime(300); // > 200 ms show animation + }); + + // Confirm the component is currently rendered. + expect(view.toJSON()).not.toBeNull(); + + // Override Animated.timing for the single hide-animation call to simulate + // Fabric's behaviour: the animation plays to completion visually but the + // callback receives { finished: false }. + jest.spyOn(Animated, 'timing').mockImplementationOnce((value, config) => ({ + start: (callback?: Animated.EndCallback) => { + setTimeout(() => { + (value as Animated.Value).setValue(config.toValue as number); + // Fabric new-arch bug: reports finished:false on a completed animation. + callback?.({ finished: false }); + }, 0); + }, + stop: () => {}, + reset: () => {}, + })); + + // Act: flip visible to false, which triggers handleOnHidden → the mocked + // Animated.timing call → schedules the callback with { finished: false }. + // rerender is async in @testing-library/react-native v14 and must be awaited + // so useLayoutEffect runs and Animated.timing is called before the spy is gone. + await view.rerender( + + Snackbar content + + ); + + // Fire the hide-animation callback, then flush the microtask queue so that + // React 19's concurrent scheduler can apply the setHidden(true) state update. + await act(async () => { + jest.advanceTimersByTime(200); + await Promise.resolve(); + }); + + jest.restoreAllMocks(); + + // Assert: setHidden(true) must have been called unconditionally so the + // component returns null and unmounts, regardless of the finished flag. + expect(view.toJSON()).toBeNull(); +}); + it('animated value changes correctly', async () => { const value = new Animated.Value(1); await render( From 0197d234c859695c5c7f994d9c96d37c2136799d Mon Sep 17 00:00:00 2001 From: Mohamed MAACHE Date: Tue, 7 Jul 2026 16:10:59 +0200 Subject: [PATCH 2/2] fix(snackbar): keep visible snackbar mounted when hide races a re-show --- src/components/Snackbar.tsx | 14 ++++++- src/components/__tests__/Snackbar.test.tsx | 46 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/components/Snackbar.tsx b/src/components/Snackbar.tsx index f9bab3a6fe..dd1fd34c21 100644 --- a/src/components/Snackbar.tsx +++ b/src/components/Snackbar.tsx @@ -162,6 +162,11 @@ const Snackbar = ({ const [hidden, setHidden] = React.useState(!visible); + // Tracks the latest `visible` value so the hide animation callback can + // check whether the Snackbar was re-shown before the animation completed. + const visibleRef = React.useRef(visible); + visibleRef.current = visible; + const { scale } = theme.animation; const animateShow = useLatestCallback(() => { @@ -203,13 +208,18 @@ const Snackbar = ({ // with `finished: false` even when the animation completes naturally. // Guarding `setHidden(true)` on `finished` causes the Snackbar to stay // mounted after `visible` becomes false on Fabric (issue #4951). - // Mirror the show-path fix from PR #4447: call setHidden unconditionally. + // Mirror the show-path fix from PR #4447: call setHidden unconditionally, + // but only if the Snackbar wasn't re-shown while the hide animation was + // still running, otherwise a race would hide a Snackbar that should be + // visible again. Animated.timing(opacity, { toValue: 0, duration: 100 * scale, useNativeDriver: true, }).start(() => { - setHidden(true); + if (!visibleRef.current) { + setHidden(true); + } }); }); diff --git a/src/components/__tests__/Snackbar.test.tsx b/src/components/__tests__/Snackbar.test.tsx index d12906aa16..a2d9f42fa9 100644 --- a/src/components/__tests__/Snackbar.test.tsx +++ b/src/components/__tests__/Snackbar.test.tsx @@ -153,6 +153,52 @@ it('unmounts after visible becomes false even when the hide animation reports fi expect(view.toJSON()).toBeNull(); }); +// Regression test for the race between the hide animation started by +// removing the `finished` guard above and a re-show that happens while that +// hide animation is still in flight. If the hide callback unconditionally +// called setHidden(true), the Snackbar would stay unmounted even though +// `visible` was flipped back to true before the animation completed, because +// the useLayoutEffect deps ([visible, ...]) don't fire again for an +// already-true `visible` value. +it('stays mounted when visible is flipped back to true before the hide animation completes', async () => { + // Arrange: mount visible Snackbar and settle the show animation. + const view = await render( + + Snackbar content + + ); + + await act(() => { + jest.advanceTimersByTime(300); // > 200 ms show animation + }); + + expect(view.toJSON()).not.toBeNull(); + + // Act: hide the Snackbar, which schedules the 100ms hide animation... + await view.rerender( + + Snackbar content + + ); + + // ...then re-show it before that hide animation has a chance to complete. + await view.rerender( + + Snackbar content + + ); + + // Let the pending hide animation's callback fire. + await act(async () => { + jest.advanceTimersByTime(200); + await Promise.resolve(); + }); + + // Assert: the Snackbar must still be rendered, the stale hide callback + // must not have hidden it after it was re-shown. + expect(view.toJSON()).not.toBeNull(); +}); + it('animated value changes correctly', async () => { const value = new Animated.Value(1); await render(