feat(ramp): make HeadlessHost invisible (Phase 9.5)#30104
feat(ramp): make HeadlessHost invisible (Phase 9.5)#30104saustrie-consensys wants to merge 11 commits into
Conversation
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## saustrie-consensys/headless-buy-phase-9 #30104 +/- ##
===========================================================================
- Coverage 81.75% 81.74% -0.01%
===========================================================================
Files 5388 5388
Lines 143619 143607 -12
Branches 32803 32798 -5
===========================================================================
- Hits 117410 117396 -14
- Misses 18185 18191 +6
+ Partials 8024 8020 -4 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
…or Bugbot)
Phase 9.5's chrome-strip commit added a beforeRemove navigation listener on
HeadlessHost that fires closeSession({ reason: 'user_dismissed' }) on every
beforeRemove event. Cursor Bugbot caught a high-severity bug: useTransakRouting
uses navigation.reset() to re-pin HEADLESS_HOST at the base of the stack when
moving to VerifyIdentity / BasicInfo / Checkout / KycWebview. The reset action
fires beforeRemove on the OLD HEADLESS_HOST instance before re-pinning the new
one, but the session is still in flight — closing it here prematurely fires
onClose({ reason: 'user_dismissed' }) and breaks the consumer's flow.
Resolution: inspect e.data.action.type inside the listener and short-circuit on
'RESET'. Legitimate user-dismissal paths (GO_BACK, POP) still close the session
synchronously. Other unmount cases (real stack reset that does NOT re-pin the
Host, hot reload) remain caught by useHeadlessSessionDismissal's unmount
cleanup, which uses isHeadlessHostStillInNavigator to differentiate.
Adds a HeadlessHost.test.tsx case 'does NOT close the session when beforeRemove
fires for a RESET action (stack rebuild guard)'. Updates the existing test
helper to pass a non-RESET action in the event arg so the listener's new
signature stays exercised.
Bug report: #30104 (review)
170b4a1 to
1c5b205
Compare
…or Bugbot)
Phase 9.5's chrome-strip commit added a beforeRemove navigation listener on
HeadlessHost that fires closeSession({ reason: 'user_dismissed' }) on every
beforeRemove event. Cursor Bugbot caught a high-severity bug: useTransakRouting
uses navigation.reset() to re-pin HEADLESS_HOST at the base of the stack when
moving to VerifyIdentity / BasicInfo / Checkout / KycWebview. The reset action
fires beforeRemove on the OLD HEADLESS_HOST instance before re-pinning the new
one, but the session is still in flight — closing it here prematurely fires
onClose({ reason: 'user_dismissed' }) and breaks the consumer's flow.
Resolution: inspect e.data.action.type inside the listener and short-circuit on
'RESET'. Legitimate user-dismissal paths (GO_BACK, POP) still close the session
synchronously. Other unmount cases (real stack reset that does NOT re-pin the
Host, hot reload) remain caught by useHeadlessSessionDismissal's unmount
cleanup, which uses isHeadlessHostStillInNavigator to differentiate.
Adds a HeadlessHost.test.tsx case 'does NOT close the session when beforeRemove
fires for a RESET action (stack rebuild guard)'. Updates the existing test
helper to pass a non-RESET action in the event arg so the listener's new
signature stays exercised.
Bug report: #30104 (review)
1c5b205 to
32f24df
Compare
…or Bugbot)
Phase 9.5's chrome-strip commit added a beforeRemove navigation listener on
HeadlessHost that fires closeSession({ reason: 'user_dismissed' }) on every
beforeRemove event. Cursor Bugbot caught a high-severity bug: useTransakRouting
uses navigation.reset() to re-pin HEADLESS_HOST at the base of the stack when
moving to VerifyIdentity / BasicInfo / Checkout / KycWebview. The reset action
fires beforeRemove on the OLD HEADLESS_HOST instance before re-pinning the new
one, but the session is still in flight — closing it here prematurely fires
onClose({ reason: 'user_dismissed' }) and breaks the consumer's flow.
Resolution: inspect e.data.action.type inside the listener and short-circuit on
'RESET'. Legitimate user-dismissal paths (GO_BACK, POP) still close the session
synchronously. Other unmount cases (real stack reset that does NOT re-pin the
Host, hot reload) remain caught by useHeadlessSessionDismissal's unmount
cleanup, which uses isHeadlessHostStillInNavigator to differentiate.
Adds a HeadlessHost.test.tsx case 'does NOT close the session when beforeRemove
fires for a RESET action (stack rebuild guard)'. Updates the existing test
helper to pass a non-RESET action in the event arg so the listener's new
signature stays exercised.
Bug report: #30104 (review)
32f24df to
2a70758
Compare
|
|
||
| ### Changed | ||
|
|
||
| - Made the Ramp Headless Host invisible so consumer-rendered loading UI is visible during a headless buy; added a transparent `RAMP.HEADLESS_ENTRY` outer route so the headless flow no longer renders an opaque V2 stack card on top of the consumer screen (Phase 9.5). |
There was a problem hiding this comment.
I believe we should update Changelog only for user facing changes.
There was a problem hiding this comment.
Removed the two internal implementation-detail entries from the [Unreleased] ### Fixed section (getUserLimits error routing and beforeRemove RESET guard) — only the user-facing invisible HeadlessHost change remains under ### Changed. Done in 2a0d75e.
There was a problem hiding this comment.
All CHANGELOG.md changes removed in d4669b8. The [Unreleased] section is now back to the original empty state.
Phase 9.5 (HeadlessHost goes invisible)
- Strip header, spinner, "no session" / error text, and Cancel button
from `HeadlessHost.tsx`. The Host renders a transparent `View` so it
keeps acting as the routing landing pad and `nativeFlowError` surface
while the consumer (TPC) renders the only user-visible loading UI.
- Replace the visible Cancel/Back-button handlers with a
`navigation.addListener('beforeRemove', ...)` so the synchronous
`closeSession({ reason: 'user_dismissed' })` still fires before
unmount on hardware back / iOS swipe-back.
`useHeadlessSessionDismissal` (Phase 8) remains as
defense-in-depth for paths that bypass `beforeRemove`.
- Remove orphaned `headless_host.*` i18n keys across all 14 locales.
CHANGELOG entry: null
After the Phase 9.5 chrome strip made HeadlessHost invisible, Goktug's
testing showed the user still sees a solid-white screen because the
headless flow enters via RAMP.TOKEN_SELECTION — an opaque V2 stack card
that sits between the consumer screen and the now-transparent Host.
Resolution: add a new outer slot RAMP.HEADLESS_ENTRY in MainNavigator
that mounts the same TokenListRoutes component as TOKEN_SELECTION, but
wraps it in the JS-stack transparent-overlay preset that BridgeModals,
EarnModals, MoneyModals, StakeModals, and PerpsModals already use:
options={{
...clearStackNavigatorOptionsWithTransitionAnimation,
presentation: 'transparentModal',
}}
useHeadlessBuy.startHeadlessBuy now navigates to HEADLESS_ENTRY instead
of TOKEN_SELECTION; the nested-screen descriptor that lands directly on
HEADLESS_HOST stays unchanged. UB2's entry through TOKEN_SELECTION is
untouched.
Slack thread:
https://consensys.slack.com/archives/C0AK3NXRM7W/p1778577403631269
…Phase 9.5 Fix A)
useTransakRouting.checkUserLimits previously caught errors from
getUserLimits, rethrew LimitExceededError, and swallowed every other
error (Logger.error). In headless mode the consumer got no onError
callback when this fired for an infrastructure failure (network blip,
malformed response), leaving the session hung.
Resolution: in the catch, gate the new behavior on headlessSessionId:
if (error instanceof LimitExceededError) throw error;
if (headlessSessionId) {
failSession(headlessSessionId, error);
throw error;
}
Logger.error(error as Error, 'Failed to check user limits');
UB2's existing swallow is preserved — the existing test
'logs error and returns when getUserLimits throws a non-limit error'
encodes the swallow as intent and continues to pass. Two new tests
cover (a) failSession invoked + rethrow propagates in headless mode and
(b) LimitExceededError short-circuits before failSession.
The parallel getUserLimits swallow in useDepositRouting.ts:137-180 is
deliberately left untouched (Deposit is not a headless route, and the
"ramps work is UB2-only" rule keeps us off the Deposit tree).
…or Bugbot)
Phase 9.5's chrome-strip commit added a beforeRemove navigation listener on
HeadlessHost that fires closeSession({ reason: 'user_dismissed' }) on every
beforeRemove event. Cursor Bugbot caught a high-severity bug: useTransakRouting
uses navigation.reset() to re-pin HEADLESS_HOST at the base of the stack when
moving to VerifyIdentity / BasicInfo / Checkout / KycWebview. The reset action
fires beforeRemove on the OLD HEADLESS_HOST instance before re-pinning the new
one, but the session is still in flight — closing it here prematurely fires
onClose({ reason: 'user_dismissed' }) and breaks the consumer's flow.
Resolution: inspect e.data.action.type inside the listener and short-circuit on
'RESET'. Legitimate user-dismissal paths (GO_BACK, POP) still close the session
synchronously. Other unmount cases (real stack reset that does NOT re-pin the
Host, hot reload) remain caught by useHeadlessSessionDismissal's unmount
cleanup, which uses isHeadlessHostStillInNavigator to differentiate.
Adds a HeadlessHost.test.tsx case 'does NOT close the session when beforeRemove
fires for a RESET action (stack rebuild guard)'. Updates the existing test
helper to pass a non-RESET action in the event arg so the listener's new
signature stays exercised.
Bug report: #30104 (review)
|
@copilot resolve conflicts |
9d33fba to
7811cf2
Compare
Agent-Logs-Url: https://github.com/MetaMask/metamask-mobile/sessions/e173a04f-aa0b-47a8-8373-fe2a154cc9ed Co-authored-by: amitabh94 <12572750+amitabh94@users.noreply.github.com>
Head branch was pushed to by a user without write access
The branch is already cleanly on top of |
Agent-Logs-Url: https://github.com/MetaMask/metamask-mobile/sessions/1e063ea2-41d4-41b3-b54c-5dd4d0421c17 Co-authored-by: amitabh94 <12572750+amitabh94@users.noreply.github.com>
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
SmokeMoney is the primary tag as all changes are within the Ramp/fiat on-ramp feature area. The existing E2E tests cover onramp unified buy, deeplinks to buy/sell flows, and card flows. SmokeWalletPlatform is selected because the SmokeMoney tag description states 'When changes touch wallet home or actions entry to buy/sell, also select SmokeWalletPlatform' - the headless buy flow is entered via wallet actions. SmokeSwap and SmokeConfirmations are NOT selected because these changes are specifically to the Transak/headless fiat on-ramp flow, not to swap or confirmation flows. The headless buy flow uses a WebView-based checkout (Transak), not the native confirmation UI. The changes don't affect core navigation infrastructure broadly (only adds a new route), so other test suites are not at risk. Performance Test Selection: |
|



