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
8 changes: 8 additions & 0 deletions app/components/Nav/Main/MainNavigator.js
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,14 @@ const MainNavigator = () => {
name={Routes.RAMP.TOKEN_SELECTION}
component={TokenListRoutes}
/>
<Stack.Screen
name={Routes.RAMP.HEADLESS_ENTRY}
component={TokenListRoutes}
options={{
...clearStackNavigatorOptionsWithTransitionAnimation,
presentation: 'transparentModal',
}}
/>
<Stack.Screen
name={Routes.RAMP.BUY}
options={{
Expand Down
119 changes: 91 additions & 28 deletions app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ describe('Checkout', () => {
const mockAddOrder = jest.fn();
const mockGetOrderFromCallback = jest.fn();
const mockAddPrecreatedOrder = jest.fn();
const mockHeadlessEntrySetOptions = jest.fn();
const mockNavigation = {
setOptions: jest.fn(),
reset: jest.fn(),
Expand Down Expand Up @@ -280,7 +281,13 @@ describe('Checkout', () => {
const nav = require('@react-navigation/native');
nav.useNavigation.mockReturnValue(mockNavigation);
mockNavigation.getParent.mockReset();
mockNavigation.getParent.mockImplementation(() => ({ pop: jest.fn() }));
mockHeadlessEntrySetOptions.mockReset();
mockNavigation.getParent.mockImplementation(() => ({
pop: jest.fn(),
getParent: () => ({
setOptions: mockHeadlessEntrySetOptions,
}),
}));
});

describe('handleNavigationStateChange (callback flow)', () => {
Expand Down Expand Up @@ -682,7 +689,12 @@ describe('Checkout', () => {
mockCloseSession.mockReset();
mockFailSession.mockReset();
mockParentPop = jest.fn();
mockNavigation.getParent.mockReturnValue({ pop: mockParentPop });
mockNavigation.getParent.mockImplementation(() => ({
pop: mockParentPop,
getParent: () => ({
setOptions: mockHeadlessEntrySetOptions,
}),
}));
mockGetOrderFromCallback.mockResolvedValue(mockOrder);
mockUseRampsUnifiedV2Enabled.mockReturnValue(true);
});
Expand Down Expand Up @@ -713,34 +725,12 @@ describe('Checkout', () => {
reason: 'completed',
});
expect(mockParentPop).toHaveBeenCalled();
expect(mockNavigation.reset).not.toHaveBeenCalled();
expect(showV2OrderToastMock).not.toHaveBeenCalled();
});

it('still adds the order to Redux and dispatches protect-wallet when headless', async () => {
mockGetSession.mockReturnValue({
id: 'hs-1',
status: 'continued',
callbacks: {
onOrderCreated: jest.fn(),
onError: jest.fn(),
onClose: jest.fn(),
},
});
mockUseParams.mockReturnValue(callbackFlowParams);

const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);

await act(async () => {
fireEvent.press(getByTestId('trigger-callback-navigation'));
});

await waitFor(() => {
expect(mockAddOrder).toHaveBeenCalledWith(mockOrder);
});
expect(mockAddOrder).toHaveBeenCalledWith(mockOrder);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'PROTECT_WALLET_MODAL_VISIBLE',
});
expect(mockNavigation.reset).not.toHaveBeenCalled();
expect(showV2OrderToastMock).not.toHaveBeenCalled();
});

it('swallows consumer onOrderCreated errors and still closes + pops', async () => {
Expand Down Expand Up @@ -820,12 +810,85 @@ describe('Checkout', () => {

await act(async () => {
fireEvent.press(getByTestId('trigger-http-error-main-uri'));
fireEvent.press(getByTestId('trigger-http-error-main-uri'));
});

expect(mockFailSession).toHaveBeenCalledTimes(1);
expect(mockParentPop).toHaveBeenCalledTimes(1);
});

it('treats an empty provider callback as user dismissal when headless', async () => {
mockUseParams.mockReturnValue(callbackFlowParams);

const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);

await act(async () => {
fireEvent.press(getByTestId('trigger-callback-empty-query'));
});

expect(mockCloseSession).toHaveBeenCalledWith('hs-1', {
reason: 'user_dismissed',
});
expect(mockParentPop).toHaveBeenCalled();
expect(mockGetOrderFromCallback).not.toHaveBeenCalled();
});

expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error));
it('closes and dismisses the headless flow when the checkout close button is pressed', () => {
mockUseParams.mockReturnValue(callbackFlowParams);

const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);

fireEvent.press(getByTestId('checkout-close-button'));
fireEvent.press(getByTestId('checkout-close-button'));

expect(mockCloseSession).toHaveBeenCalledTimes(1);
expect(mockParentPop).toHaveBeenCalledTimes(1);
});

it('closes and dismisses the headless flow when Checkout unmounts with a live session', () => {
mockGetSession.mockReturnValue({
id: 'hs-1',
status: 'continued',
callbacks: {
onOrderCreated: jest.fn(),
onError: jest.fn(),
onClose: jest.fn(),
},
});
mockUseParams.mockReturnValue(callbackFlowParams);

const { unmount } = renderWithProvider(<Checkout />, {}, true, false);

unmount();

expect(mockCloseSession).toHaveBeenCalledWith('hs-1', {
reason: 'user_dismissed',
});
expect(mockParentPop).toHaveBeenCalled();
});

it('keeps the headless entry card touch-through until Checkout finishes the first WebView load', () => {
mockUseParams.mockReturnValue(callbackFlowParams);

const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);

expect(mockHeadlessEntrySetOptions).toHaveBeenCalledWith({
cardStyle: {
backgroundColor: 'transparent',
pointerEvents: 'none',
},
});

fireEvent.press(getByTestId('trigger-load-end'));

expect(mockHeadlessEntrySetOptions).toHaveBeenCalledWith({
cardStyle: {
backgroundColor: 'transparent',
pointerEvents: 'auto',
},
});
});

