@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
@@ -1,18 +1,27 @@
1
- import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
1
+ import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { useVisibility } from '../visibility';
3
+ import {
4
+ BackEventActionsContext,
5
+ BackEventStateContext,
6
+ type BackEventActionsContextValue,
7
+ type BackEventStateContextValue,
8
+ type PrivateBackEventControls,
9
+ } from './BackEventContext';
10
+ import { useBackEventActionsContext } from './useBackEventActionsContext';
11
+ import type { BackHandlerCallback } from './useBackHandler';
3
12
 
4
- export interface BackEventControls {
5
- addEventListener: (...handlers: Array<() => void>) => void;
6
- removeEventListener: (...handlers: Array<() => void>) => void;
7
- }
13
+ export type BackEventSource = 'backButton' | 'iosSwipeGesture' | 'androidHardwareBackPress';
8
14
 
9
- interface PrivateBackEventControls extends BackEventControls {
10
- handlersRef: Set<() => void>;
11
- hasBackEvent: boolean;
12
- onBack: () => void;
15
+ export interface BackEvent {
16
+ source: BackEventSource;
13
17
  }
14
18
 
15
- const BackEventContext = createContext<PrivateBackEventControls | null>(null);
19
+ export type BackEventHandler = () => void;
20
+
21
+ export interface BackEventControls {
22
+ addEventListener: (...handlers: Array<BackEventHandler>) => void;
23
+ removeEventListener: (...handlers: Array<BackEventHandler>) => void;
24
+ }
16
25
 
17
26
  /**
18
27
  * @component
@@ -41,7 +50,37 @@ const BackEventContext = createContext<PrivateBackEventControls | null>(null);
41
50
  export function BackEventProvider({ children }: { children: ReactNode }) {
42
51
  const backEventState = useBackEventState();
43
52
 
44
- return <BackEventContext.Provider value={backEventState}>{children}</BackEventContext.Provider>;
53
+ const actions = useMemo((): BackEventActionsContextValue => {
54
+ return {
55
+ addEventListener: backEventState.addEventListener,
56
+ removeEventListener: backEventState.removeEventListener,
57
+ addBackHandler: backEventState.addBackHandler,
58
+ removeBackHandler: backEventState.removeBackHandler,
59
+ onBack: backEventState.onBack,
60
+ onBackHandler: backEventState.onBackHandler,
61
+ };
62
+ }, [
63
+ backEventState.addBackHandler,
64
+ backEventState.addEventListener,
65
+ backEventState.onBack,
66
+ backEventState.onBackHandler,
67
+ backEventState.removeBackHandler,
68
+ backEventState.removeEventListener,
69
+ ]);
70
+
71
+ const state = useMemo((): BackEventStateContextValue => {
72
+ return {
73
+ handlersRef: backEventState.handlersRef,
74
+ hasBackEvent: backEventState.hasBackEvent,
75
+ hasBackHandler: backEventState.hasBackHandler,
76
+ };
77
+ }, [backEventState.handlersRef, backEventState.hasBackEvent, backEventState.hasBackHandler]);
78
+
79
+ return (
80
+ <BackEventActionsContext.Provider value={actions}>
81
+ <BackEventStateContext.Provider value={state}>{children}</BackEventStateContext.Provider>
82
+ </BackEventActionsContext.Provider>
83
+ );
45
84
  }
46
85
 
47
86
  /**
@@ -70,11 +109,26 @@ export function BackEventProvider({ children }: { children: ReactNode }) {
70
109
  * ```
71
110
  */
72
111
  export function useBackEventState() {
73
- const handlersRef = useRef<Set<() => void>>(new Set()).current;
112
+ const handlersRef = useRef<Set<BackEventHandler>>(new Set()).current;
113
+ const backHandlersRef = useRef<Set<BackHandlerCallback>>(new Set()).current;
74
114
  const [hasBackEvent, setHasBackEvent] = useState(false);
115
+ const [hasBackHandler, setHasBackHandler] = useState(false);
116
+
117
+ const removeEventListener = useCallback(
118
+ (...handlers: Array<BackEventHandler>) => {
119
+ for (const handler of handlers) {
120
+ handlersRef.delete(handler);
121
+ }
122
+
123
+ if (handlersRef.size === 0) {
124
+ setHasBackEvent(false);
125
+ }
126
+ },
127
+ [handlersRef]
128
+ );
75
129
 
76
130
  const addEventListener = useCallback(
77
- (...handlers: Array<() => void>) => {
131
+ (...handlers: Array<BackEventHandler>) => {
78
132
  for (const handler of handlers) {
79
133
  handlersRef.add(handler);
80
134
  }
@@ -86,17 +140,53 @@ export function useBackEventState() {
86
140
  [handlersRef]
87
141
  );
88
142
 
89
- const removeEventListener = useCallback(
90
- (...handlers: Array<() => void>) => {
143
+ const removeBackHandler = useCallback(
144
+ (...handlers: Array<BackHandlerCallback>) => {
91
145
  for (const handler of handlers) {
92
- handlersRef.delete(handler);
146
+ backHandlersRef.delete(handler);
93
147
  }
94
148
 
95
- if (handlersRef.size === 0) {
96
- setHasBackEvent(false);
149
+ if (backHandlersRef.size === 0) {
150
+ setHasBackHandler(false);
97
151
  }
98
152
  },
99
- [handlersRef]
153
+ [backHandlersRef]
154
+ );
155
+
156
+ const addBackHandler = useCallback(
157
+ (handler: BackHandlerCallback) => {
158
+ backHandlersRef.add(handler);
159
+
160
+ if (backHandlersRef.size > 0) {
161
+ setHasBackHandler(true);
162
+ }
163
+
164
+ return {
165
+ remove: () => removeBackHandler(handler),
166
+ };
167
+ },
168
+ [backHandlersRef, removeBackHandler]
169
+ );
170
+
171
+ const onBack = useCallback(() => {
172
+ handlersRef.forEach((handler) => handler());
173
+ }, [handlersRef]);
174
+
175
+ const onBackHandler = useCallback(
176
+ (event: BackEvent) => {
177
+ const orderedBackHandlers = Array.from(backHandlersRef).reverse();
178
+
179
+ for (const handler of orderedBackHandlers) {
180
+ const didHandleBackEvent = handler(event) === true;
181
+
182
+ if (didHandleBackEvent) {
183
+ return true;
184
+ }
185
+ }
186
+
187
+ return false;
188
+ },
189
+ [backHandlersRef]
100
190
  );
101
191
 
102
192
  const backEvent = useMemo((): PrivateBackEventControls => {
@@ -105,11 +195,24 @@ export function useBackEventState() {
105
195
  removeEventListener,
106
196
  handlersRef,
107
197
  hasBackEvent,
108
- onBack: () => {
109
- handlersRef.forEach((handler) => handler());
110
- },
198
+ hasBackHandler,
199
+ addBackHandler,
200
+ removeBackHandler,
201
+ onBack,
202
+ onBackHandler,
111
203
  };
112
- }, [addEventListener, handlersRef, hasBackEvent, removeEventListener]);
204
+ }, [
205
+ addEventListener,
206
+ addBackHandler,
207
+ backHandlersRef,
208
+ handlersRef,
209
+ hasBackEvent,
210
+ hasBackHandler,
211
+ onBack,
212
+ onBackHandler,
213
+ removeBackHandler,
214
+ removeEventListener,
215
+ ]);
113
216
 
114
217
  return backEvent;
115
218
  }
@@ -180,36 +283,32 @@ export function useBackEventState() {
180
283
  * ```
181
284
  */
182
285
  export function useBackEvent() {
183
- const context = useContext(BackEventContext);
184
- const handlersRef = useRef<Set<() => void>>(new Set()).current;
286
+ const context = useBackEventActionsContext();
287
+ const handlersRef = useRef<Set<BackEventHandler>>(new Set()).current;
185
288
 
186
289
  const isVisible = useVisibility();
187
290
 
188
- if (context == null) {
189
- throw new Error('useBackEvent must be used within a BackEventProvider');
190
- }
191
-
192
291
  const contextAddEventListener = context.addEventListener;
193
292
  const contextRemoveEventListener = context.removeEventListener;
194
293
 
195
- const addEventListener = useCallback(
196
- (...handlers: Array<() => void>) => {
294
+ const removeEventListener = useCallback(
295
+ (...handlers: Array<BackEventHandler>) => {
197
296
  for (const handler of handlers) {
198
- handlersRef.add(handler);
199
- contextAddEventListener(handler);
297
+ handlersRef.delete(handler);
298
+ contextRemoveEventListener(handler);
200
299
  }
201
300
  },
202
- [contextAddEventListener, handlersRef]
301
+ [contextRemoveEventListener, handlersRef]
203
302
  );
204
303
 
205
- const removeEventListener = useCallback(
206
- (...handlers: Array<() => void>) => {
304
+ const addEventListener = useCallback(
305
+ (...handlers: Array<BackEventHandler>) => {
207
306
  for (const handler of handlers) {
208
- handlersRef.delete(handler);
209
- contextRemoveEventListener(handler);
307
+ handlersRef.add(handler);
308
+ contextAddEventListener(handler);
210
309
  }
211
310
  },
212
- [contextRemoveEventListener, handlersRef]
311
+ [contextAddEventListener, handlersRef]
213
312
  );
214
313
 
215
314
  /**
@@ -242,12 +341,3 @@ export function useBackEvent() {
242
341
 
243
342
  return backEvent;
244
343
  }
245
-
246
- export function useBackEventContext() {
247
- const context = useContext(BackEventContext);
248
- if (context == null) {
249
- throw new Error('useBackEvent must be used within a BackEventProvider');
250
- }
251
-
252
- return context;
253
- }
@@ -0,0 +1,11 @@
1
+ import { useContext } from 'react';
2
+ import { BackEventActionsContext } from './BackEventContext';
3
+
4
+ export function useBackEventActionsContext() {
5
+ const context = useContext(BackEventActionsContext);
6
+ if (context == null) {
7
+ throw new Error('useBackEvent must be used within a BackEventProvider');
8
+ }
9
+
10
+ return context;
11
+ }
@@ -0,0 +1,28 @@
1
+ import { useContext, useMemo } from 'react';
2
+ import {
3
+ BackEventActionsContext,
4
+ BackEventStateContext,
5
+ type PrivateBackEventControls,
6
+ } from './BackEventContext';
7
+
8
+ export function useBackEventContext() {
9
+ const actions = useContext(BackEventActionsContext);
10
+ const state = useContext(BackEventStateContext);
11
+
12
+ const context = useMemo((): PrivateBackEventControls | null => {
13
+ if (actions == null || state == null) {
14
+ return null;
15
+ }
16
+
17
+ return {
18
+ ...actions,
19
+ ...state,
20
+ };
21
+ }, [actions, state]);
22
+
23
+ if (context == null) {
24
+ throw new Error('useBackEvent must be used within a BackEventProvider');
25
+ }
26
+
27
+ return context;
28
+ }
@@ -0,0 +1,113 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { ReactNode } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { BackEventProvider } from './useBackEvent';
5
+ import { useBackEventContext } from './useBackEventContext';
6
+ import { useBackHandler } from './useBackHandler';
7
+
8
+ vi.mock('../visibility', () => ({
9
+ useVisibility: () => true,
10
+ }));
11
+
12
+ function BackEventProviderWrapper({ children }: { children: ReactNode }) {
13
+ return <BackEventProvider>{children}</BackEventProvider>;
14
+ }
15
+
16
+ function useBackHandlerTest() {
17
+ const backHandler = useBackHandler();
18
+ const backEventContext = useBackEventContext();
19
+
20
+ return {
21
+ backHandler,
22
+ backEventContext,
23
+ };
24
+ }
25
+
26
+ describe('useBackHandler', () => {
27
+ it('exposes removal only through the returned subscription', () => {
28
+ const { result } = renderHook(() => useBackHandler(), {
29
+ wrapper: BackEventProviderWrapper,
30
+ });
31
+
32
+ expect(result.current).toEqual({
33
+ addEventListener: expect.any(Function),
34
+ });
35
+ expect('removeEventListener' in result.current).toBe(false);
36
+ });
37
+
38
+ it('bubbles from the latest handler and stops when one explicitly handles the event', () => {
39
+ const parent = vi.fn(() => true);
40
+ const child = vi.fn(() => false);
41
+ const overlay = vi.fn(() => true);
42
+ const { result } = renderHook(() => useBackHandlerTest(), {
43
+ wrapper: BackEventProviderWrapper,
44
+ });
45
+
46
+ act(() => {
47
+ result.current.backHandler.addEventListener(parent);
48
+ result.current.backHandler.addEventListener(child);
49
+ result.current.backHandler.addEventListener(overlay);
50
+ });
51
+
52
+ expect(result.current.backEventContext.onBackHandler({ source: 'iosSwipeGesture' })).toBe(true);
53
+ expect(overlay).toHaveBeenCalledWith({ source: 'iosSwipeGesture' });
54
+ expect(child).not.toHaveBeenCalled();
55
+ expect(parent).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it('continues bubbling while handlers return false', () => {
59
+ const parent = vi.fn(() => true);
60
+ const child = vi.fn(() => false);
61
+ const { result } = renderHook(() => useBackHandlerTest(), {
62
+ wrapper: BackEventProviderWrapper,
63
+ });
64
+
65
+ act(() => {
66
+ result.current.backHandler.addEventListener(parent);
67
+ result.current.backHandler.addEventListener(child);
68
+ });
69
+
70
+ expect(result.current.backEventContext.onBackHandler({ source: 'backButton' })).toBe(true);
71
+ expect(child).toHaveBeenCalledWith({ source: 'backButton' });
72
+ expect(parent).toHaveBeenCalledWith({ source: 'backButton' });
73
+ });
74
+
75
+ it('allows default navigation when handlers return false or undefined', () => {
76
+ const parent = vi.fn(() => false);
77
+ const child = vi.fn();
78
+ const { result } = renderHook(() => useBackHandlerTest(), {
79
+ wrapper: BackEventProviderWrapper,
80
+ });
81
+
82
+ act(() => {
83
+ result.current.backHandler.addEventListener(parent);
84
+ result.current.backHandler.addEventListener(child);
85
+ });
86
+
87
+ expect(result.current.backEventContext.onBackHandler({ source: 'androidHardwareBackPress' })).toBe(false);
88
+ expect(child).toHaveBeenCalledWith({ source: 'androidHardwareBackPress' });
89
+ expect(parent).toHaveBeenCalledWith({ source: 'androidHardwareBackPress' });
90
+ });
91
+
92
+ it('removes a back handler through the returned subscription', () => {
93
+ const handler = vi.fn(() => true);
94
+ const { result } = renderHook(() => useBackHandlerTest(), {
95
+ wrapper: BackEventProviderWrapper,
96
+ });
97
+ let subscription!: ReturnType<typeof result.current.backHandler.addEventListener>;
98
+
99
+ act(() => {
100
+ subscription = result.current.backHandler.addEventListener(handler);
101
+ });
102
+
103
+ expect(result.current.backEventContext.onBackHandler({ source: 'backButton' })).toBe(true);
104
+
105
+ act(() => {
106
+ subscription.remove();
107
+ });
108
+
109
+ expect(result.current.backEventContext.hasBackHandler).toBe(false);
110
+ expect(result.current.backEventContext.onBackHandler({ source: 'backButton' })).toBe(false);
111
+ expect(handler).toHaveBeenCalledTimes(1);
112
+ });
113
+ });
@@ -0,0 +1,114 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import { useVisibility } from '../visibility';
3
+ import type { BackEvent } from './useBackEvent';
4
+ import { useBackEventContext } from './useBackEventContext';
5
+
6
+ export type BackHandlerCallback = (event: BackEvent) => boolean | void;
7
+
8
+ export interface BackHandlerSubscription {
9
+ remove: () => void;
10
+ }
11
+
12
+ export interface BackHandlerControls {
13
+ addEventListener: (handler: BackHandlerCallback) => BackHandlerSubscription;
14
+ }
15
+
16
+ /**
17
+ * @public
18
+ * @category Screen Control
19
+ * @name useBackHandler
20
+ * @description
21
+ * A Hook that registers back handlers. Back handlers run before handlers registered through `useBackEvent`.
22
+ * Return `true` to stop bubbling and prevent default back navigation. Return `false` or return nothing to continue bubbling to the next handler.
23
+ *
24
+ * @example
25
+ *
26
+ * ### Example of Closing an Overlay First
27
+ *
28
+ * ```tsx
29
+ * import { useEffect, useState } from 'react';
30
+ * import { Button, View } from 'react-native';
31
+ * import { useBackHandler } from '@granite-js/react-native';
32
+ *
33
+ * export function OverlayExample() {
34
+ * const backHandler = useBackHandler();
35
+ * const [isOverlayOpen, setIsOverlayOpen] = useState(false);
36
+ *
37
+ * useEffect(() => {
38
+ * const subscription = backHandler.addEventListener(() => {
39
+ * if (isOverlayOpen) {
40
+ * setIsOverlayOpen(false);
41
+ * return true;
42
+ * }
43
+ *
44
+ * return undefined;
45
+ * });
46
+ *
47
+ * return () => {
48
+ * subscription.remove();
49
+ * };
50
+ * }, [backHandler, isOverlayOpen]);
51
+ *
52
+ * return (
53
+ * <View>
54
+ * <Button title="Open Overlay" onPress={() => setIsOverlayOpen(true)} />
55
+ * </View>
56
+ * );
57
+ * }
58
+ * ```
59
+ */
60
+ export function useBackHandler() {
61
+ const context = useBackEventContext();
62
+ const handlersRef = useRef<Set<BackHandlerCallback>>(new Set()).current;
63
+
64
+ const isVisible = useVisibility();
65
+
66
+ const contextAddBackHandler = context.addBackHandler;
67
+ const contextRemoveBackHandler = context.removeBackHandler;
68
+
69
+ const removeHandler = useCallback(
70
+ (...handlers: Array<BackHandlerCallback>) => {
71
+ for (const handler of handlers) {
72
+ handlersRef.delete(handler);
73
+ contextRemoveBackHandler(handler);
74
+ }
75
+ },
76
+ [contextRemoveBackHandler]
77
+ );
78
+
79
+ const addEventListener = useCallback(
80
+ (handler: BackHandlerCallback) => {
81
+ handlersRef.add(handler);
82
+ contextAddBackHandler(handler);
83
+
84
+ return {
85
+ remove: () => removeHandler(handler),
86
+ };
87
+ },
88
+ [contextAddBackHandler, removeHandler]
89
+ );
90
+
91
+ useEffect(() => {
92
+ if (!isVisible) {
93
+ return;
94
+ }
95
+
96
+ for (const handler of handlersRef) {
97
+ contextAddBackHandler(handler);
98
+ }
99
+
100
+ return () => {
101
+ for (const handler of handlersRef) {
102
+ contextRemoveBackHandler(handler);
103
+ }
104
+ };
105
+ }, [contextAddBackHandler, contextRemoveBackHandler, isVisible]);
106
+
107
+ const backHandler = useMemo((): BackHandlerControls => {
108
+ return {
109
+ addEventListener,
110
+ };
111
+ }, [addEventListener]);
112
+
113
+ return backHandler;
114
+ }