Skip to content

Commit 94e8c0f

Browse files
MarioAslaucaieu
andauthored
feat(predict): Bottom Sheet - Try Again Toast for failed Payments cp-7.77.0 (#30167)
# PR: fix(predict): replace bet slip auto-reopen with auto-dismissing Retry toast > Suggested branch: `fix/bet-slip-auto-reopen-during-pwat` > Suggested labels: `team-mobile-predict`, `needs-qa` > Assignee: yourself --- ## **Description** ### Problem When paying with any token (PWAT) for a Predict bet, the bet slip would pop back up unexpectedly while the deposit was still in flight. The "Prediction in progress" loading toast would appear, then the slip would re-open over it (often before the deposit even confirmed on-chain), and then stay stuck open after the order completed. This was confusing and felt broken. Root cause: the auto-reopen `useEffect` in [`PredictPreviewSheetContext`](app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx) — added in #29184 to surface inline error banners after background failures — fired on **any** transient `activeOrder.error` value. The PredictController briefly sets `error` during its internal retry paths (`PredictController.ts:1277` and `:2300`) even on flows that ultimately succeed, so the slip popped back up over toasts that were still mid-loading. The reopened slip didn't close on `SUCCESS` either, because the freshly-mounted `usePredictBuyActions` instance has `didInitiateOrderRef = false` and skips the SUCCESS pop. ### Solution Replaced the auto-reopen with a user-initiated reopen via an auto-dismissing **Retry** toast. The toast lives ~3s; tapping Retry within that window reopens the slip with the original market context and the inline `order_failed` banner. If the user does nothing, the toast fades out and `activeOrder.error` is automatically cleared so the next slip open is a clean state (no stale banner flash). ```mermaid sequenceDiagram participant User participant Slip as Bet slip participant Ctrl as PredictController participant Toast User->>Slip: Confirm bet (PWAT) Ctrl->>Ctrl: state -> DEPOSITING Slip->>Slip: animate close Toast->>User: "Prediction in progress" loading toast alt Order succeeds Ctrl-->>Toast: 'confirmed' event Toast->>User: "Prediction placed" success toast else Order fails Ctrl-->>Toast: state.error transitions truthy Toast->>User: auto-dismissing "Failed to place prediction" + Retry (inline) opt User taps Retry within ~3s Toast->>Toast: cancel auto-clear timer Toast->>Slip: openBuySheet(lastBuyParams) Slip->>User: bet slip reopens with inline order_failed banner end opt User does nothing Toast->>Toast: auto-dismisses (~3s) Toast->>Ctrl: clearOrderError() end end ``` ### Key changes - **Removed** the auto-reopen `useEffect` and `dismissedWithErrorRef` from [`PredictPreviewSheetContext.tsx`](app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx). - **Added** a state-based trigger inside the provider that fires a toast via `ToastService.showToast(...)` whenever `activeOrder.error` transitions falsy → truthy AND the bottom-sheet flow is enabled AND the slip is closed AND we have remembered buy params from a previous open. This mirrors the original auto-reopen condition but surfaces a toast instead of taking over the screen. Using state (not the controller's `'failed'` event) avoids the timing race on `isBackgroundOrder` that the event-based path is subject to. - **Added** module-level `isPredictSheetProviderMounted()` so the legacy event-based toast in [`usePredictToastRegistrations.tsx`](app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx) can suppress itself when the provider is mounted (avoids a duplicate failure toast). - **Added** a `clearErrorTimerRef` 3-second timer that calls `clearOrderError()` after the toast auto-dismisses, so an unhandled failure doesn't leave a stale `activeOrder.error` for the next slip open. The timer is cancelled when the user taps Retry (so the reopened slip can show the `order_failed` banner) and on provider unmount (so we don't `setState` after teardown). - **Tap Retry** → cancels the auto-clear timer and reopens the slip with the same market context. The reopened slip's existing inline `order_failed` banner handles the per-slip error UX (preserves PR #29184's intent). #### Toast shape - Variant: `ToastVariants.Icon` - Layout: `[avatar icon] [bold label + description] [Retry]` on a single row. - `iconName`: `IconName.Error` - `iconColor`: `theme.colors.error.default` (red — _see "known limitations" below_) - `backgroundColor`: `theme.colors.error.muted` (soft red wash, matching the standard error-avatar look used in `NetworkConnectionBanner`, `ErrorBoundary`, `AlertModal`, etc.) - `hasNoTimeout: false` (auto-dismisses on platform default ~2.75s visibility + 0.25s exit) - `closeButtonOptions`: `{ label: 'Retry', variant: ButtonVariants.Link, onPress }` — the inline Retry action #### Locale keys All existing — no new strings: - `predict.order.prediction_failed` — toast title - `predict.order.order_failed_generic` — toast description - `predict.order.retry` — Retry action label ### Out of scope (intentionally) - The shared `Toast` component is **unchanged** on this PR (an earlier draft added an opt-in `compact` prop, which has since been reverted in favor of the existing `closeButtonOptions` API). - The deposit / withdraw / claim error toasts in `usePredictToastRegistrations.tsx` continue to use the existing `accent04.normal` indigo background — only the new bottom-sheet failure toast was switched to the conventional `error.muted` red wash. Harmonizing the rest is a follow-up. ### Known limitations - The `error.svg` asset (`app/component-library/components/Icons/Icon/assets/error.svg`) has hardcoded `fill="none"` on the root and `fill="#121314"` on the path, so the small Error glyph paints near-black regardless of the `iconColor` we pass. This affects every `IconName.Error` callsite in the app, not just ours. Filed for the design-system-engineers team. The `error.muted` soft red background masks the issue here visually (dark glyph on light red wash reads correctly as "error"), but the glyph itself only becomes red once the SVG asset is fixed upstream. ## **Changelog** CHANGELOG entry: Fixed an issue where the Predict bet slip would unexpectedly reopen during a pay-with-any-token deposit and remain open after the order completed. Background failures now surface a "Failed to place prediction" toast with a Retry action that reopens the slip with the order-failed banner; if the user doesn't tap Retry, the toast auto-dismisses and the order error is cleared automatically. ## **Related issues** Fixes: Jira Ticket: https://consensyssoftware.atlassian.net/browse/PRED-883 ## **Manual testing steps** ```gherkin Feature: Predict bet slip stays closed during PWAT deposit; failures show a Retry toast Background: Given the user has the predictBottomSheet feature flag enabled And the user is on a Predict market Scenario: Successful PWAT bet does not reopen the slip Given the user has chosen an external token (e.g. ETH) as the payment method When the user enters an amount and taps Confirm Then the bet slip closes via animation And the "Prediction in progress" toast appears When the deposit and order confirm on-chain Then the loading toast is replaced by the "Prediction placed" success toast And the bet slip does NOT reopen at any point during the flow Scenario: Background failure surfaces a Retry toast that reopens the slip Given the user has confirmed a PWAT bet and the slip has closed When the order fails in the background Then a "Failed to place prediction" toast appears on a soft red avatar background And the toast shows a "Transaction failed. Please try again." description And the toast shows a "Retry" link inline on the right When the user taps Retry within the toast's visibility window Then the bet slip reopens at the same market with the inline "Order failed" banner and a Retry CTA Scenario: User ignores the failure toast — error auto-clears Given the failure toast is visible When the user takes no action for ~3 seconds Then the toast auto-dismisses And the active order error is cleared automatically When the user opens any market's bet slip Then no inline order_failed banner is shown (clean state) Scenario: Failure while the slip is currently visible Given the bet slip is currently open (e.g. the user reopened it manually mid-flight) When the order fails Then no toast appears (the inline banner inside the slip handles it) Scenario: Bottom-sheet flow disabled — legacy failure toast still works Given the predictBottomSheet feature flag is OFF And the user has confirmed a bet that fails in the background Then the legacy "order failed" toast from usePredictToastRegistrations fires And the bottom-sheet provider's toast does NOT fire ``` ## **Screenshots/Recordings** ### **Before** <!-- Drop a recording of the bet slip popping back open over the loading toast during a PWAT deposit, and staying stuck open after the order completed. --> ### **After** <!-- Drop a recording of: 1. Successful PWAT bet (slip closes, loading toast, success toast — no reopen) 2. Failure path showing the auto-dismissing soft-red Retry toast 3. Tapping Retry reopens the slip with the inline order_failed banner 4. Letting the toast time out (no Retry tap) — next slip open is a clean state --> https://github.com/user-attachments/assets/968fc06c-b937-4fc4-a5ca-e9d999b05278 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable — `PredictPreviewSheetContext.test.tsx` (28 tests, including a dedicated `failure toast (state-based trigger)` suite and a `failure toast auto-clear timer` suite using `jest.useFakeTimers()`) and updated `usePredictToastRegistrations.test.tsx` for the suppression branch. Coverage on touched files: rerun `yarn jest --coverage` after final cleanup and update. - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable — provider helpers, the `clearErrorTimerRef` rationale, and the state-based trigger comment block. - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics — N/A, no new long-running operations introduced. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes Predict order-failure UX from auto-reopening the bottom sheet to a state-driven toast with retry and an auto-clear timer, which could affect error handling timing and user flows. Also adjusts toast suppression logic to avoid duplicates when the provider is mounted. > > **Overview** > Predict bottom-sheet order failures no longer auto-reopen the buy slip; instead `PredictPreviewSheetProvider` watches `activeOrder.error` transitions and shows a non-persistent **Retry** toast (via `ToastService`) that reopens the slip with the last buy params only if the user taps it. > > Adds a ~3s auto-clear timer to call `clearOrderError()` after the toast dismisses (cancelled on Retry and on provider unmount) to avoid stale inline error banners, and updates `usePredictToastRegistrations` to suppress its legacy `'failed'` toast when the provider is mounted. > > Tests were expanded/updated to cover the new toast trigger conditions, retry behavior, timer cancellation/cleanup, and to harden hook tests against leaked mounts/promises. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit adcbad0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Caainã Jeronimo <caainaje@gmail.com>
1 parent b2e95cf commit 94e8c0f

5 files changed

Lines changed: 320 additions & 66 deletions

File tree

app/components/UI/Predict/contexts/PredictPreviewSheetContext.test.tsx

Lines changed: 173 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
2-
import { render, screen, fireEvent } from '@testing-library/react-native';
2+
import { act, render, screen, fireEvent } from '@testing-library/react-native';
33
import { Text, TouchableOpacity, View } from 'react-native';
4+
import { TEST_HEX_COLORS as mockTestHexColors } from '../testUtils/mockColors';
45
import {
56
isPredictSheetProviderMounted,
67
PredictPreviewSheetProvider,
@@ -62,6 +63,28 @@ jest.mock('../hooks/usePredictActiveOrder', () => ({
6263
})),
6364
}));
6465

66+
const mockToastShowToast = jest.fn();
67+
const mockToastCloseToast = jest.fn();
68+
jest.mock('../../../../core/ToastService', () => ({
69+
__esModule: true,
70+
default: {
71+
showToast: (...args: unknown[]) => mockToastShowToast(...args),
72+
closeToast: (...args: unknown[]) => mockToastCloseToast(...args),
73+
},
74+
}));
75+
76+
jest.mock('../../../../util/theme', () => ({
77+
useAppThemeFromContext: () => ({
78+
colors: {
79+
accent04: { normal: mockTestHexColors.WHITE_BRIGHT },
80+
error: {
81+
default: mockTestHexColors.WHITE_BRIGHT,
82+
muted: mockTestHexColors.WHITE_BRIGHT,
83+
},
84+
},
85+
}),
86+
}));
87+
6588
jest.mock('../components/PredictPreviewSheet/PredictPreviewSheet', () => {
6689
const { forwardRef, useImperativeHandle } = jest.requireActual('react');
6790
const {
@@ -204,6 +227,13 @@ describe('PredictPreviewSheetContext', () => {
204227
mockSelectPredictWithAnyTokenEnabledFlag.mockImplementation(
205228
() => mockPayWithAnyTokenEnabled,
206229
);
230+
// Explicit toast/clearOrderError mock resets — `jest.clearAllMocks()`
231+
// covers them but reset them by name for clarity and resilience to
232+
// any future module-scope mock that doesn't get auto-cleared.
233+
mockToastShowToast.mockReset();
234+
mockToastCloseToast.mockReset();
235+
mockClearOrderError.mockReset();
236+
mockTrackBetslipDismissed.mockReset();
207237
});
208238

209239
it('provides openBuySheet and openSellSheet to consumers', () => {
@@ -442,8 +472,8 @@ describe('PredictPreviewSheetContext', () => {
442472
expect(screen.getByTestId('predict-sell-preview-sheet')).toBeOnTheScreen();
443473
});
444474

445-
describe('background error auto-reopen', () => {
446-
it('reopens the buy sheet when activeOrder.error appears after dismiss', () => {
475+
describe('failure toast (state-based trigger)', () => {
476+
it('does not auto-reopen the buy sheet when activeOrder.error appears after dismiss, and fires a Try again toast instead', () => {
447477
const { rerender } = render(
448478
<PredictPreviewSheetProvider>
449479
<TestConsumer />
@@ -465,18 +495,70 @@ describe('PredictPreviewSheetContext', () => {
465495
</PredictPreviewSheetProvider>,
466496
);
467497

498+
// The sheet must NOT auto-reopen.
499+
expect(
500+
screen.queryByTestId('predict-buy-preview-sheet'),
501+
).not.toBeOnTheScreen();
502+
503+
// Instead, ToastService.showToast is called with a non-persistent
504+
// toast that auto-dismisses. The Retry uses `closeButtonOptions`
505+
// (with `ButtonVariants.Link`) so it sits inline on the right of
506+
// the row, matching the deposit/Track toast convention. The
507+
// description line keeps the labels container two lines tall so
508+
// the row's flex-start alignment looks balanced against the
509+
// taller Primary Retry button.
510+
expect(mockToastShowToast).toHaveBeenCalledTimes(1);
511+
const toastCall = mockToastShowToast.mock.calls[0][0];
512+
expect(toastCall.hasNoTimeout).toBe(false);
513+
expect(toastCall.linkButtonOptions).toBeUndefined();
514+
expect(toastCall.descriptionOptions).toEqual(
515+
expect.objectContaining({ description: expect.any(String) }),
516+
);
517+
expect(toastCall.closeButtonOptions).toEqual(
518+
expect.objectContaining({
519+
label: expect.any(String),
520+
variant: 'Link',
521+
}),
522+
);
523+
expect(typeof toastCall.closeButtonOptions.onPress).toBe('function');
524+
525+
// Tapping Retry reopens the slip with the last params. The toast
526+
// is left to animate out on its own (no explicit closeToast call).
527+
act(() => {
528+
toastCall.closeButtonOptions.onPress();
529+
});
468530
expect(screen.getByTestId('predict-buy-preview-sheet')).toBeOnTheScreen();
531+
expect(mockToastCloseToast).not.toHaveBeenCalled();
469532
});
470533

471-
it('does not auto-reopen when bottomSheetEnabled is OFF', () => {
534+
it('does not fire the toast when the slip is open (banner handles it)', () => {
535+
const { rerender } = render(
536+
<PredictPreviewSheetProvider>
537+
<TestConsumer />
538+
</PredictPreviewSheetProvider>,
539+
);
540+
541+
fireEvent.press(screen.getByTestId('open-buy'));
542+
expect(screen.getByTestId('predict-buy-preview-sheet')).toBeOnTheScreen();
543+
544+
mockActiveOrder = { error: 'order/failed' };
545+
rerender(
546+
<PredictPreviewSheetProvider>
547+
<TestConsumer />
548+
</PredictPreviewSheetProvider>,
549+
);
550+
551+
expect(mockToastShowToast).not.toHaveBeenCalled();
552+
});
553+
554+
it('does not fire the toast when bottomSheetEnabled is OFF (legacy flow)', () => {
472555
mockBottomSheetEnabled = false;
473556
const { rerender } = render(
474557
<PredictPreviewSheetProvider>
475558
<TestConsumer />
476559
</PredictPreviewSheetProvider>,
477560
);
478561

479-
// navigation flow when flag is OFF - openBuySheet still records lastBuyParamsRef
480562
fireEvent.press(screen.getByTestId('open-buy'));
481563

482564
mockActiveOrder = { error: 'order/failed' };
@@ -486,56 +568,125 @@ describe('PredictPreviewSheetContext', () => {
486568
</PredictPreviewSheetProvider>,
487569
);
488570

489-
expect(
490-
screen.queryByTestId('predict-buy-preview-sheet'),
491-
).not.toBeOnTheScreen();
571+
expect(mockToastShowToast).not.toHaveBeenCalled();
492572
});
493573

494-
it('does not auto-reopen when user dismisses while error is showing', () => {
574+
it('does not fire the toast on error transitions when no buy sheet was ever opened', () => {
575+
const { rerender } = render(
576+
<PredictPreviewSheetProvider>
577+
<TestConsumer />
578+
</PredictPreviewSheetProvider>,
579+
);
580+
495581
mockActiveOrder = { error: 'order/failed' };
582+
rerender(
583+
<PredictPreviewSheetProvider>
584+
<TestConsumer />
585+
</PredictPreviewSheetProvider>,
586+
);
496587

588+
expect(mockToastShowToast).not.toHaveBeenCalled();
589+
});
590+
591+
it('only fires once per error transition (does not re-fire on unrelated rerenders)', () => {
497592
const { rerender } = render(
498593
<PredictPreviewSheetProvider>
499594
<TestConsumer />
500595
</PredictPreviewSheetProvider>,
501596
);
502597

503598
fireEvent.press(screen.getByTestId('open-buy'));
504-
expect(screen.getByTestId('predict-buy-preview-sheet')).toBeOnTheScreen();
505-
506599
fireEvent.press(screen.getByTestId('dismiss-sheet'));
507-
expect(
508-
screen.queryByTestId('predict-buy-preview-sheet'),
509-
).not.toBeOnTheScreen();
510600

601+
mockActiveOrder = { error: 'order/failed' };
511602
rerender(
512603
<PredictPreviewSheetProvider>
513604
<TestConsumer />
514605
</PredictPreviewSheetProvider>,
515606
);
607+
expect(mockToastShowToast).toHaveBeenCalledTimes(1);
516608

517-
expect(
518-
screen.queryByTestId('predict-buy-preview-sheet'),
519-
).not.toBeOnTheScreen();
609+
// A subsequent rerender with the same error must not re-fire.
610+
rerender(
611+
<PredictPreviewSheetProvider>
612+
<TestConsumer />
613+
</PredictPreviewSheetProvider>,
614+
);
615+
expect(mockToastShowToast).toHaveBeenCalledTimes(1);
520616
});
617+
});
521618

522-
it('does not auto-reopen if no buy was previously opened', () => {
523-
const { rerender } = render(
619+
describe('failure toast auto-clear timer', () => {
620+
beforeEach(() => {
621+
jest.useFakeTimers();
622+
});
623+
624+
afterEach(() => {
625+
jest.useRealTimers();
626+
});
627+
628+
const showRetryToast = () => {
629+
const { rerender, unmount } = render(
524630
<PredictPreviewSheetProvider>
525631
<TestConsumer />
526632
</PredictPreviewSheetProvider>,
527633
);
528634

635+
fireEvent.press(screen.getByTestId('open-buy'));
636+
fireEvent.press(screen.getByTestId('dismiss-sheet'));
637+
638+
// `clearOrderError` is called by the dismiss path; reset so subsequent
639+
// assertions only count timer-driven calls.
640+
mockClearOrderError.mockClear();
641+
529642
mockActiveOrder = { error: 'order/failed' };
530643
rerender(
531644
<PredictPreviewSheetProvider>
532645
<TestConsumer />
533646
</PredictPreviewSheetProvider>,
534647
);
535648

536-
expect(
537-
screen.queryByTestId('predict-buy-preview-sheet'),
538-
).not.toBeOnTheScreen();
649+
return { rerender, unmount };
650+
};
651+
652+
it('clears the order error after the toast auto-dismisses (~3s)', () => {
653+
showRetryToast();
654+
expect(mockToastShowToast).toHaveBeenCalledTimes(1);
655+
expect(mockClearOrderError).not.toHaveBeenCalled();
656+
657+
act(() => {
658+
jest.advanceTimersByTime(3000);
659+
});
660+
661+
expect(mockClearOrderError).toHaveBeenCalledTimes(1);
662+
});
663+
664+
it('does not clear the order error if Retry is tapped before the timer fires', () => {
665+
showRetryToast();
666+
const toastCall = mockToastShowToast.mock.calls[0][0];
667+
668+
act(() => {
669+
toastCall.closeButtonOptions.onPress();
670+
});
671+
672+
// Advance well past the timer — Retry should have cancelled it.
673+
act(() => {
674+
jest.advanceTimersByTime(10000);
675+
});
676+
677+
expect(mockClearOrderError).not.toHaveBeenCalled();
678+
});
679+
680+
it('cancels the pending clear timer on provider unmount', () => {
681+
const { unmount } = showRetryToast();
682+
683+
unmount();
684+
685+
act(() => {
686+
jest.advanceTimersByTime(10000);
687+
});
688+
689+
expect(mockClearOrderError).not.toHaveBeenCalled();
539690
});
540691
});
541692

0 commit comments

Comments
 (0)