Description
Lands Phase 9.5 of the incremental Headless Buy plan (
app/components/UI/Ramp/headless/PLAN.md). Independent of Phase 9 (#30103) in runtime. This PR is now based directly onmain, so either PR can land first.What ships
HeadlessHost invisible (
7be267ed). Strip header, spinner, "no session" / error text, and Cancel button fromHeadlessHost.tsx. The Host renders a transparentViewso it keeps acting as the routing landing pad and thenativeFlowErrorcallback surface, while the consumer (TPC) renders the only user-visible loading UI for a headless buy. Phase 8's dismissal contract is preserved:useHeadlessSessionDismissal's unmount cleanup still firesonClose({ reason: 'user_dismissed' }). We additionally register anavigation.addListener('beforeRemove', ...)so the synchronouscloseSessionfire that used to live on the visible Cancel/Back buttons still happens before unmount on hardware back / iOS swipe-back. Orphanedheadless_host.*i18n keys removed across all 14 locales.Fix Inject inpage js #1 — Transparent stack wrapper
RAMP.HEADLESS_ENTRY(ba35d1f2). After the chrome strip, Goktug's May 12 testing showed the user still sees a solid-white screen becauseMainNavigator.jsmounts the headless flow underRAMP.TOKEN_SELECTION, an opaque V2 stack card that sits between the consumer screen and the now-transparent Host. Fix: add a new outer slotRAMP.HEADLESS_ENTRYthat mounts the sameTokenListRoutescomponent, but wraps it in the JS-stack transparent-overlay preset already used byBridgeModals,EarnModals,MoneyModals,StakeModals, andPerpsModals:useHeadlessBuy.startHeadlessBuynow navigates toHEADLESS_ENTRYinstead ofTOKEN_SELECTION; the nested-screen descriptor stays the same so it still lands directly on the Host. UB2's entry throughTOKEN_SELECTIONis untouched.Fix A —
getUserLimitsswallow →failSession(4f5ddfbd).useTransakRouting.checkUserLimitspreviously caught errors fromgetUserLimits, rethrewLimitExceededError, and swallowed everything else (network blips, malformed responses). In headless mode the consumer got noonErrorcallback when this fired. Fix: gate the new behavior onheadlessSessionId— route swallowed infrastructure errors throughfailSession(headlessSessionId, error)and rethrow so the caller's outer catch unwinds the flow. UB2's existing swallow stays in place; the existing test atuseTransakRouting.test.ts:957-982(which encodes the UB2 swallow as intent) continues to pass. Two new tests cover the headless paths.Cursor Bugbot fix — RESET-action guard in
beforeRemove(1c5b205b). Bugbot flagged thebeforeRemovelistener as high-severity:useTransakRouting.reset()re-pins HEADLESS_HOST at the base when routing to VerifyIdentity / BasicInfo / Checkout / KycWebview. The reset firesbeforeRemoveon the OLD HEADLESS_HOST instance before re-pinning the new one — but the session is still in flight; closing it there prematurely firesonClose({ reason: 'user_dismissed' })and breaks the flow. Fix: inspecte.data.action.typeinside the listener and short-circuit on'RESET'. Legitimate user-dismissal paths (GO_BACK, POP) still close the session synchronously; other unmount cases stay caught byuseHeadlessSessionDismissal's unmount path withisHeadlessHostStillInNavigator.Was originally in scope, moved to Phase 9 PR
getProviderBuyLimitbelt-and-braces pre-flight)" landed in #30103 asfeat(ramp): pre-quote static bounds check + expose getProviderBuyLimit, with a more comprehensive implementation than this PR originally drafted (UB2-parity skip onamount <= 0, conservative "all candidates reject" rule,details.sourcediscriminator, plus a publicgetProviderBuyLimitre-export). Phase 9.5 doesn't duplicate it.Changelog
CHANGELOG entry: see CHANGELOG.md
[Unreleased]for the three entries (Phase 9.5 invisible Host +RAMP.HEADLESS_ENTRYunder Changed; Fix A + Cursor Bugbot RESET-guard under Fixed). Full text below.Related issues
main.Manual testing steps
Static / automated (verified locally):
yarn lint— clean on changed files.yarn lint:tsc— clean (the only TS errors are pre-existingtermsOfUseContentunrelated to this PR).yarn jest app/components/UI/Ramp/headless/useHeadlessBuy.test.ts app/components/UI/Ramp/hooks/useTransakRouting.test.ts app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx— 117/117 passing. Scoped coverage on the three changed files: 97% statements / 85% branches / 98% functions / 97% lines.Manual — runtime, iOS + Android:
Screenshots/Recordings
Before
HeadlessHost rendered a header, spinner, loading text, and a Cancel button while the buy flow was in flight. The consumer screen was hidden behind a solid-white V2 card (Goktug's "empty screen.mov" report).
After
HeadlessHost is fully transparent AND its wrapping stack card is now a
transparentModal, so the consumer's loading UI fills the screen behind the headless flow. Functional behaviour (orchestration, dismissal,nativeFlowError) is unchanged.useTransakRouting's stack rebuilds no longer close the session prematurely.Screen.Recording.2026-05-14.at.1.57.37.PM.mov
Pre-merge author checklist
team-money-movement)Performance checks (if applicable)
Pre-merge reviewer checklist
Note
Medium Risk
Changes headless Ramp buy navigation and session lifecycle handling (dismissal, error propagation, touch-through) across multiple screens; mistakes could prematurely close sessions or leave users stuck in the flow.
Overview
Headless buy UI is now effectively invisible.
HeadlessHostis stripped down to a transparent container view (no header/spinner/buttons/text) while preserving orchestration, adding abeforeRemovelistener to synchronously close the session on user back (but skippingRESETactions).Introduces a transparent stack entry for headless flows. Adds
Routes.RAMP.HEADLESS_ENTRYinMainNavigator(mounted as atransparentModal) and updatesuseHeadlessBuyto navigate through it so the consumer screen stays visible behind the flow.Hardens headless session flow behavior. Adds
headlessEntryNavigationhelpers (dismissHeadlessFlow,setHeadlessEntryCardTouchThrough) and updatesCheckout/useTransakRoutingto use them for consistent dismissal, prevent double-termination, treat empty callbacks as user dismissal, propagategetUserLimitsinfra errors viafailSessionin headless mode, and passheadlessSessionIdthrough Checkout routing. Tests are updated/added, and the headless playground shows an in-button spinner for the active quote.Reviewed by Cursor Bugbot for commit d4669b8. Bugbot is set up for automated code reviews on this repo. Configure here.