it('falls back to the regular reset + toast when session id is present but session is missing from registry', async () => {
mockGetSession.mockReturnValue(undefined);
mockUseParams.mockReturnValue(callbackFlowParams);
Expand Down
78 changes: 71 additions & 7 deletions app/components/UI/Ramp/Views/Checkout/Checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ import {
failSession,
getSession,
} from '../../headless/sessionRegistry';
import {
dismissHeadlessFlow,
setHeadlessEntryCardTouchThrough,
} from '../../headless/headlessEntryNavigation';
import { useStyles } from '../../../../hooks/useStyles';
import styleSheet from './Checkout.styles';
import Device from '../../../../../util/device';
Expand Down Expand Up @@ -132,12 +136,29 @@ const Checkout = () => {
const stepIndexRef = useRef(0);
const openedAtRef = useRef<number>(Date.now());
const closeSourceRef = useRef<CloseSource | null>(null);
const hasTerminatedHeadlessSessionRef = useRef(false);
const hasMadeHeadlessCheckoutInteractiveRef = useRef(false);
const loadStartTimeRef = useRef<number | null>(null);
const loadUrlErrorsRef = useRef<Set<string>>(new Set());
const lastLoadCompleteUrlRef = useRef<string | null>(null);
const previousNavStateUrlRef = useRef<string | null>(null);

const hasTrackedScreenViewRef = useRef(false);

useEffect(() => {
if (!headlessSessionId) {
return;
}

const touchThroughWhileLoading = Boolean(uri);
hasMadeHeadlessCheckoutInteractiveRef.current = !touchThroughWhileLoading;
setHeadlessEntryCardTouchThrough(navigation, touchThroughWhileLoading);

return () => {
setHeadlessEntryCardTouchThrough(navigation, false);
};
}, [navigation, headlessSessionId, uri]);

useEffect(() => {
if (uri && !hasTrackedScreenViewRef.current) {
hasTrackedScreenViewRef.current = true;
Expand Down Expand Up @@ -176,16 +197,23 @@ const Checkout = () => {
effectiveOrderId,
]);

const dismissActiveHeadlessFlow = useCallback(() => {
dismissHeadlessFlow(navigation);
}, [navigation]);
Comment thread
cursor[bot] marked this conversation as resolved.

const failHeadlessCheckout = useCallback(
(checkoutError: unknown) => {
if (!failSession(headlessSessionId, checkoutError)) {
if (
hasTerminatedHeadlessSessionRef.current ||
!failSession(headlessSessionId, checkoutError)
) {
return false;
}
// @ts-expect-error navigation prop mismatch
navigation.getParent()?.pop();
hasTerminatedHeadlessSessionRef.current = true;
dismissActiveHeadlessFlow();
return true;
},
[headlessSessionId, navigation],
[headlessSessionId, dismissActiveHeadlessFlow],
);

useEffect(() => {
Expand Down Expand Up @@ -291,6 +319,12 @@ const Checkout = () => {
const parsedUrl = parseUrl(navState.url);
if (Object.keys(parsedUrl.query).length === 0) {
closeSourceRef.current = 'callback_success';
if (headlessSessionId) {
hasTerminatedHeadlessSessionRef.current = true;
closeSession(headlessSessionId, { reason: 'user_dismissed' });
dismissActiveHeadlessFlow();
return;
}
// @ts-expect-error navigation prop mismatch
navigation.getParent()?.pop();
return;
Expand Down Expand Up @@ -327,10 +361,10 @@ const Checkout = () => {
'UnifiedCheckout: onOrderCreated callback threw',
);
}
hasTerminatedHeadlessSessionRef.current = true;
closeSession(headlessSessionId, { reason: 'completed' });
closeSourceRef.current = 'callback_success';
// @ts-expect-error navigation prop mismatch
navigation.getParent()?.pop();
dismissActiveHeadlessFlow();
return;
}

Expand Down Expand Up @@ -380,6 +414,7 @@ const Checkout = () => {
isV2Enabled,
params?.cryptocurrency,
headlessSessionId,
dismissActiveHeadlessFlow,
failHeadlessCheckout,
recordUrlChange,
createEventBuilder,
Expand All @@ -405,8 +440,17 @@ const Checkout = () => {
}, [createEventBuilder, trackEvent, rampRoutingDecision]);
const handleClosePress = useCallback(() => {
handleCancelPress();
if (headlessSessionId) {
if (hasTerminatedHeadlessSessionRef.current) {
return;
}
hasTerminatedHeadlessSessionRef.current = true;
closeSession(headlessSessionId, { reason: 'user_dismissed' });
dismissActiveHeadlessFlow();
return;
}
sheetRef.current?.onCloseBottomSheet();
}, [handleCancelPress]);
}, [handleCancelPress, headlessSessionId, dismissActiveHeadlessFlow]);
Comment thread
cursor[bot] marked this conversation as resolved.

const handleNavigationStateChangeWithDedup = useCallback(
(navState: { url: string }) => {
Expand All @@ -425,6 +469,10 @@ const Checkout = () => {

const handleLoadEnd = useCallback(
(syntheticEvent: { nativeEvent: { url: string } }) => {
if (headlessSessionId && !hasMadeHeadlessCheckoutInteractiveRef.current) {
hasMadeHeadlessCheckoutInteractiveRef.current = true;
setHeadlessEntryCardTouchThrough(navigation, false);
}
if (loadStartTimeRef.current === null) return;
const { url: loadedUrl } = syntheticEvent.nativeEvent;
const redactedLoadedUrl = redactUrlForAnalytics(loadedUrl);
Expand Down Expand Up @@ -458,6 +506,8 @@ const Checkout = () => {
checkoutSessionId,
providerName,
rampRoutingDecision,
headlessSessionId,
navigation,
],
);

Expand All @@ -469,6 +519,19 @@ const Checkout = () => {
const fireClosedRef = useRef<() => void>(() => {
/* no-op until initialized */
});
const closeHeadlessOnUnmountRef = useRef<() => void>(() => undefined);
closeHeadlessOnUnmountRef.current = () => {
if (!headlessSessionId || hasTerminatedHeadlessSessionRef.current) {
return;
}
const session = getSession(headlessSessionId);
if (!session) {
return;
}
hasTerminatedHeadlessSessionRef.current = true;
closeSession(headlessSessionId, { reason: 'user_dismissed' });
dismissActiveHeadlessFlow();
};
fireClosedRef.current = () => {
if (!hasTrackedScreenViewRef.current) return;
const lastUrl = urlHistoryRef.current.current;
Expand Down Expand Up @@ -498,6 +561,7 @@ const Checkout = () => {
};
useEffect(
() => () => {
closeHeadlessOnUnmountRef.current();
fireClosedRef.current();
},
[],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { StyleSheet } from 'react-native';
import { Theme } from '../../../../../util/theme/models';

const styleSheet = (params: { theme: Theme }) => {
const { theme } = params;
return StyleSheet.create({
const styleSheet = () =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background.default,
},
body: {
flex: 1,
Expand All @@ -25,6 +22,5 @@ const styleSheet = (params: { theme: Theme }) => {
paddingTop: 12,
},
});
};

export default styleSheet;
Loading
Loading