feat(ramp): add Headless Host + quote-first startHeadlessBuy (Phase 5)#29338
feat(ramp): add Headless Host + quote-first startHeadlessBuy (Phase 5)#29338
Conversation
Introduces the Headless Host screen as the stable stack base for the
Unified Buy v2 headless flow and switches `startHeadlessBuy` to a
quote-first signature so external consumers drive the flow without
seeding the RampsController.
- New `Routes.RAMP.HEADLESS_HOST` registered inside the Unified Buy v2
inner stack (`TokenListRoutes`) so `useTransakRouting` resets land on
the Host instead of BuildQuote.
- `useHeadlessBuy.startHeadlessBuy({ quote, ... })` derives the
`ContinueWithQuoteContext` from the supplied quote, creates a session
in the registry, and navigates into the v2 stack via the proper
nested-screen descriptor (TOKEN_SELECTION → MainRoutes → HEADLESS_HOST).
- Single-active-session policy: starting a new session
auto-`closeSession`s the previous one with `consumer_cancelled`.
- `HeadlessHost` orchestrates: on focus it picks up the session, calls
`continueWithQuote` exactly once (guarded by `hasContinuedRef` +
`session.status` so the OTP/auth loop's re-focuses don't re-trigger),
and surfaces `nativeFlowError` from `OtpCode` as
`onError({ code: 'AUTH_FAILED' })`.
- Native flow screens (`EnterEmail`, `OtpCode`, `VerifyIdentity`) now
thread `headlessSessionId` so `useTransakRouting` can be parameterized
with `baseRoute = HEADLESS_HOST`.
- Playground UI: per-quote "Start headless buy" buttons (standalone
start button removed); tapping replaces any active session.
- BuildQuote `headlessSessionId` param marked `@deprecated` — the
headless flow no longer routes through BuildQuote.
Tests: 64 passing across `useHeadlessBuy`, `useContinueWithQuote`,
`useTransakRouting`, `HeadlessHost`, and `HeadlessPlayground`.
|
|
||
| > Codifies the "headless start now requires a `Quote`" pivot agreed with product. | ||
|
|
||
| Originally Phase 5 had the Headless Host fetch quotes for the consumer; Phase 5b was a follow-up where the consumer hands in a pre-selected quote. We're flipping the order: **Phase 5 (revised) is now the quote-first start path** and the original Phase 5 ("Host fetches quotes and auto-picks") is renamed to Phase 5b and deferred until quote-first is stable. |
There was a problem hiding this comment.
Why do the consumers care about passing the quotes? Should they just not care about the token they want to buy and the amount they want to buy?
There was a problem hiding this comment.
The quote has the amounts and fees the consumer is expecting to display (currently money account funding)
Stabilize baseRouteParams identity for HeadlessHost and OtpCode so useTransakRouting callbacks do not churn every render. This keeps continueWithQuote stable and avoids unnecessary useFocusEffect listener teardown on HeadlessHost.
`startHeadlessBuy` now calls `setSelectedToken`, `setSelectedProvider`, and `setSelectedPaymentMethod` before navigating to the Headless Host. This mirrors what `BuildQuote` does before calling `continueWithQuote`. Without seeding, the native auth loop (`OtpCode`, `useTransakRouting`) reads `selectedToken.chainId`, `selectedPaymentMethod`, and `walletAddress` from the controller and gets null/empty-string values, causing the post-OTP Transak quote fetch to fail and the payment widget URL to be generated with an empty wallet address. The Phase 5 quote-first API makes the fix safe: catalogs are loaded by the time the caller picks a quote from `getQuotes()`, so the id→object lookups are reliable. Falls back to `null` for unresolved lookups (auto-select in `useRampsPaymentMethods` corrects it on refetch). PLAN.md Phase 3.1 is annotated to document the Phase 5 revision.
…andle new sessions while focused When a second headless buy starts while HeadlessHost is the active screen, React Navigation merges the new headlessSessionId param without unmounting or emitting a focus event. This caused two compounding bugs: 1. hasContinuedRef stayed `true` across sessions (it was never scoped to a specific session id), so the guard bailed immediately. 2. useFocusEffect never fired for param-only changes — no blur/focus transition occurs when the screen is already focused. Fix: drop hasContinuedRef entirely and switch to a plain useEffect with headlessSessionId in its dep array. useEffect fires whenever deps change regardless of focus state, so a new session is picked up immediately. Re-entry during the Transak auth loop is already prevented by session.status: setStatus marks it 'continued' before the loop starts, and since headlessSessionId is unchanged during the loop the effect does not re-fire. Add a separate useEffect to reset errorMessage when headlessSessionId changes so subsequent sessions start with a clean UI.
…ects - nativeFlowError effect: use getSession(headlessSessionId) instead of the render-time session reference; drop session from deps. If the processing effect's .catch already closed the session, skip onError (no double notify). - Processing effect: same pattern — re-read currentSession at effect start; in continueWithQuote .catch, re-read liveSession before onError/closeSession. Drop session from deps to avoid fragile Object.is coupling to the in-map object reference. Prevents duplicate onError when nativeFlowError and promise rejection race, and avoids relying on session object identity for effect invalidation.
…ace label when session active HeadlessHost: guard continueWithQuote call against a null walletAddress. useRampAccountAddress resolves asynchronously; without the guard the first effect run passes undefined to continueWithQuote, producing a stale address in widget/order URLs. Returning early (while status remains 'pending') lets the dep-change re-fire when walletAddress settles, ensuring the resolved value is always used. Playground: thread hasActiveSession (activeSession !== null) through QuotesList → QuoteRow and switch the per-quote button label to "Replace & start headless buy" when a session is already live, restoring the visual signal that was present on the old standalone start button. Adds the new string to en.json.
…ssHost When chainId is null (malformed assetId), useRampAccountAddress is still invoked with a falsy chain id and returns null. The walletAddress === null early return ran first, so the UNKNOWN invalid-assetId path never ran and the loader never cleared. Reorder guards so !chainId runs first. Strengthen the malformed-assetId test with mockUseRampAccountAddress(null) so ordering regressions fail.
…nder Host on invalid assetId startHeadlessBuy: run getActiveSessionId/closeSession before setSelectedToken, setSelectedProvider, and setSelectedPaymentMethod so a replaced session's onClose observes controller state from that session, not the new quote. HeadlessHost: call setErrorMessage on the invalid-assetId path so React re-renders after closeSession (registry-only change would otherwise leave the loader visible). Tests: assert onClose-before-seed ordering on replace; async malformed-asset test waits for error text on screen.
…fo logout params useTransakRouting: merge baseRouteParams onto VerifyIdentity and BasicInfo when navigation.reset runs so headlessSessionId reaches EnterEmail/OtpCode. On 401 after logout, navigate via createV2EnterEmailNavDetails when a headless session id is configured. BasicInfo: forward headlessSessionId to EnterAddress; logout uses createV2EnterEmailNavDetails with quote.fiatCurrency (string) and selectedToken.assetId (TransakBuyQuote has no nested fiat/crypto objects). EnterAddress: use headless Transak routing config when headlessSessionId is present. Tests: headless merge cases for verify/BasicInfo resets; mock useRampsController in BasicInfo tests.
routeAfterAuthentication: when handling 401 with a headless session, navigate to EnterEmail with amount (route arg or quote.fiatAmount), currency (quote.fiatCurrency or user region), and selectedToken.assetId so OtpCode can re-fetch the Transak quote after re-login. Previously only headlessSessionId was passed, so OtpCode skipped the quote path and returned to HEADLESS_HOST while the session was already continued, leaving the Host stuck on the loader. Tests: extend selectedToken mock with assetId; add headless 401 case.
- Add HeadlessSessionStatus 'failed' and CloseSessionOptions.terminalStatus - closeSession: treat failed as terminal; log when onClose throws - HeadlessHost: use terminalStatus 'failed' on error paths after continued - Extend sessionRegistry tests for failed lifecycle and Logger on throw
Explain membership check vs catalog remap and downstream fallback.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e0b4f06. Configure here.
- Track effect cancellation so .catch does not setState, onError, or closeSession - Add unit test for deferred rejection after unmount
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
The changes are scoped entirely to the Ramp feature. The modifications to useTransakRouting and useContinueWithQuote are backward-compatible (existing BuildQuote callers pass no config and get the same behavior). The NativeFlow screen changes only add optional headlessSessionId threading. SmokeRamps is the appropriate tag as it covers the on-ramp buy flows including the unified buy flow (BuildQuote, KYC screens, OTP flow) that are directly modified. No other feature areas are impacted - there are no changes to shared navigation infrastructure, core controllers, Engine, or other feature areas. No performance tests are needed as these changes are feature additions to the Ramp UI flow with no impact on rendering performance, data loading, or app startup. Performance Test Selection: |
|
|
✅ E2E Fixture Validation — Schema is up to date |




