Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/components/Snackbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment on lines 207 to 213
});

Expand Down
60 changes: 60 additions & 0 deletions src/components/__tests__/Snackbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 visible onDismiss={jest.fn()} testID="snack-bar">
Snackbar content
</Snackbar>
);

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 visible={false} onDismiss={jest.fn()} testID="snack-bar">
Snackbar content
</Snackbar>
);

// 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(
Expand Down