Skip to content

Commit f29e5ae

Browse files
fix: preserve loading through quote handoff
1 parent 4e5fed8 commit f29e5ae

2 files changed

Lines changed: 133 additions & 19 deletions

File tree

app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx

Lines changed: 105 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
useTransactionPayRequiredTokens,
3030
useIsTransactionPayLoading,
3131
useTransactionPayQuotes,
32+
useTransactionPayQuotesLastUpdated,
3233
} from '../../../hooks/pay/useTransactionPayData';
3334
import { useTransactionPayHasSourceAmount } from '../../../hooks/pay/useTransactionPayHasSourceAmount';
3435
import { strings } from '../../../../../../../locales/i18n';
@@ -276,6 +277,9 @@ describe('CustomAmountInfo', () => {
276277
);
277278

278279
const useTransactionPayQuotesMock = jest.mocked(useTransactionPayQuotes);
280+
const useTransactionPayQuotesLastUpdatedMock = jest.mocked(
281+
useTransactionPayQuotesLastUpdated,
282+
);
279283

280284
const useTransactionPayHasSourceAmountMock = jest.mocked(
281285
useTransactionPayHasSourceAmount,
@@ -403,6 +407,7 @@ describe('CustomAmountInfo', () => {
403407
});
404408
useIsTransactionPayLoadingMock.mockReturnValue(false);
405409
useTransactionPayQuotesMock.mockReturnValue([]);
410+
useTransactionPayQuotesLastUpdatedMock.mockReturnValue(undefined);
406411
useTransactionPayHasSourceAmountMock.mockReturnValue(false);
407412
useTokenFiatRatesMock.mockReturnValue([1, 1]);
408413
useTransactionMetadataRequestMock.mockReturnValue({
@@ -740,19 +745,30 @@ describe('CustomAmountInfo', () => {
740745
).toBeUndefined();
741746
});
742747

743-
it('clears local preparation when the amount update resolves', async () => {
748+
it('keeps the loading review through the handoff to controller loading', async () => {
744749
const { deferred } = arrangePendingPreparation();
745750
const view = render({
746751
transactionType: TransactionType.moneyAccountDeposit,
747752
});
748753
fireEvent.press(view.getByTestId('deposit-keyboard-done-button'));
749754

750-
useIsTransactionPayLoadingMock.mockReturnValue(true);
751755
await act(async () => {
752756
deferred.resolve();
753757
await deferred.promise;
754758
});
755759

760+
expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen();
761+
expect(
762+
view.getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON),
763+
).toBeDisabled();
764+
765+
useIsTransactionPayLoadingMock.mockReturnValue(true);
766+
view.rerender(
767+
createCustomAmountInfo({
768+
transactionType: TransactionType.moneyAccountDeposit,
769+
}),
770+
);
771+
756772
expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen();
757773

758774
useIsTransactionPayLoadingMock.mockReturnValue(false);
@@ -768,6 +784,67 @@ describe('CustomAmountInfo', () => {
768784
).not.toBeDisabled();
769785
});
770786

787+
it('releases the loading review when a fast quote completes between renders', async () => {
788+
const { deferred } = arrangePendingPreparation();
789+
const view = render({
790+
transactionType: TransactionType.moneyAccountDeposit,
791+
});
792+
fireEvent.press(view.getByTestId('deposit-keyboard-done-button'));
793+
794+
await act(async () => {
795+
deferred.resolve();
796+
await deferred.promise;
797+
});
798+
799+
expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen();
800+
801+
useTransactionPayQuotesLastUpdatedMock.mockReturnValue(123);
802+
view.rerender(
803+
createCustomAmountInfo({
804+
transactionType: TransactionType.moneyAccountDeposit,
805+
}),
806+
);
807+
808+
expect(view.getByTestId('bridge-fee-row')).toBeOnTheScreen();
809+
expect(
810+
view.getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON),
811+
).not.toBeDisabled();
812+
});
813+
814+
it('ignores stale quote updates while the amount is still committing', async () => {
815+
useTransactionPayQuotesLastUpdatedMock.mockReturnValue(1);
816+
const { deferred } = arrangePendingPreparation();
817+
const view = render({
818+
transactionType: TransactionType.moneyAccountDeposit,
819+
});
820+
fireEvent.press(view.getByTestId('deposit-keyboard-done-button'));
821+
822+
useTransactionPayQuotesLastUpdatedMock.mockReturnValue(2);
823+
view.rerender(
824+
createCustomAmountInfo({
825+
transactionType: TransactionType.moneyAccountDeposit,
826+
}),
827+
);
828+
829+
expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen();
830+
831+
await act(async () => {
832+
deferred.resolve();
833+
await deferred.promise;
834+
});
835+
836+
expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen();
837+
838+
useTransactionPayQuotesLastUpdatedMock.mockReturnValue(3);
839+
view.rerender(
840+
createCustomAmountInfo({
841+
transactionType: TransactionType.moneyAccountDeposit,
842+
}),
843+
);
844+
845+
expect(view.getByTestId('bridge-fee-row')).toBeOnTheScreen();
846+
});
847+
771848
it('keeps preparation active while the amount update is pending', async () => {
772849
const { deferred } = arrangePendingPreparation();
773850
const view = render({
@@ -910,14 +987,21 @@ describe('CustomAmountInfo', () => {
910987
onReject: jest.fn(),
911988
});
912989

913-
const { getByText } = render();
990+
const view = render();
914991

915992
await act(async () => {
916-
fireEvent.press(getByText(strings('confirm.edit_amount_done')));
993+
fireEvent.press(view.getByText(strings('confirm.edit_amount_done')));
917994
});
918995

996+
useIsTransactionPayLoadingMock.mockReturnValue(true);
997+
view.rerender(createCustomAmountInfo());
998+
useIsTransactionPayLoadingMock.mockReturnValue(false);
999+
view.rerender(createCustomAmountInfo());
1000+
9191001
await act(async () => {
920-
fireEvent.press(getByText(strings('confirm.deposit_edit_amount_done')));
1002+
fireEvent.press(
1003+
view.getByText(strings('confirm.deposit_edit_amount_done')),
1004+
);
9211005
});
9221006

9231007
expect(setIsConfirmationSubmittingMock).toHaveBeenCalledWith(true);
@@ -1184,22 +1268,25 @@ describe('CustomAmountInfo', () => {
11841268
});
11851269

11861270
describe('showPaymentDetails', () => {
1187-
async function pressDone(
1188-
getByText: ReturnType<typeof render>['getByText'],
1189-
) {
1271+
async function pressDone(view: ReturnType<typeof render>) {
11901272
await act(async () => {
1191-
fireEvent.press(getByText(strings('confirm.edit_amount_done')));
1273+
fireEvent.press(view.getByText(strings('confirm.edit_amount_done')));
11921274
});
1275+
1276+
useIsTransactionPayLoadingMock.mockReturnValue(true);
1277+
view.rerender(createCustomAmountInfo());
1278+
useIsTransactionPayLoadingMock.mockReturnValue(false);
1279+
view.rerender(createCustomAmountInfo());
11931280
}
11941281

11951282
it('shows fee rows for same-chain payment without quotes', async () => {
11961283
useTransactionPayHasSourceAmountMock.mockReturnValue(false);
11971284
useTransactionPayQuotesMock.mockReturnValue([]);
11981285

1199-
const { getByText, getByTestId } = render();
1200-
await pressDone(getByText);
1286+
const view = render();
1287+
await pressDone(view);
12011288

1202-
expect(getByTestId('bridge-fee-row')).toBeOnTheScreen();
1289+
expect(view.getByTestId('bridge-fee-row')).toBeOnTheScreen();
12031290
});
12041291

12051292
it('hides fee rows when no-quotes alert is present', async () => {
@@ -1217,20 +1304,20 @@ describe('CustomAmountInfo', () => {
12171304
fieldAlerts: [] as Alert[],
12181305
} as AlertsContextParams);
12191306

1220-
const { getByText, queryByTestId } = render();
1221-
await pressDone(getByText);
1307+
const view = render();
1308+
await pressDone(view);
12221309

1223-
expect(queryByTestId('bridge-fee-row')).toBeNull();
1310+
expect(view.queryByTestId('bridge-fee-row')).toBeNull();
12241311
});
12251312

12261313
it('shows fee rows when quotes exist regardless of source amount', async () => {
12271314
useTransactionPayHasSourceAmountMock.mockReturnValue(true);
12281315
useTransactionPayQuotesMock.mockReturnValue([{} as never]);
12291316

1230-
const { getByText, getByTestId } = render();
1231-
await pressDone(getByText);
1317+
const view = render();
1318+
await pressDone(view);
12321319

1233-
expect(getByTestId('bridge-fee-row')).toBeOnTheScreen();
1320+
expect(view.getByTestId('bridge-fee-row')).toBeOnTheScreen();
12341321
});
12351322
});
12361323

app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
useIsTransactionPayLoading,
4646
useTransactionPayFiatPayment,
4747
useTransactionPayQuotes,
48+
useTransactionPayQuotesLastUpdated,
4849
useTransactionPayRequiredTokens,
4950
} from '../../../hooks/pay/useTransactionPayData';
5051
import { useTransactionPayHasSourceAmount } from '../../../hooks/pay/useTransactionPayHasSourceAmount';
@@ -191,9 +192,14 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
191192
!isAddMusdIntent && !isDepositPrefillEnabled,
192193
);
193194
const [isAmountUpdating, setIsAmountUpdating] = useState(false);
195+
const [isAmountUpdateComplete, setIsAmountUpdateComplete] = useState(false);
194196
// React batches rapid presses before the state update rerenders, so keep a
195197
// synchronous guard separate from the render state.
196198
const isAmountUpdateInProgressRef = useRef(false);
199+
const quotesLastUpdatedBeforeAmountUpdateRef = useRef<number | undefined>(
200+
undefined,
201+
);
202+
const quotesLastUpdatedRef = useRef<number | undefined>(undefined);
197203
const wasKeyboardEverVisible = useRef(isKeyboardVisible);
198204
if (isKeyboardVisible) {
199205
wasKeyboardEverVisible.current = true;
@@ -225,6 +231,8 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
225231

226232
const isTransactionResultReady = useIsResultReady({ isKeyboardVisible });
227233
const quotes = useTransactionPayQuotes();
234+
const quotesLastUpdated = useTransactionPayQuotesLastUpdated();
235+
quotesLastUpdatedRef.current = quotesLastUpdated;
228236
const isQuotesLoading = useIsTransactionPayLoading();
229237
const showLoadingReview = isAmountUpdating || isQuotesLoading;
230238
const isResultReady =
@@ -261,6 +269,7 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
261269
}
262270

263271
isAmountUpdateInProgressRef.current = true;
272+
setIsAmountUpdateComplete(false);
264273
setIsAmountUpdating(true);
265274
setIsKeyboardVisible(false);
266275

@@ -277,6 +286,9 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
277286
// Amount committed (pre-quote) funnel event; only fires once the amount
278287
// has been successfully applied above (no-op for non-money flows).
279288
trackAmountCommitted();
289+
quotesLastUpdatedBeforeAmountUpdateRef.current =
290+
quotesLastUpdatedRef.current;
291+
setIsAmountUpdateComplete(true);
280292
} catch (error) {
281293
const prefixed = prefixError(error, AMOUNT_UPDATE_ERROR_PREFIX);
282294
toastRef?.current?.showToast(
@@ -285,11 +297,12 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
285297
),
286298
);
287299
setIsKeyboardVisible(true);
300+
setIsAmountUpdateComplete(false);
301+
setIsAmountUpdating(false);
288302
// Keep keyboard visible so the user can retry; do not advance the flow.
289303
return;
290304
} finally {
291305
isAmountUpdateInProgressRef.current = false;
292-
setIsAmountUpdating(false);
293306
}
294307
EngineService.flushState();
295308
setIsKeyboardVisible(false);
@@ -304,6 +317,20 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
304317
updateTokenAmount,
305318
]);
306319

320+
useEffect(() => {
321+
const hasCompletedQuoteRequest =
322+
quotesLastUpdated !== undefined &&
323+
quotesLastUpdated !== quotesLastUpdatedBeforeAmountUpdateRef.current;
324+
325+
if (
326+
isAmountUpdateComplete &&
327+
(isQuotesLoading || hasCompletedQuoteRequest)
328+
) {
329+
setIsAmountUpdateComplete(false);
330+
setIsAmountUpdating(false);
331+
}
332+
}, [isAmountUpdateComplete, isQuotesLoading, quotesLastUpdated]);
333+
307334
const wasPrefillPending = useRef(isPrefillPending);
308335
useEffect(() => {
309336
if (wasPrefillPending.current && !isPrefillPending) {

0 commit comments

Comments
 (0)