Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shiny-mangos-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@granite-js/react-native': patch
---

add opt-in back handler API
1 change: 1 addition & 0 deletions packages/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"@babel/preset-env": "7.28.5",
"@babel/preset-typescript": "7.28.5",
"@granite-js/native": "workspace:*",
"@granite-js/vitest": "workspace:*",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/babel__core": "^7",
Expand Down
3 changes: 3 additions & 0 deletions packages/react-native/src/app/AppRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface AppRootProps extends GraniteProps {
initialProps: InitialProps;
initialScheme: string;
setIosSwipeGestureEnabled?: ({ isEnabled }: { isEnabled: boolean }) => void;
setiOSBackPressHandler?: ({ handler }: { handler: () => void }) => Promise<void> | void;
getInitialUrl: InternalRouterProps['getInitialUrl'];
}

Expand All @@ -27,6 +28,7 @@ export function AppRoot({
initialScheme,
router,
setIosSwipeGestureEnabled,
setiOSBackPressHandler,
getInitialUrl,
}: AppRootProps) {
const prefix = getSchemePrefix({
Expand All @@ -47,6 +49,7 @@ export function AppRoot({
container={Container}
prefix={prefix}
setIosSwipeGestureEnabled={setIosSwipeGestureEnabled}
setiOSBackPressHandler={setiOSBackPressHandler}
getInitialUrl={getInitialUrl}
{...router}
/>
Expand Down
9 changes: 8 additions & 1 deletion packages/react-native/src/app/Granite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* The name of the app.
*/
appName: string;
/**

Check warning on line 17 in packages/react-native/src/app/Granite.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 'TODO' comment: '* * @description * The context of the...'
* @description
* The context of the app.
*
Expand All @@ -39,6 +39,12 @@
*/
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> | void;

/**
* @description
* The function to provide the initial URL to router.
Expand All @@ -65,7 +71,7 @@
return {
registerApp(
AppContainer: ComponentType<PropsWithChildren<InitialProps>>,
{ 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`);
Expand All @@ -80,6 +86,7 @@
initialProps={initialProps}
initialScheme={initialSchemeValue}
setIosSwipeGestureEnabled={setIosSwipeGestureEnabled}
setiOSBackPressHandler={setiOSBackPressHandler}
getInitialUrl={getInitialUrl}
appName={appName}
context={context}
Expand Down
8 changes: 6 additions & 2 deletions packages/react-native/src/router/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export interface InternalRouterProps {
initialProps: InitialProps;
initialScheme: string;
setIosSwipeGestureEnabled?: ({ isEnabled }: { isEnabled: boolean }) => Promise<void> | void;
setiOSBackPressHandler?: ({ handler }: { handler: () => void }) => Promise<void> | void;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about oniOSBackPressHandler ?

getInitialUrl?: RouterControlsConfig['getInitialUrl'];
}

Expand Down Expand Up @@ -148,6 +149,7 @@ export function Router({
defaultErrorComponent,
// Public props (StackNavigator)
setIosSwipeGestureEnabled,
setiOSBackPressHandler,
getInitialUrl,
...navigationContainerProps
}: InternalRouterProps & RouterProps): ReactElement {
Expand All @@ -162,7 +164,7 @@ export function Router({

const ref = useMemo(() => navigationContainerRef ?? createNavigationContainerRef<never>(), [navigationContainerRef]);

const { handler, canGoBack, onBack } = useInternalRouterBackHandler({
const { handler, handleBackEvent, canGoBack, hasBackEvent } = useInternalRouterBackHandler({
navigationContainerRef: ref,
onClose: closeView,
});
Expand Down Expand Up @@ -195,9 +197,11 @@ export function Router({
>
<CanGoBackGuard
canGoBack={canGoBack}
hasBackEvent={hasBackEvent}
isInitialScreen={isInitialScreen}
onBack={onBack}
onBack={handleBackEvent}
setIosSwipeGestureEnabled={setIosSwipeGestureEnabled}
setiOSBackPressHandler={setiOSBackPressHandler}
>
<Container {...initialProps}>
<StackNavigator.Navigator screenOptions={screenOptions}>{Screens}</StackNavigator.Navigator>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { render } from '@testing-library/react';
import { BackHandler } from 'react-native';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CanGoBackGuard } from './CanGoBackGuard';

describe('CanGoBackGuard', () => {
beforeEach(() => {
vi.mocked(BackHandler.addEventListener).mockClear();
});

it('passes Android hardware back events to onBack and consumes the native event', () => {
const onBack = vi.fn();

render(
<CanGoBackGuard canGoBack={false} hasBackEvent={true} isInitialScreen={true} onBack={onBack}>
<div />
</CanGoBackGuard>
);

const handler = vi.mocked(BackHandler.addEventListener).mock.calls[0]?.[1];

expect(BackHandler.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(
<CanGoBackGuard canGoBack={true} hasBackEvent={false} isInitialScreen={true} onBack={onBack}>
<div />
</CanGoBackGuard>
);

expect(BackHandler.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(
<CanGoBackGuard
canGoBack={true}
hasBackEvent={true}
isInitialScreen={true}
onBack={onBack}
setiOSBackPressHandler={setiOSBackPressHandler}
>
<div />
</CanGoBackGuard>
);

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(
<CanGoBackGuard
canGoBack={false}
hasBackEvent={true}
isInitialScreen={true}
setIosSwipeGestureEnabled={setIosSwipeGestureEnabled}
>
<div />
</CanGoBackGuard>
);

expect(setIosSwipeGestureEnabled).toHaveBeenCalledWith({ isEnabled: false });

unmount();

expect(setIosSwipeGestureEnabled).toHaveBeenLastCalledWith({ isEnabled: true });
});
});
51 changes: 37 additions & 14 deletions packages/react-native/src/router/components/CanGoBackGuard.tsx
Original file line number Diff line number Diff line change
@@ -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> | void;
type SetIOSBackPressHandler = ({ handler }: { handler: () => void }) => Promise<void> | 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 });
Expand All @@ -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}</>;
}
Loading
Loading