Description
This PR closes Phase 5 (revised) of the incremental Unified Buy (v2) headless buy plan (
app/components/UI/Ramp/headless/PLAN.md): external callers can start a headless session with a concreteQuotefromgetQuotes, without seeding the Ramps controller or visiting Build Quote.Reason
useContinueWithQuoteand parameterized Transak routing with abaseRouteso auth loops could reset off Build Quote. Phase 5 makes the consumer contract quote-first and adds a Headless Host screen as the stable stack base sonavigation.resettargets (Checkout, KYC, etc.) live in the same inner navigator as the host (fixing the “stuck on loader” issue when the host was mounted on the outer stack).What changed
Routes.RAMP.HEADLESS_HOST— new screen registered on the Unified Buy v2 inner stack (app/components/UI/Ramp/routes.tsx), not onMainNavigator, souseTransakRoutingresets resolve correctly.useHeadlessBuy.startHeadlessBuy({ quote, amount, assetId, currency, paymentMethodId?, redirectUrl? })— creates a session carrying the quote; navigates with nested params:TOKEN_SELECTION(outerTokenListRoutesmount) → innerTOKEN_SELECTION→HEADLESS_HOST(avoids routing through legacyRoutes.RAMP.BUY/ Aggregator stack).HeadlessHost— on focus, resolves chain + wallet, buildsContinueWithQuoteContextfrom the session, callscontinueWithQuoteonce (guarded byhasContinuedRef+ session status), handlesnativeFlowErrorfrom OTP asonError({ code: 'AUTH_FAILED' }).sessionRegistry—closeSession/getActiveSessionId: starting a new session cancels the previous one withconsumer_cancelled(playground UX).headlessSessionIdthreaded throughEnterEmail→OtpCode→VerifyIdentity;OtpCodepassesbaseRoute: HEADLESS_HOSTintouseTransakRoutingwhen in headless mode.headlessSessionIdparam documented as deprecated (headless no longer enters via Build Quote).References
main: #29213 (refactor(ramp): extract useContinueWithQuote hook).poc/headless-buy-base,poc/headless-buy-phase-1–phase-3if you need historical diffs.Tests
yarn run jeston:useHeadlessBuy.test.ts,useContinueWithQuote.test.ts,useTransakRouting.test.ts,HeadlessHost.test.tsx,HeadlessPlayground.test.tsx.Changelog
CHANGELOG entry: null
Related issues
Fixes: No GitHub issue — incremental POC on branch
poc/headless-buy-phase-5.Continuity: #29213 (Phase 4 —
useContinueWithQuote+ TransakbaseRouteparameterization).Manual testing steps
Screenshots/Recordings
Before
After
headless_p5_aggregator.mp4
headless_p5_native.mp4
Pre-merge author checklist
Performance checks (if applicable)
trace()for usage andaddTokenfor an exampleFor performance guidelines and tooling, see the Performance Guide.
Pre-merge reviewer checklist
Note
High Risk
High risk because it changes Ramp navigation/reset behavior during the Transak auth/KYC loop and introduces new headless session lifecycle semantics (auto-cancel, terminal statuses), which could strand users or misfire consumer callbacks if orchestration is wrong.
Overview
Adds a new
Routes.RAMP.HEADLESS_HOSTscreen that acts as the stable stack base for headless buy sessions, deriving aContinueWithQuoteContextfrom the session and callingcontinueWithQuoteonce while handling OTP/auth-loop errors vianativeFlowError.Switches
useHeadlessBuy.startHeadlessBuyto a quote-first contract ({ quote, assetId, amount, ... }), seeds controller selections from the chosen quote, navigates directly intoHEADLESS_HOSTvia nested Ramp stack params, and enforces a single-active-session policy by auto-closing any prior session.Extends core headless/session and routing plumbing:
sessionRegistrygainscloseSession,getActiveSessionId, and a new terminalfailedstatus;useContinueWithQuoteadds override-capable context plustransakRoutingoptions;useTransakRoutingnow supports a configurable reset base route/params and propagatesheadlessSessionIdthrough native flow screens (VerifyIdentity/EnterEmail/OtpCode/BasicInfo/EnterAddress). The Headless Playground UI/test flow is updated to start sessions per-quote, andBuildQuotemarksheadlessSessionIdas deprecated legacy.Reviewed by Cursor Bugbot for commit 58278dd. Bugbot is set up for automated code reviews on this repo. Configure here.