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: