@granite-js/react-native 1.0.30 → 1.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/app/AppRoot.d.ts +1 -1
  3. package/dist/app/Granite.d.ts +8 -1
  4. package/dist/intersection-observer/IntersectionObserver.d.ts +8 -4
  5. package/dist/intersection-observer/withIO.d.ts +2 -2
  6. package/dist/router/Router.d.ts +1 -1
  7. package/dist/router/components/CanGoBackGuard.d.ts +13 -5
  8. package/dist/router/components/useRouterBackHandler.d.ts +4 -1
  9. package/dist/use-back-event/BackEventContext.d.ts +15 -0
  10. package/dist/use-back-event/index.d.ts +1 -0
  11. package/dist/use-back-event/useBackEvent.d.ts +8 -9
  12. package/dist/use-back-event/useBackEventActionsContext.d.ts +1 -0
  13. package/dist/use-back-event/useBackEventContext.d.ts +2 -0
  14. package/dist/use-back-event/useBackHandler.d.ts +53 -0
  15. package/package.json +12 -11
  16. package/src/app/AppRoot.tsx +3 -0
  17. package/src/app/Granite.tsx +8 -1
  18. package/src/intersection-observer/IntersectionObserver.ts +106 -96
  19. package/src/intersection-observer/withIO.tsx +7 -2
  20. package/src/router/Router.tsx +6 -2
  21. package/src/router/components/CanGoBackGuard.spec.tsx +85 -0
  22. package/src/router/components/CanGoBackGuard.tsx +37 -14
  23. package/src/router/components/useRouterBackHandler.spec.tsx +129 -0
  24. package/src/router/components/useRouterBackHandler.tsx +34 -13
  25. package/src/use-back-event/BackEventContext.ts +26 -0
  26. package/src/use-back-event/index.ts +1 -0
  27. package/src/use-back-event/useBackEvent.spec.tsx +107 -0
  28. package/src/use-back-event/useBackEvent.tsx +138 -48
  29. package/src/use-back-event/useBackEventActionsContext.ts +11 -0
  30. package/src/use-back-event/useBackEventContext.ts +28 -0
  31. package/src/use-back-event/useBackHandler.spec.tsx +113 -0
  32. package/src/use-back-event/useBackHandler.tsx +114 -0
@@ -65,6 +65,7 @@ export interface InternalRouterProps {
65
65
  initialProps: InitialProps;
66
66
  initialScheme: string;
67
67
  setIosSwipeGestureEnabled?: ({ isEnabled }: { isEnabled: boolean }) => Promise<void> | void;
68
+ setiOSBackPressHandler?: ({ handler }: { handler: () => void }) => Promise<void> | void;
68
69
  getInitialUrl?: RouterControlsConfig['getInitialUrl'];
69
70
  }
70
71
 
@@ -148,6 +149,7 @@ export function Router({
148
149
  defaultErrorComponent,
149
150
  // Public props (StackNavigator)
150
151
  setIosSwipeGestureEnabled,
152
+ setiOSBackPressHandler,
151
153
  getInitialUrl,
152
154
  ...navigationContainerProps
153
155
  }: InternalRouterProps & RouterProps): ReactElement {
@@ -162,7 +164,7 @@ export function Router({
162
164
 
163
165
  const ref = useMemo(() => navigationContainerRef ?? createNavigationContainerRef<never>(), [navigationContainerRef]);
164
166
 
165
- const { handler, canGoBack, onBack } = useInternalRouterBackHandler({
167
+ const { handler, handleBackEvent, canGoBack, hasBackEvent } = useInternalRouterBackHandler({
166
168
  navigationContainerRef: ref,
167
169
  onClose: closeView,
168
170
  });
@@ -195,9 +197,11 @@ export function Router({
195
197
  >
196
198
  <CanGoBackGuard
197
199
  canGoBack={canGoBack}
200
+ hasBackEvent={hasBackEvent}
198
201
  isInitialScreen={isInitialScreen}
199
- onBack={onBack}
202
+ onBack={handleBackEvent}
200
203
  setIosSwipeGestureEnabled={setIosSwipeGestureEnabled}
204
+ setiOSBackPressHandler={setiOSBackPressHandler}
201
205
  >
202
206
  <Container {...initialProps}>
203
207
  <StackNavigator.Navigator screenOptions={screenOptions}>{Screens}</StackNavigator.Navigator>
@@ -0,0 +1,85 @@
1
+ import { render } from '@testing-library/react';
2
+ import { BackHandler } from 'react-native';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { CanGoBackGuard } from './CanGoBackGuard';
5
+
6
+ describe('CanGoBackGuard', () => {
7
+ beforeEach(() => {
8
+ vi.mocked(BackHandler.addEventListener).mockClear();
9
+ });
10
+
11
+ it('passes Android hardware back events to onBack and consumes the native event', () => {
12
+ const onBack = vi.fn();
13
+
14
+ render(
15
+ <CanGoBackGuard canGoBack={false} hasBackEvent={true} isInitialScreen={true} onBack={onBack}>
16
+ <div />
17
+ </CanGoBackGuard>
18
+ );
19
+
20
+ const handler = vi.mocked(BackHandler.addEventListener).mock.calls[0]?.[1];
21
+
22
+ expect(BackHandler.addEventListener).toHaveBeenCalledWith('hardwareBackPress', expect.any(Function));
23
+ expect(handler?.()).toBe(true);
24
+ expect(onBack).toHaveBeenCalledWith({ source: 'androidHardwareBackPress' });
25
+ });
26
+
27
+ it('does not register Android hardware back when no back event exists', () => {
28
+ const onBack = vi.fn();
29
+
30
+ render(
31
+ <CanGoBackGuard canGoBack={true} hasBackEvent={false} isInitialScreen={true} onBack={onBack}>
32
+ <div />
33
+ </CanGoBackGuard>
34
+ );
35
+
36
+ expect(BackHandler.addEventListener).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it('registers the iOS swipe back handler while back events exist', () => {
40
+ const onBack = vi.fn();
41
+ const setiOSBackPressHandler = vi.fn();
42
+
43
+ const { unmount } = render(
44
+ <CanGoBackGuard
45
+ canGoBack={true}
46
+ hasBackEvent={true}
47
+ isInitialScreen={true}
48
+ onBack={onBack}
49
+ setiOSBackPressHandler={setiOSBackPressHandler}
50
+ >
51
+ <div />
52
+ </CanGoBackGuard>
53
+ );
54
+
55
+ setiOSBackPressHandler.mock.calls[0]?.[0].handler();
56
+
57
+ expect(setiOSBackPressHandler).toHaveBeenCalledWith({ handler: expect.any(Function) });
58
+ expect(onBack).toHaveBeenCalledWith({ source: 'iosSwipeGesture' });
59
+
60
+ unmount();
61
+
62
+ expect(setiOSBackPressHandler).toHaveBeenLastCalledWith({ handler: expect.any(Function) });
63
+ });
64
+
65
+ it('disables iOS swipe when the current state should block default back navigation', () => {
66
+ const setIosSwipeGestureEnabled = vi.fn();
67
+
68
+ const { unmount } = render(
69
+ <CanGoBackGuard
70
+ canGoBack={false}
71
+ hasBackEvent={true}
72
+ isInitialScreen={true}
73
+ setIosSwipeGestureEnabled={setIosSwipeGestureEnabled}
74
+ >
75
+ <div />
76
+ </CanGoBackGuard>
77
+ );
78
+
79
+ expect(setIosSwipeGestureEnabled).toHaveBeenCalledWith({ isEnabled: false });
80
+
81
+ unmount();
82
+
83
+ expect(setIosSwipeGestureEnabled).toHaveBeenLastCalledWith({ isEnabled: true });
84
+ });
85
+ });
@@ -1,21 +1,27 @@
1
1
  import { ReactNode, useEffect } from 'react';
2
2
  import { BackHandler } from 'react-native';
3
+ import type { BackEvent } from '../../use-back-event';
4
+
5
+ type SetIosSwipeGestureEnabled = ({ isEnabled }: { isEnabled: boolean }) => Promise<void> | void;
6
+ type SetIOSBackPressHandler = ({ handler }: { handler: () => void }) => Promise<void> | void;
3
7
 
4
8
  export function CanGoBackGuard({
5
9
  children,
6
10
  canGoBack,
11
+ hasBackEvent,
7
12
  onBack,
8
13
  isInitialScreen,
9
14
  setIosSwipeGestureEnabled,
15
+ setiOSBackPressHandler,
10
16
  }: {
11
17
  canGoBack: boolean;
18
+ hasBackEvent: boolean;
12
19
  isInitialScreen: boolean;
13
20
  children: ReactNode;
14
- onBack?: () => void;
15
- setIosSwipeGestureEnabled?: ({ isEnabled }: { isEnabled: boolean }) => void;
21
+ onBack?: (event: BackEvent) => void;
22
+ setIosSwipeGestureEnabled?: SetIosSwipeGestureEnabled;
23
+ setiOSBackPressHandler?: SetIOSBackPressHandler;
16
24
  }) {
17
- const shouldBlockGoingBack = !canGoBack;
18
-
19
25
  useEffect(() => {
20
26
  if (!isInitialScreen || !canGoBack) {
21
27
  setIosSwipeGestureEnabled?.({ isEnabled: false });
@@ -29,19 +35,36 @@ export function CanGoBackGuard({
29
35
  }, [canGoBack, isInitialScreen, setIosSwipeGestureEnabled]);
30
36
 
31
37
  useEffect(() => {
32
- if (shouldBlockGoingBack) {
33
- const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
34
- onBack?.();
35
- return true;
36
- });
38
+ if (!hasBackEvent) {
39
+ return;
40
+ }
37
41
 
38
- return () => {
39
- subscription.remove();
40
- };
42
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
43
+ onBack?.({ source: 'androidHardwareBackPress' });
44
+
45
+ return true;
46
+ });
47
+
48
+ return () => {
49
+ subscription.remove();
50
+ };
51
+ }, [hasBackEvent, onBack]);
52
+
53
+ useEffect(() => {
54
+ if (!hasBackEvent || setiOSBackPressHandler == null) {
55
+ return;
41
56
  }
42
57
 
43
- return;
44
- }, [shouldBlockGoingBack, onBack]);
58
+ setiOSBackPressHandler({
59
+ handler: () => {
60
+ onBack?.({ source: 'iosSwipeGesture' });
61
+ },
62
+ });
63
+
64
+ return () => {
65
+ setiOSBackPressHandler({ handler: () => {} });
66
+ };
67
+ }, [hasBackEvent, onBack, setiOSBackPressHandler]);
45
68
 
46
69
  return <>{children}</>;
47
70
  }
@@ -0,0 +1,129 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { ReactNode } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { useInternalRouterBackHandler } from './useRouterBackHandler';
5
+ import { BackEventProvider, useBackEvent, useBackHandler } from '../../use-back-event';
6
+
7
+ vi.mock('../../visibility', () => ({
8
+ useVisibility: () => true,
9
+ }));
10
+
11
+ function createNavigationContainerRef() {
12
+ return {
13
+ canGoBack: vi.fn(() => true),
14
+ goBack: vi.fn(),
15
+ };
16
+ }
17
+
18
+ function BackEventProviderWrapper({ children }: { children: ReactNode }) {
19
+ return <BackEventProvider>{children}</BackEventProvider>;
20
+ }
21
+
22
+ function useBackHandlerTest({
23
+ navigationContainerRef,
24
+ onClose,
25
+ }: {
26
+ navigationContainerRef: ReturnType<typeof createNavigationContainerRef>;
27
+ onClose?: () => void;
28
+ }) {
29
+ const backEvent = useBackEvent();
30
+ const backHandler = useBackHandler();
31
+ const routerBackHandler = useInternalRouterBackHandler({
32
+ navigationContainerRef: navigationContainerRef as any,
33
+ onClose,
34
+ });
35
+
36
+ return {
37
+ backEvent,
38
+ backHandler,
39
+ routerBackHandler,
40
+ };
41
+ }
42
+
43
+ describe('useInternalRouterBackHandler', () => {
44
+ it('stops when a back handler handles the back event', () => {
45
+ const navigationContainerRef = createNavigationContainerRef();
46
+ const legacyHandler = vi.fn();
47
+ const backHandler = vi.fn(() => true);
48
+ const { result } = renderHook(() => useBackHandlerTest({ navigationContainerRef }), {
49
+ wrapper: BackEventProviderWrapper,
50
+ });
51
+
52
+ act(() => {
53
+ result.current.backEvent.addEventListener(legacyHandler);
54
+ result.current.backHandler.addEventListener(backHandler);
55
+ });
56
+
57
+ act(() => {
58
+ result.current.routerBackHandler.handleBackEvent({ source: 'backButton' });
59
+ });
60
+
61
+ expect(backHandler).toHaveBeenCalledWith({ source: 'backButton' });
62
+ expect(legacyHandler).not.toHaveBeenCalled();
63
+ expect(navigationContainerRef.goBack).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it('runs every legacy handler when back handlers do not handle the event', () => {
67
+ const navigationContainerRef = createNavigationContainerRef();
68
+ const backHandler = vi.fn(() => false);
69
+ const firstLegacyHandler = vi.fn();
70
+ const secondLegacyHandler = vi.fn();
71
+ const { result } = renderHook(() => useBackHandlerTest({ navigationContainerRef }), {
72
+ wrapper: BackEventProviderWrapper,
73
+ });
74
+
75
+ act(() => {
76
+ result.current.backHandler.addEventListener(backHandler);
77
+ result.current.backEvent.addEventListener(firstLegacyHandler, secondLegacyHandler);
78
+ });
79
+
80
+ act(() => {
81
+ result.current.routerBackHandler.handleBackEvent({ source: 'iosSwipeGesture' });
82
+ });
83
+
84
+ expect(backHandler).toHaveBeenCalledWith({ source: 'iosSwipeGesture' });
85
+ expect(firstLegacyHandler).toHaveBeenCalledTimes(1);
86
+ expect(secondLegacyHandler).toHaveBeenCalledTimes(1);
87
+ expect(navigationContainerRef.goBack).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('runs default back when back handlers return false or undefined and no legacy handler exists', () => {
91
+ const navigationContainerRef = createNavigationContainerRef();
92
+ const backHandler = vi.fn(() => false);
93
+ const passiveHandler = vi.fn();
94
+ const { result } = renderHook(() => useBackHandlerTest({ navigationContainerRef }), {
95
+ wrapper: BackEventProviderWrapper,
96
+ });
97
+
98
+ act(() => {
99
+ result.current.backHandler.addEventListener(backHandler);
100
+ result.current.backHandler.addEventListener(passiveHandler);
101
+ });
102
+
103
+ act(() => {
104
+ result.current.routerBackHandler.handleBackEvent({ source: 'androidHardwareBackPress' });
105
+ });
106
+
107
+ expect(passiveHandler).toHaveBeenCalledWith({ source: 'androidHardwareBackPress' });
108
+ expect(backHandler).toHaveBeenCalledWith({ source: 'androidHardwareBackPress' });
109
+ expect(navigationContainerRef.goBack).toHaveBeenCalledTimes(1);
110
+ });
111
+
112
+ it('uses backButton as the source for the header back handler', () => {
113
+ const navigationContainerRef = createNavigationContainerRef();
114
+ const backHandler = vi.fn(() => true);
115
+ const { result } = renderHook(() => useBackHandlerTest({ navigationContainerRef }), {
116
+ wrapper: BackEventProviderWrapper,
117
+ });
118
+
119
+ act(() => {
120
+ result.current.backHandler.addEventListener(backHandler);
121
+ });
122
+
123
+ act(() => {
124
+ result.current.routerBackHandler.handler();
125
+ });
126
+
127
+ expect(backHandler).toHaveBeenCalledWith({ source: 'backButton' });
128
+ });
129
+ });
@@ -1,6 +1,7 @@
1
- import { NavigationContainerRefWithCurrent } from '@granite-js/native/@react-navigation/native';
1
+ import type { NavigationContainerRefWithCurrent } from '@granite-js/native/@react-navigation/native';
2
2
  import { useCallback, useMemo } from 'react';
3
- import { useBackEventContext } from '../../use-back-event';
3
+ import type { BackEvent } from '../../use-back-event';
4
+ import { useBackEventContext } from '../../use-back-event/useBackEventContext';
4
5
 
5
6
  /**
6
7
  * @public
@@ -63,29 +64,49 @@ export function useInternalRouterBackHandler({
63
64
  navigationContainerRef: NavigationContainerRefWithCurrent<never>;
64
65
  onClose?: () => void;
65
66
  }) {
66
- const { hasBackEvent, onBack } = useBackEventContext();
67
- const canGoBack = !hasBackEvent;
68
-
69
- const handler = useCallback(() => {
70
- onBack?.();
71
-
72
- if (!canGoBack) {
73
- return;
74
- }
67
+ const { hasBackEvent, hasBackHandler, onBack, onBackHandler } = useBackEventContext();
68
+ const hasRegisteredBackHandler = hasBackEvent || hasBackHandler;
69
+ const canGoBack = !hasRegisteredBackHandler;
75
70
 
71
+ const handleDefaultBack = useCallback(() => {
76
72
  if (navigationContainerRef.canGoBack()) {
77
73
  navigationContainerRef.goBack();
78
74
  } else {
79
75
  onClose?.();
80
76
  }
81
- }, [canGoBack, onClose, navigationContainerRef, onBack]);
77
+ }, [navigationContainerRef, onClose]);
78
+
79
+ const handleBackEvent = useCallback(
80
+ (event: BackEvent) => {
81
+ const didHandleBackEvent = onBackHandler(event);
82
+
83
+ if (didHandleBackEvent) {
84
+ return;
85
+ }
86
+
87
+ onBack();
88
+
89
+ if (hasBackEvent) {
90
+ return;
91
+ }
92
+
93
+ handleDefaultBack();
94
+ },
95
+ [handleDefaultBack, hasBackEvent, onBack, onBackHandler]
96
+ );
97
+
98
+ const handler = useCallback(() => {
99
+ handleBackEvent({ source: 'backButton' });
100
+ }, [handleBackEvent]);
82
101
 
83
102
  return useMemo(
84
103
  () => ({
85
104
  handler,
105
+ handleBackEvent,
86
106
  canGoBack,
107
+ hasBackEvent: hasRegisteredBackHandler,
87
108
  onBack,
88
109
  }),
89
- [canGoBack, handler, onBack]
110
+ [canGoBack, handleBackEvent, handler, hasRegisteredBackHandler, onBack]
90
111
  );
91
112
  }
@@ -0,0 +1,26 @@
1
+ import { createContext } from 'react';
2
+ import type { BackEvent, BackEventControls, BackEventHandler } from './useBackEvent';
3
+ import type { BackHandlerCallback, BackHandlerControls } from './useBackHandler';
4
+
5
+ export interface PrivateBackEventControls extends BackEventControls {
6
+ handlersRef: Set<BackEventHandler>;
7
+ hasBackEvent: boolean;
8
+ hasBackHandler: boolean;
9
+ addBackHandler: BackHandlerControls['addEventListener'];
10
+ removeBackHandler: (...handlers: Array<BackHandlerCallback>) => void;
11
+ onBack: () => void;
12
+ onBackHandler: (event: BackEvent) => boolean;
13
+ }
14
+
15
+ export type BackEventActionsContextValue = Omit<
16
+ PrivateBackEventControls,
17
+ 'handlersRef' | 'hasBackEvent' | 'hasBackHandler'
18
+ >;
19
+
20
+ export type BackEventStateContextValue = Pick<
21
+ PrivateBackEventControls,
22
+ 'handlersRef' | 'hasBackEvent' | 'hasBackHandler'
23
+ >;
24
+
25
+ export const BackEventActionsContext = createContext<BackEventActionsContextValue | null>(null);
26
+ export const BackEventStateContext = createContext<BackEventStateContextValue | null>(null);
@@ -1 +1,2 @@
1
1
  export * from './useBackEvent';
2
+ export * from './useBackHandler';
@@ -0,0 +1,107 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { ReactNode } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { BackEventProvider, useBackEvent, useBackEventState } from './useBackEvent';
5
+
6
+ vi.mock('../visibility', () => ({
7
+ useVisibility: () => true,
8
+ }));
9
+
10
+ function BackEventProviderWrapper({ children }: { children: ReactNode }) {
11
+ return <BackEventProvider>{children}</BackEventProvider>;
12
+ }
13
+
14
+ describe('useBackEventState', () => {
15
+ it('keeps legacy back event handlers variadic and runs every handler', () => {
16
+ const firstHandler = vi.fn();
17
+ const secondHandler = vi.fn();
18
+ const { result } = renderHook(() => useBackEventState());
19
+
20
+ act(() => {
21
+ result.current.addEventListener(firstHandler, secondHandler);
22
+ });
23
+
24
+ result.current.onBack();
25
+
26
+ expect(result.current.hasBackEvent).toBe(true);
27
+ expect(firstHandler).toHaveBeenCalledTimes(1);
28
+ expect(secondHandler).toHaveBeenCalledTimes(1);
29
+ });
30
+
31
+ it('ignores legacy back event handler return values', () => {
32
+ const handler = vi.fn(() => false);
33
+ const { result } = renderHook(() => useBackEventState());
34
+
35
+ act(() => {
36
+ result.current.addEventListener(handler);
37
+ });
38
+
39
+ result.current.onBack();
40
+
41
+ expect(result.current.hasBackEvent).toBe(true);
42
+ expect(handler).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it('derives legacy back event state from registered handlers', () => {
46
+ const handler = vi.fn();
47
+ const { result } = renderHook(() => useBackEventState());
48
+
49
+ act(() => {
50
+ result.current.addEventListener(handler);
51
+ });
52
+
53
+ expect(result.current.hasBackEvent).toBe(true);
54
+
55
+ act(() => {
56
+ result.current.removeEventListener(handler);
57
+ });
58
+
59
+ result.current.onBack();
60
+
61
+ expect(result.current.hasBackEvent).toBe(false);
62
+ expect(handler).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it('keeps the legacy state object stable while back event existence does not change', () => {
66
+ const firstHandler = vi.fn();
67
+ const secondHandler = vi.fn();
68
+ const { result } = renderHook(() => useBackEventState());
69
+
70
+ act(() => {
71
+ result.current.addEventListener(firstHandler);
72
+ });
73
+
74
+ const activeBackEvent = result.current;
75
+
76
+ act(() => {
77
+ result.current.addEventListener(secondHandler);
78
+ });
79
+
80
+ expect(result.current).toBe(activeBackEvent);
81
+ });
82
+ });
83
+
84
+ describe('useBackEvent', () => {
85
+ it('keeps the returned back event controls stable when provider state changes', () => {
86
+ const handler = vi.fn();
87
+ const renderCount = vi.fn();
88
+ const { result } = renderHook(
89
+ () => {
90
+ renderCount();
91
+
92
+ return useBackEvent();
93
+ },
94
+ {
95
+ wrapper: BackEventProviderWrapper,
96
+ }
97
+ );
98
+ const backEvent = result.current;
99
+
100
+ act(() => {
101
+ result.current.addEventListener(handler);
102
+ });
103
+
104
+ expect(result.current).toBe(backEvent);
105
+ expect(renderCount).toHaveBeenCalledTimes(1);
106
+ });
107
+ });