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(