Skip to content

Commit cd1bf23

Browse files
chore(ramp): trim headless buy diff
1 parent c782487 commit cd1bf23

26 files changed

Lines changed: 136 additions & 438 deletions

app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -725,34 +725,12 @@ describe('Checkout', () => {
725725
reason: 'completed',
726726
});
727727
expect(mockParentPop).toHaveBeenCalled();
728-
expect(mockNavigation.reset).not.toHaveBeenCalled();
729-
expect(showV2OrderToastMock).not.toHaveBeenCalled();
730-
});
731-
732-
it('still adds the order to Redux and dispatches protect-wallet when headless', async () => {
733-
mockGetSession.mockReturnValue({
734-
id: 'hs-1',
735-
status: 'continued',
736-
callbacks: {
737-
onOrderCreated: jest.fn(),
738-
onError: jest.fn(),
739-
onClose: jest.fn(),
740-
},
741-
});
742-
mockUseParams.mockReturnValue(callbackFlowParams);
743-
744-
const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);
745-
746-
await act(async () => {
747-
fireEvent.press(getByTestId('trigger-callback-navigation'));
748-
});
749-
750-
await waitFor(() => {
751-
expect(mockAddOrder).toHaveBeenCalledWith(mockOrder);
752-
});
728+
expect(mockAddOrder).toHaveBeenCalledWith(mockOrder);
753729
expect(mockDispatch).toHaveBeenCalledWith({
754730
type: 'PROTECT_WALLET_MODAL_VISIBLE',
755731
});
732+
expect(mockNavigation.reset).not.toHaveBeenCalled();
733+
expect(showV2OrderToastMock).not.toHaveBeenCalled();
756734
});
757735

