@granite-js/react-native 1.0.31 → 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.
@@ -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
+ });
@@ -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
+ });