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(