Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 147 additions & 3 deletions app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
19 changes: 18 additions & 1 deletion app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -85,6 +87,14 @@ function HeadlessHost() {
useParams<HeadlessHostParams>();
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();

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading