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
21 changes: 20 additions & 1 deletion app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@

import TruncatedError from '../../components/TruncatedError';
import { PROVIDER_LINKS } from '../../Aggregator/types';
import { failSession } from '../../headless/sessionRegistry';
const BAILED_ORDER_STATUSES = new Set<RampsOrderStatus>([
RampsOrderStatus.Precreated,
RampsOrderStatus.IdExpired,
Expand Down Expand Up @@ -159,10 +160,24 @@

useEffect(() => {
if (params?.nativeFlowError) {
if (
params.headlessSessionId &&

Check warning on line 164 in app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'headlessSessionId' is deprecated.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ3j0MeP52aROxAE0jJS&open=AZ3j0MeP52aROxAE0jJS&pullRequest=29612
failSession(
params.headlessSessionId,

Check warning on line 166 in app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'headlessSessionId' is deprecated.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ3j0MeP52aROxAE0jJT&open=AZ3j0MeP52aROxAE0jJT&pullRequest=29612
{
code: 'AUTH_FAILED',
message: params.nativeFlowError,
},
'AUTH_FAILED',
)
) {
navigation.setParams({ nativeFlowError: undefined });
return;
}
setRampsError(params.nativeFlowError);
navigation.setParams({ nativeFlowError: undefined });
}
}, [params?.nativeFlowError, navigation]);
}, [params?.headlessSessionId, params?.nativeFlowError, navigation]);

Check warning on line 180 in app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'headlessSessionId' is deprecated.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ3j0MeP52aROxAE0jJU&open=AZ3j0MeP52aROxAE0jJU&pullRequest=29612

const {
userRegion,
Expand Down Expand Up @@ -627,6 +642,9 @@
assetId: selectedToken?.assetId ?? '',
});
} catch (err) {
if (failSession(params?.headlessSessionId, err)) {

Check warning on line 645 in app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'headlessSessionId' is deprecated.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ3j0MeP52aROxAE0jJV&open=AZ3j0MeP52aROxAE0jJV&pullRequest=29612
return;
}
setRampsError((err as Error).message);
} finally {
setIsContinueLoading(false);
Expand All @@ -642,6 +660,7 @@
selectedPaymentMethod?.id,
rampRoutingDecision,
userRegion?.regionCode,
params?.headlessSessionId,

Check warning on line 663 in app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'headlessSessionId' is deprecated.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ3j0MeP52aROxAE0jJW&open=AZ3j0MeP52aROxAE0jJW&pullRequest=29612
trackEvent,
createEventBuilder,
continueWithQuote,
Expand Down
50 changes: 50 additions & 0 deletions app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jest.mock('../../utils/v2OrderToast', () => ({
jest.mock('../../headless/sessionRegistry', () => ({
getSession: jest.fn(),
closeSession: jest.fn(),
failSession: jest.fn(),
}));

jest.mock('../../../../../util/Logger', () => ({
Expand Down Expand Up @@ -618,6 +619,8 @@ describe('Checkout', () => {
.getSession as jest.Mock;
const mockCloseSession = jest.requireMock('../../headless/sessionRegistry')
.closeSession as jest.Mock;
const mockFailSession = jest.requireMock('../../headless/sessionRegistry')
.failSession as jest.Mock;
const showV2OrderToastMock = jest.requireMock('../../utils/v2OrderToast')
.showV2OrderToast as jest.Mock;

Expand All @@ -641,6 +644,7 @@ describe('Checkout', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockCloseSession.mockReset();
mockFailSession.mockReset();
mockParentPop = jest.fn();
mockNavigation.getParent.mockReturnValue({ pop: mockParentPop });
mockGetOrderFromCallback.mockResolvedValue(mockOrder);
Expand Down Expand Up @@ -740,6 +744,52 @@ describe('Checkout', () => {
expect(mockParentPop).toHaveBeenCalled();
});

it('surfaces callback processing failures through onError and skips the ErrorView', async () => {
mockUseParams.mockReturnValue(callbackFlowParams);
mockGetOrderFromCallback.mockRejectedValueOnce(
new Error('callback failed'),
);
mockFailSession.mockReturnValue({
code: 'UNKNOWN',
message: 'callback failed',
});

const { getByTestId, queryByText } = renderWithProvider(
<Checkout />,
{},
true,
false,
);

await act(async () => {
fireEvent.press(getByTestId('trigger-callback-navigation'));
});

await waitFor(() => {
expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error));
});
expect(mockParentPop).toHaveBeenCalled();
expect(showV2OrderToastMock).not.toHaveBeenCalled();
expect(queryByText('callback failed')).toBeNull();
});

it('surfaces provider WebView HTTP errors through onError when headless', async () => {
mockUseParams.mockReturnValue(callbackFlowParams);
mockFailSession.mockReturnValue({
code: 'UNKNOWN',
message: 'fiat_on_ramp_aggregator.webview_received_error',
});

const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);

await act(async () => {
fireEvent.press(getByTestId('trigger-http-error-main-uri'));
});

expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error));
expect(mockParentPop).toHaveBeenCalled();
});

it('falls back to the regular reset + toast when session id is present but session is missing from registry', async () => {
mockGetSession.mockReturnValue(undefined);
mockUseParams.mockReturnValue(callbackFlowParams);
Expand Down
25 changes: 24 additions & 1 deletion app/components/UI/Ramp/Views/Checkout/Checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import {
import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard';
import useRampsUnifiedV2Enabled from '../../hooks/useRampsUnifiedV2Enabled';
import { showV2OrderToast } from '../../utils/v2OrderToast';
import { closeSession, getSession } from '../../headless/sessionRegistry';
import {
closeSession,
failSession,
getSession,
} from '../../headless/sessionRegistry';
import { useStyles } from '../../../../hooks/useStyles';
import styleSheet from './Checkout.styles';
import Device from '../../../../../util/device';
Expand Down Expand Up @@ -120,6 +124,18 @@ const Checkout = () => {
}
}, [uri, createEventBuilder, trackEvent, rampRoutingDecision]);

const failHeadlessCheckout = useCallback(
(checkoutError: unknown) => {
if (!failSession(headlessSessionId, checkoutError)) {
return false;
}
// @ts-expect-error navigation prop mismatch
navigation.getParent()?.pop();
return true;
},
[headlessSessionId, navigation],
);

useEffect(() => {
// For external-browser flows (e.g. PayPal), addPrecreatedOrder is called in
// BuildQuote; the user never reaches Checkout. For WebView flows,
Expand Down Expand Up @@ -234,6 +250,9 @@ const Checkout = () => {
Logger.error(navError as Error, {
message: 'UnifiedCheckout: error handling callback',
});
if (failHeadlessCheckout(navError)) {
return;
}
setError((navError as Error)?.message);
}
},
Expand All @@ -248,6 +267,7 @@ const Checkout = () => {
isV2Enabled,
params?.cryptocurrency,
headlessSessionId,
failHeadlessCheckout,
],
);

Expand Down Expand Up @@ -344,6 +364,9 @@ const Checkout = () => {
'fiat_on_ramp_aggregator.webview_received_error',
{ code: nativeEvent.statusCode },
);
if (failHeadlessCheckout(new Error(webviewHttpError))) {
return;
}
setError(webviewHttpError);
} else {
Logger.log(
Expand Down
19 changes: 19 additions & 0 deletions app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,25 @@ describe('HeadlessHost', () => {
expect(screen.getByText('quote expired')).toBeOnTheScreen();
});

it('surfaces limit failures as onError(LIMIT_EXCEEDED, ...)', async () => {
const limitError = new Error('Daily limit exceeded');
limitError.name = 'LimitExceededError';
mockContinueWithQuote.mockRejectedValueOnce(limitError);
const quote = buildNativeQuote();
const session = seedSession(quote);
const callbacks = session.callbacks;
renderHost({ headlessSessionId: session.id });
await waitFor(() => {
expect(callbacks.onError).toHaveBeenCalledWith({
code: 'LIMIT_EXCEEDED',
message: 'Daily limit exceeded',
});
});
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
expect(getSession(session.id)).toBeUndefined();
expect(screen.getByText('Daily limit exceeded')).toBeOnTheScreen();
});

it('does not run the continueWithQuote rejection path after unmount', async () => {
let rejectDeferred: ((error: Error) => void) | undefined;
mockContinueWithQuote.mockImplementation(
Expand Down
59 changes: 11 additions & 48 deletions app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ 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';
Expand Down Expand Up @@ -132,26 +132,17 @@ function HeadlessHost() {
if (!nativeFlowError) {
return;
}
const liveSession = getSession(headlessSessionId);
if (!liveSession) {
return;
}
setErrorMessage(nativeFlowError);
try {
liveSession.callbacks.onError({
code: 'AUTH_FAILED',
message: nativeFlowError,
});
} catch (e) {
Logger.error(e as Error, 'HeadlessHost: onError callback threw');
}
closeSession(
const headlessError = failSession(
headlessSessionId,
{ reason: 'unknown' },
{
terminalStatus: 'failed',
code: 'AUTH_FAILED',
message: nativeFlowError,
},
'AUTH_FAILED',
);
if (headlessError) {
setErrorMessage(headlessError.message ?? nativeFlowError);
}
}, [nativeFlowError, headlessSessionId]);

// Process the session. Uses `useEffect` (not `useFocusEffect`) so that
Expand Down Expand Up @@ -202,25 +193,11 @@ function HeadlessHost() {
if (!chainId) {
const message = `HeadlessHost: invalid assetId "${currentSession.params.assetId}"`;
Logger.error(new Error(message));
try {
currentSession.callbacks.onError({
code: 'UNKNOWN',
message,
});
} catch (e) {
Logger.error(e as Error, 'HeadlessHost: onError callback threw');
}
// closeSession alone does not trigger a re-render; without setState the
// render-time `session` ref stays truthy and the loader would spin
// forever. Surface the same message in UI as other error paths.
setErrorMessage(message);
closeSession(
headlessSessionId,
{ reason: 'unknown' },
{
terminalStatus: 'failed',
},
);
failSession(headlessSessionId, { code: 'UNKNOWN', message });
return;
}
// Defer until walletAddress resolves — avoids calling continueWithQuote
Expand Down Expand Up @@ -270,22 +247,8 @@ function HeadlessHost() {
if (!liveSession) {
return;
}
setErrorMessage(message);
try {
liveSession.callbacks.onError({
code: 'UNKNOWN',
message,
});
} catch (e) {
Logger.error(e as Error, 'HeadlessHost: onError callback threw');
}
closeSession(
headlessSessionId,
{ reason: 'unknown' },
{
terminalStatus: 'failed',
},
);
const headlessError = failSession(headlessSessionId, error);
setErrorMessage(headlessError?.message ?? message);
});
return () => {
cancelled = true;
Expand Down
2 changes: 1 addition & 1 deletion app/components/UI/Ramp/headless/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
- [x] **Phase 5 (revised)** — Quote-first headless start path — `startHeadlessBuy({ quote, redirectUrl? })` creates a session carrying the quote, navigates to Headless Host, Host calls `continueWithQuote(quote, ctx)` and re-orchestrates after auth loops
- [ ] **Phase 5b (deferred)** — `startHeadlessBuy({ assetId, amount, paymentMethodId, providerId? })` "open BuildQuote / Host fetches quotes" mode — picked up after the quote-first path is stable
- [x] **Phase 6** — Bypass order-processing redirect in Transak/aggregator routing when headless; fire `onOrderCreated` and end session
- [ ] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError`
- [x] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError`
- [ ] **Phase 8** — Cancellation + `onClose` semantics (including user-dismissed detection)
- [ ] **Phase 9** — Expose `getOrder` / `refreshOrder` from hook and show in playground
- [ ] **Phase 10** — Playground polish — event log, input persistence, aggregator/native presets
Expand Down
Loading
Loading