From 61291527f5c5de1f7d77be90c881e8f08e5ff188 Mon Sep 17 00:00:00 2001 From: heecheolman Date: Mon, 8 Jun 2026 13:46:21 +0900 Subject: [PATCH 1/8] useBackHandler --- packages/react-native/src/app/AppRoot.tsx | 3 + packages/react-native/src/app/Granite.tsx | 9 +- .../react-native/src/event/useGraniteEvent.ts | 6 +- .../native-modules/natives/getSchemeUri.ts | 16 ++- packages/react-native/src/router/Router.tsx | 8 +- .../router/components/CanGoBackGuard.spec.tsx | 86 ++++++++++++ .../src/router/components/CanGoBackGuard.tsx | 51 +++++-- .../components/useRouterBackHandler.spec.tsx | 129 ++++++++++++++++++ .../components/useRouterBackHandler.tsx | 46 +++++-- .../react-native/src/use-back-event/index.ts | 1 + .../src/use-back-event/useBackEvent.spec.tsx | 39 ++++++ .../src/use-back-event/useBackEvent.tsx | 125 +++++++++++++---- .../use-back-event/useBackHandler.spec.tsx | 112 +++++++++++++++ .../src/use-back-event/useBackHandler.tsx | 114 ++++++++++++++++ .../react-native/test/brownfieldModuleMock.ts | 8 ++ .../react-native/test/navigationNativeMock.ts | 1 + packages/react-native/test/reactNativeMock.ts | 39 ++++++ packages/react-native/vitest.config.mts | 7 + 18 files changed, 736 insertions(+), 64 deletions(-) create mode 100644 packages/react-native/src/router/components/CanGoBackGuard.spec.tsx create mode 100644 packages/react-native/src/router/components/useRouterBackHandler.spec.tsx create mode 100644 packages/react-native/src/use-back-event/useBackEvent.spec.tsx create mode 100644 packages/react-native/src/use-back-event/useBackHandler.spec.tsx create mode 100644 packages/react-native/src/use-back-event/useBackHandler.tsx create mode 100644 packages/react-native/test/brownfieldModuleMock.ts create mode 100644 packages/react-native/test/navigationNativeMock.ts create mode 100644 packages/react-native/test/reactNativeMock.ts diff --git a/packages/react-native/src/app/AppRoot.tsx b/packages/react-native/src/app/AppRoot.tsx index c00564ac3..54a6e22c8 100644 --- a/packages/react-native/src/app/AppRoot.tsx +++ b/packages/react-native/src/app/AppRoot.tsx @@ -16,6 +16,7 @@ interface AppRootProps extends GraniteProps { initialProps: InitialProps; initialScheme: string; setIosSwipeGestureEnabled?: ({ isEnabled }: { isEnabled: boolean }) => void; + setiOSBackPressHandler?: ({ handler }: { handler: () => void }) => Promise | void; getInitialUrl: InternalRouterProps['getInitialUrl']; } @@ -27,6 +28,7 @@ export function AppRoot({ initialScheme, router, setIosSwipeGestureEnabled, + setiOSBackPressHandler, getInitialUrl, }: AppRootProps) { const prefix = getSchemePrefix({ @@ -47,6 +49,7 @@ export function AppRoot({ container={Container} prefix={prefix} setIosSwipeGestureEnabled={setIosSwipeGestureEnabled} + setiOSBackPressHandler={setiOSBackPressHandler} getInitialUrl={getInitialUrl} {...router} /> diff --git a/packages/react-native/src/app/Granite.tsx b/packages/react-native/src/app/Granite.tsx index 3e3fff7d5..2c639111b 100644 --- a/packages/react-native/src/app/Granite.tsx +++ b/packages/react-native/src/app/Granite.tsx @@ -39,6 +39,12 @@ export interface GraniteProps { */ setIosSwipeGestureEnabled?: ({ isEnabled }: { isEnabled: boolean }) => void; + /** + * @description + * The function to register a handler that runs when the iOS swipe back gesture is detected. + */ + setiOSBackPressHandler?: ({ handler }: { handler: () => void }) => Promise | void; + /** * @description * The function to provide the initial URL to router. @@ -65,7 +71,7 @@ const createApp = () => { return { registerApp( AppContainer: ComponentType>, - { appName, context, router, initialScheme, setIosSwipeGestureEnabled, getInitialUrl }: GraniteProps + { appName, context, router, initialScheme, setIosSwipeGestureEnabled, setiOSBackPressHandler, getInitialUrl }: GraniteProps ): (initialProps: InitialProps) => JSX.Element { if (appName === ENTRY_BUNDLE_NAME) { throw new Error(`Reserved app name 'shared' cannot be used`); @@ -80,6 +86,7 @@ const createApp = () => { initialProps={initialProps} initialScheme={initialSchemeValue} setIosSwipeGestureEnabled={setIosSwipeGestureEnabled} + setiOSBackPressHandler={setiOSBackPressHandler} getInitialUrl={getInitialUrl} appName={appName} context={context} diff --git a/packages/react-native/src/event/useGraniteEvent.ts b/packages/react-native/src/event/useGraniteEvent.ts index 456f02487..916353a98 100644 --- a/packages/react-native/src/event/useGraniteEvent.ts +++ b/packages/react-native/src/event/useGraniteEvent.ts @@ -18,8 +18,10 @@ class BackEvent extends GraniteEventDefinition { } listener(_: void, onEvent: (response: void) => void): void { - this.backEventControls.addEventListener(onEvent); - this.ref.remove = () => this.backEventControls.removeEventListener(onEvent); + const handler = () => onEvent(undefined); + + this.backEventControls.addEventListener(handler); + this.ref.remove = () => this.backEventControls.removeEventListener(handler); } } diff --git a/packages/react-native/src/native-modules/natives/getSchemeUri.ts b/packages/react-native/src/native-modules/natives/getSchemeUri.ts index 02985536e..ce771ec27 100644 --- a/packages/react-native/src/native-modules/natives/getSchemeUri.ts +++ b/packages/react-native/src/native-modules/natives/getSchemeUri.ts @@ -1,10 +1,18 @@ -import { GraniteBrownfieldModule } from "@granite-js/brownfield-module"; +import { GraniteBrownfieldModule } from '@granite-js/brownfield-module'; export function getSchemeUri() { try { - return GraniteBrownfieldModule.getSchemeUri(); + const graniteBrownfieldModule = GraniteBrownfieldModule as typeof GraniteBrownfieldModule & { + getSchemeUri?: () => string; + }; + + if (graniteBrownfieldModule.getSchemeUri != null) { + return graniteBrownfieldModule.getSchemeUri(); + } } catch { // Fallback to the deprecated `schemeUri` constant for older versions of the native module - return GraniteBrownfieldModule.getConstants().schemeUri; } -} \ No newline at end of file + + // Fallback to the deprecated `schemeUri` constant for older versions of the native module + return GraniteBrownfieldModule.getConstants().schemeUri; +} diff --git a/packages/react-native/src/router/Router.tsx b/packages/react-native/src/router/Router.tsx index c393e8c1d..f978a4c24 100644 --- a/packages/react-native/src/router/Router.tsx +++ b/packages/react-native/src/router/Router.tsx @@ -65,6 +65,7 @@ export interface InternalRouterProps { initialProps: InitialProps; initialScheme: string; setIosSwipeGestureEnabled?: ({ isEnabled }: { isEnabled: boolean }) => Promise | void; + setiOSBackPressHandler?: ({ handler }: { handler: () => void }) => Promise | void; getInitialUrl?: RouterControlsConfig['getInitialUrl']; } @@ -148,6 +149,7 @@ export function Router({ defaultErrorComponent, // Public props (StackNavigator) setIosSwipeGestureEnabled, + setiOSBackPressHandler, getInitialUrl, ...navigationContainerProps }: InternalRouterProps & RouterProps): ReactElement { @@ -162,7 +164,7 @@ export function Router({ const ref = useMemo(() => navigationContainerRef ?? createNavigationContainerRef(), [navigationContainerRef]); - const { handler, canGoBack, onBack } = useInternalRouterBackHandler({ + const { handler, handleBackEvent, canGoBack, hasBackEvent } = useInternalRouterBackHandler({ navigationContainerRef: ref, onClose: closeView, }); @@ -195,9 +197,11 @@ export function Router({ > {Screens} diff --git a/packages/react-native/src/router/components/CanGoBackGuard.spec.tsx b/packages/react-native/src/router/components/CanGoBackGuard.spec.tsx new file mode 100644 index 000000000..5d5b1b07b --- /dev/null +++ b/packages/react-native/src/router/components/CanGoBackGuard.spec.tsx @@ -0,0 +1,86 @@ +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { __backHandlerMock } from '../../../test/reactNativeMock'; + +import { CanGoBackGuard } from './CanGoBackGuard'; + +describe('CanGoBackGuard', () => { + beforeEach(() => { + __backHandlerMock.reset(); + }); + + it('passes Android hardware back events to onBack and consumes the native event', () => { + const onBack = vi.fn(); + + render( + +
+ + ); + + const [handler] = __backHandlerMock.handlers; + + expect(__backHandlerMock.addEventListener).toHaveBeenCalledWith('hardwareBackPress', expect.any(Function)); + expect(handler?.()).toBe(true); + expect(onBack).toHaveBeenCalledWith({ source: 'androidHardwareBackPress' }); + }); + + it('does not register Android hardware back when no back event exists', () => { + const onBack = vi.fn(); + + render( + +
+ + ); + + expect(__backHandlerMock.addEventListener).not.toHaveBeenCalled(); + }); + + it('registers the iOS swipe back handler while back events exist', () => { + const onBack = vi.fn(); + const setiOSBackPressHandler = vi.fn(); + + const { unmount } = render( + +
+ + ); + + setiOSBackPressHandler.mock.calls[0]?.[0].handler(); + + expect(setiOSBackPressHandler).toHaveBeenCalledWith({ handler: expect.any(Function) }); + expect(onBack).toHaveBeenCalledWith({ source: 'iosSwipeGesture' }); + + unmount(); + + expect(setiOSBackPressHandler).toHaveBeenLastCalledWith({ handler: expect.any(Function) }); + }); + + it('disables iOS swipe when the current state should block default back navigation', () => { + const setIosSwipeGestureEnabled = vi.fn(); + + const { unmount } = render( + +
+ + ); + + expect(setIosSwipeGestureEnabled).toHaveBeenCalledWith({ isEnabled: false }); + + unmount(); + + expect(setIosSwipeGestureEnabled).toHaveBeenLastCalledWith({ isEnabled: true }); + }); +}); diff --git a/packages/react-native/src/router/components/CanGoBackGuard.tsx b/packages/react-native/src/router/components/CanGoBackGuard.tsx index aca2d6b6c..ca5e15cf0 100644 --- a/packages/react-native/src/router/components/CanGoBackGuard.tsx +++ b/packages/react-native/src/router/components/CanGoBackGuard.tsx @@ -1,21 +1,27 @@ import { ReactNode, useEffect } from 'react'; import { BackHandler } from 'react-native'; +import type { BackEvent } from '../../use-back-event'; + +type SetIosSwipeGestureEnabled = ({ isEnabled }: { isEnabled: boolean }) => Promise | void; +type SetIOSBackPressHandler = ({ handler }: { handler: () => void }) => Promise | void; export function CanGoBackGuard({ children, canGoBack, + hasBackEvent, onBack, isInitialScreen, setIosSwipeGestureEnabled, + setiOSBackPressHandler, }: { canGoBack: boolean; + hasBackEvent: boolean; isInitialScreen: boolean; children: ReactNode; - onBack?: () => void; - setIosSwipeGestureEnabled?: ({ isEnabled }: { isEnabled: boolean }) => void; + onBack?: (event: BackEvent) => void; + setIosSwipeGestureEnabled?: SetIosSwipeGestureEnabled; + setiOSBackPressHandler?: SetIOSBackPressHandler; }) { - const shouldBlockGoingBack = !canGoBack; - useEffect(() => { if (!isInitialScreen || !canGoBack) { setIosSwipeGestureEnabled?.({ isEnabled: false }); @@ -29,19 +35,36 @@ export function CanGoBackGuard({ }, [canGoBack, isInitialScreen, setIosSwipeGestureEnabled]); useEffect(() => { - if (shouldBlockGoingBack) { - const subscription = BackHandler.addEventListener('hardwareBackPress', () => { - onBack?.(); - return true; - }); + if (!hasBackEvent) { + return; + } - return () => { - subscription.remove(); - }; + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + onBack?.({ source: 'androidHardwareBackPress' }); + + return true; + }); + + return () => { + subscription.remove(); + }; + }, [hasBackEvent, onBack]); + + useEffect(() => { + if (!hasBackEvent || setiOSBackPressHandler == null) { + return; } - return; - }, [shouldBlockGoingBack, onBack]); + setiOSBackPressHandler({ + handler: () => { + onBack?.({ source: 'iosSwipeGesture' }); + }, + }); + + return () => { + setiOSBackPressHandler({ handler: () => {} }); + }; + }, [hasBackEvent, onBack, setiOSBackPressHandler]); return <>{children}; } diff --git a/packages/react-native/src/router/components/useRouterBackHandler.spec.tsx b/packages/react-native/src/router/components/useRouterBackHandler.spec.tsx new file mode 100644 index 000000000..6b16604d2 --- /dev/null +++ b/packages/react-native/src/router/components/useRouterBackHandler.spec.tsx @@ -0,0 +1,129 @@ +import { act, renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { BackEventProvider, useBackEvent, useBackHandler } from '../../use-back-event'; +import { useInternalRouterBackHandler } from './useRouterBackHandler'; + +vi.mock('../../visibility', () => ({ + useVisibility: () => true, +})); + +function createNavigationContainerRef() { + return { + canGoBack: vi.fn(() => true), + goBack: vi.fn(), + }; +} + +function BackEventProviderWrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function useBackHandlerTest({ + navigationContainerRef, + onClose, +}: { + navigationContainerRef: ReturnType; + onClose?: () => void; +}) { + const backEvent = useBackEvent(); + const backHandler = useBackHandler(); + const routerBackHandler = useInternalRouterBackHandler({ + navigationContainerRef: navigationContainerRef as any, + onClose, + }); + + return { + backEvent, + backHandler, + routerBackHandler, + }; +} + +describe('useInternalRouterBackHandler', () => { + it('stops when a back handler handles the back event', () => { + const navigationContainerRef = createNavigationContainerRef(); + const legacyHandler = vi.fn(); + const backHandler = vi.fn(() => true); + const { result } = renderHook(() => useBackHandlerTest({ navigationContainerRef }), { + wrapper: BackEventProviderWrapper, + }); + + act(() => { + result.current.backEvent.addEventListener(legacyHandler); + result.current.backHandler.addEventListener(backHandler); + }); + + act(() => { + result.current.routerBackHandler.handleBackEvent({ source: 'backButton' }); + }); + + expect(backHandler).toHaveBeenCalledWith({ source: 'backButton' }); + expect(legacyHandler).not.toHaveBeenCalled(); + expect(navigationContainerRef.goBack).not.toHaveBeenCalled(); + }); + + it('runs every legacy handler when back handlers do not handle the event', () => { + const navigationContainerRef = createNavigationContainerRef(); + const backHandler = vi.fn(() => false); + const firstLegacyHandler = vi.fn(); + const secondLegacyHandler = vi.fn(); + const { result } = renderHook(() => useBackHandlerTest({ navigationContainerRef }), { + wrapper: BackEventProviderWrapper, + }); + + act(() => { + result.current.backHandler.addEventListener(backHandler); + result.current.backEvent.addEventListener(firstLegacyHandler, secondLegacyHandler); + }); + + act(() => { + result.current.routerBackHandler.handleBackEvent({ source: 'iosSwipeGesture' }); + }); + + expect(backHandler).toHaveBeenCalledWith({ source: 'iosSwipeGesture' }); + expect(firstLegacyHandler).toHaveBeenCalledTimes(1); + expect(secondLegacyHandler).toHaveBeenCalledTimes(1); + expect(navigationContainerRef.goBack).not.toHaveBeenCalled(); + }); + + it('runs default back when back handlers return false or undefined and no legacy handler exists', () => { + const navigationContainerRef = createNavigationContainerRef(); + const backHandler = vi.fn(() => false); + const passiveHandler = vi.fn(); + const { result } = renderHook(() => useBackHandlerTest({ navigationContainerRef }), { + wrapper: BackEventProviderWrapper, + }); + + act(() => { + result.current.backHandler.addEventListener(backHandler); + result.current.backHandler.addEventListener(passiveHandler); + }); + + act(() => { + result.current.routerBackHandler.handleBackEvent({ source: 'androidHardwareBackPress' }); + }); + + expect(passiveHandler).toHaveBeenCalledWith({ source: 'androidHardwareBackPress' }); + expect(backHandler).toHaveBeenCalledWith({ source: 'androidHardwareBackPress' }); + expect(navigationContainerRef.goBack).toHaveBeenCalledTimes(1); + }); + + it('uses backButton as the source for the header back handler', () => { + const navigationContainerRef = createNavigationContainerRef(); + const backHandler = vi.fn(() => true); + const { result } = renderHook(() => useBackHandlerTest({ navigationContainerRef }), { + wrapper: BackEventProviderWrapper, + }); + + act(() => { + result.current.backHandler.addEventListener(backHandler); + }); + + act(() => { + result.current.routerBackHandler.handler(); + }); + + expect(backHandler).toHaveBeenCalledWith({ source: 'backButton' }); + }); +}); diff --git a/packages/react-native/src/router/components/useRouterBackHandler.tsx b/packages/react-native/src/router/components/useRouterBackHandler.tsx index 5f9ce7151..fc9699b55 100644 --- a/packages/react-native/src/router/components/useRouterBackHandler.tsx +++ b/packages/react-native/src/router/components/useRouterBackHandler.tsx @@ -1,6 +1,6 @@ -import { NavigationContainerRefWithCurrent } from '@granite-js/native/@react-navigation/native'; +import type { NavigationContainerRefWithCurrent } from '@granite-js/native/@react-navigation/native'; import { useCallback, useMemo } from 'react'; -import { useBackEventContext } from '../../use-back-event'; +import { type BackEvent, useBackEventContext } from '../../use-back-event'; /** * @public @@ -63,29 +63,49 @@ export function useInternalRouterBackHandler({ navigationContainerRef: NavigationContainerRefWithCurrent; onClose?: () => void; }) { - const { hasBackEvent, onBack } = useBackEventContext(); - const canGoBack = !hasBackEvent; - - const handler = useCallback(() => { - onBack?.(); - - if (!canGoBack) { - return; - } + const { hasBackEvent, hasBackHandler, onBack, onBackHandler } = useBackEventContext(); + const hasRegisteredBackHandler = hasBackEvent || hasBackHandler; + const canGoBack = !hasRegisteredBackHandler; + const handleDefaultBack = useCallback(() => { if (navigationContainerRef.canGoBack()) { navigationContainerRef.goBack(); } else { onClose?.(); } - }, [canGoBack, onClose, navigationContainerRef, onBack]); + }, [navigationContainerRef, onClose]); + + const handleBackEvent = useCallback( + (event: BackEvent) => { + const didHandleBackEvent = onBackHandler(event); + + if (didHandleBackEvent) { + return; + } + + onBack(); + + if (hasBackEvent) { + return; + } + + handleDefaultBack(); + }, + [handleDefaultBack, hasBackEvent, onBack, onBackHandler] + ); + + const handler = useCallback(() => { + handleBackEvent({ source: 'backButton' }); + }, [handleBackEvent]); return useMemo( () => ({ handler, + handleBackEvent, canGoBack, + hasBackEvent: hasRegisteredBackHandler, onBack, }), - [canGoBack, handler, onBack] + [canGoBack, handleBackEvent, handler, hasRegisteredBackHandler, onBack] ); } diff --git a/packages/react-native/src/use-back-event/index.ts b/packages/react-native/src/use-back-event/index.ts index f72d5c2b6..ab702a246 100644 --- a/packages/react-native/src/use-back-event/index.ts +++ b/packages/react-native/src/use-back-event/index.ts @@ -1 +1,2 @@ export * from './useBackEvent'; +export * from './useBackHandler'; diff --git a/packages/react-native/src/use-back-event/useBackEvent.spec.tsx b/packages/react-native/src/use-back-event/useBackEvent.spec.tsx new file mode 100644 index 000000000..f26fd7704 --- /dev/null +++ b/packages/react-native/src/use-back-event/useBackEvent.spec.tsx @@ -0,0 +1,39 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useBackEventState } from './useBackEvent'; + +vi.mock('../visibility', () => ({ + useVisibility: () => true, +})); + +describe('useBackEventState', () => { + it('keeps legacy back event handlers variadic and runs every handler', () => { + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + const { result } = renderHook(() => useBackEventState()); + + act(() => { + result.current.addEventListener(firstHandler, secondHandler); + }); + + result.current.onBack(); + + expect(result.current.hasBackEvent).toBe(true); + expect(firstHandler).toHaveBeenCalledTimes(1); + expect(secondHandler).toHaveBeenCalledTimes(1); + }); + + it('ignores legacy back event handler return values', () => { + const handler = vi.fn(() => false); + const { result } = renderHook(() => useBackEventState()); + + act(() => { + result.current.addEventListener(handler); + }); + + result.current.onBack(); + + expect(result.current.hasBackEvent).toBe(true); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-native/src/use-back-event/useBackEvent.tsx b/packages/react-native/src/use-back-event/useBackEvent.tsx index 5e6be1311..6b453e354 100644 --- a/packages/react-native/src/use-back-event/useBackEvent.tsx +++ b/packages/react-native/src/use-back-event/useBackEvent.tsx @@ -1,15 +1,29 @@ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useVisibility } from '../visibility'; +import type { BackHandlerCallback, BackHandlerControls } from './useBackHandler'; + +export type BackEventSource = 'backButton' | 'iosSwipeGesture' | 'androidHardwareBackPress'; + +export interface BackEvent { + source: BackEventSource; +} + +export type BackEventHandler = () => void; export interface BackEventControls { - addEventListener: (...handlers: Array<() => void>) => void; - removeEventListener: (...handlers: Array<() => void>) => void; + addEventListener: (...handlers: Array) => void; + removeEventListener: (...handlers: Array) => void; } interface PrivateBackEventControls extends BackEventControls { - handlersRef: Set<() => void>; + handlersRef: Set; + backHandlersRef: Set; hasBackEvent: boolean; + hasBackHandler: boolean; + addBackHandler: BackHandlerControls['addEventListener']; + removeBackHandler: (...handlers: Array) => void; onBack: () => void; + onBackHandler: (event: BackEvent) => boolean; } const BackEventContext = createContext(null); @@ -70,33 +84,62 @@ export function BackEventProvider({ children }: { children: ReactNode }) { * ``` */ export function useBackEventState() { - const handlersRef = useRef void>>(new Set()).current; + const handlersRef = useRef>(new Set()).current; + const backHandlersRef = useRef>(new Set()).current; const [hasBackEvent, setHasBackEvent] = useState(false); + const [hasBackHandler, setHasBackHandler] = useState(false); + + const syncBackEventState = useCallback(() => { + setHasBackEvent(handlersRef.size > 0); + }, [handlersRef]); + + const syncBackHandlerState = useCallback(() => { + setHasBackHandler(backHandlersRef.size > 0); + }, [backHandlersRef]); + + const removeEventListener = useCallback( + (...handlers: Array) => { + for (const handler of handlers) { + handlersRef.delete(handler); + } + + syncBackEventState(); + }, + [handlersRef, syncBackEventState] + ); const addEventListener = useCallback( - (...handlers: Array<() => void>) => { + (...handlers: Array) => { for (const handler of handlers) { handlersRef.add(handler); } - if (handlersRef.size > 0) { - setHasBackEvent(true); - } + syncBackEventState(); }, - [handlersRef] + [handlersRef, syncBackEventState] ); - const removeEventListener = useCallback( - (...handlers: Array<() => void>) => { + const removeBackHandler = useCallback( + (...handlers: Array) => { for (const handler of handlers) { - handlersRef.delete(handler); + backHandlersRef.delete(handler); } - if (handlersRef.size === 0) { - setHasBackEvent(false); - } + syncBackHandlerState(); + }, + [backHandlersRef, syncBackHandlerState] + ); + + const addBackHandler = useCallback( + (handler: BackHandlerCallback) => { + backHandlersRef.add(handler); + syncBackHandlerState(); + + return { + remove: () => removeBackHandler(handler), + }; }, - [handlersRef] + [backHandlersRef, removeBackHandler, syncBackHandlerState] ); const backEvent = useMemo((): PrivateBackEventControls => { @@ -104,12 +147,38 @@ export function useBackEventState() { addEventListener, removeEventListener, handlersRef, + backHandlersRef, hasBackEvent, + hasBackHandler, + addBackHandler, + removeBackHandler, onBack: () => { handlersRef.forEach((handler) => handler()); }, + onBackHandler: (event) => { + const backHandlers = Array.from(backHandlersRef).reverse(); + + for (const handler of backHandlers) { + const didHandleBackEvent = handler(event) === true; + + if (didHandleBackEvent) { + return true; + } + } + + return false; + }, }; - }, [addEventListener, handlersRef, hasBackEvent, removeEventListener]); + }, [ + addEventListener, + addBackHandler, + backHandlersRef, + handlersRef, + hasBackEvent, + hasBackHandler, + removeBackHandler, + removeEventListener, + ]); return backEvent; } @@ -181,7 +250,7 @@ export function useBackEventState() { */ export function useBackEvent() { const context = useContext(BackEventContext); - const handlersRef = useRef void>>(new Set()).current; + const handlersRef = useRef>(new Set()).current; const isVisible = useVisibility(); @@ -192,24 +261,24 @@ export function useBackEvent() { const contextAddEventListener = context.addEventListener; const contextRemoveEventListener = context.removeEventListener; - const addEventListener = useCallback( - (...handlers: Array<() => void>) => { + const removeEventListener = useCallback( + (...handlers: Array) => { for (const handler of handlers) { - handlersRef.add(handler); - contextAddEventListener(handler); + handlersRef.delete(handler); + contextRemoveEventListener(handler); } }, - [contextAddEventListener, handlersRef] + [contextRemoveEventListener, handlersRef] ); - const removeEventListener = useCallback( - (...handlers: Array<() => void>) => { + const addEventListener = useCallback( + (...handlers: Array) => { for (const handler of handlers) { - handlersRef.delete(handler); - contextRemoveEventListener(handler); + handlersRef.add(handler); + contextAddEventListener(handler); } }, - [contextRemoveEventListener, handlersRef] + [contextAddEventListener, handlersRef] ); /** diff --git a/packages/react-native/src/use-back-event/useBackHandler.spec.tsx b/packages/react-native/src/use-back-event/useBackHandler.spec.tsx new file mode 100644 index 000000000..94f86ebc2 --- /dev/null +++ b/packages/react-native/src/use-back-event/useBackHandler.spec.tsx @@ -0,0 +1,112 @@ +import { act, renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { BackEventProvider, useBackEventContext } from './useBackEvent'; +import { useBackHandler } from './useBackHandler'; + +vi.mock('../visibility', () => ({ + useVisibility: () => true, +})); + +function BackEventProviderWrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function useBackHandlerTest() { + const backHandler = useBackHandler(); + const backEventContext = useBackEventContext(); + + return { + backHandler, + backEventContext, + }; +} + +describe('useBackHandler', () => { + it('exposes removal only through the returned subscription', () => { + const { result } = renderHook(() => useBackHandler(), { + wrapper: BackEventProviderWrapper, + }); + + expect(result.current).toEqual({ + addEventListener: expect.any(Function), + }); + expect('removeEventListener' in result.current).toBe(false); + }); + + it('bubbles from the latest handler and stops when one explicitly handles the event', () => { + const parent = vi.fn(() => true); + const child = vi.fn(() => false); + const overlay = vi.fn(() => true); + const { result } = renderHook(() => useBackHandlerTest(), { + wrapper: BackEventProviderWrapper, + }); + + act(() => { + result.current.backHandler.addEventListener(parent); + result.current.backHandler.addEventListener(child); + result.current.backHandler.addEventListener(overlay); + }); + + expect(result.current.backEventContext.onBackHandler({ source: 'iosSwipeGesture' })).toBe(true); + expect(overlay).toHaveBeenCalledWith({ source: 'iosSwipeGesture' }); + expect(child).not.toHaveBeenCalled(); + expect(parent).not.toHaveBeenCalled(); + }); + + it('continues bubbling while handlers return false', () => { + const parent = vi.fn(() => true); + const child = vi.fn(() => false); + const { result } = renderHook(() => useBackHandlerTest(), { + wrapper: BackEventProviderWrapper, + }); + + act(() => { + result.current.backHandler.addEventListener(parent); + result.current.backHandler.addEventListener(child); + }); + + expect(result.current.backEventContext.onBackHandler({ source: 'backButton' })).toBe(true); + expect(child).toHaveBeenCalledWith({ source: 'backButton' }); + expect(parent).toHaveBeenCalledWith({ source: 'backButton' }); + }); + + it('allows default navigation when handlers return false or undefined', () => { + const parent = vi.fn(() => false); + const child = vi.fn(); + const { result } = renderHook(() => useBackHandlerTest(), { + wrapper: BackEventProviderWrapper, + }); + + act(() => { + result.current.backHandler.addEventListener(parent); + result.current.backHandler.addEventListener(child); + }); + + expect(result.current.backEventContext.onBackHandler({ source: 'androidHardwareBackPress' })).toBe(false); + expect(child).toHaveBeenCalledWith({ source: 'androidHardwareBackPress' }); + expect(parent).toHaveBeenCalledWith({ source: 'androidHardwareBackPress' }); + }); + + it('removes a back handler through the returned subscription', () => { + const handler = vi.fn(() => true); + const { result } = renderHook(() => useBackHandlerTest(), { + wrapper: BackEventProviderWrapper, + }); + let subscription!: ReturnType; + + act(() => { + subscription = result.current.backHandler.addEventListener(handler); + }); + + expect(result.current.backEventContext.onBackHandler({ source: 'backButton' })).toBe(true); + + act(() => { + subscription.remove(); + }); + + expect(result.current.backEventContext.hasBackHandler).toBe(false); + expect(result.current.backEventContext.onBackHandler({ source: 'backButton' })).toBe(false); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-native/src/use-back-event/useBackHandler.tsx b/packages/react-native/src/use-back-event/useBackHandler.tsx new file mode 100644 index 000000000..9da716723 --- /dev/null +++ b/packages/react-native/src/use-back-event/useBackHandler.tsx @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useVisibility } from '../visibility'; +import type { BackEvent } from './useBackEvent'; +import { useBackEventContext } from './useBackEvent'; + +export type BackHandlerCallback = (event: BackEvent) => boolean | void; + +export interface BackHandlerSubscription { + remove: () => void; +} + +export interface BackHandlerControls { + addEventListener: (handler: BackHandlerCallback) => BackHandlerSubscription; +} + +/** + * @public + * @category Screen Control + * @name useBackHandler + * @description + * A Hook that registers back handlers. Back handlers run before handlers registered through `useBackEvent`. + * Return `true` to stop bubbling and prevent default back navigation. Return `false` or return nothing to continue bubbling to the next handler. + * + * @example + * + * ### Example of Closing an Overlay First + * + * ```tsx + * import { useEffect, useState } from 'react'; + * import { Button, View } from 'react-native'; + * import { useBackHandler } from '@granite-js/react-native'; + * + * export function OverlayExample() { + * const backHandler = useBackHandler(); + * const [isOverlayOpen, setIsOverlayOpen] = useState(false); + * + * useEffect(() => { + * const subscription = backHandler.addEventListener(() => { + * if (isOverlayOpen) { + * setIsOverlayOpen(false); + * return true; + * } + * + * return undefined; + * }); + * + * return () => { + * subscription.remove(); + * }; + * }, [backHandler, isOverlayOpen]); + * + * return ( + * + *