758736
it('swallows consumer onOrderCreated errors and still closes + pops', async () => {

app/components/UI/Ramp/Views/Checkout/Checkout.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -513,9 +513,7 @@ const Checkout = () => {
513513
const fireClosedRef = useRef<() => void>(() => {
514514
/* no-op until initialized */
515515
});
516-
const closeHeadlessOnUnmountRef = useRef<() => void>(() => {
517-
/* no-op until initialized */
518-
});
516+
const closeHeadlessOnUnmountRef = useRef<() => void>(() => undefined);
519517
closeHeadlessOnUnmountRef.current = () => {
520518
if (!headlessSessionId || hasTerminatedHeadlessSessionRef.current) {
521519
return;

app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.styles.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
1-
/* eslint-disable react-native/no-color-literals -- Phase 9.5: Host renders
2-
* no visible chrome. The container fills the screen so React Navigation has
3-
* a stack base for resets, but is fully transparent so the consumer's
4-
* loading UI renders underneath. There is no theme token for "transparent",
5-
* and the consumer paints the visible surface — the color literal is
6-
* intentional and does not need a theme equivalent. */
71
import { StyleSheet } from 'react-native';
82

93
const styleSheet = () =>
104
StyleSheet.create({
115
container: {
126
flex: 1,
13-
backgroundColor: 'transparent',
7+
},
8+
body: {
9+
flex: 1,
10+
alignItems: 'center',
11+
justifyContent: 'center',
12+
paddingHorizontal: 24,
13+
gap: 16,
14+
},
15+
spinner: {
16+
marginBottom: 8,
17+
},
18+
text: {
19+
textAlign: 'center',
20+
},
21+
cancelRow: {
22+
paddingTop: 12,
1423
},
1524
});
1625

app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,11 @@ const mockContinueWithQuote = jest.fn();
3333
const mockUseContinueWithQuoteOptions = jest.fn();
3434
let mockIsFocused = true;
3535

36-
// Holds the most recent 'beforeRemove' listener registered against the
37-
// mocked navigation object. Tests fire it directly to exercise the
38-
// production beforeRemove path without spinning up a real navigator.
39-
// The listener now reads `e.data.action.type` so it can ignore RESET
40-
// stack-rebuilds; tests pass an event arg accordingly.
4136
interface BeforeRemoveEvent {
4237
data: { action: { type: string } };
4338
}
4439
let registeredBeforeRemoveListener: ((e?: BeforeRemoveEvent) => void) | null =
4540
null;
46-
// Default event passed when a test wants to simulate a user back/swipe — any
47-
// non-RESET action type triggers the close path.
4841
const DEFAULT_GO_BACK_EVENT: BeforeRemoveEvent = {
4942
data: { action: { type: 'GO_BACK' } },
5043
};
@@ -214,8 +207,6 @@ describe('HeadlessHost', () => {
214207
});
215208

216209
it('renders only a transparent container — no header, spinner, or buttons after Phase 9.5', () => {
217-
// The Phase 9.5 contract: the consumer (TPC / MMPay) renders all
218-
// user-visible loading UI. The Host is a stack base only.
219210
const session = seedSession(buildAggregatorQuote());
220211
renderHost({ headlessSessionId: session.id });
221212
expect(
@@ -225,7 +216,6 @@ describe('HeadlessHost', () => {
225216
'pointerEvents',
226217
'none',
227218
);
228-
// No legacy chrome should be present.
229219
expect(screen.queryByText(/Cancel/i)).not.toBeOnTheScreen();
230220
expect(screen.queryByText(/Preparing/i)).not.toBeOnTheScreen();
231221
expect(screen.queryByText(/no longer active/i)).not.toBeOnTheScreen();
@@ -238,8 +228,6 @@ describe('HeadlessHost', () => {
238228
});
239229

240230
it('renders the transparent container even with no session — no UI affordances surface to the user', () => {
241-
// Pre-Phase 9.5 this rendered a "no session" message; the consumer
242-
// now owns that surface.
243231
renderHost({ headlessSessionId: 'headless-buy-not-real' });
244232
expect(
245233
screen.getByTestId(HEADLESS_HOST_CONTAINER_TEST_ID),
@@ -342,12 +330,8 @@ describe('HeadlessHost', () => {
342330
it('skips orchestration when the session has already been cancelled', async () => {
343331
const quote = buildAggregatorQuote();
344332
const session = seedSession(quote);
345-
// Mark the session terminal *before* the screen mounts: the focus
346-
// effect must respect that and avoid a stale re-trigger.
347333
closeSession(session.id, { reason: 'consumer_cancelled' });
348334
renderHost({ headlessSessionId: session.id });
349-
// No session left → orchestration short-circuits. The consumer
350-
// already received onClose from the closeSession call above.
351335
expect(mockContinueWithQuote).not.toHaveBeenCalled();
352336
});
353337
});
@@ -421,13 +405,6 @@ describe('HeadlessHost', () => {
421405
expect(mockContinueWithQuote).toHaveBeenCalledTimes(1),
422406
);
423407
unmount();
424-
// Phase 8: unmount fires the dismissal close because the session
425-
// had not reached a terminal status. After unmount the session is
426-
// gone from the registry, so the .catch's live-session re-read
427-
// short-circuits and does not produce a second onClose or an
428-
// onError. (The .catch's `cancelled` flag is independent React
429-
// unmount-state protection; this test does not exercise it
430-
// directly.)
431408
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
432409
expect(callbacks.onClose).toHaveBeenCalledWith({
433410
reason: 'user_dismissed',
@@ -454,8 +431,6 @@ describe('HeadlessHost', () => {
454431
});
455432
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
456433
expect(getSession(session.id)).toBeUndefined();
457-
// The auth-error path also short-circuits the continue-on-focus effect
458-
// — we never want to push EnterEmail again on top of the error message.
459434
expect(mockContinueWithQuote).not.toHaveBeenCalled();
460435
});
461436

@@ -477,17 +452,12 @@ describe('HeadlessHost', () => {
477452
},
478453
);
479454
renderHost({ headlessSessionId: session.id });
480-
// Even though onError throws, the close path still runs and the
481-
// session is gone from the registry.
482455
await waitFor(() => expect(getSession(session.id)).toBeUndefined());
483456
});
484457
});
485458

486459
describe('Dismissal (Phase 8 + 9.5)', () => {
487460
it('registers a beforeRemove listener that synchronously closes the session with user_dismissed', () => {
488-
// Phase 9.5 replaces the old visible Cancel/Back buttons with a
489-
// navigation listener so the synchronous close still fires when the
490-
// user backs out — even with no chrome to render.
491461
const quote = buildAggregatorQuote();
492462
const session = seedSession(quote);
493463
const callbacks = session.callbacks;
@@ -498,7 +468,6 @@ describe('HeadlessHost', () => {
498468
);
499469
expect(typeof registeredBeforeRemoveListener).toBe('function');
500470

501-
// Fire the listener like React Navigation would on a back gesture.
502471
registeredBeforeRemoveListener?.(DEFAULT_GO_BACK_EVENT);
503472

504473
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
@@ -509,12 +478,6 @@ describe('HeadlessHost', () => {
509478
});
510479

511480
it('does NOT close the session when beforeRemove fires for a RESET action (stack rebuild guard)', () => {
512-
// Cursor Bugbot — useTransakRouting calls navigation.reset() to
513-
// re-pin HEADLESS_HOST at the base when moving to VerifyIdentity /
514-
// BasicInfo / Checkout / KycWebview. The reset fires beforeRemove on
515-
// the OLD instance, but the session is still in flight; closing it
516-
// here would prematurely fire onClose({ reason: 'user_dismissed' })
517-
// and break the flow.
518481
const quote = buildAggregatorQuote();
519482
const session = seedSession(quote);
520483
const callbacks = session.callbacks;
@@ -526,9 +489,6 @@ describe('HeadlessHost', () => {
526489
});
527490

528491
expect(callbacks.onClose).not.toHaveBeenCalled();
529-
// The session is still live so useTransakRouting's reset can complete
530-
// and re-pin the new HEADLESS_HOST without losing the consumer's
531-
// callbacks.
532492
expect(getSession(session.id)).toBeDefined();
533493
});
534494

@@ -565,15 +525,10 @@ describe('HeadlessHost', () => {
565525
expect(mockContinueWithQuote).toHaveBeenCalledTimes(1),
566526
);
567527

568-
// Simulate Phase 6: useTransakRouting / Checkout fires
569-
// closeSession({ reason: 'completed' }) after onOrderCreated.
570528
closeSession(session.id, { reason: 'completed' });
571529
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
572530
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'completed' });
573531

574-
// beforeRemove fires too (React Navigation always fires it on screen
575-
// removal), then unmount cleanup runs. Both find the session gone and
576-
// no-op.
577532
registeredBeforeRemoveListener?.(DEFAULT_GO_BACK_EVENT);
578533
unmount();
579534

@@ -589,24 +544,19 @@ describe('HeadlessHost', () => {
589544
nativeFlowError: 'OTP rejected',
590545
});
591546

592-
// Phase 7: nativeFlowError handler funnels through failSession →
593-
// closeSession({ reason: 'unknown' }, { terminalStatus: 'failed' }).
594547
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
595548
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
596549

597550
registeredBeforeRemoveListener?.(DEFAULT_GO_BACK_EVENT);
598551
unmount();
599552

600-
// Both follow-up paths re-read, see nothing, no-op.
601553
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
602554
});
603555

604556
it('no-ops on unmount when the host mounted against an already-terminated session', () => {
605557
const quote = buildAggregatorQuote();
606558
const session = seedSession(quote);
607559
const callbacks = session.callbacks;
608-
// Cancel before the screen mounts; the Phase 8 dismissal cleanup must
609-
// not produce a spurious second onClose.
610560
closeSession(session.id, { reason: 'consumer_cancelled' });
611561
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
612562
expect(callbacks.onClose).toHaveBeenCalledWith({

app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx

Lines changed: 0 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,6 @@ import { getQuoteProviderName } from '../../types';
3636

3737
import styleSheet from './HeadlessHost.styles';
3838

39-
/**
40-
* Test-only anchor for the Host's transparent placeholder. The Host renders
41-
* no user-visible chrome after Phase 9.5 — every loading / error / cancel
42-
* surface is owned by the consumer (MetaMask Pay's `TransactionPayController`).
43-
*/
4439
export const HEADLESS_HOST_CONTAINER_TEST_ID = 'headless-host-container';
4540

4641
export interface HeadlessHostParams {
@@ -63,26 +58,6 @@ export interface HeadlessHostParams {
6358
export const createHeadlessHostNavDetails =
6459
createNavigationDetails<HeadlessHostParams>(Routes.RAMP.HEADLESS_HOST);
6560

66-
/**
67-
* Headless Host screen.
68-
*
69-
* After Phase 9.5 the Host is intentionally **invisible** — it renders a
70-
* transparent placeholder so React Navigation has a stack base for resets,
71-
* but the consumer (TPC / MMPay) renders the only user-visible loading UI
72-
* for a headless buy. The Host still picks up the live session by
73-
* `headlessSessionId` and calls `useContinueWithQuote().continueWithQuote(...)`
74-
* exactly once on mount (status guard prevents re-entry on the post-OTP auth
75-
* loop), surfaces `nativeFlowError` (set by OtpCode on routing failure) as
76-
* `onError('AUTH_FAILED', ...)` and closes the session, and fires
77-
* `onClose({ reason: 'user_dismissed' })` from two paths.
78-
*
79-
* Dismissal: `navigation.addListener('beforeRemove', ...)` catches synchronous
80-
* user-driven pops of the Host itself (hardware back / iOS swipe-back when the
81-
* Host is the focused screen, or a programmatic pop targeting it).
82-
* `useHeadlessSessionDismissal` (Phase 8) catches any other unmount path:
83-
* stack resets that bypass `beforeRemove`, hot reloads, parent navigator
84-
* pops, etc. `closeSession` is idempotent so the two are safe to coexist.
85-
*/
8661
function HeadlessHost() {
8762
const navigation = useNavigation();
8863
const isFocused = useIsFocused();
@@ -99,32 +74,8 @@ function HeadlessHost() {
9974
};
10075
}, [navigation, isFocused]);
10176

102-
// Phase 8: defense-in-depth dismissal. The `beforeRemove` listener below
103-
// fires the synchronous close on every user-driven exit, so this hook's
104-
// unmount cleanup is effectively a no-op in production. Kept because some
105-
// flows (hot reload, programmatic stack reset) skip `beforeRemove`.
10677
useHeadlessSessionDismissal(headlessSessionId);
10778

108-
// Phase 9.5: replace the old visible Cancel/Back-button handlers with a
109-
// navigation listener. `beforeRemove` only fires when *this* screen is
110-
// being popped (the listener is per-screen). It catches the common case:
111-
// hardware back / iOS swipe-back while the Host is focused, or a
112-
// programmatic pop targeting the Host. Other unmount paths
113-
// (stack reset, parent pop while a child screen has focus, hot reload)
114-
// are caught by `useHeadlessSessionDismissal`'s unmount cleanup above.
115-
// closeSession is idempotent: Phase 6 success and Phase 7 errors clear
116-
// the session before `beforeRemove` and turn this into a no-op.
117-
//
118-
// Stack-rebuild guard (Cursor Bugbot): `useTransakRouting` calls
119-
// `navigation.reset()` to re-pin HEADLESS_HOST at the base of the stack
120-
// when navigating to VerifyIdentity / BasicInfo / Checkout / KycWebview.
121-
// The reset action fires `beforeRemove` on the OLD HEADLESS_HOST instance
122-
// before re-pinning the new one, but the session is still in flight —
123-
// closing it here would prematurely fire `onClose({ reason: 'user_dismissed' })`
124-
// and break the flow. Skip the close when the action is a RESET; the
125-
// legitimate unmount cases (stack reset that does NOT re-pin the Host,
126-
// hot reload) are caught by `useHeadlessSessionDismissal`'s unmount path
127-
// with `isHeadlessHostStillInNavigator`.
12879
useEffect(() => {
12980
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
13081
if (e.data.action.type === 'RESET') {
@@ -294,14 +245,6 @@ function HeadlessHost() {
294245
continueWithQuote,
295246
]);
296247

297-
// The View is intentionally empty — Phase 9.5 hands all visible UI to the
298-
// consumer. It must also be touch-transparent: the navigation route exists
299-
// only as an orchestration base, so the empty placeholder should not eat
300-
// taps/scrolls intended for the consumer surface below.
301-
//
302-
// We do NOT set `accessibilityElementsHidden` here because there are no
303-
// descendants to hide; it would only confuse screen readers about an
304-
// already-empty stack base.
305248
return (
306249
<View
307250
testID={HEADLESS_HOST_CONTAINER_TEST_ID}

0 commit comments

Comments
 (0)