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..8ff0253541f 100644 --- a/app/components/UI/Ramp/headless/PLAN.md +++ b/app/components/UI/Ramp/headless/PLAN.md @@ -12,12 +12,13 @@ - [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 9.5** — HeadlessHost visual treatment (transparent or bottom-sheet) — driven by the May 6 design thread +- [ ] **Phase 10** — Implement deferred Phase 5b + playground polish + navigation/state cleanups + headless toast suppression --- @@ -44,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. @@ -440,6 +463,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 +548,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 +564,22 @@ 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 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. - Add `getOrder(orderId)` using `useRampsOrders.getOrderById` (already available via [app/components/UI/Ramp/hooks/useRampsOrders.ts](../hooks/useRampsOrders.ts)). @@ -554,15 +589,82 @@ Goal: complete the hook surface. --- -## Phase 10 — Playground polish & discoverability +## 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. -Goal: make the playground actually useful for exploring the API. +### 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. +> +> 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) + +- 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 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. - 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/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..dcf56fe6f66 --- /dev/null +++ b/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts @@ -0,0 +1,322 @@ +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'; + +// 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() }, +})); + +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(); + mockGetState.mockReturnValue({ routes: [] }); +}); + +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(); + }); + + 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 new file mode 100644 index 00000000000..73c3baacb34 --- /dev/null +++ b/app/components/UI/Ramp/headless/useHeadlessSessionDismissal.ts @@ -0,0 +1,104 @@ +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'; + +/** + * 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. + * + * 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`). + */ +export function useHeadlessSessionDismissal( + headlessSessionId: string | undefined, +): void { + const navigation = useNavigation(); + const navigationRef = useRef(navigation); + navigationRef.current = navigation; + + useEffect( + () => () => { + 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;