diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js
index ef3b1b36448a..fea995a5c728 100644
--- a/app/components/Nav/Main/MainNavigator.js
+++ b/app/components/Nav/Main/MainNavigator.js
@@ -1174,6 +1174,14 @@ const MainNavigator = () => {
name={Routes.RAMP.TOKEN_SELECTION}
component={TokenListRoutes}
/>
+
{
const mockAddOrder = jest.fn();
const mockGetOrderFromCallback = jest.fn();
const mockAddPrecreatedOrder = jest.fn();
+ const mockHeadlessEntrySetOptions = jest.fn();
const mockNavigation = {
setOptions: jest.fn(),
reset: jest.fn(),
@@ -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)', () => {
@@ -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);
});
@@ -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(, {}, 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 () => {
@@ -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(, {}, 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(, {}, 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(, {}, 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(, {}, 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);
diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx
index 566815d8f0c7..1e78970249b2 100644
--- a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx
+++ b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx
@@ -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';
@@ -132,12 +136,29 @@ const Checkout = () => {
const stepIndexRef = useRef(0);
const openedAtRef = useRef(Date.now());
const closeSourceRef = useRef(null);
+ const hasTerminatedHeadlessSessionRef = useRef(false);
+ const hasMadeHeadlessCheckoutInteractiveRef = useRef(false);
const loadStartTimeRef = useRef(null);
const loadUrlErrorsRef = useRef>(new Set());
const lastLoadCompleteUrlRef = useRef(null);
const previousNavStateUrlRef = useRef(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;
@@ -176,16 +197,23 @@ const Checkout = () => {
effectiveOrderId,
]);
+ const dismissActiveHeadlessFlow = useCallback(() => {
+ dismissHeadlessFlow(navigation);
+ }, [navigation]);
+
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(() => {
@@ -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;
@@ -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;
}
@@ -380,6 +414,7 @@ const Checkout = () => {
isV2Enabled,
params?.cryptocurrency,
headlessSessionId,
+ dismissActiveHeadlessFlow,
failHeadlessCheckout,
recordUrlChange,
createEventBuilder,
@@ -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]);
const handleNavigationStateChangeWithDedup = useCallback(
(navState: { url: string }) => {
@@ -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);
@@ -458,6 +506,8 @@ const Checkout = () => {
checkoutSessionId,
providerName,
rampRoutingDecision,
+ headlessSessionId,
+ navigation,
],
);
@@ -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;
@@ -498,6 +561,7 @@ const Checkout = () => {
};
useEffect(
() => () => {
+ closeHeadlessOnUnmountRef.current();
fireClosedRef.current();
},
[],
diff --git a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.styles.ts b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.styles.ts
index 86cc34697be1..34ad83fdec60 100644
--- a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.styles.ts
+++ b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.styles.ts
@@ -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,
@@ -25,6 +22,5 @@ const styleSheet = (params: { theme: Theme }) => {
paddingTop: 12,
},
});
-};
export default styleSheet;
diff --git a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx
index b1d7fe2e2a48..010f1dd431e5 100644
--- a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx
+++ b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx
@@ -1,11 +1,8 @@
import React from 'react';
-import { act, fireEvent, screen, waitFor } from '@testing-library/react-native';
+import { act, screen, waitFor } from '@testing-library/react-native';
import HeadlessHost, {
- HEADLESS_HOST_BACK_BUTTON_TEST_ID,
- HEADLESS_HOST_CANCEL_BUTTON_TEST_ID,
- HEADLESS_HOST_LOADER_TEST_ID,
- HEADLESS_HOST_NO_SESSION_TEST_ID,
+ HEADLESS_HOST_CONTAINER_TEST_ID,
createHeadlessHostNavDetails,
type HeadlessHostParams,
} from './HeadlessHost';
@@ -30,8 +27,20 @@ import Routes from '../../../../../constants/navigation/Routes';
const mockGoBack = jest.fn();
const mockNavigate = jest.fn();
+const mockAddListener = jest.fn();
+const mockHeadlessEntrySetOptions = jest.fn();
const mockContinueWithQuote = jest.fn();
const mockUseContinueWithQuoteOptions = jest.fn();
+let mockIsFocused = true;
+
+interface BeforeRemoveEvent {
+ data: { action: { type: string } };
+}
+let registeredBeforeRemoveListener: ((e?: BeforeRemoveEvent) => void) | null =
+ null;
+const DEFAULT_GO_BACK_EVENT: BeforeRemoveEvent = {
+ data: { action: { type: 'GO_BACK' } },
+};
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
@@ -40,7 +49,14 @@ jest.mock('@react-navigation/native', () => {
useNavigation: () => ({
goBack: mockGoBack,
navigate: mockNavigate,
+ addListener: mockAddListener,
+ getParent: () => ({
+ getParent: () => ({
+ setOptions: mockHeadlessEntrySetOptions,
+ }),
+ }),
}),
+ useIsFocused: () => mockIsFocused,
};
});
@@ -158,6 +174,16 @@ describe('HeadlessHost', () => {
beforeEach(() => {
jest.clearAllMocks();
__resetSessionRegistryForTests();
+ registeredBeforeRemoveListener = null;
+ mockIsFocused = true;
+ mockAddListener.mockImplementation(
+ (eventName: string, listener: (e?: BeforeRemoveEvent) => void) => {
+ if (eventName === 'beforeRemove') {
+ registeredBeforeRemoveListener = listener;
+ }
+ return jest.fn();
+ },
+ );
mockUseRampAccountAddress.mockReturnValue('0xWALLET');
mockUseRampsUserRegion.mockReturnValue({
userRegion: { country: { currency: 'EUR' } },
@@ -180,39 +206,47 @@ describe('HeadlessHost', () => {
expect(params).toEqual({ headlessSessionId: 'headless-buy-abc' });
});
- it('renders the no-session message when the session id is unknown', () => {
- renderHost({ headlessSessionId: 'headless-buy-not-real' });
+ it('renders only a transparent container — no header, spinner, or buttons after Phase 9.5', () => {
+ const session = seedSession(buildAggregatorQuote());
+ renderHost({ headlessSessionId: session.id });
expect(
- screen.getByTestId(HEADLESS_HOST_NO_SESSION_TEST_ID),
+ screen.getByTestId(HEADLESS_HOST_CONTAINER_TEST_ID),
).toBeOnTheScreen();
- expect(
- screen.queryByTestId(HEADLESS_HOST_LOADER_TEST_ID),
- ).not.toBeOnTheScreen();
- expect(mockContinueWithQuote).not.toHaveBeenCalled();
+ expect(screen.getByTestId(HEADLESS_HOST_CONTAINER_TEST_ID)).toHaveProp(
+ 'pointerEvents',
+ 'none',
+ );
+ expect(screen.queryByText(/Cancel/i)).not.toBeOnTheScreen();
+ expect(screen.queryByText(/Preparing/i)).not.toBeOnTheScreen();
+ expect(screen.queryByText(/no longer active/i)).not.toBeOnTheScreen();
+ expect(mockHeadlessEntrySetOptions).toHaveBeenCalledWith({
+ cardStyle: {
+ backgroundColor: 'transparent',
+ pointerEvents: 'none',
+ },
+ });
});
- it('renders the loader while a matching session is being orchestrated', () => {
- // Make continueWithQuote hang so the loader stays on screen.
- mockContinueWithQuote.mockImplementation(
- () => new Promise(() => undefined),
- );
- const session = seedSession(buildAggregatorQuote());
- renderHost({ headlessSessionId: session.id });
+ it('renders the transparent container even with no session — no UI affordances surface to the user', () => {
+ renderHost({ headlessSessionId: 'headless-buy-not-real' });
expect(
- screen.getByTestId(HEADLESS_HOST_LOADER_TEST_ID),
+ screen.getByTestId(HEADLESS_HOST_CONTAINER_TEST_ID),
).toBeOnTheScreen();
+ expect(mockContinueWithQuote).not.toHaveBeenCalled();
});
- it('navigates back when the cancel button is pressed', () => {
- renderHost();
- fireEvent.press(screen.getByTestId(HEADLESS_HOST_CANCEL_BUTTON_TEST_ID));
- expect(mockGoBack).toHaveBeenCalledTimes(1);
- });
+ it('restores the headless entry card to interactive when the Host is not focused', () => {
+ mockIsFocused = false;
+ const session = seedSession(buildAggregatorQuote());
- it('navigates back when the header back button is pressed', () => {
- renderHost();
- fireEvent.press(screen.getByTestId(HEADLESS_HOST_BACK_BUTTON_TEST_ID));
- expect(mockGoBack).toHaveBeenCalledTimes(1);
+ renderHost({ headlessSessionId: session.id });
+
+ expect(mockHeadlessEntrySetOptions).toHaveBeenCalledWith({
+ cardStyle: {
+ backgroundColor: 'transparent',
+ pointerEvents: 'auto',
+ },
+ });
});
});
@@ -296,21 +330,14 @@ describe('HeadlessHost', () => {
it('skips orchestration when the session has already been cancelled', async () => {
const quote = buildAggregatorQuote();
const session = seedSession(quote);
- // Mark the session terminal *before* the screen mounts: the focus
- // effect must respect that and avoid a stale re-trigger.
closeSession(session.id, { reason: 'consumer_cancelled' });
renderHost({ headlessSessionId: session.id });
- // No session left → the no-session branch renders, continueWithQuote
- // is never called.
- expect(
- screen.getByTestId(HEADLESS_HOST_NO_SESSION_TEST_ID),
- ).toBeOnTheScreen();
expect(mockContinueWithQuote).not.toHaveBeenCalled();
});
});
describe('Error handling', () => {
- it('forwards a malformed assetId as onError(UNKNOWN, ...) and closes the session', async () => {
+ it('forwards a malformed assetId as onError(UNKNOWN, ...) and closes the session', () => {
// Real hook: falsy chain id → null wallet. The invalid-assetId branch
// must run before the wallet deferral or the effect would return early
// forever (regression guard for guard ordering).
@@ -326,12 +353,9 @@ describe('HeadlessHost', () => {
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
expect(getSession(session.id)).toBeUndefined();
expect(mockContinueWithQuote).not.toHaveBeenCalled();
- await waitFor(() => {
- expect(screen.getByText(/not-a-caip-19/)).toBeOnTheScreen();
- });
});
- it('surfaces a continueWithQuote rejection as onError(UNKNOWN, ...) and renders the message', async () => {
+ it('surfaces a continueWithQuote rejection as onError(UNKNOWN, ...)', async () => {
mockContinueWithQuote.mockRejectedValueOnce(new Error('quote expired'));
const quote = buildAggregatorQuote();
const session = seedSession(quote);
@@ -345,7 +369,6 @@ describe('HeadlessHost', () => {
});
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
expect(getSession(session.id)).toBeUndefined();
- expect(screen.getByText('quote expired')).toBeOnTheScreen();
});
it('surfaces limit failures as onError(LIMIT_EXCEEDED, ...)', async () => {
@@ -364,7 +387,6 @@ describe('HeadlessHost', () => {
});
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
expect(getSession(session.id)).toBeUndefined();
- expect(screen.getByText('Daily limit exceeded')).toBeOnTheScreen();
});
it('does not surface a continueWithQuote rejection that arrives after unmount', async () => {
@@ -383,13 +405,6 @@ describe('HeadlessHost', () => {
expect(mockContinueWithQuote).toHaveBeenCalledTimes(1),
);
unmount();
- // Phase 8: unmount fires the dismissal close because the session
- // had not reached a terminal status. After unmount the session is
- // gone from the registry, so the .catch's live-session re-read
- // short-circuits and does not produce a second onClose or an
- // onError. (The .catch's `cancelled` flag is independent React
- // unmount-state protection; this test does not exercise it
- // directly.)
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onClose).toHaveBeenCalledWith({
reason: 'user_dismissed',
@@ -402,7 +417,7 @@ describe('HeadlessHost', () => {
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
});
- it('forwards a nativeFlowError param as onError(AUTH_FAILED, ...), renders it, and closes the session', () => {
+ it('forwards a nativeFlowError param as onError(AUTH_FAILED, ...) and closes the session', () => {
const quote = buildNativeQuote();
const session = seedSession(quote);
const callbacks = session.callbacks;
@@ -416,10 +431,7 @@ describe('HeadlessHost', () => {
});
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
expect(getSession(session.id)).toBeUndefined();
- // The auth-error path also short-circuits the continue-on-focus effect
- // — we never want to push EnterEmail again on top of the error message.
expect(mockContinueWithQuote).not.toHaveBeenCalled();
- expect(screen.getByText('OTP rejected')).toBeOnTheScreen();
});
it('does not crash when the consumer onError callback throws', async () => {
@@ -440,57 +452,44 @@ describe('HeadlessHost', () => {
},
);
renderHost({ headlessSessionId: session.id });
- // Even though onError throws, the close path still runs and the
- // session is gone from the registry.
await waitFor(() => expect(getSession(session.id)).toBeUndefined());
});
});
- describe('Dismissal (Phase 8)', () => {
- it('fires onClose({ reason: "user_dismissed" }) and navigates back when the cancel button is pressed mid-flow', async () => {
- // Make continueWithQuote hang so the session stays non-terminal while
- // the user taps Cancel — this is the typical dismissal path.
- mockContinueWithQuote.mockImplementation(
- () => new Promise(() => undefined),
- );
+ describe('Dismissal (Phase 8 + 9.5)', () => {
+ it('registers a beforeRemove listener that synchronously closes the session with user_dismissed', () => {
const quote = buildAggregatorQuote();
const session = seedSession(quote);
const callbacks = session.callbacks;
renderHost({ headlessSessionId: session.id });
- await waitFor(() =>
- expect(mockContinueWithQuote).toHaveBeenCalledTimes(1),
+ expect(mockAddListener).toHaveBeenCalledWith(
+ 'beforeRemove',
+ expect.any(Function),
);
+ expect(typeof registeredBeforeRemoveListener).toBe('function');
- fireEvent.press(screen.getByTestId(HEADLESS_HOST_CANCEL_BUTTON_TEST_ID));
+ registeredBeforeRemoveListener?.(DEFAULT_GO_BACK_EVENT);
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onClose).toHaveBeenCalledWith({
reason: 'user_dismissed',
});
- expect(mockGoBack).toHaveBeenCalledTimes(1);
expect(getSession(session.id)).toBeUndefined();
});
- it('fires onClose({ reason: "user_dismissed" }) and navigates back when the header back button is pressed mid-flow', async () => {
- mockContinueWithQuote.mockImplementation(
- () => new Promise(() => undefined),
- );
+ it('does NOT close the session when beforeRemove fires for a RESET action (stack rebuild guard)', () => {
const quote = buildAggregatorQuote();
const session = seedSession(quote);
const callbacks = session.callbacks;
renderHost({ headlessSessionId: session.id });
- await waitFor(() =>
- expect(mockContinueWithQuote).toHaveBeenCalledTimes(1),
- );
-
- fireEvent.press(screen.getByTestId(HEADLESS_HOST_BACK_BUTTON_TEST_ID));
+ expect(typeof registeredBeforeRemoveListener).toBe('function');
- expect(callbacks.onClose).toHaveBeenCalledTimes(1);
- expect(callbacks.onClose).toHaveBeenCalledWith({
- reason: 'user_dismissed',
+ registeredBeforeRemoveListener?.({
+ data: { action: { type: 'RESET' } },
});
- expect(mockGoBack).toHaveBeenCalledTimes(1);
- expect(getSession(session.id)).toBeUndefined();
+
+ expect(callbacks.onClose).not.toHaveBeenCalled();
+ expect(getSession(session.id)).toBeDefined();
});
it('fires onClose({ reason: "user_dismissed" }) once when the host unmounts mid-flow without a terminal status', async () => {
@@ -526,16 +525,13 @@ describe('HeadlessHost', () => {
expect(mockContinueWithQuote).toHaveBeenCalledTimes(1),
);
- // Simulate Phase 6: useTransakRouting / Checkout fires
- // closeSession({ reason: 'completed' }) after onOrderCreated.
closeSession(session.id, { reason: 'completed' });
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'completed' });
+ registeredBeforeRemoveListener?.(DEFAULT_GO_BACK_EVENT);
unmount();
- // Dismissal hook re-reads from the registry on cleanup, sees the
- // session is gone, and no-ops. No second onClose.
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
});
@@ -548,14 +544,12 @@ describe('HeadlessHost', () => {
nativeFlowError: 'OTP rejected',
});
- // Phase 7: nativeFlowError handler funnels through failSession →
- // closeSession({ reason: 'unknown' }, { terminalStatus: 'failed' }).
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
+ registeredBeforeRemoveListener?.(DEFAULT_GO_BACK_EVENT);
unmount();
- // Dismissal hook re-reads, sees nothing, no-ops.
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
});
@@ -563,9 +557,6 @@ describe('HeadlessHost', () => {
const quote = buildAggregatorQuote();
const session = seedSession(quote);
const callbacks = session.callbacks;
- // Cancel before the screen mounts; matches the existing
- // "skips orchestration" assertion but additionally verifies the
- // dismissal cleanup does not produce a spurious second onClose.
closeSession(session.id, { reason: 'consumer_cancelled' });
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onClose).toHaveBeenCalledWith({
@@ -573,6 +564,7 @@ describe('HeadlessHost', () => {
});
const { unmount } = renderHost({ headlessSessionId: session.id });
+ registeredBeforeRemoveListener?.(DEFAULT_GO_BACK_EVENT);
unmount();
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
diff --git a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx
index 4eec03d51ed7..2dd68c0d1136 100644
--- a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx
+++ b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx
@@ -1,15 +1,6 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { ActivityIndicator, View } from 'react-native';
-import { useNavigation } from '@react-navigation/native';
-import { SafeAreaView } from 'react-native-safe-area-context';
-import {
- Button,
- ButtonVariant,
- HeaderStandard,
- Text,
- TextColor,
- TextVariant,
-} from '@metamask/design-system-react-native';
+import React, { useEffect, useMemo } from 'react';
+import { View } from 'react-native';
+import { useIsFocused, useNavigation } from '@react-navigation/native';
import type { CaipChainId } from '@metamask/utils';
import { strings } from '../../../../../../locales/i18n';
@@ -32,6 +23,7 @@ import {
getSession,
setStatus,
} from '../../headless/sessionRegistry';
+import { setHeadlessEntryCardTouchThrough } from '../../headless/headlessEntryNavigation';
import { useHeadlessSessionDismissal } from '../../headless/useHeadlessSessionDismissal';
import { getChainIdFromAssetId } from '../../headless/useHeadlessBuy';
import useContinueWithQuote, {
@@ -44,11 +36,7 @@ import { getQuoteProviderName } from '../../types';
import styleSheet from './HeadlessHost.styles';
-export const HEADLESS_HOST_HEADER_TEST_ID = 'headless-host-header';
-export const HEADLESS_HOST_BACK_BUTTON_TEST_ID = 'headless-host-back-button';
-export const HEADLESS_HOST_LOADER_TEST_ID = 'headless-host-loader';
-export const HEADLESS_HOST_NO_SESSION_TEST_ID = 'headless-host-no-session';
-export const HEADLESS_HOST_CANCEL_BUTTON_TEST_ID = 'headless-host-cancel';
+export const HEADLESS_HOST_CONTAINER_TEST_ID = 'headless-host-container';
export interface HeadlessHostParams {
/** Session id created by `useHeadlessBuy().startHeadlessBuy(...)`. */
@@ -70,31 +58,34 @@ export interface HeadlessHostParams {
export const createHeadlessHostNavDetails =
createNavigationDetails(Routes.RAMP.HEADLESS_HOST);
-/**
- * Headless Host screen.
- *
- * Acts as the (stable) stack base for the headless buy flow:
- * - On focus, picks up the live session by `headlessSessionId`.
- * - Derives a `ContinueWithQuoteContext` directly from `session.params.quote` (no controller selections needed).
- * - Calls `useContinueWithQuote().continueWithQuote(...)` exactly once, using the session status as a guard so re-focuses caused by the Transak auth loop don't re-trigger the flow.
- * - Surfaces `nativeFlowError` (set by OtpCode on routing failure) as `onError('AUTH_FAILED', ...)` and closes the session.
- * - When no session is found (e.g. consumer cancelled meanwhile), shows a passive "no session" message with a cancel/back affordance.
- */
function HeadlessHost() {
const navigation = useNavigation();
+ const isFocused = useIsFocused();
const { styles } = useStyles(styleSheet, {});
const { headlessSessionId, nativeFlowError } =
useParams();
const session = getSession(headlessSessionId);
- // Phase 8: when the Host unmounts (= user unwound the entire headless
- // stack) without a terminal status, fire `onClose({ reason:
- // 'user_dismissed' })`. Phase 6 success and Phase 7 errors remove the
- // session from the registry beforehand, so the cleanup no-ops in those
- // cases. Wiring lives on the Host because it is the stack base for the
- // headless flow and stays mounted while child screens are pushed on top.
+ useEffect(() => {
+ setHeadlessEntryCardTouchThrough(navigation, isFocused);
+
+ return () => {
+ setHeadlessEntryCardTouchThrough(navigation, false);
+ };
+ }, [navigation, isFocused]);
+
useHeadlessSessionDismissal(headlessSessionId);
+ useEffect(() => {
+ const unsubscribe = navigation.addListener('beforeRemove', (e) => {
+ if (e.data.action.type === 'RESET') {
+ return;
+ }
+ closeSession(headlessSessionId, { reason: 'user_dismissed' });
+ });
+ return unsubscribe;
+ }, [navigation, headlessSessionId]);
+
const { userRegion } = useRampsUserRegion();
const { paymentMethods } = useRampsPaymentMethods();
@@ -118,25 +109,6 @@ function HeadlessHost() {
: null;
const walletAddress = useRampAccountAddress(chainId ?? ('' as CaipChainId));
- const [errorMessage, setErrorMessage] = useState(null);
-
- // Reset UI state whenever a new session is wired up, so the second (and
- // subsequent) headless buy starts with a clean slate.
- useEffect(() => {
- setErrorMessage(null);
- }, [headlessSessionId]);
-
- const handleBack = useCallback(() => {
- // Fire dismissal close synchronously at the moment of intent. The
- // unmount cleanup in `useHeadlessSessionDismissal` is a defense-in-depth
- // fallback for paths that don't go through this handler (back-gesture,
- // programmatic navigation). `closeSession` is idempotent — when the
- // session is already terminal (Phase 6/7 cleared it before the user
- // tapped Back), this is a no-op.
- closeSession(headlessSessionId, { reason: 'user_dismissed' });
- navigation.goBack();
- }, [headlessSessionId, navigation]);
-
// Auth-loop error path: OtpCode resets back to the Host with
// `nativeFlowError` set when post-OTP routing fails. Forward to the
// consumer once and close the session.
@@ -144,12 +116,12 @@ function HeadlessHost() {
// Re-reads from the registry rather than using the render-time `session`
// reference so that if the processing effect's .catch already closed the
// session (both paths firing simultaneously), this becomes a no-op and the
- // consumer's onError is not called a second time (Bug 1 / Bug 2 fix).
+ // consumer's onError is not called a second time.
useEffect(() => {
if (!nativeFlowError) {
return;
}
- const headlessError = failSession(
+ failSession(
headlessSessionId,
{
code: 'AUTH_FAILED',
@@ -157,9 +129,6 @@ function HeadlessHost() {
},
'AUTH_FAILED',
);
- if (headlessError) {
- setErrorMessage(headlessError.message ?? nativeFlowError);
- }
}, [nativeFlowError, headlessSessionId]);
// Process the session. Uses `useEffect` (not `useFocusEffect`) so that
@@ -176,24 +145,21 @@ function HeadlessHost() {
// `walletAddress` begins as null while `useRampAccountAddress` resolves
// async. The effect body validates chainId before deferring on wallet:
// a null chainId also yields walletAddress === null (falsy chain id), so
- // the invalid-assetId branch must run first or the host would spin forever.
- // After chainId is valid, defer (leave status as 'pending') until
+ // the invalid-assetId branch must run first or the host would defer
+ // forever. After chainId is valid, defer (leave status as 'pending') until
// walletAddress settles — a non-null value is a required input for
// widget/order URLs.
- // When it resolves the effect re-fires (walletAddress is a dep) and
- // proceeds with the real address. The status guard prevents a second
- // invocation once continued.
//
// `session` is intentionally excluded from deps and re-read inside via
// `getSession(headlessSessionId)`. This removes the fragile object-reference
// dep and lets the .catch handler confirm the session is still live before
// firing onError (preventing duplicate callbacks when nativeFlowError and
- // the promise rejection race — see previous fixes).
+ // the promise rejection race).
//
// `continueWithQuote` is async with no cancellation API; on unmount (or when
// deps change after this run has started the promise) we must not call
- // `setErrorMessage`, consumer callbacks, or `closeSession` from a late
- // rejection — avoids setState-on-unmounted warnings and spurious `onClose`.
+ // consumer callbacks or `closeSession` from a late rejection — avoids
+ // spurious `onClose`/`onError` after the consumer already moved on.
useEffect(() => {
let cancelled = false;
const currentSession = getSession(headlessSessionId);
@@ -206,14 +172,10 @@ function HeadlessHost() {
// Invalid assetId must run before the wallet deferral: when chainId is
// null we still call useRampAccountAddress with a falsy chain id, which
// yields walletAddress === null. If we deferred on wallet first, we'd
- // spin forever and never surface the UNKNOWN invalid-assetId error.
+ // never surface the UNKNOWN invalid-assetId error.
if (!chainId) {
const message = `HeadlessHost: invalid assetId "${currentSession.params.assetId}"`;
Logger.error(new Error(message));
- // closeSession alone does not trigger a re-render; without setState the
- // render-time `session` ref stays truthy and the loader would spin
- // forever. Surface the same message in UI as other error paths.
- setErrorMessage(message);
failSession(headlessSessionId, { code: 'UNKNOWN', message });
return;
}
@@ -256,16 +218,19 @@ function HeadlessHost() {
}
const message =
error?.message ?? strings('deposit.buildQuote.unexpectedError');
+ Logger.error(
+ error,
+ `HeadlessHost: continueWithQuote rejected: ${message}`,
+ );
// Re-read from the registry: the nativeFlowError effect may have already
// closed this session if auth failure arrived via params simultaneously
// with the promise rejection. If so, bail — the consumer already got
- // onError from the nativeFlowError path (Bug 1 fix).
+ // onError from the nativeFlowError path.
const liveSession = getSession(headlessSessionId);
if (!liveSession) {
return;
}
- const headlessError = failSession(headlessSessionId, error);
- setErrorMessage(headlessError?.message ?? message);
+ failSession(headlessSessionId, error);
});
return () => {
cancelled = true;
@@ -281,54 +246,11 @@ function HeadlessHost() {
]);
return (
-
-
-
- {errorMessage ? (
-
- {errorMessage}
-
- ) : session ? (
- <>
-
-
- {strings('app_settings.fiat_on_ramp.headless_host.loading')}
-
- >
- ) : (
-
- {strings('app_settings.fiat_on_ramp.headless_host.no_session')}
-
- )}
-
-
-
-
-
+
);
}
diff --git a/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.test.tsx b/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.test.tsx
index 0d92ee73a298..fa011a076830 100644
--- a/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.test.tsx
+++ b/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.test.tsx
@@ -26,6 +26,7 @@ import HeadlessPlayground, {
HEADLESS_PLAYGROUND_RESET_ASSET_TEST_ID,
HEADLESS_PLAYGROUND_RESET_PAYMENT_METHOD_TEST_ID,
HEADLESS_PLAYGROUND_RESET_PROVIDER_TEST_ID,
+ HEADLESS_PLAYGROUND_START_BUTTON_SPINNER_TEST_ID,
HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID,
HEADLESS_PLAYGROUND_SUMMARY_DIVIDER_TEST_ID,
HEADLESS_PLAYGROUND_SUMMARY_TEST_ID,
@@ -1136,6 +1137,23 @@ describe('HeadlessPlayground', () => {
).toBeOnTheScreen();
});
+ it('shows a loading spinner inside the quote button that started the session', async () => {
+ await renderWithQuotes();
+ fireEvent.press(
+ screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
+ );
+ expect(
+ screen.getByTestId(
+ `${HEADLESS_PLAYGROUND_START_BUTTON_SPINNER_TEST_ID}-0`,
+ ),
+ ).toBeOnTheScreen();
+ expect(
+ screen.queryByTestId(
+ `${HEADLESS_PLAYGROUND_START_BUTTON_SPINNER_TEST_ID}-1`,
+ ),
+ ).not.toBeOnTheScreen();
+ });
+
it('appends a started entry to the event log including the session id', async () => {
await renderWithQuotes();
fireEvent.press(
diff --git a/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.tsx b/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.tsx
index 8b45beb30e10..6389c31eab71 100644
--- a/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.tsx
+++ b/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.tsx
@@ -17,6 +17,7 @@ import {
IconColor,
IconName,
IconSize,
+ Spinner,
Text,
TextColor,
TextVariant,
@@ -64,6 +65,8 @@ export const HEADLESS_PLAYGROUND_RESET_PROVIDER_TEST_ID =
'headless-playground-reset-provider';
export const HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID =
'headless-playground-start-button';
+export const HEADLESS_PLAYGROUND_START_BUTTON_SPINNER_TEST_ID =
+ 'headless-playground-start-button-spinner';
export const HEADLESS_PLAYGROUND_CANCEL_BUTTON_TEST_ID =
'headless-playground-cancel-button';
export const HEADLESS_PLAYGROUND_EVENT_LOG_TEST_ID =
@@ -469,6 +472,7 @@ function HeadlessPlayground() {
const [activeSession, setActiveSession] = useState<{
sessionId: string;
cancel: () => void;
+ quoteIndex: number;
} | null>(null);
const appendEvent = useCallback((message: string) => {
@@ -486,7 +490,7 @@ function HeadlessPlayground() {
// policy (auto-cancels any prior active session), so this UI only has
// to wire callbacks + log events.
const handleStartHeadlessBuyForQuote = useCallback(
- (quote: Quote) => {
+ (quote: Quote, quoteIndex: number) => {
if (!headlessAmountIsValid) {
return;
}
@@ -542,7 +546,7 @@ function HeadlessPlayground() {
},
),
);
- setActiveSession(session);
+ setActiveSession({ ...session, quoteIndex });
},
[
appendEvent,
@@ -1035,6 +1039,74 @@ function HeadlessPlayground() {
) : null}
+
+
+
+
+ {headlessQuotesStatus === 'idle' ? (
+
+ {strings(
+ 'app_settings.fiat_on_ramp.headless_playground.quotes_idle',
+ )}
+
+ ) : null}
+ {headlessQuotesStatus === 'loading' ? (
+
+ {strings(
+ 'app_settings.fiat_on_ramp.headless_playground.loading',
+ )}
+
+ ) : null}
+ {headlessQuotesStatus === 'error' ? (
+
+ {`${strings(
+ 'app_settings.fiat_on_ramp.headless_playground.error',
+ )}: ${headlessQuotesError ?? ''}`}
+
+ ) : null}
+ {headlessQuotesStatus === 'success' ? (
+
+ ) : null}
+
+
+
+
+
{activeSession ? (
-
-
-
-
-
- {headlessQuotesStatus === 'idle' ? (
-
- {strings(
- 'app_settings.fiat_on_ramp.headless_playground.quotes_idle',
- )}
-
- ) : null}
- {headlessQuotesStatus === 'loading' ? (
-
- {strings(
- 'app_settings.fiat_on_ramp.headless_playground.loading',
- )}
-
- ) : null}
- {headlessQuotesStatus === 'error' ? (
-
- {`${strings(
- 'app_settings.fiat_on_ramp.headless_playground.error',
- )}: ${headlessQuotesError ?? ''}`}
-
- ) : null}
- {headlessQuotesStatus === 'success' ? (
-
- ) : null}
-
-
-
-
@@ -1182,7 +1189,8 @@ interface QuotesListProps {
fiatCurrency?: string;
canStart: boolean;
hasActiveSession: boolean;
- onStartHeadlessBuy: (quote: Quote) => void;
+ activeSessionQuoteIndex: number | null;
+ onStartHeadlessBuy: (quote: Quote, quoteIndex: number) => void;
styles: ReturnType;
}
@@ -1193,6 +1201,7 @@ function QuotesList({
fiatCurrency,
canStart,
hasActiveSession,
+ activeSessionQuoteIndex,
onStartHeadlessBuy,
styles,
}: QuotesListProps) {
@@ -1221,6 +1230,7 @@ function QuotesList({
fiatCurrency={fiatCurrency}
canStart={canStart}
hasActiveSession={hasActiveSession}
+ isActiveSessionQuote={activeSessionQuoteIndex === index}
onStartHeadlessBuy={onStartHeadlessBuy}
styles={styles}
/>
@@ -1266,7 +1276,8 @@ interface QuoteRowProps {
fiatCurrency?: string;
canStart: boolean;
hasActiveSession: boolean;
- onStartHeadlessBuy: (quote: Quote) => void;
+ isActiveSessionQuote: boolean;
+ onStartHeadlessBuy: (quote: Quote, quoteIndex: number) => void;
styles: ReturnType;
}
@@ -1278,6 +1289,7 @@ function QuoteRow({
fiatCurrency,
canStart,
hasActiveSession,
+ isActiveSessionQuote,
onStartHeadlessBuy,
styles,
}: QuoteRowProps) {
@@ -1396,7 +1408,16 @@ function QuoteRow({
variant={ButtonVariant.Primary}
isFullWidth
isDisabled={!canStart}
- onPress={() => onStartHeadlessBuy(entry)}
+ onPress={() => onStartHeadlessBuy(entry, index)}
+ startAccessory={
+ isActiveSessionQuote ? (
+
+ ) : undefined
+ }
testID={`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-${index}`}
>
{hasActiveSession
diff --git a/app/components/UI/Ramp/headless/headlessEntryNavigation.ts b/app/components/UI/Ramp/headless/headlessEntryNavigation.ts
new file mode 100644
index 000000000000..73e15e09facf
--- /dev/null
+++ b/app/components/UI/Ramp/headless/headlessEntryNavigation.ts
@@ -0,0 +1,42 @@
+interface NavigationNode {
+ getParent?: () => NavigationNode | undefined;
+ goBack?: () => void;
+ pop?: () => void;
+ setOptions?: (options: Record) => void;
+}
+
+export const setHeadlessEntryCardTouchThrough = (
+ navigation: NavigationNode | undefined,
+ touchThrough: boolean,
+): boolean => {
+ const headlessEntryNavigation = navigation?.getParent?.()?.getParent?.();
+ if (!headlessEntryNavigation?.setOptions) {
+ return false;
+ }
+
+ headlessEntryNavigation.setOptions({
+ cardStyle: {
+ backgroundColor: 'transparent',
+ pointerEvents: touchThrough ? 'none' : 'auto',
+ },
+ });
+ return true;
+};
+
+export const dismissHeadlessFlow = (
+ navigation: NavigationNode | undefined,
+): boolean => {
+ const parentNavigation = navigation?.getParent?.();
+ const outerNavigation = parentNavigation?.getParent?.();
+ const dismiss =
+ outerNavigation?.goBack ??
+ outerNavigation?.pop ??
+ parentNavigation?.pop ??
+ navigation?.goBack;
+
+ if (!dismiss) {
+ return false;
+ }
+ dismiss();
+ return true;
+};
diff --git a/app/components/UI/Ramp/headless/useHeadlessBuy.test.ts b/app/components/UI/Ramp/headless/useHeadlessBuy.test.ts
index dc8e6dbdb8f6..fefe566191a9 100644
--- a/app/components/UI/Ramp/headless/useHeadlessBuy.test.ts
+++ b/app/components/UI/Ramp/headless/useHeadlessBuy.test.ts
@@ -35,6 +35,7 @@ jest.mock('../../../../constants/navigation/Routes', () => ({
BUY: 'RampBuy',
TOKEN_SELECTION: 'RampTokenSelection',
HEADLESS_HOST: 'RampHeadlessHost',
+ HEADLESS_ENTRY: 'RampHeadlessEntry',
},
},
}));
@@ -447,7 +448,7 @@ describe('useHeadlessBuy', () => {
if (!started) {
throw new Error('startHeadlessBuy did not return a session');
}
- expect(mockNavigate).toHaveBeenCalledWith('RampTokenSelection', {
+ expect(mockNavigate).toHaveBeenCalledWith('RampHeadlessEntry', {
screen: 'RampTokenSelection',
params: {
screen: 'RampHeadlessHost',
diff --git a/app/components/UI/Ramp/headless/useHeadlessBuy.ts b/app/components/UI/Ramp/headless/useHeadlessBuy.ts
index 12e60ed07dcf..3f64a1d2f26d 100644
--- a/app/components/UI/Ramp/headless/useHeadlessBuy.ts
+++ b/app/components/UI/Ramp/headless/useHeadlessBuy.ts
@@ -168,19 +168,7 @@ export function useHeadlessBuy(): HeadlessBuyResult {
const session = createSession(params, callbacks);
- // The Headless Host is registered inside the Unified Buy v2 stack
- // (`app/components/UI/Ramp/routes.tsx` → `MainRoutes`) so it lives
- // next to every post-auth reset target (`Checkout`, `BasicInfo`,
- // `KycWebview`, …). From outside that stack we have to enter the
- // V2 stack via its outer mount point (`RAMP.TOKEN_SELECTION` in
- // `MainNavigator.js`, which renders `TokenListRoutes`) and hand
- // React Navigation a nested-screen descriptor:
- // RAMP.TOKEN_SELECTION (outer)
- // → RAMP.TOKEN_SELECTION (RootStack slot wrapping `MainRoutes`)
- // → RAMP.HEADLESS_HOST (target screen on the inner stack)
- // Resetting the Host's nearest navigator (the `MainRoutes` inner
- // stack) then resolves all the `useTransakRouting` targets.
- navigation.navigate(Routes.RAMP.TOKEN_SELECTION, {
+ navigation.navigate(Routes.RAMP.HEADLESS_ENTRY, {
screen: Routes.RAMP.TOKEN_SELECTION,
params: {
screen: Routes.RAMP.HEADLESS_HOST,
diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts
index d766852dce98..deb67d180b07 100644
--- a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts
+++ b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts
@@ -158,17 +158,25 @@ jest.mock('../Views/Checkout', () => ({
url,
providerName,
onNavigationStateChange,
+ headlessSessionId,
workFlowRunId,
}: {
url: string;
providerName: string;
onNavigationStateChange?: (nav: { url: string }) => void;
+ headlessSessionId?: string;
workFlowRunId?: string;
}) => {
capturedHandleNavigationStateChange = onNavigationStateChange ?? null;
return [
'Checkout',
- { url, providerName, onNavigationStateChange, workFlowRunId },
+ {
+ url,
+ providerName,
+ onNavigationStateChange,
+ headlessSessionId,
+ workFlowRunId,
+ },
];
},
),
@@ -980,6 +988,86 @@ describe('useTransakRouting', () => {
expect(mockRequestOtt).toHaveBeenCalled();
});
+
+ it('routes getUserLimits infrastructure errors through failSession when headlessSessionId is set', async () => {
+ const mockFailSession = jest.requireMock('../headless/sessionRegistry')
+ .failSession as jest.Mock;
+ mockGetUserDetails.mockResolvedValue({
+ firstName: 'John',
+ address: {},
+ });
+ mockGetKycRequirement.mockResolvedValue({
+ status: 'APPROVED',
+ kycType: 'SIMPLE',
+ });
+ const networkError = new Error('Network failure');
+ mockGetUserLimits.mockRejectedValue(networkError);
+ mockRequestOtt.mockResolvedValue({ ott: 'test-ott' });
+ mockGeneratePaymentWidgetUrl.mockReturnValue(
+ 'https://payment.example.com',
+ );
+
+ const { result } = renderHook(() =>
+ useTransakRouting({
+ baseRoute: 'RampHeadlessHost',
+ baseRouteParams: { headlessSessionId: 'headless-buy-fixa' },
+ }),
+ );
+
+ let caught: unknown;
+ await act(async () => {
+ try {
+ await result.current.routeAfterAuthentication(
+ mockQuote as never,
+ mockQuote.fiatAmount,
+ );
+ } catch (e) {
+ caught = e;
+ }
+ });
+ expect(caught).toBeInstanceOf(Error);
+
+ expect(mockFailSession).toHaveBeenCalledWith(
+ 'headless-buy-fixa',
+ networkError,
+ );
+ expect(mockRequestOtt).not.toHaveBeenCalled();
+ });
+
+ it('does not call failSession when LimitExceededError fires in headless mode (early rethrow wins)', async () => {
+ const mockFailSession = jest.requireMock('../headless/sessionRegistry')
+ .failSession as jest.Mock;
+ mockFailSession.mockReset();
+ mockGetUserDetails.mockResolvedValue({
+ firstName: 'John',
+ address: {},
+ });
+ mockGetKycRequirement.mockResolvedValue({
+ status: 'APPROVED',
+ kycType: 'SIMPLE',
+ });
+ mockGetUserLimits.mockResolvedValue({
+ remaining: { '1': 0, '30': 50000, '365': 200000 },
+ });
+
+ const { result } = renderHook(() =>
+ useTransakRouting({
+ baseRoute: 'RampHeadlessHost',
+ baseRouteParams: { headlessSessionId: 'headless-buy-fixa-limit' },
+ }),
+ );
+
+ await expect(
+ act(async () => {
+ await result.current.routeAfterAuthentication(
+ mockQuote as never,
+ mockQuote.fiatAmount,
+ );
+ }),
+ ).rejects.toThrow();
+
+ expect(mockFailSession).not.toHaveBeenCalled();
+ });
});
describe('navigateToVerifyIdentity', () => {
@@ -1491,42 +1579,29 @@ describe('useTransakRouting', () => {
reason: 'completed',
});
expect(mockParentPop).toHaveBeenCalled();
- expect(mockReset).not.toHaveBeenCalledWith(
- expect.objectContaining({
- routes: [expect.objectContaining({ name: 'RampsOrderDetails' })],
- }),
- );
- expect(mockShowV2OrderToast).not.toHaveBeenCalled();
- });
-
- it('still adds the order and tracks the transaction event when headless', async () => {
- mockGetSession.mockReturnValue({
- id: 'hs-1',
- status: 'continued',
- callbacks: {
- onOrderCreated: jest.fn(),
- onError: jest.fn(),
- onClose: jest.fn(),
- },
- });
-
- const handler = await runApprovedFlowHeadless();
- expect(handler).not.toBeNull();
- if (!handler) return;
-
- await act(async () => {
- await handler({
- url: 'https://redirect.example.com?orderId=order-hs',
- });
- });
-
expect(mockAddOrder).toHaveBeenCalledWith(
expect.objectContaining({ providerOrderId: 'order-hs' }),
);
+ expect(mockReset).toHaveBeenCalledWith(
+ expect.objectContaining({
+ routes: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'Checkout',
+ params: expect.objectContaining({ headlessSessionId: 'hs-1' }),
+ }),
+ ]),
+ }),
+ );
expect(mockTrackEvent).toHaveBeenCalledWith(
'RAMPS_TRANSACTION_CONFIRMED',
expect.objectContaining({ ramp_type: 'DEPOSIT' }),
);
+ expect(mockReset).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ routes: [expect.objectContaining({ name: 'RampsOrderDetails' })],
+ }),
+ );
+ expect(mockShowV2OrderToast).not.toHaveBeenCalled();
});
it('swallows consumer onOrderCreated errors and still closes + pops', async () => {
@@ -1619,6 +1694,25 @@ describe('useTransakRouting', () => {
expect(mockParentPop).toHaveBeenCalled();
});
+ it('treats a Transak redirect without orderId as user dismissal when headless', async () => {
+ const handler = await runApprovedFlowHeadless();
+ expect(handler).not.toBeNull();
+ if (!handler) return;
+
+ await act(async () => {
+ await handler({
+ url: 'https://redirect.example.com/callback',
+ });
+ });
+
+ expect(mockCloseSession).toHaveBeenCalledWith('hs-1', {
+ reason: 'user_dismissed',
+ });
+ expect(mockParentPop).toHaveBeenCalled();
+ expect(mockGetOrder).not.toHaveBeenCalled();
+ expect(mockShowV2OrderToast).not.toHaveBeenCalled();
+ });
+
it('routes manual bank transfer order success through headless callbacks without showing a toast', async () => {
const onOrderCreated = jest.fn();
mockGetSession.mockReturnValue({
diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.ts b/app/components/UI/Ramp/hooks/useTransakRouting.ts
index 5319a7e41f84..53b4eff3f826 100644
--- a/app/components/UI/Ramp/hooks/useTransakRouting.ts
+++ b/app/components/UI/Ramp/hooks/useTransakRouting.ts
@@ -33,6 +33,7 @@ import {
failSession,
getSession,
} from '../headless/sessionRegistry';
+import { dismissHeadlessFlow } from '../headless/headlessEntryNavigation';
interface RampStackParamList {
/** `baseRouteParams` (e.g. `headlessSessionId`) are merged onto this route in resets — see `navigateToVerifyIdentityCallback`. */
@@ -145,6 +146,10 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => {
const processingOrderIdRef = useRef(null);
const { addOrder, refreshOrder } = useRampsOrders();
+ const dismissActiveHeadlessFlow = useCallback(() => {
+ dismissHeadlessFlow(navigation);
+ }, [navigation]);
+
const {
logoutFromProvider,
getUserDetails,
@@ -242,10 +247,14 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => {
if (error instanceof LimitExceededError) {
throw error;
}
+ if (headlessSessionId) {
+ failSession(headlessSessionId, error);
+ throw error;
+ }
Logger.error(error as Error, 'Failed to check user limits');
}
},
- [getUserLimits, fiatCurrency, selectedPaymentMethod?.id],
+ [getUserLimits, fiatCurrency, selectedPaymentMethod?.id, headlessSessionId],
);
const navigateToVerifyIdentityCallback = useCallback(
@@ -334,10 +343,7 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => {
);
}
closeSession(headlessSessionId, { reason: 'completed' });
- // @ts-expect-error `pop` exists on the parent stack navigator at
- // runtime but is not surfaced on the generic `NavigationProp`
- // type returned by `getParent()`.
- navigation.getParent()?.pop();
+ dismissActiveHeadlessFlow();
return;
}
navigation.reset({
@@ -350,7 +356,7 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => {
],
});
},
- [navigation, headlessSessionId],
+ [navigation, headlessSessionId, dismissActiveHeadlessFlow],
);
const navigateToAdditionalVerificationCallback = useCallback(
@@ -395,7 +401,16 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => {
return;
}
- if (!orderId || processingOrderIdRef.current === orderId) return;
+ if (!orderId) {
+ if (headlessSessionId) {
+ closeSession(headlessSessionId, { reason: 'user_dismissed' });
+ dismissActiveHeadlessFlow();
+ }
+ return;
+ }
+ if (processingOrderIdRef.current === orderId) {
+ return;
+ }
processingOrderIdRef.current = orderId;
try {
@@ -460,10 +475,7 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => {
message: 'useTransakRouting: Failed to process order after checkout',
});
if (failSession(headlessSessionId, error)) {
- // @ts-expect-error `pop` exists on the parent stack navigator at
- // runtime but is not surfaced on the generic `NavigationProp`
- // type returned by `getParent()`.
- navigation.getParent()?.pop();
+ dismissActiveHeadlessFlow();
}
}
},
@@ -476,7 +488,7 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => {
regionIsoCode,
trackEvent,
headlessSessionId,
- navigation,
+ dismissActiveHeadlessFlow,
],
);
@@ -486,6 +498,7 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => {
url: paymentUrl,
providerName: 'Transak',
onNavigationStateChange: handleNavigationStateChange,
+ headlessSessionId,
});
const baseEntry = buildBaseRouteEntry({ amount });
navigation.reset({
@@ -493,7 +506,12 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => {
routes: [baseEntry, { name: routeName, params: routeParams }],
});
},
- [navigation, handleNavigationStateChange, buildBaseRouteEntry],
+ [
+ navigation,
+ handleNavigationStateChange,
+ buildBaseRouteEntry,
+ headlessSessionId,
+ ],
);
const navigateToKycProcessingCallback = useCallback(
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 6ff4e76b47a0..7c6692bdee9f 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -23,6 +23,7 @@ const Routes = {
ACTIVATION_KEY_FORM: 'RampActivationKeyForm',
HEADLESS_PLAYGROUND: 'RampHeadlessPlayground',
HEADLESS_HOST: 'RampHeadlessHost',
+ HEADLESS_ENTRY: 'RampHeadlessEntry',
AMOUNT_INPUT: 'RampAmountInput',
ENTER_EMAIL: 'RampEnterEmail',
OTP_CODE: 'RampOtpCode',
diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts
index b1064d6fb881..7b66e4e7cd8e 100644
--- a/app/core/NavigationService/types.ts
+++ b/app/core/NavigationService/types.ts
@@ -265,6 +265,7 @@ export interface RootStackParamList extends ParamListBase {
RampBuy: RampBuySellParams | undefined;
RampSell: RampBuySellParams | undefined;
RampTokenSelection: undefined;
+ RampHeadlessEntry: undefined;
GetStarted: undefined;
/**
* BuildQuote route is shared between: