Skip to content

Commit 170b4a1

Browse files
feat(ramp): make HeadlessHost invisible (Phase 9.5)
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
1 parent 4257d6a commit 170b4a1

18 files changed

Lines changed: 131 additions & 313 deletions

File tree

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,17 @@
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. */
17
import { StyleSheet } from 'react-native';
2-
import { Theme } from '../../../../../util/theme/models';
38

4-
const styleSheet = (params: { theme: Theme }) => {
5-
const { theme } = params;
6-
return StyleSheet.create({
9+
const styleSheet = () =>
10+
StyleSheet.create({
711
container: {
812
flex: 1,
9-
backgroundColor: theme.colors.background.default,
10-
},
11-
body: {
12-
flex: 1,
13-
alignItems: 'center',
14-
justifyContent: 'center',
15-
paddingHorizontal: 24,
16-
gap: 16,
17-
},
18-
spinner: {
19-
marginBottom: 8,
20-
},
21-
text: {
22-
textAlign: 'center',
23-
},
24-
cancelRow: {
25-
paddingTop: 12,
13+
backgroundColor: 'transparent',
2614
},
2715
});
28-
};
2916

3017
export default styleSheet;

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

Lines changed: 59 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import React from 'react';
2-
import { act, fireEvent, screen, waitFor } from '@testing-library/react-native';
2+
import { act, screen, waitFor } from '@testing-library/react-native';
33

44
import HeadlessHost, {
5-
HEADLESS_HOST_BACK_BUTTON_TEST_ID,
6-
HEADLESS_HOST_CANCEL_BUTTON_TEST_ID,
7-
HEADLESS_HOST_LOADER_TEST_ID,
8-
HEADLESS_HOST_NO_SESSION_TEST_ID,
5+
HEADLESS_HOST_CONTAINER_TEST_ID,
96
createHeadlessHostNavDetails,
107
type HeadlessHostParams,
118
} from './HeadlessHost';
@@ -30,16 +27,23 @@ import Routes from '../../../../../constants/navigation/Routes';
3027

3128
const mockGoBack = jest.fn();
3229
const mockNavigate = jest.fn();
30+
const mockAddListener = jest.fn();
3331
const mockContinueWithQuote = jest.fn();
3432
const mockUseContinueWithQuoteOptions = jest.fn();
3533

34+
// Holds the most recent 'beforeRemove' listener registered against the
35+
// mocked navigation object. Tests fire it directly to exercise the
36+
// production beforeRemove path without spinning up a real navigator.
37+
let registeredBeforeRemoveListener: (() => void) | null = null;
38+
3639
jest.mock('@react-navigation/native', () => {
3740
const actual = jest.requireActual('@react-navigation/native');
3841
return {
3942
...actual,
4043
useNavigation: () => ({
4144
goBack: mockGoBack,
4245
navigate: mockNavigate,
46+
addListener: mockAddListener,
4347
}),
4448
};
4549
});
@@ -158,6 +162,15 @@ describe('HeadlessHost', () => {
158162
beforeEach(() => {
159163
jest.clearAllMocks();
160164
__resetSessionRegistryForTests();
165+
registeredBeforeRemoveListener = null;
166+
mockAddListener.mockImplementation(
167+
(eventName: string, listener: () => void) => {
168+
if (eventName === 'beforeRemove') {
169+
registeredBeforeRemoveListener = listener;
170+
}
171+
return jest.fn();
172+
},
173+
);
161174
mockUseRampAccountAddress.mockReturnValue('0xWALLET');
162175
mockUseRampsUserRegion.mockReturnValue({
163176
userRegion: { country: { currency: 'EUR' } },
@@ -180,39 +193,28 @@ describe('HeadlessHost', () => {
180193
expect(params).toEqual({ headlessSessionId: 'headless-buy-abc' });
181194
});
182195

183-
it('renders the no-session message when the session id is unknown', () => {
184-
renderHost({ headlessSessionId: 'headless-buy-not-real' });
185-
expect(
186-
screen.getByTestId(HEADLESS_HOST_NO_SESSION_TEST_ID),
187-
).toBeOnTheScreen();
188-
expect(
189-
screen.queryByTestId(HEADLESS_HOST_LOADER_TEST_ID),
190-
).not.toBeOnTheScreen();
191-
expect(mockContinueWithQuote).not.toHaveBeenCalled();
192-
});
193-
194-
it('renders the loader while a matching session is being orchestrated', () => {
195-
// Make continueWithQuote hang so the loader stays on screen.
196-
mockContinueWithQuote.mockImplementation(
197-
() => new Promise(() => undefined),
198-
);
196+
it('renders only a transparent container — no header, spinner, or buttons after Phase 9.5', () => {
197+
// The Phase 9.5 contract: the consumer (TPC / MMPay) renders all
198+
// user-visible loading UI. The Host is a stack base only.
199199
const session = seedSession(buildAggregatorQuote());
200200
renderHost({ headlessSessionId: session.id });
201201
expect(
202-
screen.getByTestId(HEADLESS_HOST_LOADER_TEST_ID),
202+
screen.getByTestId(HEADLESS_HOST_CONTAINER_TEST_ID),
203203
).toBeOnTheScreen();
204+
// No legacy chrome should be present.
205+
expect(screen.queryByText(/Cancel/i)).not.toBeOnTheScreen();
206+
expect(screen.queryByText(/Preparing/i)).not.toBeOnTheScreen();
207+
expect(screen.queryByText(/no longer active/i)).not.toBeOnTheScreen();
204208
});
205209

206-
it('navigates back when the cancel button is pressed', () => {
207-
renderHost();
208-
fireEvent.press(screen.getByTestId(HEADLESS_HOST_CANCEL_BUTTON_TEST_ID));
209-
expect(mockGoBack).toHaveBeenCalledTimes(1);
210-
});
211-
212-
it('navigates back when the header back button is pressed', () => {
213-
renderHost();
214-
fireEvent.press(screen.getByTestId(HEADLESS_HOST_BACK_BUTTON_TEST_ID));
215-
expect(mockGoBack).toHaveBeenCalledTimes(1);
210+
it('renders the transparent container even with no session — no UI affordances surface to the user', () => {
211+
// Pre-Phase 9.5 this rendered a "no session" message; the consumer
212+
// now owns that surface.
213+
renderHost({ headlessSessionId: 'headless-buy-not-real' });
214+
expect(
215+
screen.getByTestId(HEADLESS_HOST_CONTAINER_TEST_ID),
216+
).toBeOnTheScreen();
217+
expect(mockContinueWithQuote).not.toHaveBeenCalled();
216218
});
217219
});
218220

@@ -300,17 +302,14 @@ describe('HeadlessHost', () => {
300302
// effect must respect that and avoid a stale re-trigger.
301303
closeSession(session.id, { reason: 'consumer_cancelled' });
302304
renderHost({ headlessSessionId: session.id });
303-
// No session left → the no-session branch renders, continueWithQuote
304-
// is never called.
305-
expect(
306-
screen.getByTestId(HEADLESS_HOST_NO_SESSION_TEST_ID),
307-
).toBeOnTheScreen();
305+
// No session left → orchestration short-circuits. The consumer
306+
// already received onClose from the closeSession call above.
308307
expect(mockContinueWithQuote).not.toHaveBeenCalled();
309308
});
310309
});
311310

312311
describe('Error handling', () => {
313-
it('forwards a malformed assetId as onError(UNKNOWN, ...) and closes the session', async () => {
312+
it('forwards a malformed assetId as onError(UNKNOWN, ...) and closes the session', () => {
314313
// Real hook: falsy chain id → null wallet. The invalid-assetId branch
315314
// must run before the wallet deferral or the effect would return early
316315
// forever (regression guard for guard ordering).
@@ -326,12 +325,9 @@ describe('HeadlessHost', () => {
326325
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
327326
expect(getSession(session.id)).toBeUndefined();
328327
expect(mockContinueWithQuote).not.toHaveBeenCalled();
329-
await waitFor(() => {
330-
expect(screen.getByText(/not-a-caip-19/)).toBeOnTheScreen();
331-
});
332328
});
333329

334-
it('surfaces a continueWithQuote rejection as onError(UNKNOWN, ...) and renders the message', async () => {
330+
it('surfaces a continueWithQuote rejection as onError(UNKNOWN, ...)', async () => {
335331
mockContinueWithQuote.mockRejectedValueOnce(new Error('quote expired'));
336332
const quote = buildAggregatorQuote();
337333
const session = seedSession(quote);
@@ -345,7 +341,6 @@ describe('HeadlessHost', () => {
345341
});
346342
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
347343
expect(getSession(session.id)).toBeUndefined();
348-
expect(screen.getByText('quote expired')).toBeOnTheScreen();
349344
});
350345

351346
it('surfaces limit failures as onError(LIMIT_EXCEEDED, ...)', async () => {
@@ -364,7 +359,6 @@ describe('HeadlessHost', () => {
364359
});
365360
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
366361
expect(getSession(session.id)).toBeUndefined();
367-
expect(screen.getByText('Daily limit exceeded')).toBeOnTheScreen();
368362
});
369363

370364
it('does not surface a continueWithQuote rejection that arrives after unmount', async () => {
@@ -402,7 +396,7 @@ describe('HeadlessHost', () => {
402396
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
403397
});
404398

405-
it('forwards a nativeFlowError param as onError(AUTH_FAILED, ...), renders it, and closes the session', () => {
399+
it('forwards a nativeFlowError param as onError(AUTH_FAILED, ...) and closes the session', () => {
406400
const quote = buildNativeQuote();
407401
const session = seedSession(quote);
408402
const callbacks = session.callbacks;
@@ -419,7 +413,6 @@ describe('HeadlessHost', () => {
419413
// The auth-error path also short-circuits the continue-on-focus effect
420414
// — we never want to push EnterEmail again on top of the error message.
421415
expect(mockContinueWithQuote).not.toHaveBeenCalled();
422-
expect(screen.getByText('OTP rejected')).toBeOnTheScreen();
423416
});
424417

425418
it('does not crash when the consumer onError callback throws', async () => {
@@ -446,50 +439,28 @@ describe('HeadlessHost', () => {
446439
});
447440
});
448441

449-
describe('Dismissal (Phase 8)', () => {
450-
it('fires onClose({ reason: "user_dismissed" }) and navigates back when the cancel button is pressed mid-flow', async () => {
451-
// Make continueWithQuote hang so the session stays non-terminal while
452-
// the user taps Cancel — this is the typical dismissal path.
453-
mockContinueWithQuote.mockImplementation(
454-
() => new Promise(() => undefined),
455-
);
456-
const quote = buildAggregatorQuote();
457-
const session = seedSession(quote);
458-
const callbacks = session.callbacks;
459-
renderHost({ headlessSessionId: session.id });
460-
await waitFor(() =>
461-
expect(mockContinueWithQuote).toHaveBeenCalledTimes(1),
462-
);
463-
464-
fireEvent.press(screen.getByTestId(HEADLESS_HOST_CANCEL_BUTTON_TEST_ID));
465-
466-
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
467-
expect(callbacks.onClose).toHaveBeenCalledWith({
468-
reason: 'user_dismissed',
469-
});
470-
expect(mockGoBack).toHaveBeenCalledTimes(1);
471-
expect(getSession(session.id)).toBeUndefined();
472-
});
473-
474-
it('fires onClose({ reason: "user_dismissed" }) and navigates back when the header back button is pressed mid-flow', async () => {
475-
mockContinueWithQuote.mockImplementation(
476-
() => new Promise(() => undefined),
477-
);
442+
describe('Dismissal (Phase 8 + 9.5)', () => {
443+
it('registers a beforeRemove listener that synchronously closes the session with user_dismissed', () => {
444+
// Phase 9.5 replaces the old visible Cancel/Back buttons with a
445+
// navigation listener so the synchronous close still fires when the
446+
// user backs out — even with no chrome to render.
478447
const quote = buildAggregatorQuote();
479448
const session = seedSession(quote);
480449
const callbacks = session.callbacks;
481450
renderHost({ headlessSessionId: session.id });
482-
await waitFor(() =>
483-
expect(mockContinueWithQuote).toHaveBeenCalledTimes(1),
451+
expect(mockAddListener).toHaveBeenCalledWith(
452+
'beforeRemove',
453+
expect.any(Function),
484454
);
455+
expect(typeof registeredBeforeRemoveListener).toBe('function');
485456

486-
fireEvent.press(screen.getByTestId(HEADLESS_HOST_BACK_BUTTON_TEST_ID));
457+
// Fire the listener like React Navigation would on a back gesture.
458+
registeredBeforeRemoveListener?.();
487459

488460
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
489461
expect(callbacks.onClose).toHaveBeenCalledWith({
490462
reason: 'user_dismissed',
491463
});
492-
expect(mockGoBack).toHaveBeenCalledTimes(1);
493464
expect(getSession(session.id)).toBeUndefined();
494465
});
495466

@@ -532,10 +503,12 @@ describe('HeadlessHost', () => {
532503
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
533504
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'completed' });
534505

506+
// beforeRemove fires too (React Navigation always fires it on screen
507+
// removal), then unmount cleanup runs. Both find the session gone and
508+
// no-op.
509+
registeredBeforeRemoveListener?.();
535510
unmount();
536511

537-
// Dismissal hook re-reads from the registry on cleanup, sees the
538-
// session is gone, and no-ops. No second onClose.
539512
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
540513
});
541514

@@ -553,26 +526,27 @@ describe('HeadlessHost', () => {
553526
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
554527
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
555528

529+
registeredBeforeRemoveListener?.();
556530
unmount();
557531

558-
// Dismissal hook re-reads, sees nothing, no-ops.
532+
// Both follow-up paths re-read, see nothing, no-op.
559533
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
560534
});
561535

562536
it('no-ops on unmount when the host mounted against an already-terminated session', () => {
563537
const quote = buildAggregatorQuote();
564538
const session = seedSession(quote);
565539
const callbacks = session.callbacks;
566-
// Cancel before the screen mounts; matches the existing
567-
// "skips orchestration" assertion but additionally verifies the
568-
// dismissal cleanup does not produce a spurious second onClose.
540+
// Cancel before the screen mounts; the Phase 8 dismissal cleanup must
541+
// not produce a spurious second onClose.
569542
closeSession(session.id, { reason: 'consumer_cancelled' });
570543
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
571544
expect(callbacks.onClose).toHaveBeenCalledWith({
572545
reason: 'consumer_cancelled',
573546
});
574547

575548
const { unmount } = renderHost({ headlessSessionId: session.id });
549+
registeredBeforeRemoveListener?.();
576550
unmount();
577551

578552
expect(callbacks.onClose).toHaveBeenCalledTimes(1);

0 commit comments

Comments
 (0)