From 76ebcb82838f434c087023e663a6ace6430f096d Mon Sep 17 00:00:00 2001 From: saustrie-consensys <270766059+saustrie-consensys@users.noreply.github.com> Date: Fri, 8 May 2026 16:56:09 +0300 Subject: [PATCH 1/5] feat(ramp): wire onClose for user dismissal (Phase 8) Closes Phase 8 of the Headless Buy plan. When the user backs out of the headless flow without producing an order, the consumer's onClose callback now fires with { reason: 'user_dismissed' } so external consumers (notably MetaMask Pay's TransactionPayController) can detect dismissal and clean up. - Add useHeadlessSessionDismissal hook that fires the dismissal close on unmount when the session is still in the registry - Call the hook from HeadlessHost (the only headless entry under quote-first) and fire closeSession synchronously from handleBack so the close happens at the moment of intent, with the unmount cleanup as a defense-in-depth fallback for back-gesture / programmatic nav - Idempotent: closeSession no-ops on terminal sessions, so Phase 6 (completed), Phase 7 (failSession), Phase 5 restart and consumer cancel paths each fire onClose exactly once before unmount and the dismissal cleanup that follows is a no-op - PLAN.md: check off Phase 8, relocate BuildQuote-dismissal bullet (deferred to Phase 10), add Phase 9 Update reflecting MetaMask Pay's awaitOrderTerminalState requirement, restructure Phase 10 to absorb deferred Phase 5b --- .../Views/HeadlessHost/HeadlessHost.test.tsx | 150 ++++++++++++- .../Ramp/Views/HeadlessHost/HeadlessHost.tsx | 19 +- app/components/UI/Ramp/headless/PLAN.md | 41 +++- app/components/UI/Ramp/headless/index.ts | 1 + .../useHeadlessSessionDismissal.test.ts | 207 ++++++++++++++++++ .../headless/useHeadlessSessionDismissal.ts | 38 ++++ 6 files changed, 441 insertions(+), 15 deletions(-) create mode 100644 app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts create mode 100644 app/components/UI/Ramp/headless/useHeadlessSessionDismissal.ts diff --git a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx index 67d2031d98a..b1d7fe2e2a4 100644 --- a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx +++ b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx @@ -367,7 +367,7 @@ describe('HeadlessHost', () => { expect(screen.getByText('Daily limit exceeded')).toBeOnTheScreen(); }); - it('does not run the continueWithQuote rejection path after unmount', async () => { + it('does not surface a continueWithQuote rejection that arrives after unmount', async () => { let rejectDeferred: ((error: Error) => void) | undefined; mockContinueWithQuote.mockImplementation( () => @@ -383,12 +383,23 @@ 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', + }); + expect(getSession(session.id)).toBeUndefined(); await act(async () => { rejectDeferred?.(new Error('late failure')); }); expect(callbacks.onError).not.toHaveBeenCalled(); - expect(callbacks.onClose).not.toHaveBeenCalled(); - expect(getSession(session.id)?.status).toBe('continued'); + expect(callbacks.onClose).toHaveBeenCalledTimes(1); }); it('forwards a nativeFlowError param as onError(AUTH_FAILED, ...), renders it, and closes the session', () => { @@ -434,4 +445,137 @@ describe('HeadlessHost', () => { 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), + ); + 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_CANCEL_BUTTON_TEST_ID)); + + 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), + ); + 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(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" }) once when the host unmounts mid-flow without a terminal status', async () => { + mockContinueWithQuote.mockImplementation( + () => new Promise(() => undefined), + ); + const quote = buildAggregatorQuote(); + const session = seedSession(quote); + const callbacks = session.callbacks; + const { unmount } = renderHost({ headlessSessionId: session.id }); + await waitFor(() => + expect(mockContinueWithQuote).toHaveBeenCalledTimes(1), + ); + + unmount(); + + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + expect(callbacks.onClose).toHaveBeenCalledWith({ + reason: 'user_dismissed', + }); + expect(getSession(session.id)).toBeUndefined(); + }); + + it('does not fire a second onClose when the session was already closed by Phase 6 success before unmount', async () => { + mockContinueWithQuote.mockImplementation( + () => new Promise(() => undefined), + ); + const quote = buildAggregatorQuote(); + const session = seedSession(quote); + const callbacks = session.callbacks; + const { unmount } = renderHost({ headlessSessionId: session.id }); + await waitFor(() => + 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' }); + + 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); + }); + + it('does not fire a second onClose when a Phase 7 nativeFlowError already closed the session before unmount', () => { + const quote = buildNativeQuote(); + const session = seedSession(quote); + const callbacks = session.callbacks; + const { unmount } = renderHost({ + headlessSessionId: session.id, + 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' }); + + unmount(); + + // Dismissal hook re-reads, sees nothing, no-ops. + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + }); + + it('no-ops on unmount when the host mounted against an already-terminated session', () => { + 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({ + reason: 'consumer_cancelled', + }); + + const { unmount } = renderHost({ headlessSessionId: session.id }); + 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 22a843f8170..fc1cd285b93 100644 --- a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx +++ b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx @@ -27,10 +27,12 @@ import Logger from '../../../../../util/Logger'; // Going through the barrel would leave the registry exports `undefined` // at evaluation time inside this module. import { + closeSession, failSession, getSession, setStatus, } from '../../headless/sessionRegistry'; +import { useHeadlessSessionDismissal } from '../../headless/useHeadlessSessionDismissal'; import { getChainIdFromAssetId } from '../../headless/useHeadlessBuy'; import useContinueWithQuote, { type ContinueWithQuoteContext, @@ -85,6 +87,14 @@ function HeadlessHost() { 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. + useHeadlessSessionDismissal(headlessSessionId); + const { userRegion } = useRampsUserRegion(); const { paymentMethods } = useRampsPaymentMethods(); @@ -117,8 +127,15 @@ function HeadlessHost() { }, [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(); - }, [navigation]); + }, [headlessSessionId, navigation]); // Auth-loop error path: OtpCode resets back to the Host with // `nativeFlowError` set when post-OTP routing fails. Forward to the diff --git a/app/components/UI/Ramp/headless/PLAN.md b/app/components/UI/Ramp/headless/PLAN.md index b85630e161b..6d14dc8ee8c 100644 --- a/app/components/UI/Ramp/headless/PLAN.md +++ b/app/components/UI/Ramp/headless/PLAN.md @@ -12,12 +12,12 @@ - [x] **Phase 4b** — Introduce Headless Host screen as stack base for the headless flow + parameterize `useTransakRouting` reset helpers with `baseRoute` - [x] **Phase 4c** — Make `useContinueWithQuote` headless-ready — extend `ContinueWithQuoteContext` with optional overrides so callers without controller state (the Host) can drive it from a `Quote` - [x] **Phase 5 (revised)** — Quote-first headless start path — `startHeadlessBuy({ quote, redirectUrl? })` creates a session carrying the quote, navigates to Headless Host, Host calls `continueWithQuote(quote, ctx)` and re-orchestrates after auth loops -- [ ] **Phase 5b (deferred)** — `startHeadlessBuy({ assetId, amount, paymentMethodId, providerId? })` "open BuildQuote / Host fetches quotes" mode — picked up after the quote-first path is stable +- [ ] **Phase 5b** — superseded; scheduled as part of **Phase 10** (see below). Spec preserved in the Phase 5b section for historical reference. - [x] **Phase 6** — Bypass order-processing redirect in Transak/aggregator routing when headless; fire `onOrderCreated` and end session - [x] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError` -- [ ] **Phase 8** — Cancellation + `onClose` semantics (including user-dismissed detection) -- [ ] **Phase 9** — Expose `getOrder` / `refreshOrder` from hook and show in playground -- [ ] **Phase 10** — Playground polish — event log, input persistence, aggregator/native presets +- [x] **Phase 8** — Cancellation + `onClose` semantics (including user-dismissed detection) +- [ ] **Phase 9** — Expose `getOrder` / `refreshOrder` from hook and show in playground (now an MVP requirement — see Phase 9 Update) +- [ ] **Phase 10** — Implement deferred Phase 5b + playground polish --- @@ -440,6 +440,8 @@ Deliverable: an external dev can build a quote-comparison UI on top of `getQuote ## Phase 5b (deferred) — Raw-params start (Host fetches quotes & auto-picks) +> Scheduled as part of Phase 10 (May 2026). Content here is the canonical spec. +> > Was the original Phase 5; sequenced after the quote-first path is stable. Goal: support the second developer story — "I have user intent (amount + filters) but I don't want to do the quote dance myself; just take me to checkout". @@ -523,11 +525,9 @@ Today (post-Phase 3) only the consumer-initiated `cancel()` returned by `startHe ### Implementation plan - Add a small `useHeadlessSessionDismissal(headlessSessionId)` hook in `app/components/UI/Ramp/headless/`: - - On mount: marks session as alive (`setStatus('pending')` if not already past). - - On unmount or blur-with-no-headless-route-on-stack: if `getSession(id)?.status` is not in `{'completed', 'cancelled'}`, call `endSession(id)` and `callbacks.onClose({ reason: 'user_dismissed' })`. -- Wire it from: - - `BuildQuote` (Phase 8) — handles the "user opens headless, backs out of BuildQuote" case directly. - - Headless Host (Phase 4b) — handles every subsequent screen, since the Host is the stack base for the headless flow and unmounts only when the user truly leaves. + - On unmount (or when `headlessSessionId` changes): if `getSession(id)` is still present, call `closeSession(id, { reason: 'user_dismissed' })`. Terminal sessions are removed from the registry by their respective close paths (Phase 6 `completed`, Phase 7 `unknown`, consumer / restart `consumer_cancelled`), so the live-session check is the same predicate as "still cancellable". + - No on-mount work needed — `createSession` already initializes status to `'pending'`. +- Wire it from `HeadlessHost` only — the Host is the single headless entry under quote-first (Phase 5 revised), and React Navigation keeps it mounted while child screens are pushed on top, so a single dismissal point catches every "user left the headless flow" path. **BuildQuote dismissal is deferred to Phase 10** (which absorbs the deferred Phase 5b raw-params start mode); BuildQuote is not a headless entry today, so there is no production target for that wiring. - Centralize the terminal-state lifecycle in the registry to avoid double-close: add a small helper `closeSession(id, reason)` that does `setStatus(id, 'completed' | 'cancelled')` + `endSession(id)` + `callbacks.onClose({ reason })`, no-op if the session is already gone. All call-sites (Phase 6 success, Phase 7 errors, Phase 8 dismissal, the existing Phase 3 `cancel()`) should funnel through it. - Idempotency contract: `onClose` fires **at most once per session**, regardless of how many code paths try to close it. @@ -541,10 +541,20 @@ Today (post-Phase 3) only the consumer-initiated `cancel()` returned by `startHe Deliverable: closing the buy flow from anywhere on the headless stack notifies the consumer; the playground no longer needs the manual "Cancel headless session" tap (the button stays for explicit consumer-side cancellation but is no longer required for cleanup). +> **Drove the elevated priority:** MetaMask Pay's `TransactionPayController` ([MetaMask/core#8628](https://github.com/MetaMask/core/pull/8628)) depends on terminal `onClose` events to sequence step II of its two-step Fiat-purchase → Intent-transaction flow. Flagged in the Apr 28 2026 progress sync as the open blocker ("Callbacks for canceling/going back not yet wired to events"); production money account ships end of May. + --- ## Phase 9 — Expose `getOrder` + polling helpers +### Update (May 2026 — driven by MetaMask Pay) + +Phase 9 is now an MVP requirement, not playground polish. MetaMask Pay's `TransactionPayController` ([MetaMask/core#8628](https://github.com/MetaMask/core/pull/8628)) needs to know when the fiat order reaches a terminal state to fire step II (intent transaction) of its two-step flow. + +The original Phase 9 surface (`getOrder`, `refreshOrder` + a "Refresh order" playground button) doesn't fit a controller consumer. The Phase 9 API should add an **imperative `awaitOrderTerminalState(orderId)` Promise** so TPC can `await` settlement instead of polling itself. Polling and the playground button stay as additional surfaces; the Promise is the load-bearing API. + +The Apr 28 progress sync also called out a missing **auto-select-best-provider utility** ("Need utility function to auto-select best provider rather than requiring explicit provider ID"). Either fold into Phase 9 alongside `getOrder`, or split as Phase 9.5 — to be decided during Phase 9 implementation. + Goal: complete the hook surface. - Add `getOrder(orderId)` using `useRampsOrders.getOrderById` (already available via [app/components/UI/Ramp/hooks/useRampsOrders.ts](../hooks/useRampsOrders.ts)). @@ -554,9 +564,18 @@ Goal: complete the hook surface. --- -## Phase 10 — Playground polish & discoverability +## Phase 10 — Implement deferred Phase 5b + playground polish + +> Goal 1 (primary): implement the deferred Phase 5b — `startHeadlessBuy({ assetId, amount, paymentMethodId, providerId? })` raw-params start mode, where the Host fetches quotes itself and auto-picks one. See the existing "Phase 5b (deferred)" section above for the full spec. +> +> Goal 2 (secondary): playground polish — the bullets below. + +### Goal 1 — implement Phase 5b (raw-params start mode) + +- Pick up the deferred spec from the [Phase 5b (deferred)](#phase-5b-deferred--raw-params-start-host-fetches-quotes--auto-picks) section above. The discriminated-union `HeadlessBuyParams`, the in-Host `getQuotes` + auto-pick step, and the `NO_QUOTES` / `LIMIT_EXCEEDED` error mapping all carry over unchanged. +- Wire `useHeadlessSessionDismissal` into `BuildQuote` as part of this work, since BuildQuote becomes a headless entry under raw-params and needs the same dismissal contract as the Host. This closes out the deferral noted in Phase 8. -Goal: make the playground actually useful for exploring the API. +### Goal 2 — playground polish & discoverability - Per-quote "Start headless buy" buttons + standalone-button removal landed early in Phase 5 (revised) — see that section for details. Phase 10 picks up the polish work on top of that surface. - Pretty-print session events (`onOrderCreated`, `onError`, `onClose`) in a scrolling log panel. diff --git a/app/components/UI/Ramp/headless/index.ts b/app/components/UI/Ramp/headless/index.ts index 5812bc4207c..40d6ddd0775 100644 --- a/app/components/UI/Ramp/headless/index.ts +++ b/app/components/UI/Ramp/headless/index.ts @@ -12,3 +12,4 @@ export { getSession, setStatus, } from './sessionRegistry'; +export { useHeadlessSessionDismissal } from './useHeadlessSessionDismissal'; diff --git a/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts b/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts new file mode 100644 index 00000000000..9396bbd852d --- /dev/null +++ b/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts @@ -0,0 +1,207 @@ +import { renderHook } from '@testing-library/react-native'; + +import { + __resetSessionRegistryForTests, + closeSession, + createSession, + failSession, +} from './sessionRegistry'; +import type { HeadlessBuyCallbacks, HeadlessBuyParams } from './types'; +import type { Quote } from '../types'; + +import { useHeadlessSessionDismissal } from './useHeadlessSessionDismissal'; + +jest.mock('../../../../util/Logger', () => ({ + __esModule: true, + default: { error: jest.fn(), log: jest.fn() }, +})); + +const mockQuote = { + provider: '/providers/transak-native', + quote: { + amountIn: 25, + amountOut: 0.01, + paymentMethod: '/payments/debit-credit-card', + }, + providerInfo: { + id: '/providers/transak-native', + name: 'Transak', + type: 'native' as const, + }, +} as unknown as Quote; + +const baseParams: HeadlessBuyParams = { + quote: mockQuote, + assetId: 'eip155:1/erc20:0xabc', + amount: 25, + paymentMethodId: '/payments/debit-credit-card', +}; + +function buildCallbacks(): HeadlessBuyCallbacks { + return { + onOrderCreated: jest.fn(), + onError: jest.fn(), + onClose: jest.fn(), + }; +} + +beforeEach(() => { + __resetSessionRegistryForTests(); + jest.clearAllMocks(); +}); + +describe('useHeadlessSessionDismissal', () => { + it('fires onClose({ reason: "user_dismissed" }) when the host unmounts with a live session', () => { + const callbacks = buildCallbacks(); + const session = createSession(baseParams, callbacks); + + const { unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { + initialProps: session.id, + }, + ); + + expect(callbacks.onClose).not.toHaveBeenCalled(); + + unmount(); + + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + expect(callbacks.onClose).toHaveBeenCalledWith({ + reason: 'user_dismissed', + }); + }); + + it('no-ops on unmount after Phase 6 success already closed the session', () => { + const callbacks = buildCallbacks(); + const session = createSession(baseParams, callbacks); + + const { unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { + initialProps: session.id, + }, + ); + + // Simulate Phase 6 onOrderCreated success: registry's closeSession with + // 'completed' fires onClose and removes the session. + closeSession(session.id, { reason: 'completed' }); + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'completed' }); + + unmount(); + + // Unmount must not produce a second onClose. + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + }); + + it('no-ops on unmount after Phase 7 failSession already closed the session', () => { + const callbacks = buildCallbacks(); + const session = createSession(baseParams, callbacks); + + const { unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { + initialProps: session.id, + }, + ); + + failSession(session.id, new Error('boom'), 'AUTH_FAILED'); + expect(callbacks.onError).toHaveBeenCalledTimes(1); + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' }); + + unmount(); + + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + }); + + it('no-ops on unmount after the consumer cancelled the session', () => { + const callbacks = buildCallbacks(); + const session = createSession(baseParams, callbacks); + + const { unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { + initialProps: session.id, + }, + ); + + closeSession(session.id, { reason: 'consumer_cancelled' }); + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + expect(callbacks.onClose).toHaveBeenCalledWith({ + reason: 'consumer_cancelled', + }); + + unmount(); + + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + }); + + it('does not fire on re-render when the session id is stable', () => { + const callbacks = buildCallbacks(); + const session = createSession(baseParams, callbacks); + + const { rerender, unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { initialProps: session.id }, + ); + + rerender(session.id); + rerender(session.id); + + expect(callbacks.onClose).not.toHaveBeenCalled(); + + unmount(); + + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + }); + + it('fires for the old session when the id changes to a new live session', () => { + const firstCallbacks = buildCallbacks(); + const firstSession = createSession(baseParams, firstCallbacks); + const secondCallbacks = buildCallbacks(); + const secondSession = createSession(baseParams, secondCallbacks); + + const { rerender, unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { initialProps: firstSession.id }, + ); + + rerender(secondSession.id); + + expect(firstCallbacks.onClose).toHaveBeenCalledTimes(1); + expect(firstCallbacks.onClose).toHaveBeenCalledWith({ + reason: 'user_dismissed', + }); + expect(secondCallbacks.onClose).not.toHaveBeenCalled(); + + unmount(); + + expect(secondCallbacks.onClose).toHaveBeenCalledTimes(1); + expect(secondCallbacks.onClose).toHaveBeenCalledWith({ + reason: 'user_dismissed', + }); + expect(firstCallbacks.onClose).toHaveBeenCalledTimes(1); + }); + + it('no-ops when mounted with an undefined session id', () => { + const { unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { initialProps: undefined as string | undefined }, + ); + + // No session means no callbacks to call — just confirm unmount is safe + // and produces no thrown errors. + expect(() => unmount()).not.toThrow(); + }); + + it('no-ops when mounted with an unknown session id', () => { + const { unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { initialProps: 'unknown-id' as string | undefined }, + ); + + expect(() => unmount()).not.toThrow(); + }); +}); diff --git a/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.ts b/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.ts new file mode 100644 index 00000000000..3eb7354eb59 --- /dev/null +++ b/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; + +import { closeSession, getSession } from './sessionRegistry'; + +/** + * Fires `onClose({ reason: 'user_dismissed' })` for a headless session when + * the host screen unmounts without the session having terminated through + * any other path. + * + * Termination paths that run before unmount remove the session from the + * registry, so the cleanup re-reads `getSession(id)` and no-ops: + * + * - Phase 6 success — `closeSession({ reason: 'completed' })` + * - Phase 7 errors — `failSession` → `closeSession({ reason: 'unknown' })` + * - Phase 5 single-live-session restart — `closeSession({ reason: 'consumer_cancelled' })` + * - Consumer `cancel()` — same + * - `handleBack` (this PR) — fires `closeSession({ reason: 'user_dismissed' })` synchronously before `goBack`, so the cleanup that follows the unmount is also a no-op. + * + * Wire this into the screen that acts as the stack base for the headless + * flow (today: `HeadlessHost`). React Navigation keeps that screen mounted + * while child screens are pushed on top, so the cleanup only runs when the + * user has actually unwound the entire headless stack. + */ +export function useHeadlessSessionDismissal( + headlessSessionId: string | undefined, +): void { + useEffect( + () => () => { + if (!getSession(headlessSessionId)) { + return; + } + closeSession(headlessSessionId, { reason: 'user_dismissed' }); + }, + [headlessSessionId], + ); +} + +export default useHeadlessSessionDismissal; From a90c1ed52eca02edc7786f29ff1448beecfc56df Mon Sep 17 00:00:00 2001 From: saustrie-consensys <270766059+saustrie-consensys@users.noreply.github.com> Date: Fri, 8 May 2026 17:53:54 +0300 Subject: [PATCH 2/5] docs(ramp): add Phase 9.5 (Host visual treatment) and Phase 9 timeout open question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures decisions and open questions from the May 6 2026 design thread (https://consensys.slack.com/archives/C0AK3NXRM7W/p1778072992397499) on how MetaMask Pay consumes the headless flow. - New Phase 9.5: HeadlessHost visual treatment. Pedro confirmed the Host must stay mounted (routing landing pad + nativeFlowError surface) but doesn't have to be visible. Two shapes evaluated: transparent overlay with consumer-rendered spinner (Pedro's pick) or bottom-sheet with the Host's own spinner. Final shape pending Lucas's design rec (May 13). Phase 8's dismissal contract is unaffected — back-press still fires onClose(user_dismissed). - Phase 9 Update: append the timeout open question Barbara raised (reply 36). Two API shapes worth considering during Phase 9 implementation: timeoutMs on awaitOrderTerminalState, or a registry-side per-session timeout that fires onError(TIMED_OUT) + onClose. Not blocking for v1. - Resolve a naming conflict: the auto-select-best-provider utility was tentatively listed as "Phase 9.5"; renamed to "follow-up phase" so the new visual-treatment phase can own the 9.5 number. --- app/components/UI/Ramp/headless/PLAN.md | 39 ++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Ramp/headless/PLAN.md b/app/components/UI/Ramp/headless/PLAN.md index 6d14dc8ee8c..35bdc32c387 100644 --- a/app/components/UI/Ramp/headless/PLAN.md +++ b/app/components/UI/Ramp/headless/PLAN.md @@ -17,6 +17,7 @@ - [x] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError` - [x] **Phase 8** — Cancellation + `onClose` semantics (including user-dismissed detection) - [ ] **Phase 9** — Expose `getOrder` / `refreshOrder` from hook and show in playground (now an MVP requirement — see Phase 9 Update) +- [ ] **Phase 9.5** — HeadlessHost visual treatment (transparent or bottom-sheet) — driven by the May 6 design thread - [ ] **Phase 10** — Implement deferred Phase 5b + playground polish --- @@ -553,7 +554,9 @@ Phase 9 is now an MVP requirement, not playground polish. MetaMask Pay's `Transa The original Phase 9 surface (`getOrder`, `refreshOrder` + a "Refresh order" playground button) doesn't fit a controller consumer. The Phase 9 API should add an **imperative `awaitOrderTerminalState(orderId)` Promise** so TPC can `await` settlement instead of polling itself. Polling and the playground button stay as additional surfaces; the Promise is the load-bearing API. -The Apr 28 progress sync also called out a missing **auto-select-best-provider utility** ("Need utility function to auto-select best provider rather than requiring explicit provider ID"). Either fold into Phase 9 alongside `getOrder`, or split as Phase 9.5 — to be decided during Phase 9 implementation. +The Apr 28 progress sync also called out a missing **auto-select-best-provider utility** ("Need utility function to auto-select best provider rather than requiring explicit provider ID"). Either fold into Phase 9 alongside `getOrder` or split as a follow-up phase — to be decided during Phase 9 implementation. + +**Open question** (Barbara, [May 6 design thread](https://consensys.slack.com/archives/C0AK3NXRM7W/p1778072992397499)): does ramps need an internal timeout — distinct from the registry's 1-hour GC — so the consumer isn't pinned to a "loading forever" state when a quote stalls? Two shapes worth considering during Phase 9 implementation: (i) `awaitOrderTerminalState(orderId, { timeoutMs })` rejects with a timed-out error so the consumer can decide what to surface; (ii) the registry grows a per-session timeout that fires `onError({ code: 'TIMED_OUT' })` + `onClose` if no terminal event arrives within N seconds. Pedro's reply 37 in the same thread noted Phase 3's `cancel()` is today's escape hatch; not blocking for v1 but worth resolving before MMPay launch. Goal: complete the hook surface. @@ -564,6 +567,40 @@ Goal: complete the hook surface. --- +## Phase 9.5 — HeadlessHost visual treatment + +Goal: make production HeadlessHost stop rendering user-visible chrome (header, spinner, cancel button, error text). The Host stays mounted — it is still the routing landing pad and the `nativeFlowError` surface from Phase 4b — but becomes either fully transparent or a bottom-sheet, so the consumer (TPC / MMPay) renders the only user-visible loading UI during a headless buy. + +Driver: [May 6 2026 design thread](https://consensys.slack.com/archives/C0AK3NXRM7W/p1778072992397499). Pedro confirmed the Host can't be removed (it's the routing base and Phase 7's error surface) but it doesn't have to be visible. Two shapes were evaluated: (a) transparent overlay with consumer-rendered spinner, (b) bottom-sheet with the Host's own spinner and cancel action. Pedro favored (a). Final shape pending Lucas's design recommendation (deadline May 13). + +### Settled in the same thread + +- **Back navigation stays available during loading** (Lucas, May 8 16:54 reply 39). Phase 8's `useHeadlessSessionDismissal` already fires `onClose({ reason: 'user_dismissed' })` on unmount, so the consumer's back-press still produces a terminal callback regardless of Host visibility. +- Latency expectation: 1–2 seconds typical (Lorenzo, reply 19). +- Cancel-session (Phase 3 `cancel()`) is the escape hatch for stuck quotes (Pedro, reply 37). + +### Implementation sketch + +- Strip `HeaderCompactStandard`, `ActivityIndicator`, the loader/no-session/error `Text` blocks, and the bottom `Cancel` button from [HeadlessHost.tsx](../Views/HeadlessHost/HeadlessHost.tsx). +- The wrapping `SafeAreaView` becomes either fully transparent (option a) or sized to the bottom-sheet spec (option b, per Lucas's design). +- The local `errorMessage` state becomes redundant once the consumer renders all UI; remove it. `failSession` / `closeSession` calls stay — the consumer receives the error via `onError`. +- Keep the orchestration `useEffect`, the `nativeFlowError` handler, and `useHeadlessSessionDismissal` untouched — they are behavior, not chrome. +- Update `HeadlessHost.test.tsx`: drop the visual-presence assertions, keep all orchestration / dismissal / `nativeFlowError` tests. + +### Out of scope + +- The MMPay consumer-side loading UI (TPC PR [`MetaMask/core#8628`](https://github.com/MetaMask/core/pull/8628) and downstream MMPay UI work). +- Internal Host timeout — see the Open question in the Phase 9 Update. + +### References + +- [Money Account Figma](https://www.figma.com/design/XKZ8hRqSn2iTiuzmlQLuYQ/Money-Account?node-id=3041-13117) (Lorenzo, reply 16). +- [UB2 loading-states reference](https://www.figma.com/design/ItZzm9CzSAjOWQTUKsOdSk/BUY?node-id=4347-3909) (Lorenzo, reply 23) for design parity. + +Deliverable: HeadlessHost is invisible (or bottom-sheet) and MMPay's TPC renders the only user-visible loading UI during a headless buy. Phase 8's dismissal contract continues to work unchanged. + +--- + ## Phase 10 — Implement deferred Phase 5b + playground polish > Goal 1 (primary): implement the deferred Phase 5b — `startHeadlessBuy({ assetId, amount, paymentMethodId, providerId? })` raw-params start mode, where the Host fetches quotes itself and auto-picks one. See the existing "Phase 5b (deferred)" section above for the full spec. From 9d545602d834e1c661aee54c604202da735811fa Mon Sep 17 00:00:00 2001 From: saustrie-consensys <270766059+saustrie-consensys@users.noreply.github.com> Date: Fri, 8 May 2026 18:32:14 +0300 Subject: [PATCH 3/5] docs(ramp): add Design principles section to Headless Buy plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures two cross-cutting API rules so future contributors know the constraints before extending `useHeadlessBuy`: 1. Callbacks-only, three terminal events. No intermediate progress callbacks (onAuthStarted / onKycRequired / etc.) — they would couple consumers to ramp internals and force them to update on every flow change. 2. The consumer renders all visible UI. No render-shape props (loadingText / spinnerComponent / etc.). Headless Ramps is a behavior provider, not a UI provider — Phase 9.5 implements this on the Host side; the API side must stay this shape. Both principles were implicit in the API as designed but undocumented; making them explicit makes them defensible in PR review and harder to erode by accretion. Section sits between "Architecture at a glance" and Phase 1 so it is visible to anyone reading PLAN.md top-down. --- app/components/UI/Ramp/headless/PLAN.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/components/UI/Ramp/headless/PLAN.md b/app/components/UI/Ramp/headless/PLAN.md index 35bdc32c387..24ffc8e9a15 100644 --- a/app/components/UI/Ramp/headless/PLAN.md +++ b/app/components/UI/Ramp/headless/PLAN.md @@ -45,6 +45,28 @@ Key idea: the hook orchestrates by (a) seeding the controller with the quote's t --- +## Design principles + +Cross-cutting rules that shape what `useHeadlessBuy` does and doesn't expose. They keep the consumer surface small so new consumers (MMPay's `TransactionPayController`, future SDKs, future non-MMPay teams) can integrate without coupling to ramp internals or being forced into a specific UI. + +### 1. Callbacks-only, three terminal events + +The hook exposes exactly three lifecycle callbacks: `onOrderCreated`, `onError`, `onClose`. Each session ends in exactly one of them. **No intermediate progress callbacks** (`onAuthStarted`, `onKycRequired`, `onPaymentMethodChosen`, etc.) — consumers do not get a play-by-play of the ramp flow. + +**Why.** Intermediate callbacks couple consumers to ramp internals; changing the order of OTP / KYC / payment-method screens or adding a fraud step would force every consumer to update. The "wait for any of the three terminal callbacks" model lets ramp evolve internally without breaking consumers. + +**How to apply.** When a consumer asks for state-aware copy ("Verifying email…", "Awaiting KYC…"), point them at their own context — they know the quote, the user, and the time elapsed. Don't add a callback. If timing-based copy is the real ask, see the Phase 9 timeout open question for a single opaque "past expected latency" signal that doesn't expose flow internals. + +### 2. The consumer renders all visible UI + +`useHeadlessBuy` returns no render-shape props (no `loadingText`, no `spinnerComponent`, no required JSX). Consumers render whatever loading indicator, error treatment, or copy fits their design system. **Headless Ramps is a behavior provider, not a UI provider.** + +**Why.** Render-shape props would force consumers either to accept ramp's design choices or to opt out via overrides — both bad. The [May 6 design thread](https://consensys.slack.com/archives/C0AK3NXRM7W/p1778072992397499) settled on "consumer owns the visible UI" (Pedro reply 11; Lorenzo + Yanrong reply 9/17; Goktug reply 20). Phase 9.5 implements this on the Host side by stripping its visible chrome. + +**How to apply.** When a consumer asks "can the hook render the spinner for me?", the answer is no — give them the three callbacks and let them flip their own `isLoading` boolean. Shared UI primitives belong in a design-system library, not in `useHeadlessBuy`. + +--- + ## Phase 1 — Playground scaffolding (read-only) Goal: land an empty playground screen wired to the existing `useRampsController`. No behavior changes. From 9d5867700bd1cf7434dd49af9d26a9a5f2e3cccb Mon Sep 17 00:00:00 2001 From: saustrie-consensys <270766059+saustrie-consensys@users.noreply.github.com> Date: Mon, 11 May 2026 16:56:10 +0300 Subject: [PATCH 4/5] fix(ramp): skip dismissal close when HEADLESS_HOST survives stack rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-device verification of Phase 8 revealed that `useTransakRouting` opens Checkout via `navigation.reset({ routes: [HEADLESS_HOST, CHECKOUT] })`, which rebuilds the navigator with fresh route keys and unmounts the original HeadlessHost instance — even though logically the user is still in the headless flow. The first `useHeadlessSessionDismissal` treated that unmount as a dismissal and fired `closeSession({ reason: 'user_dismissed' })`. By the time the Transak widget redirected back ~50s later, `getSession(id)` returned undefined and the Phase 6 bypass fell through to `RAMPS_ORDER_DETAILS`, breaking the headless contract on every successful buy. Fix: the unmount cleanup now reads `navigation.getState()` and walks its routes (recursively, for nested navigators). If HEADLESS_HOST is still present, the unmount is a stack rebuild and the close is skipped. If `getState()` throws (navigator torn down), the cleanup falls through to close — treating a missing navigator as "user left" is the safe default. The original Phase 8 termination paths (Phase 6 completed, Phase 7 unknown, consumer cancellation, handleBack) keep working unchanged because `getSession(id)` already returns undefined by the time the cleanup runs. Tests: - 4 new tests under `stack-rebuild guard` in useHeadlessSessionDismissal.test.ts covering: HEADLESS_HOST present as direct route, HEADLESS_HOST in nested navigator state, absent (true dismissal), and getState throwing. Suite is now 12 tests at 100% coverage. - Existing HeadlessHost.test.tsx Dismissal block (23 tests) keeps passing because the test mock's missing getState triggers the defensive throw branch, which matches the existing "close on unmount" assertions. PLAN.md (Phase 10 polish surfaced during verification): added two secondary goals so the next reviewer can sequence them. - Goal 3 — Navigation/state cleanups: flatten the nested RampTokenSelection descriptor in `startHeadlessBuy` (3-level warning); move Checkout's `onNavigationStateChange` function out of route params and into the session registry (state-restore failure mode if the app is killed mid-Checkout). - Goal 4 — Suppress the global order toast for headless orders (Phase 7 follow-up): Phase 7 audited the in-flow toast call sites but missed `processFiatOrder` in index.tsx, which fires `showV2OrderToast` whenever a polled order's state transitions to Completed. Fix shape: stamp `headless: true` on the order in the three bypass paths; short-circuit the toast in processFiatOrder while preserving Redux state + analytics parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/components/UI/Ramp/headless/PLAN.md | 26 +++- .../useHeadlessSessionDismissal.test.ts | 115 ++++++++++++++++++ .../headless/useHeadlessSessionDismissal.ts | 76 +++++++++++- 3 files changed, 211 insertions(+), 6 deletions(-) diff --git a/app/components/UI/Ramp/headless/PLAN.md b/app/components/UI/Ramp/headless/PLAN.md index 24ffc8e9a15..8ff0253541f 100644 --- a/app/components/UI/Ramp/headless/PLAN.md +++ b/app/components/UI/Ramp/headless/PLAN.md @@ -18,7 +18,7 @@ - [x] **Phase 8** — Cancellation + `onClose` semantics (including user-dismissed detection) - [ ] **Phase 9** — Expose `getOrder` / `refreshOrder` from hook and show in playground (now an MVP requirement — see Phase 9 Update) - [ ] **Phase 9.5** — HeadlessHost visual treatment (transparent or bottom-sheet) — driven by the May 6 design thread -- [ ] **Phase 10** — Implement deferred Phase 5b + playground polish +- [ ] **Phase 10** — Implement deferred Phase 5b + playground polish + navigation/state cleanups + headless toast suppression --- @@ -628,6 +628,10 @@ Deliverable: HeadlessHost is invisible (or bottom-sheet) and MMPay's TPC renders > Goal 1 (primary): implement the deferred Phase 5b — `startHeadlessBuy({ assetId, amount, paymentMethodId, providerId? })` raw-params start mode, where the Host fetches quotes itself and auto-picks one. See the existing "Phase 5b (deferred)" section above for the full spec. > > Goal 2 (secondary): playground polish — the bullets below. +> +> Goal 3 (secondary): clean up two React Navigation warnings (nested-screen descriptor + non-serializable route param) surfaced during Phase 8 — the bullets below. +> +> Goal 4 (secondary): close the global-toast leak that Phase 7 missed — suppress `showV2OrderToast` for headless orders in the background order processor. Bullets below. ### Goal 1 — implement Phase 5b (raw-params start mode) @@ -641,6 +645,26 @@ Deliverable: HeadlessHost is invisible (or bottom-sheet) and MMPay's TPC renders - Persist the last playground input to `AsyncStorage` to speed iteration. - Add a quick "Try aggregator" vs "Try native" preset pair (preset = a hardcoded `{ amount, paymentMethodId, providerId }` triple that pre-fills the sandbox inputs and triggers `getQuotes`). +### Goal 3 — Navigation / state cleanups (surfaced during Phase 8) + +Two React Navigation warnings surface from the existing headless wiring. Both are pre-existing, non-blocking for Phase 8's bypass fix, but each carries a real (if narrow) failure mode. Address as part of Phase 10 since both touch the same `startHeadlessBuy` → `HeadlessHost` → `Checkout` path Phase 5b will be re-walking. + +- **Flatten the `startHeadlessBuy` nested-screen descriptor.** [useHeadlessBuy.ts:183-191](useHeadlessBuy.ts#L183-L191) currently produces a navigation tree with three `RampTokenSelection` levels (`Main > RampTokenSelection > RampTokenSelection > RampTokenSelection`), triggering React Navigation's "Found screens with the same name nested inside one another" warning. The in-code comment describes a two-level descriptor (outer mount in `MainNavigator` + RootStack slot wrapping `MainRoutes`); something is over-wrapping. Investigate whether the descriptor itself adds an extra `screen` level or whether `RAMP.TOKEN_SELECTION` is registered at an extra slot. **Risk if left:** ambiguous `navigation.navigate('RampTokenSelection')` targets, `useNavigation()` returning a different navigator than expected, and fragile `navigation.reset` behavior — any of which can hide real bugs. +- **Move `Checkout`'s `onNavigationStateChange` callback out of route params.** [useTransakRouting.ts:483-494](../hooks/useTransakRouting.ts#L483-L494) stashes `handleNavigationStateChange` (the Transak WebView redirect handler) as a Checkout route param, producing the "Non-serializable values were found in the navigation state ... `Checkout > params.onNavigationStateChange (Function)`" warning. **Risk if left:** if the app is killed mid-Checkout (OOM, swipe-to-close) and React Navigation restores state, the function reference is gone — the WebView redirect would fire with no handler, the order would be created server-side but the headless consumer would never get `onOrderCreated` (and so never get `onClose`). Suggested fix shape: register the redirect handler in the session registry keyed by `headlessSessionId`, then look it up in `Checkout` at WebView mount time instead of capturing it in route params. The same shape can replace any other "function-via-route-param" instances under the ramp stack. + +### Goal 4 — Suppress the global order toast for headless orders (Phase 7 follow-up) + +After a headless buy completes, the global FiatOrders background processor still fires `showV2OrderToast` ("Your purchase of X ETH was successful") when the order's polled state transitions to `Completed`. Phase 7 audited the in-flow toast call sites (`Checkout.tsx`, `useTransakRouting.ts`) and added `getSession(headlessSessionId)` guards there, but missed the global processor in [index.tsx:62-77](../index.tsx#L62-L77), which runs at the FiatOrders level and has no access to the headless session id. By the time it runs the session is already terminated (Phase 6 closed it with `'completed'` immediately after `onOrderCreated`), so consulting the session registry isn't viable either. + +**Fix shape — stamp the order on creation.** In the Phase 6 bypass paths in [Checkout.tsx:212-225](../Views/Checkout/Checkout.tsx#L212-L225) and [useTransakRouting.ts:321-353](../hooks/useTransakRouting.ts#L321-L353) (and the manual-bank-transfer success path at [useTransakRouting.ts:605-609](../hooks/useTransakRouting.ts#L605-L609)), add a `headless: true` (or `headlessSessionId: id`) field to the order object before `addOrder(...)`. The flag survives Redux persistence and lives with the order itself. In `processFiatOrder` ([index.tsx:62-77](../index.tsx#L62-L77)), check that flag and skip `showV2OrderToast` — but keep `dispatchUpdateFiatOrder` and the analytics `trackEvent` calls so Redux state + telemetry parity is preserved (matches the Phase 7 in-flow guard's pattern). + +Tests: + +- `processFiatOrder.test.ts` (or wherever it's covered) — given an order with `headless: true`, no toast is shown but state + analytics still flow. +- `Checkout.test.tsx` / `useTransakRouting.test.ts` — bypass paths set `headless: true` on the added order. + +This closes the same gap Phase 7 set out to close, just at the FiatOrders layer instead of the in-flow layer. + --- ## Out of scope for now diff --git a/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts b/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts index 9396bbd852d..dcf56fe6f66 100644 --- a/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts +++ b/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts @@ -11,6 +11,29 @@ import type { Quote } from '../types'; import { useHeadlessSessionDismissal } from './useHeadlessSessionDismissal'; +// Default mock: navigator has no HEADLESS_HOST in its routes, so the dismissal +// cleanup treats unmount as a real user dismissal and closes the session. +// Tests covering the stack-rebuild guard override `mockGetState` per-test. +interface MockRoute { + name: string; + state?: { routes: MockRoute[] }; +} +const mockGetState = jest.fn<{ routes: MockRoute[] }, []>(() => ({ + routes: [], +})); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ getState: mockGetState }), +})); + +jest.mock('../../../../constants/navigation/Routes', () => ({ + __esModule: true, + default: { + RAMP: { + HEADLESS_HOST: 'RampHeadlessHost', + }, + }, +})); + jest.mock('../../../../util/Logger', () => ({ __esModule: true, default: { error: jest.fn(), log: jest.fn() }, @@ -48,6 +71,7 @@ function buildCallbacks(): HeadlessBuyCallbacks { beforeEach(() => { __resetSessionRegistryForTests(); jest.clearAllMocks(); + mockGetState.mockReturnValue({ routes: [] }); }); describe('useHeadlessSessionDismissal', () => { @@ -204,4 +228,95 @@ describe('useHeadlessSessionDismissal', () => { expect(() => unmount()).not.toThrow(); }); + + describe('stack-rebuild guard', () => { + it('skips close when HEADLESS_HOST is still a direct route after unmount', () => { + const callbacks = buildCallbacks(); + const session = createSession(baseParams, callbacks); + + // Simulate `useTransakRouting.navigateToWebviewModalCallback` rebuilding + // the stack: HEADLESS_HOST is re-pinned at the base, Checkout is on top. + // The old HEADLESS_HOST instance unmounts but the user is still in the + // headless flow. + mockGetState.mockReturnValue({ + routes: [{ name: 'RampHeadlessHost' }, { name: 'Checkout' }], + }); + + const { unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { initialProps: session.id }, + ); + + unmount(); + + expect(callbacks.onClose).not.toHaveBeenCalled(); + }); + + it('skips close when HEADLESS_HOST appears in a nested navigator state', () => { + const callbacks = buildCallbacks(); + const session = createSession(baseParams, callbacks); + + mockGetState.mockReturnValue({ + routes: [ + { + name: 'RampTokenSelection', + state: { + routes: [{ name: 'RampHeadlessHost' }, { name: 'Checkout' }], + }, + }, + ], + }); + + const { unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { initialProps: session.id }, + ); + + unmount(); + + expect(callbacks.onClose).not.toHaveBeenCalled(); + }); + + it('still closes when HEADLESS_HOST is absent from the navigator state (true dismissal)', () => { + const callbacks = buildCallbacks(); + const session = createSession(baseParams, callbacks); + + mockGetState.mockReturnValue({ + routes: [{ name: 'WalletView' }], + }); + + const { unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { initialProps: session.id }, + ); + + unmount(); + + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + expect(callbacks.onClose).toHaveBeenCalledWith({ + reason: 'user_dismissed', + }); + }); + + it('falls through to close when getState throws (navigator torn down)', () => { + const callbacks = buildCallbacks(); + const session = createSession(baseParams, callbacks); + + mockGetState.mockImplementation(() => { + throw new Error('navigator unmounted'); + }); + + const { unmount } = renderHook( + (id: string | undefined) => useHeadlessSessionDismissal(id), + { initialProps: session.id }, + ); + + unmount(); + + expect(callbacks.onClose).toHaveBeenCalledTimes(1); + expect(callbacks.onClose).toHaveBeenCalledWith({ + reason: 'user_dismissed', + }); + }); + }); }); diff --git a/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.ts b/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.ts index 3eb7354eb59..73c3baacb34 100644 --- a/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.ts +++ b/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.ts @@ -1,5 +1,11 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; +import { + useNavigation, + type NavigationState, + type PartialState, +} from '@react-navigation/native'; +import Routes from '../../../../constants/navigation/Routes'; import { closeSession, getSession } from './sessionRegistry'; /** @@ -16,23 +22,83 @@ import { closeSession, getSession } from './sessionRegistry'; * - Consumer `cancel()` — same * - `handleBack` (this PR) — fires `closeSession({ reason: 'user_dismissed' })` synchronously before `goBack`, so the cleanup that follows the unmount is also a no-op. * + * Stack-rebuild guard: `useTransakRouting` uses `navigation.reset` (not push) + * to open Checkout, BasicInfo, KycWebview, etc. with HEADLESS_HOST re-pinned + * at the stack base. `navigation.reset` swaps the navigator's route keys, so + * the original HEADLESS_HOST instance unmounts even though logically the user + * is still inside the headless flow. Treating that as `user_dismissed` would + * close the session mid-flow and break Phase 6 / Phase 7 callbacks. + * + * To distinguish a real dismissal from a stack rebuild, the cleanup inspects + * the navigator's current state after unmount: if HEADLESS_HOST is still in + * the route list, this is a rebuild and we skip the close. Otherwise the + * user has unwound the entire headless stack and we fire `user_dismissed` as + * before. + * * Wire this into the screen that acts as the stack base for the headless - * flow (today: `HeadlessHost`). React Navigation keeps that screen mounted - * while child screens are pushed on top, so the cleanup only runs when the - * user has actually unwound the entire headless stack. + * flow (today: `HeadlessHost`). */ export function useHeadlessSessionDismissal( headlessSessionId: string | undefined, ): void { + const navigation = useNavigation(); + const navigationRef = useRef(navigation); + navigationRef.current = navigation; + useEffect( () => () => { - if (!getSession(headlessSessionId)) { + const session = getSession(headlessSessionId); + if (!session) { + return; + } + + if (isHeadlessHostStillInNavigator(navigationRef.current)) { return; } + closeSession(headlessSessionId, { reason: 'user_dismissed' }); }, [headlessSessionId], ); } +interface NavigatorLike { + getState: () => NavigationState | PartialState | undefined; +} + +/** + * Returns true if any route in the navigator's current state (or its nested + * states) is named `HEADLESS_HOST`. Used by the dismissal cleanup to detect + * stack rebuilds (`navigation.reset` keeping HEADLESS_HOST as the base) + * versus genuine user dismissal (HEADLESS_HOST gone from the navigator). + * + * Wrapped in try/catch because after unmount the navigator may be torn down + * — in which case treating the absence as "user left" is the safe default. + */ +function isHeadlessHostStillInNavigator( + nav: NavigatorLike | undefined, +): boolean { + try { + const state = nav?.getState(); + return state ? routeStateContainsHeadlessHost(state) : false; + } catch { + return false; + } +} + +function routeStateContainsHeadlessHost( + state: NavigationState | PartialState, +): boolean { + const routes = state.routes ?? []; + for (const route of routes) { + if (route?.name === Routes.RAMP.HEADLESS_HOST) { + return true; + } + if (route?.state && routeStateContainsHeadlessHost(route.state)) { + return true; + } + } + return false; +} + export default useHeadlessSessionDismissal; From 08b88f5e00a96e8db3a13386cff26adfdcb48b76 Mon Sep 17 00:00:00 2001 From: saustrie-consensys <270766059+saustrie-consensys@users.noreply.github.com> Date: Mon, 11 May 2026 17:28:41 +0300 Subject: [PATCH 5/5] ci: retrigger after upstream GitHub 500s during checkout