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
17 changes: 15 additions & 2 deletions src/components/Snackbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -199,12 +204,20 @@ 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,
// 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(({ finished }) => {
if (finished) {
}).start(() => {
if (!visibleRef.current) {
setHidden(true);
}
});
Comment on lines 215 to 223
Comment on lines +219 to 223
Expand Down
106 changes: 106 additions & 0 deletions src/components/__tests__/Snackbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,112 @@ 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();
});

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

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

// ...then re-show it before that hide animation has a chance to complete.
await view.rerender(
<Snackbar visible onDismiss={jest.fn()} testID="snack-bar">
Snackbar content
</Snackbar>
);

// 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();
Comment on lines +197 to +199
});

it('animated value changes correctly', async () => {
const value = new Animated.Value(1);
await render(
Expand Down