Skip to content

Commit 7a2f6cb

Browse files
authored
fix: toast with autoHide: true gets stuck when panning (#574)
* Added panning control with GestureContext. Toast control fix. * Yarn v2+ files added gitignore. * Add missing GestureContext components. * Panning event start-end fix. * Return handler object reordered. * Safeguard against unmanageable child behavior. * Disable condition fix. * Return to safer use of pointerEvents. * Condition simplify. * Update some tests. * Tests revised, test support for new features. * useToast test description change.
1 parent bdf870d commit 7a2f6cb

File tree

15 files changed

+192
-42
lines changed

15 files changed

+192
-42
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ node_modules
44
.idea
55
coverage
66
lib
7+
.yarn
8+
.yarnrc.yml

src/Toast.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22

3-
import { LoggerProvider } from './contexts';
3+
import { LoggerProvider, GestureProvider } from './contexts';
44
import { ToastUI } from './ToastUI';
55
import {
66
ToastHideParams,
@@ -88,7 +88,9 @@ export function Toast(props: ToastProps): React.ReactElement {
8888

8989
return (
9090
<LoggerProvider enableLogs={false}>
91-
<ToastRoot ref={setRef} {...props} />
91+
<GestureProvider>
92+
<ToastRoot ref={setRef} {...props} />
93+
</GestureProvider>
9294
</LoggerProvider>
9395
);
9496
}

src/__helpers__/PanResponder.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@ export function mockPanResponder() {
2424
.spyOn(PanResponder, 'create')
2525
.mockImplementation(
2626
({
27+
onStartShouldSetPanResponder,
28+
onPanResponderGrant,
2729
onMoveShouldSetPanResponder,
2830
onMoveShouldSetPanResponderCapture,
2931
onPanResponderMove,
3032
onPanResponderRelease
3133
}: PanResponderCallbacks) => ({
3234
panHandlers: {
35+
onStartShouldSetResponder: onStartShouldSetPanResponder,
36+
onResponderGrant: onPanResponderGrant,
3337
onMoveShouldSetResponder: onMoveShouldSetPanResponder,
3438
onMoveShouldSetResponderCapture: onMoveShouldSetPanResponderCapture,
3539
onResponderMove: onPanResponderMove,

src/__tests__/Toast.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import { Button, Modal, Text } from 'react-native';
77
import { Toast } from '../Toast';
88

99
/*
10-
The Modal component is automatically mocked by RN and apparently contains a bug which makes the Modal
10+
The Modal component is automatically mocked by RN and apparently contains a bug which makes the Modal
1111
(and its children) to always be visible in the test tree.
1212
1313
This fixes the issue:
1414
*/
1515
jest.mock('react-native/Libraries/Modal/Modal', () => {
1616
const ActualModal = jest.requireActual('react-native/Libraries/Modal/Modal');
17-
return (props) => <ActualModal {...props} />;
17+
return (props: any) => <ActualModal {...props} />;
1818
});
1919

2020
jest.mock('react-native/Libraries/LogBox/LogBox');
@@ -84,7 +84,7 @@ describe('test Toast component', () => {
8484
// Show the Modal
8585
const showModalButton = utils.queryByText('Show modal');
8686
expect(showModalButton).toBeTruthy();
87-
fireEvent.press(showModalButton);
87+
fireEvent.press(showModalButton as any);
8888
await waitFor(() => {
8989
expect(utils.queryByText('Inside modal')).toBeTruthy();
9090
});
@@ -104,7 +104,7 @@ describe('test Toast component', () => {
104104
// Hide modal
105105
const hideModalButton = utils.queryByText('Hide modal');
106106
expect(hideModalButton).toBeTruthy();
107-
fireEvent.press(hideModalButton);
107+
fireEvent.press(hideModalButton as any);
108108
await waitFor(() => {
109109
expect(utils.queryByText('Inside modal')).toBeFalsy();
110110
});

src/__tests__/useToast.test.ts renamed to src/__tests__/useToast.test.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,30 @@
11
/* eslint-env jest */
22

33
import { act, renderHook } from '@testing-library/react-hooks';
4+
import React from 'react';
45

56
import { ToastOptions } from '../types';
67
import { DEFAULT_DATA, DEFAULT_OPTIONS, useToast } from '../useToast';
8+
import { GestureProvider } from '../contexts';
79

8-
const setup = () => {
10+
const setupGestureWrapper = (panning: boolean) => {
11+
return ({ children }: { children: React.ReactNode }) => (
12+
<GestureProvider panning={panning}>{children}</GestureProvider>
13+
);
14+
};
15+
16+
const setup = (panning = false) => {
17+
const wrapper = setupGestureWrapper(panning)
918
const utils = renderHook(() =>
10-
useToast({
11-
defaultOptions: DEFAULT_OPTIONS
12-
})
19+
useToast({ defaultOptions: DEFAULT_OPTIONS }),
20+
{ wrapper }
1321
);
1422
return {
1523
...utils
1624
};
1725
};
1826

27+
1928
describe('test useToast hook', () => {
2029
it('returns defaults', () => {
2130
const { result } = setup();
@@ -167,6 +176,29 @@ describe('test useToast hook', () => {
167176
expect(onHide).toHaveBeenCalled();
168177
});
169178

179+
it('does not hide when autoHide is true but user is panning', () => {
180+
jest.useFakeTimers();
181+
const { result } = setup(true);
182+
const onHide = jest.fn();
183+
act(() => {
184+
result.current.show({
185+
text1: 'test',
186+
autoHide: true,
187+
onHide
188+
});
189+
});
190+
191+
expect(result.current.isVisible).toBe(true);
192+
193+
act(() => {
194+
jest.runAllTimers();
195+
});
196+
197+
expect(result.current.isVisible).toBe(true);
198+
expect(onHide).not.toHaveBeenCalled();
199+
});
200+
201+
170202
it('shows using only text2', () => {
171203
const { result } = setup();
172204

src/components/AnimatedContainer.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import { Animated, Dimensions, PanResponderGestureState } from 'react-native';
33

4-
import { useLogger } from '../contexts';
4+
import { useLogger, useGesture } from '../contexts';
55
import {
66
usePanResponder,
77
useSlideAnimation,
@@ -81,6 +81,7 @@ export function AnimatedContainer({
8181
swipeable
8282
}: AnimatedContainerProps) {
8383
const { log } = useLogger();
84+
const { panning } = useGesture();
8485

8586
const { computeViewDimensions, height } = useViewDimensions();
8687

@@ -93,6 +94,18 @@ export function AnimatedContainer({
9394
avoidKeyboard
9495
});
9596

97+
const disable = !swipeable || !isVisible;
98+
99+
const onStart = React.useCallback(() => {
100+
log('Swipe, pan start');
101+
panning.current = true;
102+
}, [log, panning]);
103+
104+
const onEnd = React.useCallback(() => {
105+
log('Swipe, pan end');
106+
panning.current = false;
107+
}, [log, panning]);
108+
96109
const onDismiss = React.useCallback(() => {
97110
log('Swipe, dismissing');
98111
animate(0);
@@ -118,7 +131,9 @@ export function AnimatedContainer({
118131
computeNewAnimatedValueForGesture,
119132
onDismiss,
120133
onRestore,
121-
disable: !swipeable
134+
onStart,
135+
onEnd,
136+
disable,
122137
});
123138

124139
React.useLayoutEffect(() => {
@@ -133,7 +148,7 @@ export function AnimatedContainer({
133148
style={[styles.base, styles[position], animationStyles]}
134149
// This container View is never the target of touch events but its subviews can be.
135150
// By doing this, tapping buttons behind the Toast is allowed
136-
pointerEvents={isVisible ? 'box-none' : 'none'}
151+
pointerEvents='box-none'
137152
{...panResponder.panHandlers}>
138153
{children}
139154
</Animated.View>

src/components/__tests__/AnimatedContainer.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const setup = (props?: Omit<Partial<AnimatedContainerProps>, 'children'>) => {
3030
topOffset: 40,
3131
bottomOffset: 40,
3232
keyboardOffset: 10,
33+
avoidKeyboard: true,
3334
onHide
3435
};
3536

@@ -95,6 +96,7 @@ describe('test AnimatedContainer component', () => {
9596
moveY: 100,
9697
dy: 10
9798
};
99+
panHandler?.props.onResponderGrant();
98100
panHandler?.props.onResponderMove(undefined, gesture);
99101
panHandler?.props.onResponderRelease(undefined, gesture);
100102
expect(onRestorePosition).toHaveBeenCalled();
@@ -119,6 +121,7 @@ describe('test AnimatedContainer component', () => {
119121
moveY: 5,
120122
dy: -78
121123
};
124+
panHandler?.props.onResponderGrant();
122125
panHandler?.props.onResponderMove(undefined, gesture);
123126
panHandler?.props.onResponderRelease(undefined, gesture);
124127
expect(onHide).toHaveBeenCalled();

src/contexts/GestureContext.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React from 'react';
2+
3+
import { ReactChildren } from '../types';
4+
5+
export type GestureContextType = {
6+
panning: React.MutableRefObject<boolean>;
7+
};
8+
9+
export type GestureProviderProps = {
10+
children: ReactChildren;
11+
panning?: boolean;
12+
};
13+
14+
const GestureContext = React.createContext<GestureContextType>({
15+
panning: { current: false }
16+
});
17+
18+
function GestureProvider({ children, panning = false }: GestureProviderProps) {
19+
const panningRef = React.useRef(panning);
20+
const value = { panning: panningRef };
21+
return (
22+
<GestureContext.Provider value={value}>{children}</GestureContext.Provider>
23+
);
24+
}
25+
26+
function useGesture() {
27+
const ctx = React.useContext(GestureContext);
28+
return ctx;
29+
}
30+
31+
export { GestureProvider, useGesture };
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* eslint-env jest */
2+
3+
import { renderHook, act } from '@testing-library/react-hooks';
4+
import React from 'react';
5+
6+
import { ReactChildren } from '../../types';
7+
import { GestureProvider, useGesture } from '../GestureContext';
8+
import { GestureProviderProps } from '..';
9+
10+
const setup = (props?: Omit<GestureProviderProps, 'children'>) => {
11+
const wrapper = ({ children }: { children: ReactChildren }) => (
12+
<GestureProvider {...props}>{children}</GestureProvider>
13+
);
14+
const utils = renderHook(() => useGesture(), { wrapper });
15+
return { ...utils };
16+
};
17+
18+
describe('GestureContext', () => {
19+
it('provides a panning ref with current defaulting to false', () => {
20+
const { result } = setup();
21+
expect(result.current.panning).toBeDefined();
22+
expect(result.current.panning.current).toBe(false);
23+
});
24+
25+
it('allows updating the panning ref value', () => {
26+
const { result } = setup();
27+
act(() => {
28+
result.current.panning.current = true
29+
});
30+
expect(result.current.panning.current).toBe(true);
31+
});
32+
});

src/contexts/__tests__/LoggerContext.test.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,8 @@ const setup = (props?: Omit<LoggerProviderProps, 'children'>) => {
1111
const wrapper = ({ children }: { children: ReactChildren }) => (
1212
<LoggerProvider {...props}>{children}</LoggerProvider>
1313
);
14-
const utils = renderHook(useLogger, {
15-
wrapper
16-
});
17-
return {
18-
...utils
19-
};
14+
const utils = renderHook(useLogger, { wrapper });
15+
return { ...utils };
2016
};
2117

2218
describe('test Logger context', () => {

0 commit comments

Comments
 (0)