diff --git a/apps/cowswap-frontend/src/common/constants/routes.ts b/apps/cowswap-frontend/src/common/constants/routes.ts index 6da2bfd741f..aec4366ba99 100644 --- a/apps/cowswap-frontend/src/common/constants/routes.ts +++ b/apps/cowswap-frontend/src/common/constants/routes.ts @@ -39,6 +39,7 @@ export const Routes = { FAQ_ETH_FLOW: '/faq/sell-native', PLAY_COWRUNNER: '/play/cow-runner', PLAY_MEVSLICER: '/play/mev-slicer', + DEBUG_PROGRESS_BAR: '/debug/progress-bar', ANYSWAP_AFFECTED: '/anyswap-affected-users', CHAT: '/chat', DOCS: '/docs', diff --git a/apps/cowswap-frontend/src/legacy/state/orders/actions.ts b/apps/cowswap-frontend/src/legacy/state/orders/actions.ts index ce749fded0f..3b0fd4ea615 100644 --- a/apps/cowswap-frontend/src/legacy/state/orders/actions.ts +++ b/apps/cowswap-frontend/src/legacy/state/orders/actions.ts @@ -109,6 +109,10 @@ export interface Order extends BaseOrder { bridgeOutputAmount?: CurrencyAmount } +export interface SerializedBridgeOutputAmount { + amount: string +} + /** * Order used for persisting it in the state. * The only difference with Order is that all it's fields are serializable @@ -116,6 +120,7 @@ export interface Order extends BaseOrder { export interface SerializedOrder extends BaseOrder { inputToken: SerializedToken // for dapp use only, readable by user outputToken: SerializedToken // for dapp use only, readable by user + bridgeOutputAmount?: SerializedBridgeOutputAmount } export type SetOrderCancellationHashParams = ChangeOrderStatusParams & { hash: string } diff --git a/apps/cowswap-frontend/src/legacy/state/orders/hooks.ts b/apps/cowswap-frontend/src/legacy/state/orders/hooks.ts index 429aedbb484..2d5f6255da8 100644 --- a/apps/cowswap-frontend/src/legacy/state/orders/hooks.ts +++ b/apps/cowswap-frontend/src/legacy/state/orders/hooks.ts @@ -8,7 +8,7 @@ import { UiOrderType } from '@cowprotocol/types' import { useDispatch, useSelector } from 'react-redux' import useSWR from 'swr' -import { addPendingOrderStep } from 'modules/trade/utils/addPendingOrderStep' +import { addPendingOrderStep } from 'modules/trade' import { getUiOrderType } from 'utils/orderUtils/getUiOrderType' @@ -296,6 +296,9 @@ export const useAddOrUpdateOrders = (): AddOrUpdateOrdersCallback => { (params: AddOrUpdateUnserialisedOrdersParams) => { const orders = params.orders.map((order) => ({ ...order, + bridgeOutputAmount: order.bridgeOutputAmount + ? { amount: order.bridgeOutputAmount.quotient.toString() } + : undefined, inputToken: serializeToken(order.inputToken), outputToken: serializeToken(order.outputToken), })) diff --git a/apps/cowswap-frontend/src/legacy/state/orders/utils.ts b/apps/cowswap-frontend/src/legacy/state/orders/utils.ts index a5effac04db..ae40b6f8ad9 100644 --- a/apps/cowswap-frontend/src/legacy/state/orders/utils.ts +++ b/apps/cowswap-frontend/src/legacy/state/orders/utils.ts @@ -8,7 +8,7 @@ import { UiOrderType } from '@cowprotocol/types' import BigNumber from 'bignumber.js' import JSBI from 'jsbi' -import { decodeAppData } from 'modules/appData/utils/decodeAppData' +import { decodeAppData } from 'modules/appData' import { GenericOrder } from 'common/types' import { getIsComposableCowParentOrder } from 'utils/orderUtils/getIsComposableCowParentOrder' @@ -464,6 +464,7 @@ export function partialOrderUpdate({ chainId, order, isSafeWallet }: UpdateOrder chainId, order: { ...order, + ...(order.bridgeOutputAmount && { bridgeOutputAmount: { amount: order.bridgeOutputAmount.quotient.toString() } }), ...(order.inputToken && { inputToken: serializeToken(order.inputToken) }), ...(order.outputToken && { outputToken: serializeToken(order.outputToken) }), } as UpdateOrderParamsAction['order'], diff --git a/apps/cowswap-frontend/src/legacy/state/orders/utils/deserializeOrder.test.ts b/apps/cowswap-frontend/src/legacy/state/orders/utils/deserializeOrder.test.ts new file mode 100644 index 00000000000..17d9ea94812 --- /dev/null +++ b/apps/cowswap-frontend/src/legacy/state/orders/utils/deserializeOrder.test.ts @@ -0,0 +1,61 @@ +import { USDC_MAINNET as USDC, USDT } from '@cowprotocol/common-const' +import { getCurrencyAddress } from '@cowprotocol/common-utils' +import { CurrencyAmount } from '@cowprotocol/currency' + +import { deserializeOrder } from './deserializeOrder' + +import { serializeToken } from '../../user/hooks' +import { SerializedOrder } from '../actions' +import { generateOrder } from '../mocks' +import { OrderObject } from '../reducer' + +const CHAIN_ID = USDT.chainId +const OWNER = '0x0000000000000000000000000000000000000001' + +function createSerializedOrder(overrides?: Partial): OrderObject { + const order = generateOrder({ owner: OWNER, sellToken: USDT, buyToken: USDC }) + const { bridgeOutputAmount: _bridgeOutputAmount, ...serializableOrder } = order + + return { + id: order.id, + isSafeWallet: false, + order: { + ...serializableOrder, + inputToken: serializeToken(order.inputToken), + outputToken: serializeToken(order.outputToken), + ...overrides, + }, + } +} + +describe('deserializeOrder', () => { + it('rehydrates serialized bridge output amounts', () => { + const bridgeOutputAmount = CurrencyAmount.fromRawAmount(USDC, '1234500') + const serializedOrder = createSerializedOrder({ + bridgeOutputAmount: { amount: bridgeOutputAmount.quotient.toString() }, + }) + + const result = deserializeOrder(serializedOrder) + const resultBridgeOutputAmount = result?.bridgeOutputAmount + + expect(resultBridgeOutputAmount?.quotient.toString()).toBe('1234500') + expect(resultBridgeOutputAmount?.currency.chainId).toBe(CHAIN_ID) + + if (!resultBridgeOutputAmount) { + throw new Error('Expected bridge output amount to be rehydrated') + } + + expect(getCurrencyAddress(resultBridgeOutputAmount.currency)).toBe(USDC.address) + }) + + it('drops malformed cached bridge output amounts instead of crashing', () => { + const serializedOrder = createSerializedOrder({ + bridgeOutputAmount: { numerator: { bad: true } } as unknown as SerializedOrder['bridgeOutputAmount'], + }) + + const result = deserializeOrder(serializedOrder) + + expect(result?.bridgeOutputAmount).toBeUndefined() + expect(result?.outputToken.address).toBe(USDC.address) + }) +}) diff --git a/apps/cowswap-frontend/src/legacy/state/orders/utils/deserializeOrder.ts b/apps/cowswap-frontend/src/legacy/state/orders/utils/deserializeOrder.ts index c143ad14578..9eaddd6e272 100644 --- a/apps/cowswap-frontend/src/legacy/state/orders/utils/deserializeOrder.ts +++ b/apps/cowswap-frontend/src/legacy/state/orders/utils/deserializeOrder.ts @@ -1,13 +1,12 @@ import { TokenWithLogo } from '@cowprotocol/common-const' +import { Currency, CurrencyAmount } from '@cowprotocol/currency' import { SerializedToken } from '../../user/types' -import { Order, OrderStatus } from '../actions' +import { Order, OrderStatus, SerializedBridgeOutputAmount } from '../actions' import { OrderObject, V2OrderObject } from '../reducer' import { isOrderExpired } from '../utils' -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function deserializeOrder(orderObject: OrderObject | V2OrderObject | undefined) { +export function deserializeOrder(orderObject: OrderObject | V2OrderObject | undefined): Order | undefined { let order: Order | undefined // we need to make sure the incoming order is a valid // V3 typed order as users can have stale data from V2 @@ -16,8 +15,14 @@ export function deserializeOrder(orderObject: OrderObject | V2OrderObject | unde const deserialisedInputToken = deserializeToken(serialisedOrder.inputToken) const deserialisedOutputToken = deserializeToken(serialisedOrder.outputToken) + const deserialisedBridgeOutputAmount = deserializeBridgeOutputAmount( + serialisedOrder.bridgeOutputAmount, + deserialisedOutputToken, + ) + order = { ...serialisedOrder, + bridgeOutputAmount: deserialisedBridgeOutputAmount, inputToken: deserialisedInputToken, outputToken: deserialisedOutputToken, } @@ -38,6 +43,30 @@ function deserializeToken(serializedToken: SerializedToken): TokenWithLogo { return TokenWithLogo.fromToken(serializedToken, serializedToken.logoURI) } +function deserializeBridgeOutputAmount( + serializedAmount: SerializedBridgeOutputAmount | unknown, + outputToken: TokenWithLogo, +): CurrencyAmount | undefined { + if (typeof serializedAmount === 'string') { + return CurrencyAmount.fromRawAmount(outputToken, serializedAmount) + } + + if (isSerializedBridgeOutputAmount(serializedAmount)) { + return CurrencyAmount.fromRawAmount(outputToken, serializedAmount.amount) + } + + return undefined +} + +function isSerializedBridgeOutputAmount(value: unknown): value is SerializedBridgeOutputAmount { + return ( + typeof value === 'object' && + value !== null && + 'amount' in value && + typeof (value as { amount: unknown }).amount === 'string' + ) +} + // TODO: Replace any with proper type definitions // eslint-disable-next-line @typescript-eslint/no-explicit-any function isV3Order(orderObject: any): orderObject is OrderObject { diff --git a/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx index 590960bc628..66f1f3cf2e4 100644 --- a/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx +++ b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx @@ -10,7 +10,7 @@ import { TWITTER_LINK, } from '@cowprotocol/common-const' -import { Navigate, Route, Routes } from 'react-router' +import { Navigate, Route, Routes, useLocation } from 'react-router' import { Loading } from 'legacy/components/FlashingLoading' import { RedirectPathToSwapOnly, RedirectToPath } from 'legacy/pages/Swap/redirects' @@ -32,10 +32,17 @@ import { LimitOrdersPage } from 'pages/LimitOrders/LimitOrders.page' import { SwapPage } from 'pages/Swap' import YieldPage from 'pages/Yield' +import { isDebugProgressBarRouteEnabled } from './RoutesApp.utils' + // Async routes const NotFound = lazy(() => import(/* webpackChunkName: "not_found" */ 'pages/error/NotFound')) const CowRunner = lazy(() => import(/* webpackChunkName: "cow_runner" */ 'pages/games/CowRunner')) const MevSlicer = lazy(() => import(/* webpackChunkName: "mev_slicer" */ 'pages/games/MevSlicer')) +const OrderProgressBarPlaygroundRoute = lazy(() => + import(/* webpackChunkName: "order_progress_bar_playground" */ 'pages/debug/OrderProgressBarPlayground.page').then( + ({ OrderProgressBarPlaygroundPage }) => ({ default: OrderProgressBarPlaygroundPage }), + ), +) // External routes const LegalExternal = @@ -81,6 +88,9 @@ const lazyRoutes: LazyRouteProps[] = [ ] export function RoutesApp(): ReactNode { + const { search } = useLocation() + const isDebugProgressBarEnabled = isDebugProgressBarRouteEnabled(search, process.env.NODE_ENV) + return ( {/*Account*/} @@ -109,6 +119,16 @@ export function RoutesApp(): ReactNode { } /> } /> } /> + {isDebugProgressBarEnabled && ( + }> + + + } + /> + )} {lazyRoutes.map((item, key) => LazyRoute({ ...item, key }))} diff --git a/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.utils.test.ts b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.utils.test.ts new file mode 100644 index 00000000000..3f1aec2d0ec --- /dev/null +++ b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.utils.test.ts @@ -0,0 +1,14 @@ +import { DEBUG_PROGRESS_BAR_QUERY_FLAG, isDebugProgressBarRouteEnabled } from './RoutesApp.utils' + +describe('isDebugProgressBarRouteEnabled', () => { + it('always enables the route in development', () => { + expect(isDebugProgressBarRouteEnabled('', 'development')).toBe(true) + expect(isDebugProgressBarRouteEnabled(`?${DEBUG_PROGRESS_BAR_QUERY_FLAG}=0`, 'development')).toBe(true) + }) + + it('enables the route in non-development only when the query flag is set', () => { + expect(isDebugProgressBarRouteEnabled(`?${DEBUG_PROGRESS_BAR_QUERY_FLAG}=1`, 'preview')).toBe(true) + expect(isDebugProgressBarRouteEnabled(`?${DEBUG_PROGRESS_BAR_QUERY_FLAG}=true`, 'preview')).toBe(false) + expect(isDebugProgressBarRouteEnabled('', 'preview')).toBe(false) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.utils.ts b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.utils.ts new file mode 100644 index 00000000000..f2dee690a86 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.utils.ts @@ -0,0 +1,9 @@ +export const DEBUG_PROGRESS_BAR_QUERY_FLAG = 'debugProgressBar' + +export function isDebugProgressBarRouteEnabled(search: string, environment: string | undefined): boolean { + if (environment === 'development') return true + + const searchParams = new URLSearchParams(search) + + return searchParams.get(DEBUG_PROGRESS_BAR_QUERY_FLAG) === '1' +} diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.cancelling.test.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.cancelling.test.ts new file mode 100644 index 00000000000..fba47dd0e62 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.cancelling.test.ts @@ -0,0 +1,29 @@ +import { getProgressBarStepName } from './useOrderProgressBarProps' + +import { OrderProgressBarStepName } from '../constants' +import { OrderProgressBarState } from '../types' + +const OPEN_STATUS = 'open' as OrderProgressBarState['backendApiStatus'] + +describe('getProgressBarStepName cancelling regressions', () => { + it('prioritizes cancelling over the price change step for open unfillable orders', () => { + const result = getProgressBarStepName( + true, // isUnfillable + false, // isCancelled + false, // isExpired + true, // isCancelling + undefined, // cancellationTriggered + false, // isConfirmed + null, // countdown + OrderProgressBarStepName.UNFILLABLE, + OPEN_STATUS, + OPEN_STATUS, + OrderProgressBarStepName.SOLVING, + undefined, + undefined, + false, + ) + + expect(result).toBe(OrderProgressBarStepName.CANCELLING) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts index eeb3e1f2b1d..e626f0b6cea 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts @@ -1,22 +1,41 @@ -import { getProgressBarStepName } from './useOrderProgressBarProps' +import { SwapAndBridgeStatus } from 'modules/bridge' + +import { + getProgressBarStepName, + shouldApplyCompletionDrivenExecutingImmediately, + shouldApplyStepNameImmediately, +} from './useOrderProgressBarProps' import { OrderProgressBarStepName } from '../constants' import { OrderProgressBarState } from '../types' const OPEN_STATUS = 'open' as OrderProgressBarState['backendApiStatus'] const EXECUTING_STATUS = 'executing' as OrderProgressBarState['backendApiStatus'] +const TRADED_STATUS = 'traded' as OrderProgressBarState['backendApiStatus'] describe('getProgressBarStepName', () => { function callGetProgressBarStepName({ isUnfillable = false, + isConfirmed = false, + countdown = null, + currentStepName, backendApiStatus, previousStepName = OrderProgressBarStepName.SOLVING, previousBackendApiStatus, + hasShownExecutingInCurrentAttempt, + bridgingStatus, + isBridgingTrade = false, }: { isUnfillable?: boolean + isConfirmed?: boolean + countdown?: OrderProgressBarState['countdown'] + currentStepName?: OrderProgressBarStepName | undefined backendApiStatus?: OrderProgressBarState['backendApiStatus'] previousStepName?: OrderProgressBarStepName | undefined previousBackendApiStatus?: OrderProgressBarState['previousBackendApiStatus'] + hasShownExecutingInCurrentAttempt?: true + bridgingStatus?: SwapAndBridgeStatus | undefined + isBridgingTrade?: boolean }): OrderProgressBarStepName { return getProgressBarStepName( isUnfillable, @@ -24,13 +43,15 @@ describe('getProgressBarStepName', () => { false, // isExpired false, // isCancelling undefined, // cancellationTriggered - false, // isConfirmed - null, // countdown + isConfirmed, + countdown, + currentStepName, backendApiStatus, previousBackendApiStatus, previousStepName, - undefined, // bridgingStatus - false, // isBridgingTrade + hasShownExecutingInCurrentAttempt, + bridgingStatus, + isBridgingTrade, ) } @@ -51,6 +72,28 @@ describe('getProgressBarStepName', () => { expect(result).toBe(OrderProgressBarStepName.SOLVING) }) + it('keeps the first visible screen on step 1 when an order starts as locally unfillable', () => { + const result = callGetProgressBarStepName({ + isUnfillable: true, + currentStepName: OrderProgressBarStepName.INITIAL, + backendApiStatus: OPEN_STATUS, + previousStepName: undefined, + }) + + expect(result).toBe(OrderProgressBarStepName.INITIAL) + }) + + it('shows price change once the progress bar has already moved past step 1', () => { + const result = callGetProgressBarStepName({ + isUnfillable: true, + currentStepName: OrderProgressBarStepName.SOLVING, + backendApiStatus: OPEN_STATUS, + previousStepName: OrderProgressBarStepName.INITIAL, + }) + + expect(result).toBe(OrderProgressBarStepName.UNFILLABLE) + }) + it('still transitions to executing when the backend reports progress', () => { const result = callGetProgressBarStepName({ backendApiStatus: EXECUTING_STATUS, @@ -60,4 +103,177 @@ describe('getProgressBarStepName', () => { expect(result).toBe(OrderProgressBarStepName.EXECUTING) }) + + it('keeps step 2 after the order has already left the initial state once', () => { + const result = callGetProgressBarStepName({ + currentStepName: OrderProgressBarStepName.SOLVING, + backendApiStatus: OPEN_STATUS, + previousStepName: undefined, + }) + + expect(result).toBe(OrderProgressBarStepName.DELAYED) + }) + + it('stays on step 1 while the order has not moved past the initial state yet', () => { + const result = callGetProgressBarStepName({ + currentStepName: OrderProgressBarStepName.INITIAL, + backendApiStatus: OPEN_STATUS, + previousStepName: undefined, + }) + + expect(result).toBe(OrderProgressBarStepName.INITIAL) + }) + + it('stages executing before finishing when the order completes from step 2', () => { + const result = callGetProgressBarStepName({ + currentStepName: OrderProgressBarStepName.SOLVING, + previousStepName: OrderProgressBarStepName.INITIAL, + isConfirmed: true, + }) + + expect(result).toBe(OrderProgressBarStepName.EXECUTING) + }) + + it('finishes once the executing step has already been shown', () => { + const result = callGetProgressBarStepName({ + currentStepName: OrderProgressBarStepName.EXECUTING, + previousStepName: OrderProgressBarStepName.SOLVING, + isConfirmed: true, + }) + + expect(result).toBe(OrderProgressBarStepName.FINISHED) + }) + + it('restages executing after a submission retry path before finishing', () => { + const result = callGetProgressBarStepName({ + currentStepName: OrderProgressBarStepName.SUBMISSION_FAILED, + previousStepName: OrderProgressBarStepName.EXECUTING, + isConfirmed: true, + }) + + expect(result).toBe(OrderProgressBarStepName.EXECUTING) + }) + + it('replays executing after a retry even when the bar already returned to solving', () => { + const result = callGetProgressBarStepName({ + currentStepName: OrderProgressBarStepName.SOLVING, + previousStepName: OrderProgressBarStepName.EXECUTING, + backendApiStatus: TRADED_STATUS, + }) + + expect(result).toBe(OrderProgressBarStepName.EXECUTING) + }) + + it('finishes after restaging executing for a submission retry path', () => { + const result = callGetProgressBarStepName({ + currentStepName: OrderProgressBarStepName.EXECUTING, + previousStepName: OrderProgressBarStepName.SUBMISSION_FAILED, + isConfirmed: true, + }) + + expect(result).toBe(OrderProgressBarStepName.FINISHED) + }) + + it('stages executing before showing bridge progress when the swap completes instantly', () => { + const result = callGetProgressBarStepName({ + currentStepName: OrderProgressBarStepName.SOLVING, + previousStepName: OrderProgressBarStepName.INITIAL, + bridgingStatus: SwapAndBridgeStatus.PENDING, + isBridgingTrade: true, + }) + + expect(result).toBe(OrderProgressBarStepName.EXECUTING) + }) + + it('keeps bridge progress when bridge context temporarily disappears after fill', () => { + const result = callGetProgressBarStepName({ + currentStepName: OrderProgressBarStepName.BRIDGING_IN_PROGRESS, + previousStepName: OrderProgressBarStepName.EXECUTING, + backendApiStatus: TRADED_STATUS, + isBridgingTrade: true, + }) + + expect(result).toBe(OrderProgressBarStepName.BRIDGING_IN_PROGRESS) + }) + + it('keeps bridge completion when bridge context temporarily disappears after fill', () => { + const result = callGetProgressBarStepName({ + currentStepName: OrderProgressBarStepName.BRIDGING_FINISHED, + previousStepName: OrderProgressBarStepName.BRIDGING_IN_PROGRESS, + backendApiStatus: TRADED_STATUS, + isBridgingTrade: true, + }) + + expect(result).toBe(OrderProgressBarStepName.BRIDGING_FINISHED) + }) +}) + +describe('shouldApplyCompletionDrivenExecutingImmediately', () => { + it('bypasses the 5s hold when executing is only a staging step before completion', () => { + const result = shouldApplyCompletionDrivenExecutingImmediately( + OrderProgressBarStepName.EXECUTING, + OrderProgressBarStepName.SOLVING, + undefined, + true, + undefined, + undefined, + false, + ) + + expect(result).toBe(true) + }) + + it('keeps the normal delay for a regular backend executing transition', () => { + const result = shouldApplyCompletionDrivenExecutingImmediately( + OrderProgressBarStepName.EXECUTING, + OrderProgressBarStepName.SOLVING, + undefined, + false, + EXECUTING_STATUS, + undefined, + false, + ) + + expect(result).toBe(false) + }) + + it('applies the replayed executing step immediately after a submission retry', () => { + const result = shouldApplyCompletionDrivenExecutingImmediately( + OrderProgressBarStepName.EXECUTING, + OrderProgressBarStepName.SOLVING, + undefined, + false, + TRADED_STATUS, + undefined, + false, + ) + + expect(result).toBe(true) + }) +}) + +describe('shouldApplyStepNameImmediately', () => { + it('shows cancelling immediately because it is a high-priority state', () => { + const result = shouldApplyStepNameImmediately(1000, 500, OrderProgressBarStepName.CANCELLING) + + expect(result).toBe(true) + }) + + it('shows the submission failed step immediately so the retry screen is not skipped', () => { + const result = shouldApplyStepNameImmediately(1000, 500, OrderProgressBarStepName.SUBMISSION_FAILED) + + expect(result).toBe(true) + }) + + it('shows bridge completion immediately once the bridge succeeds', () => { + const result = shouldApplyStepNameImmediately(1000, 500, OrderProgressBarStepName.BRIDGING_FINISHED) + + expect(result).toBe(true) + }) + + it('still delays regular transitional steps when they changed too recently', () => { + const result = shouldApplyStepNameImmediately(1000, 500, OrderProgressBarStepName.SOLVING) + + expect(result).toBe(false) + }) }) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts index 73f8708680a..9a3d54730dc 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { SWR_NO_REFRESH_OPTIONS } from '@cowprotocol/common-const' import { SolverInfo } from '@cowprotocol/core' -import { CompetitionOrderStatus, SupportedChainId } from '@cowprotocol/cow-sdk' +import { CompetitionOrderStatus, OrderClass, SupportedChainId } from '@cowprotocol/cow-sdk' import { useENS } from '@cowprotocol/ens' import { Command } from '@cowprotocol/types' @@ -16,6 +16,7 @@ import { Order, OrderStatus } from 'legacy/state/orders/actions' import { type SwapAndBridgeContext, SwapAndBridgeStatus } from 'modules/bridge' import { useInjectedWidgetParams } from 'modules/injectedWidget' +import { type OrderFillability, usePendingOrdersFillability } from 'modules/ordersTable' import { getOrderCompetitionStatus } from 'api/cowProtocol/api' import { useCancelOrder } from 'common/hooks/useCancelOrder' @@ -36,6 +37,12 @@ import { updateOrderProgressBarStepName, } from '../state/atoms' import { OrderProgressBarProps, OrderProgressBarState } from '../types' +import { + getCompletionDelayMs, + hasProgressBarLeftInitialStep, + shouldShowUnfillableProgressStep, + shouldStageExecutingStep, +} from '../updaters/utils' export type UseOrderProgressBarResult = Pick & { stepName: Exclude @@ -48,10 +55,11 @@ export type UseOrderProgressBarResult = Pick type UseOrderProgressBarPropsParams = { activityDerivedState: ActivityDerivedState | null chainId: SupportedChainId + currentOrderFillability?: OrderFillability isBridgingTrade: boolean } -const MINIMUM_STEP_DISPLAY_TIME = ms`5s` +export const MINIMUM_STEP_DISPLAY_TIME = ms`5s` export const PROGRESS_BAR_TIMER_DURATION = 15 // in seconds /** @@ -64,6 +72,20 @@ export function useOrderProgressBarProps( ): { props: OrderProgressBarProps activityDerivedState: ActivityDerivedState | null +} { + const pendingOrdersFillability = usePendingOrdersFillability(OrderClass.MARKET) + const currentOrderFillability = order?.id ? pendingOrdersFillability[order.id] : undefined + + return useOrderProgressBarPropsWithFillability(chainId, order, currentOrderFillability) +} + +export function useOrderProgressBarPropsWithFillability( + chainId: SupportedChainId, + order: Order | undefined, + currentOrderFillability: OrderFillability | undefined, +): { + props: OrderProgressBarProps + activityDerivedState: ActivityDerivedState | null } { const orderId = order?.id const isBridgingTrade = !!order && order.inputToken.chainId !== order.outputToken.chainId @@ -74,6 +96,7 @@ export function useOrderProgressBarProps( const progressBarProps = useOrderBaseProgressBarProps({ chainId, activityDerivedState, + currentOrderFillability, isBridgingTrade, }) @@ -137,7 +160,7 @@ function getDoNotQueryStatusEndpoint( // TODO: Reduce function complexity by extracting logic // eslint-disable-next-line max-lines-per-function, complexity function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): UseOrderProgressBarResult | undefined { - const { activityDerivedState, chainId, isBridgingTrade } = params + const { activityDerivedState, chainId, currentOrderFillability, isBridgingTrade } = params const { order, @@ -172,7 +195,9 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U previousStepName, lastTimeChangedSteps, cancellationTriggered, + hasShownExecutingInCurrentAttempt, } = useGetExecutingOrderState(orderId) + const shouldShowUnfillableStep = shouldShowUnfillableProgressStep(isUnfillable, currentOrderFillability) const solversInfo = useSolversInfo(chainId) const totalSolvers = Object.keys(solversInfo).length @@ -190,17 +215,19 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U useBackendApiStatusUpdater(chainId, orderId, doNotQuery) useProgressBarStepNameUpdater( orderId, - isUnfillable, + shouldShowUnfillableStep, isCancelled, isExpired, isCancelling, cancellationTriggered, isConfirmed, countdown, + progressBarStepName, backendApiStatus, previousBackendApiStatus, lastTimeChangedSteps, previousStepName, + hasShownExecutingInCurrentAttempt, bridgingStatus, isBridgingTrade, ) @@ -209,7 +236,7 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U orderId, countdown, backendApiStatus, - isUnfillable || isCancelled || isCancelling || isExpired, + shouldShowUnfillableStep || isCancelled || isCancelling || isExpired, ) const solverCompetition = useMemo(() => { @@ -259,8 +286,35 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U const DEFAULT_STATE = {} -// TODO: Break down this large function into smaller functions -// TODO: Reduce function complexity by extracting logic +function getCompletionStepName( + currentStepName: OrderProgressBarState['progressBarStepName'], + hasShownExecutingInCurrentAttempt: OrderProgressBarState['hasShownExecutingInCurrentAttempt'], + isConfirmed: boolean, + backendApiStatus: OrderProgressBarState['backendApiStatus'], +): OrderProgressBarStepName | undefined { + if (!isConfirmed && backendApiStatus !== CompetitionOrderStatus.type.TRADED) { + return undefined + } + + // Completion can arrive without a visible backend `executing` poll, so we synthesize step 3 + // when the current attempt has not shown it yet before moving to the final state. + return shouldStageExecutingStep(currentStepName, OrderProgressBarStepName.FINISHED, hasShownExecutingInCurrentAttempt) + ? OrderProgressBarStepName.EXECUTING + : OrderProgressBarStepName.FINISHED +} + +function isSubmissionFailedStatusTransition( + previousBackendApiStatus: OrderProgressBarState['previousBackendApiStatus'], + backendApiStatus: OrderProgressBarState['backendApiStatus'], +): boolean { + return ( + previousBackendApiStatus === CompetitionOrderStatus.type.EXECUTING && + (backendApiStatus === CompetitionOrderStatus.type.ACTIVE || + backendApiStatus === CompetitionOrderStatus.type.OPEN || + backendApiStatus === CompetitionOrderStatus.type.SCHEDULED) + ) +} + // eslint-disable-next-line complexity export function getProgressBarStepName( isUnfillable: boolean, @@ -270,33 +324,39 @@ export function getProgressBarStepName( cancellationTriggered: undefined | true, isConfirmed: boolean, countdown: OrderProgressBarState['countdown'], + currentStepName: OrderProgressBarState['progressBarStepName'], backendApiStatus: OrderProgressBarState['backendApiStatus'], previousBackendApiStatus: OrderProgressBarState['previousBackendApiStatus'], previousStepName: OrderProgressBarState['previousStepName'], + hasShownExecutingInCurrentAttempt: OrderProgressBarState['hasShownExecutingInCurrentAttempt'], bridgingStatus: SwapAndBridgeStatus | undefined, isBridgingTrade: boolean, ): OrderProgressBarStepName { const isTradedOrConfirmed = backendApiStatus === CompetitionOrderStatus.type.TRADED || isConfirmed + const hasMovedPastInitialStep = !!currentStepName && currentStepName !== OrderProgressBarStepName.INITIAL + const bridgingStepName = getBridgingStepName(bridgingStatus) + const completionStepName = getCompletionStepName( + currentStepName, + hasShownExecutingInCurrentAttempt, + isConfirmed, + backendApiStatus, + ) - if (bridgingStatus) { - if (bridgingStatus === SwapAndBridgeStatus.DONE) { - return OrderProgressBarStepName.BRIDGING_FINISHED - } + if (shouldStageExecutingStep(currentStepName, bridgingStepName, hasShownExecutingInCurrentAttempt)) { + return OrderProgressBarStepName.EXECUTING + } - if (bridgingStatus === SwapAndBridgeStatus.REFUND_COMPLETE) { - return OrderProgressBarStepName.REFUND_COMPLETED - } + if (bridgingStepName) { + return bridgingStepName + } - if (bridgingStatus === SwapAndBridgeStatus.FAILED) { - return OrderProgressBarStepName.BRIDGING_FAILED - } + if (isTradedOrConfirmed && isBridgingTrade && !bridgingStatus) { + const persistedBridgingStepName = getPersistedBridgingStepName(currentStepName) - if (bridgingStatus && [SwapAndBridgeStatus.PENDING, SwapAndBridgeStatus.DEFAULT].includes(bridgingStatus)) { - return OrderProgressBarStepName.BRIDGING_IN_PROGRESS + if (persistedBridgingStepName) { + return persistedBridgingStepName } - } - if (isTradedOrConfirmed && isBridgingTrade && !bridgingStatus) { return OrderProgressBarStepName.EXECUTING } @@ -309,20 +369,18 @@ export function getProgressBarStepName( } else if (cancellationTriggered && isTradedOrConfirmed) { // Was cancelling, but got executed in the meantime return OrderProgressBarStepName.CANCELLATION_FAILED - } else if (isConfirmed) { - // already traded - return OrderProgressBarStepName.FINISHED - } else if ( - previousBackendApiStatus === CompetitionOrderStatus.type.EXECUTING && - (backendApiStatus === CompetitionOrderStatus.type.ACTIVE || - backendApiStatus === CompetitionOrderStatus.type.OPEN || - backendApiStatus === CompetitionOrderStatus.type.SCHEDULED) - ) { - // moved back from executing to active + } else if (completionStepName) { + return completionStepName + } else if (isSubmissionFailedStatusTransition(previousBackendApiStatus, backendApiStatus)) { + // Submission failed and the backend is searching again. Show the retry screen first; + // if this attempt later completes, step 3 will be replayed before the final state. return OrderProgressBarStepName.SUBMISSION_FAILED } else if (isUnfillable) { - // out of market order - return OrderProgressBarStepName.UNFILLABLE + // A local unfillable flag can race immediately after approval / permit updates. + // Keep the first visible screen on step 1 until the bar has actually advanced. + return hasProgressBarLeftInitialStep(currentStepName) + ? OrderProgressBarStepName.UNFILLABLE + : OrderProgressBarStepName.INITIAL } else if ( (backendApiStatus == null || backendApiStatus === CompetitionOrderStatus.type.OPEN || @@ -338,8 +396,7 @@ export function getProgressBarStepName( } else if ( (backendApiStatus === CompetitionOrderStatus.type.OPEN || backendApiStatus === CompetitionOrderStatus.type.SCHEDULED) && - previousStepName && - previousStepName !== OrderProgressBarStepName.INITIAL + hasMovedPastInitialStep ) { // once moved out of initial state, never go back to it return OrderProgressBarStepName.DELAYED @@ -351,6 +408,91 @@ export function getProgressBarStepName( return OrderProgressBarStepName.INITIAL } +function getCompletionTargetStepName( + isConfirmed: boolean, + backendApiStatus: OrderProgressBarState['backendApiStatus'], + bridgingStatus: SwapAndBridgeStatus | undefined, +): OrderProgressBarStepName | undefined { + const bridgingStepName = getBridgingStepName(bridgingStatus) + + if (bridgingStepName) { + return bridgingStepName + } + + if (isConfirmed || backendApiStatus === CompetitionOrderStatus.type.TRADED) { + return OrderProgressBarStepName.FINISHED + } + + return undefined +} + +export function shouldApplyCompletionDrivenExecutingImmediately( + stepName: OrderProgressBarStepName, + currentStepName: OrderProgressBarState['progressBarStepName'], + hasShownExecutingInCurrentAttempt: OrderProgressBarState['hasShownExecutingInCurrentAttempt'], + isConfirmed: boolean, + backendApiStatus: OrderProgressBarState['backendApiStatus'], + bridgingStatus: SwapAndBridgeStatus | undefined, + isBridgingTrade: boolean, +): boolean { + if ( + stepName !== OrderProgressBarStepName.EXECUTING || + !currentStepName || + currentStepName === OrderProgressBarStepName.EXECUTING + ) { + return false + } + + const completionTargetStepName = getCompletionTargetStepName(isConfirmed, backendApiStatus, bridgingStatus) + + if (shouldStageExecutingStep(currentStepName, completionTargetStepName, hasShownExecutingInCurrentAttempt)) { + return true + } + + const isTradedOrConfirmed = backendApiStatus === CompetitionOrderStatus.type.TRADED || isConfirmed + + return isTradedOrConfirmed && isBridgingTrade && !bridgingStatus +} + +function getBridgingStepName(bridgingStatus: SwapAndBridgeStatus | undefined): OrderProgressBarStepName | undefined { + if (!bridgingStatus) { + return undefined + } + + if (bridgingStatus === SwapAndBridgeStatus.DONE) { + return OrderProgressBarStepName.BRIDGING_FINISHED + } + + if (bridgingStatus === SwapAndBridgeStatus.REFUND_COMPLETE) { + return OrderProgressBarStepName.REFUND_COMPLETED + } + + if (bridgingStatus === SwapAndBridgeStatus.FAILED) { + return OrderProgressBarStepName.BRIDGING_FAILED + } + + if ([SwapAndBridgeStatus.PENDING, SwapAndBridgeStatus.DEFAULT].includes(bridgingStatus)) { + return OrderProgressBarStepName.BRIDGING_IN_PROGRESS + } + + return undefined +} + +function getPersistedBridgingStepName( + currentStepName: OrderProgressBarState['progressBarStepName'], +): OrderProgressBarStepName | undefined { + if ( + currentStepName === OrderProgressBarStepName.BRIDGING_IN_PROGRESS || + currentStepName === OrderProgressBarStepName.BRIDGING_FAILED || + currentStepName === OrderProgressBarStepName.REFUND_COMPLETED || + currentStepName === OrderProgressBarStepName.BRIDGING_FINISHED + ) { + return currentStepName + } + + return undefined +} + function useCancellingOrderUpdater(orderId: string | undefined, isCancelling: boolean): void { const setCancellationTriggered = useSetAtom(setOrderProgressBarCancellationTriggered) @@ -404,7 +546,48 @@ function useGetExecutingOrderState(orderId?: string): OrderProgressBarState { return useMemo(() => singleState || DEFAULT_STATE, [singleState]) } -// TODO: Break down this large function into smaller functions +function getStepNameUpdateTimer( + updateStepName: (stepName: OrderProgressBarStepName) => void, + stepName: OrderProgressBarStepName, + currentStepName: OrderProgressBarState['progressBarStepName'], + hasShownExecutingInCurrentAttempt: OrderProgressBarState['hasShownExecutingInCurrentAttempt'], + lastTimeChangedSteps: OrderProgressBarState['lastTimeChangedSteps'], + isConfirmed: boolean, + backendApiStatus: OrderProgressBarState['backendApiStatus'], + bridgingStatus: SwapAndBridgeStatus | undefined, + isBridgingTrade: boolean, +): NodeJS.Timeout | undefined { + const timeSinceLastChange = lastTimeChangedSteps ? Date.now() - lastTimeChangedSteps : 0 + const completionDelayMs = getCompletionDelayMs(currentStepName, stepName, lastTimeChangedSteps) + const shouldApplyStepNow = shouldApplyStepNameNow( + lastTimeChangedSteps, + timeSinceLastChange, + stepName, + currentStepName, + hasShownExecutingInCurrentAttempt, + isConfirmed, + backendApiStatus, + bridgingStatus, + isBridgingTrade, + ) + + if (completionDelayMs > 0) { + return setTimeout(() => updateStepName(stepName), completionDelayMs) + } + + if (shouldApplyStepNow) { + updateStepName(stepName) + + if (stepName === OrderProgressBarStepName.SUBMISSION_FAILED) { + return setTimeout(() => updateStepName(OrderProgressBarStepName.SOLVING), MINIMUM_STEP_DISPLAY_TIME) + } + + return undefined + } + + return setTimeout(() => updateStepName(stepName), MINIMUM_STEP_DISPLAY_TIME - timeSinceLastChange) +} + function useProgressBarStepNameUpdater( orderId: string | undefined, isUnfillable: boolean, @@ -414,15 +597,16 @@ function useProgressBarStepNameUpdater( cancellationTriggered: undefined | true, isConfirmed: boolean, countdown: OrderProgressBarState['countdown'], + currentStepName: OrderProgressBarState['progressBarStepName'], backendApiStatus: OrderProgressBarState['backendApiStatus'], previousBackendApiStatus: OrderProgressBarState['previousBackendApiStatus'], lastTimeChangedSteps: OrderProgressBarState['lastTimeChangedSteps'], previousStepName: OrderProgressBarState['previousStepName'], + hasShownExecutingInCurrentAttempt: OrderProgressBarState['hasShownExecutingInCurrentAttempt'], bridgingStatus: SwapAndBridgeStatus | undefined, isBridgingTrade: boolean, ): void { - const setProgressBarStepName = useSetExecutingOrderProgressBarStepNameCallback() - + const updateStepName = useUpdateProgressBarStepName(orderId) const stepName = getProgressBarStepName( isUnfillable, isCancelled, @@ -431,52 +615,89 @@ function useProgressBarStepNameUpdater( cancellationTriggered, isConfirmed, countdown, + currentStepName, backendApiStatus, previousBackendApiStatus, previousStepName, + hasShownExecutingInCurrentAttempt, bridgingStatus, isBridgingTrade, ) - // Update state with new step name useEffect(() => { if (!orderId) { return } - - const ensuredOrderId = orderId - - function updateStepName(name: OrderProgressBarStepName): void { - setProgressBarStepName(ensuredOrderId, name || DEFAULT_STEP_NAME) - } - - let timer: NodeJS.Timeout | undefined - - const timeSinceLastChange = lastTimeChangedSteps ? Date.now() - lastTimeChangedSteps : 0 - - if ( - lastTimeChangedSteps === undefined || - timeSinceLastChange >= MINIMUM_STEP_DISPLAY_TIME || - stepName === OrderProgressBarStepName.FINISHED || - stepName === OrderProgressBarStepName.CANCELLATION_FAILED || - stepName === OrderProgressBarStepName.CANCELLED || - stepName === OrderProgressBarStepName.EXPIRED - ) { - updateStepName(stepName) - - // schedule update for temporary steps - if (stepName === OrderProgressBarStepName.SUBMISSION_FAILED) { - timer = setTimeout(() => updateStepName(OrderProgressBarStepName.SOLVING), MINIMUM_STEP_DISPLAY_TIME) - } - } else { - // Delay if it was updated less than MINIMUM_STEP_DISPLAY_TIME ago - timer = setTimeout(() => updateStepName(stepName), MINIMUM_STEP_DISPLAY_TIME - timeSinceLastChange) - } + const timer = getStepNameUpdateTimer( + updateStepName, + stepName, + currentStepName, + hasShownExecutingInCurrentAttempt, + lastTimeChangedSteps, + isConfirmed, + backendApiStatus, + bridgingStatus, + isBridgingTrade, + ) return () => { if (timer) clearTimeout(timer) } - }, [orderId, stepName, lastTimeChangedSteps, setProgressBarStepName]) + }, [ + backendApiStatus, + bridgingStatus, + currentStepName, + isBridgingTrade, + isConfirmed, + lastTimeChangedSteps, + orderId, + previousStepName, + hasShownExecutingInCurrentAttempt, + stepName, + updateStepName, + ]) +} + +function shouldApplyStepNameNow( + lastTimeChangedSteps: OrderProgressBarState['lastTimeChangedSteps'], + timeSinceLastChange: number, + stepName: OrderProgressBarStepName, + currentStepName: OrderProgressBarState['progressBarStepName'], + hasShownExecutingInCurrentAttempt: OrderProgressBarState['hasShownExecutingInCurrentAttempt'], + isConfirmed: boolean, + backendApiStatus: OrderProgressBarState['backendApiStatus'], + bridgingStatus: SwapAndBridgeStatus | undefined, + isBridgingTrade: boolean, +): boolean { + return ( + shouldApplyCompletionDrivenExecutingImmediately( + stepName, + currentStepName, + hasShownExecutingInCurrentAttempt, + isConfirmed, + backendApiStatus, + bridgingStatus, + isBridgingTrade, + ) || shouldApplyStepNameImmediately(lastTimeChangedSteps, timeSinceLastChange, stepName) + ) +} + +export function shouldApplyStepNameImmediately( + lastTimeChangedSteps: OrderProgressBarState['lastTimeChangedSteps'], + timeSinceLastChange: number, + stepName: OrderProgressBarStepName, +): boolean { + return ( + lastTimeChangedSteps === undefined || + timeSinceLastChange >= MINIMUM_STEP_DISPLAY_TIME || + stepName === OrderProgressBarStepName.CANCELLING || + stepName === OrderProgressBarStepName.SUBMISSION_FAILED || + stepName === OrderProgressBarStepName.BRIDGING_FINISHED || + stepName === OrderProgressBarStepName.FINISHED || + stepName === OrderProgressBarStepName.CANCELLATION_FAILED || + stepName === OrderProgressBarStepName.CANCELLED || + stepName === OrderProgressBarStepName.EXPIRED + ) } function useSetExecutingOrderCountdownCallback(): (orderId: string, value: number | null) => void { @@ -496,6 +717,21 @@ function useSetExecutingOrderProgressBarStepNameCallback(): (orderId: string, va ) } +function useUpdateProgressBarStepName(orderId: string | undefined): (name: OrderProgressBarStepName) => void { + const setProgressBarStepName = useSetExecutingOrderProgressBarStepNameCallback() + + return useCallback( + (name: OrderProgressBarStepName) => { + if (!orderId) { + return + } + + setProgressBarStepName(orderId, name || DEFAULT_STEP_NAME) + }, + [orderId, setProgressBarStepName], + ) +} + const BACKEND_TYPE_TO_PROGRESS_BAR_STEP_NAME: Record = { [CompetitionOrderStatus.type.SCHEDULED]: OrderProgressBarStepName.INITIAL, [CompetitionOrderStatus.type.OPEN]: OrderProgressBarStepName.INITIAL, diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts index c55bdf66e80..ee671328633 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts @@ -9,6 +9,11 @@ export { SurplusModalSetup } from './containers/SurplusModalSetup' // Hooks export { OrderSubmittedContent } from './containers/OrderSubmittedContent' +export { MINIMUM_STEP_DISPLAY_TIME, shouldApplyStepNameImmediately } from './hooks/useOrderProgressBarProps' + +// Pure components +export { OrderProgressBar } from './pure/OrderProgressBar' +export { EXECUTING_STEP_MIN_DISPLAY_TIME_MS, getCompletionDelayMs } from './updaters/utils' export * from './types' diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/pure/LottieContainer.test.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/pure/LottieContainer.test.tsx new file mode 100644 index 00000000000..11e42e0b9ef --- /dev/null +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/pure/LottieContainer.test.tsx @@ -0,0 +1,39 @@ +import { MEDIA_WIDTHS } from '@cowprotocol/ui' + +import { render, screen } from '@testing-library/react' + +import { FullSizeLottie } from './LottieContainer' + +jest.mock('lottie-react', () => ({ + __esModule: true, + default: function MockLottie() { + return
+ }, +})) + +describe('FullSizeLottie', () => { + it('zooms opted-in animations slightly on large phones without shrinking the stage', async () => { + const { container } = render() + + await screen.findByTestId('mock-lottie') + + expect(container.firstChild).toHaveStyleRule('--mobile-animation-scale', '1.1', { + media: `(min-width: 414px) and (max-width: ${MEDIA_WIDTHS.upToSmall}px)`, + }) + expect(container.firstChild).toHaveStyleRule('transform', 'scale(var(--mobile-animation-scale))', { + modifier: '> div', + }) + expect(container.firstChild).toHaveStyleRule('width', 'var(--size)') + expect(container.firstChild).not.toHaveStyleRule('margin-inline', 'auto') + }) + + it('keeps small helper animations unscaled by default', async () => { + const { container } = render() + + await screen.findByTestId('mock-lottie') + + expect(container.firstChild).toHaveStyleRule('--mobile-animation-scale', '1', { + media: `(min-width: 414px) and (max-width: ${MEDIA_WIDTHS.upToSmall}px)`, + }) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/pure/LottieContainer.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/pure/LottieContainer.tsx index cefe8cc6d67..e746936ccf3 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/pure/LottieContainer.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/pure/LottieContainer.tsx @@ -1,11 +1,16 @@ import { ReactNode, useRef, useEffect, lazy, Suspense } from 'react' +import { MEDIA_WIDTHS } from '@cowprotocol/ui' + import styled from 'styled-components/macro' const Lottie = lazy(() => import('lottie-react')) +const LARGE_PHONE_MIN_WIDTH = 414 +const LARGE_MOBILE_MEDIA_QUERY = `@media (min-width: ${LARGE_PHONE_MIN_WIDTH}px) and (max-width: ${MEDIA_WIDTHS.upToSmall}px)` -const LottieWrapper = styled.div` +const LottieWrapper = styled.div<{ $largePhoneScale: number }>` --size: 100%; + --mobile-animation-scale: 1; width: var(--size); height: var(--size); display: flex; @@ -13,10 +18,21 @@ const LottieWrapper = styled.div` justify-content: center; overflow: hidden; + ${LARGE_MOBILE_MEDIA_QUERY} { + /* + * Max/Plus phones keep the short mobile top-section height but get a noticeably wider card. + * The Lottie artwork itself has generous safe margins, so zoom the animation slightly on those + * phones instead of shrinking the whole stage and introducing side gutters. + */ + --mobile-animation-scale: ${({ $largePhoneScale }) => $largePhoneScale}; + } + /* Ensure Lottie container uses full space */ > div { width: var(--size) !important; height: var(--size) !important; + transform: scale(var(--mobile-animation-scale)); + transform-origin: center; } svg { @@ -29,9 +45,15 @@ interface FullSizeLottieProps { animationData: unknown loop?: boolean autoplay?: boolean + largePhoneScale?: number } -export function FullSizeLottie({ animationData, loop = true, autoplay = true }: FullSizeLottieProps): ReactNode { +export function FullSizeLottie({ + animationData, + loop = true, + autoplay = true, + largePhoneScale = 1, +}: FullSizeLottieProps): ReactNode { const lottieRef = useRef(null) useEffect(() => { @@ -61,7 +83,7 @@ export function FullSizeLottie({ animationData, loop = true, autoplay = true }: }, [animationData]) return ( - + {/* TODO: what fallback should be used here? */} (stepName) const currentStep = debugMode ? debugStep : stepName const analytics = useCowAnalytics() @@ -26,6 +26,10 @@ export function OrderProgressBar(props: OrderProgressBarProps): ReactNode { // Separate useEffect for initial step useEffect(() => { + if (disableAnalytics) { + return + } + if (currentStep === OrderProgressBarStepName.INITIAL && !initialStepTriggeredRef.current) { startTimeRef.current = Date.now() initialStepTriggeredRef.current = true @@ -36,10 +40,14 @@ export function OrderProgressBar(props: OrderProgressBarProps): ReactNode { value: 0, // This remains 0 for the initial step }) } - }, [currentStep, analytics]) + }, [currentStep, analytics, disableAnalytics]) // useEffect for other steps useEffect(() => { + if (disableAnalytics) { + return + } + if (currentStep === OrderProgressBarStepName.INITIAL) return // Skip for initial step const duration = getDuration() @@ -66,7 +74,7 @@ export function OrderProgressBar(props: OrderProgressBarProps): ReactNode { initialStepTriggeredRef.current = false // Reset the initial step trigger flag } } - }, [currentStep, getDuration, analytics]) + }, [currentStep, getDuration, analytics, disableAnalytics]) return ( <> diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/pure/TopSections.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/pure/TopSections.tsx index 7f6d46235ad..0b6edf6f542 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/pure/TopSections.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/pure/TopSections.tsx @@ -61,7 +61,7 @@ export function DelayedSolvedSubmissionFailedTopSection(): ReactNode { if (!STEP_LOTTIE_NEXTBATCH) return null - return + return } interface SolvingTopSectionProps { @@ -107,7 +107,7 @@ export function ExecutingTopSection({ stepName }: BaseTopSectionProps): ReactNod return ( - + ) } diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.test.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.test.ts index 835098f672b..11ca44e56f2 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.test.ts @@ -1,10 +1,14 @@ import { createStore } from 'jotai' +import { CompetitionOrderStatus } from '@cowprotocol/cow-sdk' + import { cancellationTrackedOrderIdsAtom, ordersProgressBarStateAtom, pruneOrdersProgressBarState, + updateOrderProgressBarBackendInfo, updateOrderProgressBarCountdown, + updateOrderProgressBarStepName, } from './atoms' import { OrderProgressBarStepName, OrdersProgressBarState } from '../types' @@ -138,3 +142,81 @@ describe('cancellationTrackedOrderIdsAtom', () => { expect(store.get(cancellationTrackedOrderIdsAtom)).toEqual(['a', 'c']) }) }) + +describe('executing replay state', () => { + const orderId = 'retry-order' + const EXECUTING_STATUS = CompetitionOrderStatus.type.EXECUTING + const OPEN_STATUS = CompetitionOrderStatus.type.OPEN + + it('marks the order to replay executing after the backend falls back from executing to open', () => { + const store = createStore() + + store.set(ordersProgressBarStateAtom, { + [orderId]: { + backendApiStatus: EXECUTING_STATUS, + progressBarStepName: OrderProgressBarStepName.EXECUTING, + }, + }) + + store.set(updateOrderProgressBarBackendInfo, { orderId, value: { backendApiStatus: OPEN_STATUS } }) + + expect(store.get(ordersProgressBarStateAtom)[orderId]).toMatchObject({ + backendApiStatus: OPEN_STATUS, + previousBackendApiStatus: EXECUTING_STATUS, + }) + expect(store.get(ordersProgressBarStateAtom)[orderId]?.hasShownExecutingInCurrentAttempt).toBeUndefined() + }) + + it('keeps the attempt reset while the retry screen moves back to solving', () => { + const store = createStore() + + store.set(ordersProgressBarStateAtom, { + [orderId]: { + progressBarStepName: OrderProgressBarStepName.SUBMISSION_FAILED, + previousStepName: OrderProgressBarStepName.EXECUTING, + }, + }) + + store.set(updateOrderProgressBarStepName, { orderId, value: OrderProgressBarStepName.SOLVING }) + + expect(store.get(ordersProgressBarStateAtom)[orderId]).toMatchObject({ + progressBarStepName: OrderProgressBarStepName.SOLVING, + previousStepName: OrderProgressBarStepName.SUBMISSION_FAILED, + }) + expect(store.get(ordersProgressBarStateAtom)[orderId]?.hasShownExecutingInCurrentAttempt).toBeUndefined() + }) + + it('marks the current attempt as having shown executing once step 3 appears', () => { + const store = createStore() + + store.set(ordersProgressBarStateAtom, { + [orderId]: { + progressBarStepName: OrderProgressBarStepName.SOLVING, + previousStepName: OrderProgressBarStepName.SUBMISSION_FAILED, + }, + }) + + store.set(updateOrderProgressBarStepName, { orderId, value: OrderProgressBarStepName.EXECUTING }) + + expect(store.get(ordersProgressBarStateAtom)[orderId]).toMatchObject({ + progressBarStepName: OrderProgressBarStepName.EXECUTING, + previousStepName: OrderProgressBarStepName.SOLVING, + hasShownExecutingInCurrentAttempt: true, + }) + }) + + it('clears the attempt flag when the backend falls back from executing to open', () => { + const store = createStore() + + store.set(ordersProgressBarStateAtom, { + [orderId]: { + backendApiStatus: EXECUTING_STATUS, + hasShownExecutingInCurrentAttempt: true, + }, + }) + + store.set(updateOrderProgressBarBackendInfo, { orderId, value: { backendApiStatus: OPEN_STATUS } }) + + expect(store.get(ordersProgressBarStateAtom)[orderId]?.hasShownExecutingInCurrentAttempt).toBeUndefined() + }) +}) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.ts index 8c279872e8a..61034319da1 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.ts @@ -1,6 +1,7 @@ import { atom } from 'jotai' import { deepEqual } from '@cowprotocol/common-utils' +import { CompetitionOrderStatus } from '@cowprotocol/cow-sdk' import { OrderProgressBarState, @@ -9,6 +10,12 @@ import { OrdersProgressBarState, } from '../types' +const EXECUTION_ATTEMPT_RESET_BACKEND_STATUSES = new Set([ + CompetitionOrderStatus.type.ACTIVE, + CompetitionOrderStatus.type.OPEN, + CompetitionOrderStatus.type.SCHEDULED, +]) + /** * Base Atom for orders progress bar state */ @@ -145,6 +152,12 @@ export const updateOrderProgressBarStepName = atom( // Keep track when state was changed singleOrderState.lastTimeChangedSteps = Date.now() + if (value === OrderProgressBarStepName.EXECUTING) { + // Track the UI step, not just the backend status. This lets completion paths replay + // `Executing` after a submission retry only when step 3 has actually been shown. + singleOrderState.hasShownExecutingInCurrentAttempt = true + } + set(ordersProgressBarStateAtom, { ...fullState, [orderId]: singleOrderState }) }, ) @@ -182,6 +195,16 @@ export const updateOrderProgressBarBackendInfo = atom( singleOrderState.previousBackendApiStatus = currentBackendApiStatus singleOrderState.backendApiStatus = backendApiStatus + if ( + currentBackendApiStatus === CompetitionOrderStatus.type.EXECUTING && + backendApiStatus && + EXECUTION_ATTEMPT_RESET_BACKEND_STATUSES.has(backendApiStatus) + ) { + // Backend fell back to a searching state, so the next successful completion belongs to + // a new attempt and should be allowed to show `Executing` again before finishing. + delete singleOrderState.hasShownExecutingInCurrentAttempt + } + // Only update solver competition if changed and not falsy if (solverCompetitionChanged && solverCompetition) { singleOrderState.solverCompetition = solverCompetition diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/types.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/types.ts index cc549bdf3f7..b8c28f8d1de 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/types.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/types.ts @@ -21,6 +21,7 @@ export type OrderProgressBarState = { previousStepName?: OrderProgressBarStepName lastTimeChangedSteps?: number cancellationTriggered?: true + hasShownExecutingInCurrentAttempt?: true } export type OrdersProgressBarState = Record @@ -37,6 +38,7 @@ export type OrderProgressBarProps = { stepName?: OrderProgressBarStepName chainId: SupportedChainId countdown?: number | null | undefined + disableAnalytics?: boolean solverCompetition?: SolverCompetition[] totalSolvers?: number order?: Order diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx new file mode 100644 index 00000000000..a279ff38018 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx @@ -0,0 +1,434 @@ +import { Provider as JotaiProvider } from 'jotai' +import { createStore } from 'jotai/vanilla' +import { ReactNode } from 'react' + +import { type EnrichedOrder, OrderClass, SupportedChainId } from '@cowprotocol/cow-sdk' +import { CowWidgetEvents, type OnBridgingSuccessPayload, type OnFulfilledOrderPayload } from '@cowprotocol/events' +import { type BridgeOrderDataSerialized } from '@cowprotocol/types' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { act, render } from '@testing-library/react' +import { WIDGET_EVENT_EMITTER } from 'widgetEventEmitter' + +import type { Order } from 'legacy/state/orders/actions' +import { useOnlyPendingOrders } from 'legacy/state/orders/hooks' + +import { usePendingOrdersFillability } from 'modules/ordersTable' + +import { OrderProgressEventsUpdater } from './OrderProgressEventsUpdater' +import { EXECUTING_STEP_MIN_DISPLAY_TIME_MS } from './utils' + +import { OrderProgressBarStepName } from '../constants' +import { MINIMUM_STEP_DISPLAY_TIME } from '../hooks/useOrderProgressBarProps' +import { ordersProgressBarStateAtom } from '../state/atoms' + +jest.mock('@cowprotocol/wallet', () => ({ + useWalletInfo: jest.fn(), +})) + +jest.mock('legacy/state/orders/hooks', () => ({ + useOnlyPendingOrders: jest.fn(), +})) + +jest.mock('modules/ordersTable', () => ({ + usePendingOrdersFillability: jest.fn(), +})) + +const useWalletInfoMock = useWalletInfo as jest.MockedFunction +const useOnlyPendingOrdersMock = useOnlyPendingOrders as jest.MockedFunction +const usePendingOrdersFillabilityMock = usePendingOrdersFillability as jest.MockedFunction< + typeof usePendingOrdersFillability +> + +type WalletInfo = ReturnType + +function getWrapper(): { + store: ReturnType + TestComponent: (props: { children: ReactNode }) => ReactNode +} { + const store = createStore() + + function TestComponent({ children }: { children: ReactNode }): ReactNode { + return {children} + } + + return { store, TestComponent } +} + +function emitFulfilledOrder(orderUid: string, bridgeOrder?: BridgeOrderDataSerialized): void { + const payload: OnFulfilledOrderPayload = { + chainId: SupportedChainId.MAINNET, + order: { uid: orderUid } as EnrichedOrder, + bridgeOrder, + } + + WIDGET_EVENT_EMITTER.emit(CowWidgetEvents.ON_FULFILLED_ORDER, payload) +} + +function emitBridgingSuccess(orderUid: string): void { + const payload = { + chainId: SupportedChainId.MAINNET, + order: { uid: orderUid } as EnrichedOrder, + } as unknown as OnBridgingSuccessPayload + + WIDGET_EVENT_EMITTER.emit(CowWidgetEvents.ON_BRIDGING_SUCCESS, payload) +} + +describe('OrderProgressEventsUpdater', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-04-08T12:00:00Z')) + + useWalletInfoMock.mockReturnValue({ + chainId: SupportedChainId.MAINNET, + account: '0xabc', + } as unknown as WalletInfo) + useOnlyPendingOrdersMock.mockReturnValue([] as Order[]) + usePendingOrdersFillabilityMock.mockReturnValue({}) + }) + + afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + jest.clearAllMocks() + }) + + it('stages executing before finishing fulfilled orders and clears the countdown', () => { + const orderUid = '0xorder' + const { store, TestComponent } = getWrapper() + + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + countdown: 12, + progressBarStepName: OrderProgressBarStepName.SOLVING, + previousStepName: OrderProgressBarStepName.INITIAL, + }, + }) + + const { unmount } = render(, { wrapper: TestComponent }) + + act(() => emitFulfilledOrder(orderUid)) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ + previousStepName: OrderProgressBarStepName.SOLVING, + progressBarStepName: OrderProgressBarStepName.EXECUTING, + }) + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.countdown).toBeUndefined() + + act(() => { + jest.advanceTimersByTime(EXECUTING_STEP_MIN_DISPLAY_TIME_MS - 1) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe( + OrderProgressBarStepName.EXECUTING, + ) + + act(() => { + jest.advanceTimersByTime(1) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ + previousStepName: OrderProgressBarStepName.EXECUTING, + progressBarStepName: OrderProgressBarStepName.FINISHED, + }) + + unmount() + }) + + it('stages executing before entering bridge progress when the fulfilled event includes a bridge order', () => { + const orderUid = '0xbridge-order' + const { store, TestComponent } = getWrapper() + + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + progressBarStepName: OrderProgressBarStepName.SOLVING, + previousStepName: OrderProgressBarStepName.INITIAL, + }, + }) + + const { unmount } = render(, { wrapper: TestComponent }) + + act(() => emitFulfilledOrder(orderUid, {} as BridgeOrderDataSerialized)) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe( + OrderProgressBarStepName.EXECUTING, + ) + + act(() => { + jest.advanceTimersByTime(EXECUTING_STEP_MIN_DISPLAY_TIME_MS) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ + previousStepName: OrderProgressBarStepName.EXECUTING, + progressBarStepName: OrderProgressBarStepName.BRIDGING_IN_PROGRESS, + }) + + unmount() + }) + + it('applies bridge completion immediately when the bridge success event arrives', () => { + const orderUid = '0xbridge-finished' + const { store, TestComponent } = getWrapper() + + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + lastTimeChangedSteps: Date.now(), + previousStepName: OrderProgressBarStepName.EXECUTING, + progressBarStepName: OrderProgressBarStepName.BRIDGING_IN_PROGRESS, + }, + }) + + const { unmount } = render(, { wrapper: TestComponent }) + + act(() => emitBridgingSuccess(orderUid)) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ + previousStepName: OrderProgressBarStepName.BRIDGING_IN_PROGRESS, + progressBarStepName: OrderProgressBarStepName.BRIDGING_FINISHED, + }) + + act(() => { + jest.advanceTimersByTime(MINIMUM_STEP_DISPLAY_TIME) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe( + OrderProgressBarStepName.BRIDGING_FINISHED, + ) + + unmount() + }) + + it('does not stage executing when a fulfilled event arrives before the bar leaves the initial step', () => { + const orderUid = '0xinitial-order' + const { store, TestComponent } = getWrapper() + + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + progressBarStepName: OrderProgressBarStepName.INITIAL, + }, + }) + + const { unmount } = render(, { wrapper: TestComponent }) + + act(() => emitFulfilledOrder(orderUid)) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ + previousStepName: OrderProgressBarStepName.INITIAL, + progressBarStepName: OrderProgressBarStepName.FINISHED, + }) + + act(() => { + jest.advanceTimersByTime(EXECUTING_STEP_MIN_DISPLAY_TIME_MS) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe(OrderProgressBarStepName.FINISHED) + + unmount() + }) + + it('does not overwrite a newer step after the delayed completion timer fires', () => { + const orderUid = '0xcancelled-order' + const { store, TestComponent } = getWrapper() + + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + progressBarStepName: OrderProgressBarStepName.SOLVING, + previousStepName: OrderProgressBarStepName.INITIAL, + }, + }) + + const { unmount } = render(, { wrapper: TestComponent }) + + act(() => emitFulfilledOrder(orderUid)) + act(() => { + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + progressBarStepName: OrderProgressBarStepName.CANCELLATION_FAILED, + previousStepName: OrderProgressBarStepName.EXECUTING, + }, + }) + }) + + act(() => { + jest.advanceTimersByTime(EXECUTING_STEP_MIN_DISPLAY_TIME_MS) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ + previousStepName: OrderProgressBarStepName.EXECUTING, + progressBarStepName: OrderProgressBarStepName.CANCELLATION_FAILED, + }) + + unmount() + }) + + it('does not recreate pruned state after the delayed completion timer fires', () => { + const orderUid = '0xpruned-order' + const { store, TestComponent } = getWrapper() + + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + progressBarStepName: OrderProgressBarStepName.SOLVING, + previousStepName: OrderProgressBarStepName.INITIAL, + }, + }) + + const { unmount } = render(, { wrapper: TestComponent }) + + act(() => emitFulfilledOrder(orderUid)) + act(() => { + store.set(ordersProgressBarStateAtom, {}) + }) + + act(() => { + jest.advanceTimersByTime(EXECUTING_STEP_MIN_DISPLAY_TIME_MS) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toBeUndefined() + + unmount() + }) + + it('replays executing before finishing after a submission retry path', () => { + const orderUid = '0xretry-order' + const { store, TestComponent } = getWrapper() + + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + progressBarStepName: OrderProgressBarStepName.SOLVING, + previousStepName: OrderProgressBarStepName.EXECUTING, + }, + }) + + const { unmount } = render(, { wrapper: TestComponent }) + + act(() => emitFulfilledOrder(orderUid)) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ + previousStepName: OrderProgressBarStepName.SOLVING, + progressBarStepName: OrderProgressBarStepName.EXECUTING, + hasShownExecutingInCurrentAttempt: true, + }) + + act(() => { + jest.advanceTimersByTime(EXECUTING_STEP_MIN_DISPLAY_TIME_MS) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ + previousStepName: OrderProgressBarStepName.EXECUTING, + progressBarStepName: OrderProgressBarStepName.FINISHED, + }) + + unmount() + }) + + it('does not show unfillable before the progress bar leaves the initial step', () => { + const orderUid = '0xinitial-unfillable' + const pendingOrder = { id: orderUid, class: OrderClass.MARKET, isUnfillable: true } as Order + const { store, TestComponent } = getWrapper() + + useOnlyPendingOrdersMock.mockReturnValue([pendingOrder]) + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + progressBarStepName: OrderProgressBarStepName.INITIAL, + }, + }) + + const { unmount } = render(, { wrapper: TestComponent }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe(OrderProgressBarStepName.INITIAL) + + act(() => { + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + progressBarStepName: OrderProgressBarStepName.SOLVING, + previousStepName: OrderProgressBarStepName.INITIAL, + lastTimeChangedSteps: Date.now(), + }, + }) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe(OrderProgressBarStepName.SOLVING) + + act(() => { + jest.advanceTimersByTime(MINIMUM_STEP_DISPLAY_TIME - 1) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe(OrderProgressBarStepName.SOLVING) + + act(() => { + jest.advanceTimersByTime(1) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe( + OrderProgressBarStepName.UNFILLABLE, + ) + + unmount() + }) + + it('does not create a solving step when a deferred unfillable order recovers before being shown', () => { + const orderUid = '0xrecovered-unfillable' + const pendingOrder = { id: orderUid, class: OrderClass.MARKET, isUnfillable: true } as Order + const { store, TestComponent } = getWrapper() + + useOnlyPendingOrdersMock.mockReturnValue([pendingOrder]) + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + progressBarStepName: OrderProgressBarStepName.INITIAL, + }, + }) + + const { rerender, unmount } = render(, { wrapper: TestComponent }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe(OrderProgressBarStepName.INITIAL) + + useOnlyPendingOrdersMock.mockReturnValue([]) + + rerender() + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe(OrderProgressBarStepName.INITIAL) + + unmount() + }) + + it('keeps the unfillable screen for the minimum display time before recovering to solving', () => { + const orderUid = '0xunfillable-recovery' + const pendingOrder = { id: orderUid, class: OrderClass.MARKET, isUnfillable: true } as Order + const { store, TestComponent } = getWrapper() + + useOnlyPendingOrdersMock.mockReturnValue([pendingOrder]) + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + progressBarStepName: OrderProgressBarStepName.UNFILLABLE, + previousStepName: OrderProgressBarStepName.SOLVING, + lastTimeChangedSteps: Date.now(), + }, + }) + + const { rerender, unmount } = render(, { wrapper: TestComponent }) + + useOnlyPendingOrdersMock.mockReturnValue([]) + + rerender() + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe( + OrderProgressBarStepName.UNFILLABLE, + ) + + act(() => { + jest.advanceTimersByTime(MINIMUM_STEP_DISPLAY_TIME - 1) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe( + OrderProgressBarStepName.UNFILLABLE, + ) + + act(() => { + jest.advanceTimersByTime(1) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe(OrderProgressBarStepName.SOLVING) + + unmount() + }) +}) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts index 341aa9147b6..bf8b8b602f4 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts @@ -1,29 +1,35 @@ import type { OrderFillability } from 'modules/ordersTable' -import { computeUnfillableOrderIds, getNewlyFillableOrderIds } from './utils' +import { + computeUnfillableOrderIds, + EXECUTING_STEP_MIN_DISPLAY_TIME_MS, + getCompletionDelayMs, + getNewlyFillableOrderIds, + shouldShowUnfillableProgressStep, + shouldStageExecutingStep, +} from './utils' + +import { OrderProgressBarStepName } from '../constants' type TestOrder = { id: string isUnfillable?: boolean } -const FILLABILITY_OK: OrderFillability = { - hasEnoughBalance: true, - hasEnoughAllowance: true, - hasPermit: false, -} +const TEST_GENERIC_ORDER = { id: 'fillability-order' } as OrderFillability['order'] -const FILLABILITY_LACKING_BALANCE: OrderFillability = { - hasEnoughBalance: false, - hasEnoughAllowance: true, - hasPermit: false, +function createFillability(overrides: Omit, 'order'>): OrderFillability { + return { + hasEnoughBalance: true, + hasEnoughAllowance: true, + hasPermit: false, + order: TEST_GENERIC_ORDER, + ...overrides, + } } -const FILLABILITY_LACKING_ALLOWANCE: OrderFillability = { - hasEnoughBalance: true, - hasEnoughAllowance: false, - hasPermit: false, -} +const FILLABILITY_LACKING_BALANCE = createFillability({ hasEnoughBalance: false }) +const FILLABILITY_LACKING_ALLOWANCE = createFillability({ hasEnoughAllowance: false }) describe('computeUnfillableOrderIds', () => { it('includes orders flagged as unfillable by price', () => { @@ -34,38 +40,45 @@ describe('computeUnfillableOrderIds', () => { expect(result).toEqual(['1']) }) - it('includes orders lacking balance or allowance', () => { + it('excludes orders when the only issue is allowance or balance lag', () => { const orders: TestOrder[] = [{ id: '2' }] const result = computeUnfillableOrderIds(orders, { '2': FILLABILITY_LACKING_BALANCE, }) - expect(result).toEqual(['2']) + expect(result).toEqual([]) }) - it('deduplicates orders flagged by both sources', () => { + it('suppresses price change when a flagged order still has an allowance lag', () => { const orders: TestOrder[] = [{ id: '3', isUnfillable: true }] const result = computeUnfillableOrderIds(orders, { '3': FILLABILITY_LACKING_ALLOWANCE, }) - expect(result).toEqual(['3']) + expect(result).toEqual([]) }) it('ignores orders that are fillable and not flagged', () => { const orders: TestOrder[] = [{ id: '4', isUnfillable: false }, { id: '5' }] - const result = computeUnfillableOrderIds(orders, { - '4': FILLABILITY_OK, - '5': FILLABILITY_OK, - }) + const result = computeUnfillableOrderIds(orders, {}) expect(result).toEqual([]) }) }) +describe('shouldShowUnfillableProgressStep', () => { + it('keeps the price change screen for true price-derived unfillable states', () => { + expect(shouldShowUnfillableProgressStep(true, undefined)).toBe(true) + }) + + it('suppresses the price change screen when allowance data has not caught up yet', () => { + expect(shouldShowUnfillableProgressStep(true, FILLABILITY_LACKING_ALLOWANCE)).toBe(false) + }) +}) + describe('getNewlyFillableOrderIds', () => { it('returns orders that were previously unfillable but not in the current set', () => { const previous = new Set(['1', '2', '3']) @@ -94,3 +107,72 @@ describe('getNewlyFillableOrderIds', () => { expect(result).toEqual([]) }) }) + +describe('shouldStageExecutingStep', () => { + it('requires executing before the final step when the order finishes from step 2', () => { + const result = shouldStageExecutingStep(OrderProgressBarStepName.SOLVING, OrderProgressBarStepName.FINISHED) + + expect(result).toBe(true) + }) + + it('restages executing after a submission retry path before finishing', () => { + const result = shouldStageExecutingStep( + OrderProgressBarStepName.SUBMISSION_FAILED, + OrderProgressBarStepName.FINISHED, + ) + + expect(result).toBe(true) + }) + + it('replays executing after a retry even if the UI already moved back to solving', () => { + const result = shouldStageExecutingStep( + OrderProgressBarStepName.SOLVING, + OrderProgressBarStepName.FINISHED, + undefined, + ) + + expect(result).toBe(true) + }) + + it('does not replay executing after the current attempt already showed it', () => { + const result = shouldStageExecutingStep(OrderProgressBarStepName.SOLVING, OrderProgressBarStepName.FINISHED, true) + + expect(result).toBe(false) + }) + + it('does not stage executing while the progress bar is still on the initial step', () => { + const result = shouldStageExecutingStep(OrderProgressBarStepName.INITIAL, OrderProgressBarStepName.FINISHED) + + expect(result).toBe(false) + }) + + it('does not move backwards from a completion step', () => { + const result = shouldStageExecutingStep(OrderProgressBarStepName.FINISHED, OrderProgressBarStepName.FINISHED) + + expect(result).toBe(false) + }) +}) + +describe('getCompletionDelayMs', () => { + it('keeps executing visible for the configured minimum duration', () => { + const result = getCompletionDelayMs( + OrderProgressBarStepName.EXECUTING, + OrderProgressBarStepName.FINISHED, + 1000, + 1500, + ) + + expect(result).toBe(EXECUTING_STEP_MIN_DISPLAY_TIME_MS - 500) + }) + + it('does not delay non-completion steps', () => { + const result = getCompletionDelayMs( + OrderProgressBarStepName.EXECUTING, + OrderProgressBarStepName.DELAYED, + 1000, + 1500, + ) + + expect(result).toBe(0) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.timers.test.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.timers.test.tsx new file mode 100644 index 00000000000..abcdea0cc23 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.timers.test.tsx @@ -0,0 +1,157 @@ +import { Provider as JotaiProvider } from 'jotai' +import { createStore } from 'jotai/vanilla' +import { ReactNode } from 'react' + +import { type EnrichedOrder, OrderClass, SupportedChainId } from '@cowprotocol/cow-sdk' +import { CowWidgetEvents, type OnFulfilledOrderPayload } from '@cowprotocol/events' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { act, render } from '@testing-library/react' +import { WIDGET_EVENT_EMITTER } from 'widgetEventEmitter' + +import type { Order } from 'legacy/state/orders/actions' +import { useOnlyPendingOrders } from 'legacy/state/orders/hooks' + +import { usePendingOrdersFillability } from 'modules/ordersTable' + +import { OrderProgressEventsUpdater } from './OrderProgressEventsUpdater' +import { EXECUTING_STEP_MIN_DISPLAY_TIME_MS } from './utils' + +import { OrderProgressBarStepName } from '../constants' +import { MINIMUM_STEP_DISPLAY_TIME } from '../hooks/useOrderProgressBarProps' +import { ordersProgressBarStateAtom } from '../state/atoms' + +jest.mock('@cowprotocol/wallet', () => ({ + useWalletInfo: jest.fn(), +})) + +jest.mock('legacy/state/orders/hooks', () => ({ + useOnlyPendingOrders: jest.fn(), +})) + +jest.mock('modules/ordersTable', () => ({ + usePendingOrdersFillability: jest.fn(), +})) + +const useWalletInfoMock = useWalletInfo as jest.MockedFunction +const useOnlyPendingOrdersMock = useOnlyPendingOrders as jest.MockedFunction +const usePendingOrdersFillabilityMock = usePendingOrdersFillability as jest.MockedFunction< + typeof usePendingOrdersFillability +> + +type WalletInfo = ReturnType + +function getWrapper(): { + store: ReturnType + TestComponent: (props: { children: ReactNode }) => ReactNode +} { + const store = createStore() + + function TestComponent({ children }: { children: ReactNode }): ReactNode { + return {children} + } + + return { store, TestComponent } +} + +function emitFulfilledOrder(orderUid: string): void { + const payload: OnFulfilledOrderPayload = { + chainId: SupportedChainId.MAINNET, + order: { uid: orderUid } as EnrichedOrder, + } + + WIDGET_EVENT_EMITTER.emit(CowWidgetEvents.ON_FULFILLED_ORDER, payload) +} + +describe('OrderProgressEventsUpdater timer cleanup', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-04-09T12:00:00Z')) + + useWalletInfoMock.mockReturnValue({ + chainId: SupportedChainId.MAINNET, + account: '0xabc', + } as unknown as WalletInfo) + usePendingOrdersFillabilityMock.mockReturnValue({}) + }) + + afterEach(() => { + act(() => { + jest.clearAllTimers() + }) + jest.useRealTimers() + jest.clearAllMocks() + }) + + it('clears a queued unfillable timer when the order becomes fillable again before it fires', () => { + const orderUid = '0xrecovering-order' + const order = { id: orderUid, class: OrderClass.MARKET, isUnfillable: true } as Order + const { store, TestComponent } = getWrapper() + + useOnlyPendingOrdersMock.mockReturnValue([order]) + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + lastTimeChangedSteps: Date.now(), + previousStepName: OrderProgressBarStepName.INITIAL, + progressBarStepName: OrderProgressBarStepName.SOLVING, + }, + }) + + const { rerender, unmount } = render(, { wrapper: TestComponent }) + + useOnlyPendingOrdersMock.mockReturnValue([{ ...order, isUnfillable: false } as Order]) + + act(() => { + rerender() + }) + + act(() => { + jest.advanceTimersByTime(MINIMUM_STEP_DISPLAY_TIME) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ + previousStepName: OrderProgressBarStepName.INITIAL, + progressBarStepName: OrderProgressBarStepName.SOLVING, + }) + + unmount() + }) + + it('keeps a queued completion timer when a fulfilled order also becomes newly fillable', () => { + const orderUid = '0xfulfilled-unfillable-order' + const order = { id: orderUid, class: OrderClass.MARKET, isUnfillable: true } as Order + const { store, TestComponent } = getWrapper() + + useOnlyPendingOrdersMock.mockReturnValue([order]) + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + previousStepName: OrderProgressBarStepName.SOLVING, + progressBarStepName: OrderProgressBarStepName.UNFILLABLE, + }, + }) + + const { rerender, unmount } = render(, { wrapper: TestComponent }) + + act(() => { + emitFulfilledOrder(orderUid) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe( + OrderProgressBarStepName.EXECUTING, + ) + + useOnlyPendingOrdersMock.mockReturnValue([{ ...order, isUnfillable: false } as Order]) + + act(() => { + rerender() + }) + + act(() => { + jest.advanceTimersByTime(EXECUTING_STEP_MIN_DISPLAY_TIME_MS + 1) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]?.progressBarStepName).toBe(OrderProgressBarStepName.FINISHED) + + unmount() + }) +}) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx index 22f8c677d88..bac17683231 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx @@ -1,5 +1,5 @@ -import { useAtomValue, useSetAtom } from 'jotai' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useAtomValue, useSetAtom, useStore } from 'jotai' +import { MutableRefObject, useCallback, useEffect, useMemo, useRef } from 'react' import { OrderClass, SupportedChainId } from '@cowprotocol/cow-sdk' import { @@ -18,14 +18,24 @@ import { useOnlyPendingOrders } from 'legacy/state/orders/hooks' import { usePendingOrdersFillability } from 'modules/ordersTable' -import { computeUnfillableOrderIds, getNewlyFillableOrderIds } from './utils' +import { + computeUnfillableOrderIds, + EXECUTING_STEP_MIN_DISPLAY_TIME_MS, + getCompletionDelayMs, + getNewlyFillableOrderIds, + hasProgressBarLeftInitialStep, + isCompletionStep, + shouldStageExecutingStep, +} from './utils' import { OrderProgressBarStepName } from '../constants' +import { MINIMUM_STEP_DISPLAY_TIME, shouldApplyStepNameImmediately } from '../hooks/useOrderProgressBarProps' import { ordersProgressBarStateAtom, updateOrderProgressBarCountdown, updateOrderProgressBarStepName, } from '../state/atoms' +import { OrdersProgressBarState } from '../types' function useUnfillableOrderIds(): string[] { const { chainId, account } = useWalletInfo() @@ -42,59 +52,41 @@ function useUnfillableOrderIds(): string[] { ) } -export function OrderProgressEventsUpdater(): null { - const ordersProgressState = useAtomValue(ordersProgressBarStateAtom) - const setCountdown = useSetAtom(updateOrderProgressBarCountdown) - const setStepName = useSetAtom(updateOrderProgressBarStepName) - const unfillableIds = useUnfillableOrderIds() - const previousUnfillableRef = useRef>(new Set()) +function useOrdersProgressStateRef( + ordersProgressState: OrdersProgressBarState, +): MutableRefObject { const ordersProgressStateRef = useRef(ordersProgressState) useEffect(() => { ordersProgressStateRef.current = ordersProgressState }, [ordersProgressState]) - const finalizeOrderStep = useCallback( - (orderUid: string, step: OrderProgressBarStepName) => { - const currentState = ordersProgressStateRef.current[orderUid] - - if (currentState?.progressBarStepName === step) { - return - } - - setStepName({ orderId: orderUid, value: step }) + return ordersProgressStateRef +} - const currentCountdown = currentState?.countdown +type ScheduledStepTimer = { + expectedCurrentStep: OrderProgressBarStepName | undefined + step: OrderProgressBarStepName + timer: NodeJS.Timeout +} - if (typeof currentCountdown !== 'undefined' && currentCountdown !== null) { - setCountdown({ orderId: orderUid, value: null }) - } - }, - [setCountdown, setStepName], - ) +function useStepTimersRef(): MutableRefObject> { + const stepTimersRef = useRef>({}) useEffect(() => { - const previousUnfillable = previousUnfillableRef.current - const currentUnfillable = new Set(unfillableIds) - const currentProgressState = ordersProgressStateRef.current - - const newlyFillable = getNewlyFillableOrderIds(previousUnfillable, currentUnfillable) - - newlyFillable.forEach((orderId) => { - const currentStep = currentProgressState[orderId]?.progressBarStepName - - if (currentStep && currentStep !== OrderProgressBarStepName.UNFILLABLE) { - return - } + const stepTimers = stepTimersRef.current - finalizeOrderStep(orderId, OrderProgressBarStepName.SOLVING) - }) - currentUnfillable.forEach((orderId) => finalizeOrderStep(orderId, OrderProgressBarStepName.UNFILLABLE)) + return () => { + Object.values(stepTimers).forEach(({ timer }) => clearTimeout(timer)) + } + }, []) - // Persist for the next diff so we only reset orders that actually recovered. - previousUnfillableRef.current = currentUnfillable - }, [unfillableIds, finalizeOrderStep]) + return stepTimersRef +} +function useOrderProgressEventListeners( + finalizeOrderStep: (orderUid: string, step: OrderProgressBarStepName) => void, +): void { useEffect(() => { const listeners: CowWidgetEventListener[] = [ { @@ -110,22 +102,19 @@ export function OrderProgressEventsUpdater(): null { { event: CowWidgetEvents.ON_BRIDGING_SUCCESS, handler: (payload: OnBridgingSuccessPayload) => { - const orderUid = payload.order.uid - finalizeOrderStep(orderUid, OrderProgressBarStepName.BRIDGING_FINISHED) + finalizeOrderStep(payload.order.uid, OrderProgressBarStepName.BRIDGING_FINISHED) }, }, { event: CowWidgetEvents.ON_CANCELLED_ORDER, handler: (payload: OnCancelledOrderPayload) => { - const orderUid = payload.order.uid - finalizeOrderStep(orderUid, OrderProgressBarStepName.CANCELLED) + finalizeOrderStep(payload.order.uid, OrderProgressBarStepName.CANCELLED) }, }, { event: CowWidgetEvents.ON_EXPIRED_ORDER, handler: (payload: OnExpiredOrderPayload) => { - const orderUid = payload.order.uid - finalizeOrderStep(orderUid, OrderProgressBarStepName.EXPIRED) + finalizeOrderStep(payload.order.uid, OrderProgressBarStepName.EXPIRED) }, }, ] @@ -136,6 +125,184 @@ export function OrderProgressEventsUpdater(): null { listeners.forEach((listener) => WIDGET_EVENT_EMITTER.off(listener)) } }, [finalizeOrderStep]) +} + +function useUnfillableProgressTransitions( + ordersProgressState: OrdersProgressBarState, + unfillableIds: string[], + getCurrentProgressState: (orderUid: string) => OrdersProgressBarState[string] | undefined, + clearStepTimer: ( + orderUid: string, + shouldClear?: ( + scheduledStep: OrderProgressBarStepName, + expectedCurrentStep: OrderProgressBarStepName | undefined, + ) => boolean, + ) => void, + finalizeOrderStep: (orderUid: string, step: OrderProgressBarStepName) => void, +): void { + const previousUnfillableRef = useRef>(new Set()) + + useEffect(() => { + const previousUnfillable = previousUnfillableRef.current + const currentUnfillable = new Set(unfillableIds) + + const newlyFillable = getNewlyFillableOrderIds(previousUnfillable, currentUnfillable) + + newlyFillable.forEach((orderId) => { + const currentStep = getCurrentProgressState(orderId)?.progressBarStepName + + if (currentStep !== OrderProgressBarStepName.UNFILLABLE) { + clearStepTimer(orderId, (scheduledStep) => scheduledStep === OrderProgressBarStepName.UNFILLABLE) + return + } + + finalizeOrderStep(orderId, OrderProgressBarStepName.SOLVING) + }) + + currentUnfillable.forEach((orderId) => { + const currentStep = getCurrentProgressState(orderId)?.progressBarStepName + + if (!hasProgressBarLeftInitialStep(currentStep)) { + return + } + + if ( + currentStep === OrderProgressBarStepName.EXECUTING || + currentStep === OrderProgressBarStepName.CANCELLING || + currentStep === OrderProgressBarStepName.CANCELLED || + currentStep === OrderProgressBarStepName.EXPIRED || + currentStep === OrderProgressBarStepName.CANCELLATION_FAILED || + isCompletionStep(currentStep) + ) { + return + } + + finalizeOrderStep(orderId, OrderProgressBarStepName.UNFILLABLE) + }) + + // Persist for the next diff so we only reset orders that actually recovered. + previousUnfillableRef.current = currentUnfillable + }, [clearStepTimer, finalizeOrderStep, getCurrentProgressState, ordersProgressState, unfillableIds]) +} + +export function OrderProgressEventsUpdater(): null { + const store = useStore() + const ordersProgressState = useAtomValue(ordersProgressBarStateAtom) + const setCountdown = useSetAtom(updateOrderProgressBarCountdown) + const setStepName = useSetAtom(updateOrderProgressBarStepName) + const unfillableIds = useUnfillableOrderIds() + const ordersProgressStateRef = useOrdersProgressStateRef(ordersProgressState) + const stepTimersRef = useStepTimersRef() + const getCurrentProgressState = useCallback( + (orderUid: string) => store.get(ordersProgressBarStateAtom)[orderUid], + [store], + ) + const clearStepTimer = useCallback( + ( + orderUid: string, + shouldClear: ( + scheduledStep: OrderProgressBarStepName, + expectedCurrentStep: OrderProgressBarStepName | undefined, + ) => boolean = () => true, + ) => { + const existingTimer = stepTimersRef.current[orderUid] + + if (!existingTimer || !shouldClear(existingTimer.step, existingTimer.expectedCurrentStep)) { + return + } + + clearTimeout(existingTimer.timer) + delete stepTimersRef.current[orderUid] + }, + [stepTimersRef], + ) + + const scheduleStepUpdate = useCallback( + ( + orderUid: string, + step: OrderProgressBarStepName, + delayMs: number, + expectedCurrentStep: OrderProgressBarStepName | undefined, + ) => { + const timerRef = setTimeout(() => { + if (stepTimersRef.current[orderUid]?.timer !== timerRef) { + return + } + + delete stepTimersRef.current[orderUid] + + const latestState = ordersProgressStateRef.current[orderUid] + + if (!latestState || latestState.progressBarStepName !== expectedCurrentStep) { + return + } + + setStepName({ orderId: orderUid, value: step }) + }, delayMs) + + stepTimersRef.current[orderUid] = { + expectedCurrentStep, + step, + timer: timerRef, + } + }, + [ordersProgressStateRef, setStepName, stepTimersRef], + ) + + const finalizeOrderStep = useCallback( + (orderUid: string, step: OrderProgressBarStepName) => { + const currentState = ordersProgressStateRef.current[orderUid] + const currentStep = currentState?.progressBarStepName + clearStepTimer(orderUid) + + if (currentStep === step) { + return + } + + const currentCountdown = currentState?.countdown + + if (typeof currentCountdown !== 'undefined' && currentCountdown !== null) { + setCountdown({ orderId: orderUid, value: null }) + } + + if (shouldStageExecutingStep(currentStep, step, currentState?.hasShownExecutingInCurrentAttempt)) { + setStepName({ orderId: orderUid, value: OrderProgressBarStepName.EXECUTING }) + scheduleStepUpdate(orderUid, step, EXECUTING_STEP_MIN_DISPLAY_TIME_MS, OrderProgressBarStepName.EXECUTING) + + return + } + + const completionDelayMs = getCompletionDelayMs(currentStep, step, currentState?.lastTimeChangedSteps) + + if (completionDelayMs > 0) { + scheduleStepUpdate(orderUid, step, completionDelayMs, OrderProgressBarStepName.EXECUTING) + + return + } + + const timeSinceLastChange = currentState?.lastTimeChangedSteps + ? Date.now() - currentState.lastTimeChangedSteps + : 0 + + if (!shouldApplyStepNameImmediately(currentState?.lastTimeChangedSteps, timeSinceLastChange, step)) { + scheduleStepUpdate(orderUid, step, MINIMUM_STEP_DISPLAY_TIME - timeSinceLastChange, currentStep) + + return + } + + setStepName({ orderId: orderUid, value: step }) + }, + [clearStepTimer, ordersProgressStateRef, scheduleStepUpdate, setCountdown, setStepName], + ) + + useUnfillableProgressTransitions( + ordersProgressState, + unfillableIds, + getCurrentProgressState, + clearStepTimer, + finalizeOrderStep, + ) + useOrderProgressEventListeners(finalizeOrderStep) return null } diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.test.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.test.tsx index 11c122002d8..a091e11a9fc 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.test.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.test.tsx @@ -3,17 +3,19 @@ import type { PrimitiveAtom } from 'jotai' import { OrderClass } from '@cowprotocol/cow-sdk' import { useWalletInfo } from '@cowprotocol/wallet' -import { render } from '@testing-library/react' +import { act, render } from '@testing-library/react' import { useSurplusQueueOrderIds } from 'entities/surplusModal' import type { Order } from 'legacy/state/orders/actions' import { useOnlyPendingOrders } from 'legacy/state/orders/hooks' +import { usePendingOrdersFillability } from 'modules/ordersTable' import { useTradeConfirmState } from 'modules/trade' import { OrderProgressStateUpdater } from './OrderProgressStateUpdater' -import { useOrderProgressBarProps } from '../hooks/useOrderProgressBarProps' +import { useOrderProgressBarPropsWithFillability } from '../hooks/useOrderProgressBarProps' +import { OrderProgressBarStepName } from '../types' jest.mock('@cowprotocol/wallet', () => ({ useWalletInfo: jest.fn(), @@ -23,8 +25,12 @@ jest.mock('legacy/state/orders/hooks', () => ({ useOnlyPendingOrders: jest.fn(), })) +jest.mock('modules/ordersTable', () => ({ + usePendingOrdersFillability: jest.fn(), +})) + jest.mock('../hooks/useOrderProgressBarProps', () => ({ - useOrderProgressBarProps: jest.fn(), + useOrderProgressBarPropsWithFillability: jest.fn(), })) jest.mock('entities/surplusModal', () => ({ @@ -37,28 +43,57 @@ jest.mock('modules/trade', () => ({ const mockPruneOrders = jest.fn() const mockCancellationIds = jest.fn() +const progressStateSubscribers = new Set<() => void>() +let currentOrdersProgressState: Record = {} + +function setOrdersProgressState(nextState: Record, notify = true): void { + currentOrdersProgressState = nextState + if (!notify) { + return + } + progressStateSubscribers.forEach((listener) => listener()) +} + +const mockStore = { + get: jest.fn((atom: PrimitiveAtom) => { + const { ordersProgressBarStateAtom } = jest.requireActual('../state/atoms') + if (atom === ordersProgressBarStateAtom) { + return currentOrdersProgressState + } + return undefined + }), + set: jest.fn((atom: PrimitiveAtom, trackedOrderIds: string[]) => { + const { pruneOrdersProgressBarState } = jest.requireActual('../state/atoms') + if (atom === pruneOrdersProgressBarState) { + mockPruneOrders(trackedOrderIds) + setOrdersProgressState( + Object.fromEntries( + Object.entries(currentOrdersProgressState).filter(([orderId]) => trackedOrderIds.includes(orderId)), + ), + false, + ) + } + }), + sub: jest.fn((atom: PrimitiveAtom, listener: () => void) => { + const { ordersProgressBarStateAtom } = jest.requireActual('../state/atoms') + if (atom === ordersProgressBarStateAtom) { + progressStateSubscribers.add(listener) + return () => progressStateSubscribers.delete(listener) + } + return () => undefined + }), +} jest.mock('jotai', () => { const actual = jest.requireActual('jotai') - return { ...actual, - useSetAtom: jest.fn((atom: PrimitiveAtom) => { - const { pruneOrdersProgressBarState } = jest.requireActual('../state/atoms') - - if (atom === pruneOrdersProgressBarState) { - return mockPruneOrders - } - - return actual.useSetAtom(atom) - }), + useStore: jest.fn(() => mockStore), useAtomValue: jest.fn((atom: PrimitiveAtom) => { const { cancellationTrackedOrderIdsAtom } = jest.requireActual('../state/atoms') - if (atom === cancellationTrackedOrderIdsAtom) { return mockCancellationIds() } - return actual.useAtomValue(atom) }), } @@ -66,7 +101,12 @@ jest.mock('jotai', () => { const useWalletInfoMock = useWalletInfo as jest.MockedFunction const useOnlyPendingOrdersMock = useOnlyPendingOrders as jest.MockedFunction -const useOrderProgressBarPropsMock = useOrderProgressBarProps as jest.MockedFunction +const usePendingOrdersFillabilityMock = usePendingOrdersFillability as jest.MockedFunction< + typeof usePendingOrdersFillability +> +const useOrderProgressBarPropsMock = useOrderProgressBarPropsWithFillability as jest.MockedFunction< + typeof useOrderProgressBarPropsWithFillability +> const useSurplusQueueOrderIdsMock = useSurplusQueueOrderIds as jest.MockedFunction const useTradeConfirmStateMock = useTradeConfirmState as jest.MockedFunction @@ -76,63 +116,50 @@ const stubOrder = (overrides: Partial): Order => overrides as Order describe('OrderProgressStateUpdater', () => { beforeEach(() => { - useOrderProgressBarPropsMock.mockReturnValue({ - props: {} as never, - activityDerivedState: null, - }) + useOrderProgressBarPropsMock.mockReturnValue({ props: {} as never, activityDerivedState: null }) + usePendingOrdersFillabilityMock.mockReturnValue({}) useSurplusQueueOrderIdsMock.mockReturnValue([]) useTradeConfirmStateMock.mockReturnValue({ transactionHash: null } as never) mockCancellationIds.mockReturnValue([]) + setOrdersProgressState({}, false) }) afterEach(() => { jest.clearAllMocks() mockPruneOrders.mockReset() + progressStateSubscribers.clear() + jest.useRealTimers() }) it('subscribes to pending market orders even when the progress bar UI is not mounted', () => { - useWalletInfoMock.mockReturnValue({ - chainId: 1, - account: '0xabc', - } as unknown as WalletInfo) - + useWalletInfoMock.mockReturnValue({ chainId: 1, account: '0xabc' } as unknown as WalletInfo) useOnlyPendingOrdersMock.mockReturnValue([ stubOrder({ id: '1', class: OrderClass.MARKET }), stubOrder({ id: '2', class: OrderClass.LIMIT }), stubOrder({ id: '3', class: OrderClass.MARKET }), ]) - render() - + expect(usePendingOrdersFillabilityMock).toHaveBeenCalledTimes(1) + expect(usePendingOrdersFillabilityMock).toHaveBeenCalledWith(OrderClass.MARKET) expect(useOrderProgressBarPropsMock).toHaveBeenCalledTimes(2) - expect(useOrderProgressBarPropsMock).toHaveBeenNthCalledWith(1, 1, expect.objectContaining({ id: '1' })) - expect(useOrderProgressBarPropsMock).toHaveBeenNthCalledWith(2, 1, expect.objectContaining({ id: '3' })) + expect(useOrderProgressBarPropsMock).toHaveBeenNthCalledWith(1, 1, expect.objectContaining({ id: '1' }), undefined) + expect(useOrderProgressBarPropsMock).toHaveBeenNthCalledWith(2, 1, expect.objectContaining({ id: '3' }), undefined) expect(mockPruneOrders).toHaveBeenLastCalledWith(['1', '3']) }) it('does nothing when wallet information is missing', () => { - useWalletInfoMock.mockReturnValue({ - chainId: undefined, - account: undefined, - } as unknown as WalletInfo) + useWalletInfoMock.mockReturnValue({ chainId: undefined, account: undefined } as unknown as WalletInfo) useOnlyPendingOrdersMock.mockReturnValue([]) - render() - expect(useOrderProgressBarPropsMock).not.toHaveBeenCalled() expect(mockPruneOrders).toHaveBeenLastCalledWith([]) }) it('keeps state for orders queued for the surplus modal', () => { - useWalletInfoMock.mockReturnValue({ - chainId: undefined, - account: undefined, - } as unknown as WalletInfo) + useWalletInfoMock.mockReturnValue({ chainId: undefined, account: undefined } as unknown as WalletInfo) useOnlyPendingOrdersMock.mockReturnValue([]) useSurplusQueueOrderIdsMock.mockReturnValue(['queued-order', 'next-order']) - render() - expect(mockPruneOrders).toHaveBeenLastCalledWith(['queued-order', 'next-order']) }) @@ -143,9 +170,7 @@ describe('OrderProgressStateUpdater', () => { } as unknown as WalletInfo) useOnlyPendingOrdersMock.mockReturnValue([]) useTradeConfirmStateMock.mockReturnValue({ transactionHash: '0xorder' } as never) - render() - expect(mockPruneOrders).toHaveBeenLastCalledWith(['0xorder']) }) @@ -160,9 +185,7 @@ describe('OrderProgressStateUpdater', () => { ]) useSurplusQueueOrderIdsMock.mockReturnValue(['2', '3']) useTradeConfirmStateMock.mockReturnValue({ transactionHash: '2' } as never) - render() - expect(mockPruneOrders).toHaveBeenLastCalledWith(['1', '2', '3']) }) @@ -173,9 +196,47 @@ describe('OrderProgressStateUpdater', () => { } as unknown as WalletInfo) useOnlyPendingOrdersMock.mockReturnValue([]) mockCancellationIds.mockReturnValue(['abc']) - render() - expect(mockPruneOrders).toHaveBeenLastCalledWith(['abc']) }) + + it('keeps recently untracked order state long enough for the completion sequence to finish', () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-04-08T12:00:00Z')) + + useWalletInfoMock.mockReturnValue({ + chainId: 1, + account: '0xabc', + } as unknown as WalletInfo) + useOnlyPendingOrdersMock.mockReturnValue([stubOrder({ id: '1', class: OrderClass.MARKET })]) + setOrdersProgressState({ '1': { progressBarStepName: OrderProgressBarStepName.DELAYED } }, false) + const { rerender } = render() + expect(mockPruneOrders).toHaveBeenLastCalledWith(['1']) + useOnlyPendingOrdersMock.mockReturnValue([]) + rerender() + expect(mockPruneOrders).toHaveBeenLastCalledWith(['1']) + act(() => { + jest.advanceTimersByTime(10_000) + }) + expect(mockPruneOrders).toHaveBeenLastCalledWith([]) + }) + + it('does not rerender market order observers when pruning reacts to progress-state updates', () => { + useWalletInfoMock.mockReturnValue({ + chainId: 1, + account: '0xabc', + } as unknown as WalletInfo) + useOnlyPendingOrdersMock.mockReturnValue([stubOrder({ id: '1', class: OrderClass.MARKET })]) + setOrdersProgressState({ '1': { progressBarStepName: OrderProgressBarStepName.DELAYED } }, false) + render() + expect(useOrderProgressBarPropsMock).toHaveBeenCalledTimes(1) + useOrderProgressBarPropsMock.mockClear() + act(() => { + setOrdersProgressState({ + '1': { progressBarStepName: OrderProgressBarStepName.EXECUTING }, + }) + }) + expect(mockPruneOrders).toHaveBeenLastCalledWith(['1']) + expect(useOrderProgressBarPropsMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx index 1498667b7f1..07784bd88a7 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx @@ -1,5 +1,5 @@ -import { useAtomValue, useSetAtom } from 'jotai' -import { ReactNode, useEffect, useMemo } from 'react' +import { useAtomValue, useStore } from 'jotai' +import { MutableRefObject, ReactNode, useEffect, useMemo, useRef } from 'react' import { OrderClass, SupportedChainId } from '@cowprotocol/cow-sdk' import { useWalletInfo } from '@cowprotocol/wallet' @@ -9,66 +9,248 @@ import { useSurplusQueueOrderIds } from 'entities/surplusModal' import { Order } from 'legacy/state/orders/actions' import { useOnlyPendingOrders } from 'legacy/state/orders/hooks' +import { type OrderFillability, usePendingOrdersFillability } from 'modules/ordersTable' import { useTradeConfirmState } from 'modules/trade' -import { useOrderProgressBarProps } from '../hooks/useOrderProgressBarProps' -import { cancellationTrackedOrderIdsAtom, pruneOrdersProgressBarState } from '../state/atoms' +import { useOrderProgressBarPropsWithFillability } from '../hooks/useOrderProgressBarProps' +import { + cancellationTrackedOrderIdsAtom, + ordersProgressBarStateAtom, + pruneOrdersProgressBarState, +} from '../state/atoms' -function OrderProgressStateObserver({ chainId, order }: { chainId: SupportedChainId; order: Order }): null { - useOrderProgressBarProps(chainId, order) +const UNTRACKED_ORDER_STATE_GRACE_PERIOD_MS = 10_000 + +type OrderProgressStatePrunerProps = { + account: string | undefined + chainId: number | undefined + marketOrders: Order[] + surplusQueueOrderIds: string[] + transactionHash: string | null +} + +type TrackedOrderIdsParams = OrderProgressStatePrunerProps & { + cancellationTrackedOrderIds: string[] +} + +function getTrackedOrderIdsWithGracePeriod( + trackedOrderIds: Set, + ordersProgressState: Record, + recentlyUntrackedOrderIdsRef: MutableRefObject>, + now = Date.now(), +): string[] { + const recentlyUntrackedOrderIds = recentlyUntrackedOrderIdsRef.current + + Object.keys(ordersProgressState).forEach((orderId) => { + if (trackedOrderIds.has(orderId)) { + delete recentlyUntrackedOrderIds[orderId] + return + } + + recentlyUntrackedOrderIds[orderId] ??= now + UNTRACKED_ORDER_STATE_GRACE_PERIOD_MS + }) + + Object.entries(recentlyUntrackedOrderIds).forEach(([orderId, expiresAt]) => { + if (expiresAt > now && ordersProgressState[orderId]) { + trackedOrderIds.add(orderId) + return + } + + delete recentlyUntrackedOrderIds[orderId] + }) + + return Array.from(trackedOrderIds) +} + +function getNextGracePeriodDelayMs( + recentlyUntrackedOrderIdsRef: MutableRefObject>, + now = Date.now(), +): number | undefined { + const futureExpiryTimes = Object.values(recentlyUntrackedOrderIdsRef.current).filter((expiresAt) => expiresAt > now) + + if (!futureExpiryTimes.length) { + return undefined + } + + return Math.max(Math.min(...futureExpiryTimes) - now, 0) +} + +function getTrackedOrderIds({ + account, + cancellationTrackedOrderIds, + chainId, + marketOrders, + surplusQueueOrderIds, + transactionHash, +}: TrackedOrderIdsParams): Set { + const trackedIdsSet = new Set() + + // Surplus and confirmation modals can stay mounted while the wallet reconnects or is disconnected, + // so we still prune based on their IDs even when `account`/`chainId` are temporarily unavailable. + if (account && chainId) { + marketOrders.forEach((order) => trackedIdsSet.add(order.id)) + } + + surplusQueueOrderIds.forEach((orderId) => trackedIdsSet.add(orderId)) + + if (transactionHash) { + trackedIdsSet.add(transactionHash) + } + + cancellationTrackedOrderIds.forEach((orderId) => trackedIdsSet.add(orderId)) + + return trackedIdsSet +} + +function OrderProgressStateObserver({ + chainId, + currentOrderFillability, + order, +}: { + chainId: SupportedChainId + currentOrderFillability: OrderFillability | undefined + order: Order +}): null { + useOrderProgressBarPropsWithFillability(chainId, order, currentOrderFillability) return null } -export function OrderProgressStateUpdater(): ReactNode { - const { chainId, account } = useWalletInfo() - const pruneProgressState = useSetAtom(pruneOrdersProgressBarState) - const { transactionHash } = useTradeConfirmState() - const surplusQueueOrderIds = useSurplusQueueOrderIds() +function OrderProgressStatePruner({ + account, + chainId, + marketOrders, + surplusQueueOrderIds, + transactionHash, +}: OrderProgressStatePrunerProps): null { + const store = useStore() + // Keep the progress-state subscriptions inside the pruner so countdown/step ticks do not + // rerender every order observer. The parent only owns the observer list. const cancellationTrackedOrderIds = useAtomValue(cancellationTrackedOrderIdsAtom) - - const pendingOrders = useOnlyPendingOrders(chainId as SupportedChainId, account) - const marketOrders = useMemo( - () => pendingOrders.filter((order) => order.class === OrderClass.MARKET), - [pendingOrders], - ) + const recentlyUntrackedOrderIdsRef = useRef>({}) + const pruneTimerRef = useRef | undefined>(undefined) + const isPruningRef = useRef(false) useEffect(() => { - const trackedIdsSet = new Set() + function clearPruneTimer(): void { + if (pruneTimerRef.current == null) { + return + } - // Surplus and confirmation modals can stay mounted while the wallet reconnects or is disconnected, - // so we still prune based on their IDs even when `account`/`chainId` are temporarily unavailable. - if (account && chainId) { - marketOrders.forEach((order) => trackedIdsSet.add(order.id)) + clearTimeout(pruneTimerRef.current) + pruneTimerRef.current = undefined } - surplusQueueOrderIds.forEach((orderId) => trackedIdsSet.add(orderId)) + function runPruneCycle(): void { + if (isPruningRef.current) { + return + } + + isPruningRef.current = true + + try { + const trackedOrderIds = getTrackedOrderIdsWithGracePeriod( + getTrackedOrderIds({ + account, + cancellationTrackedOrderIds, + chainId, + marketOrders, + surplusQueueOrderIds, + transactionHash, + }), + store.get(ordersProgressBarStateAtom), + recentlyUntrackedOrderIdsRef, + ) + + store.set(pruneOrdersProgressBarState, trackedOrderIds) + + clearPruneTimer() + + const nextGracePeriodDelayMs = getNextGracePeriodDelayMs(recentlyUntrackedOrderIdsRef) + + if (nextGracePeriodDelayMs == null) { + return + } - if (transactionHash) { - trackedIdsSet.add(transactionHash) + // Re-run when the next retained stale entry expires, even if nothing else rerenders. + pruneTimerRef.current = setTimeout(runPruneCycle, nextGracePeriodDelayMs) + } finally { + isPruningRef.current = false + } } - cancellationTrackedOrderIds.forEach((orderId) => trackedIdsSet.add(orderId)) + const unsubscribe = store.sub(ordersProgressBarStateAtom, runPruneCycle) + runPruneCycle() - pruneProgressState(Array.from(trackedIdsSet)) - }, [ - account, - cancellationTrackedOrderIds, - chainId, - marketOrders, - pruneProgressState, - surplusQueueOrderIds, - transactionHash, - ]) + return () => { + unsubscribe() + clearPruneTimer() + } + }, [account, cancellationTrackedOrderIds, chainId, marketOrders, store, surplusQueueOrderIds, transactionHash]) - if (!chainId || !account) { - return null - } + return null +} +function OrderProgressStateObservers({ + chainId, + marketOrders, + pendingOrdersFillability, +}: { + chainId: SupportedChainId + marketOrders: Order[] + pendingOrdersFillability: Record +}): ReactNode { return ( <> {marketOrders.map((order) => ( - + ))} ) } + +export function OrderProgressStateUpdater(): ReactNode { + const { chainId, account } = useWalletInfo() + const { transactionHash } = useTradeConfirmState() + const surplusQueueOrderIds = useSurplusQueueOrderIds() + + const pendingOrders = useOnlyPendingOrders(chainId as SupportedChainId, account) + const marketOrders = useMemo( + () => pendingOrders.filter((order) => order.class === OrderClass.MARKET), + [pendingOrders], + ) + const pendingOrdersFillability = usePendingOrdersFillability(OrderClass.MARKET) + + if (!chainId || !account) { + return ( + + ) + } + + return ( + <> + + + + ) +} diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts index 3fb7a65a04c..fb73f35e2c3 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts @@ -1,37 +1,49 @@ import { type OrderFillability } from 'modules/ordersTable' +import { OrderProgressBarStepName } from '../constants' + type OrderLike = { id: string isUnfillable?: boolean } -export function computeUnfillableOrderIds( - marketOrders: OrderLike[], - pendingOrdersFillability: Record, -): string[] { - // `isUnfillable` is toggled on the client (see UnfillableOrdersUpdater and OrdersTableList) after comparing quotes and allowances. - const priceDerived = marketOrders.filter((order) => order.isUnfillable).map((order) => order.id) +const COMPLETION_STEPS = new Set([ + OrderProgressBarStepName.FINISHED, + OrderProgressBarStepName.BRIDGING_IN_PROGRESS, + OrderProgressBarStepName.BRIDGING_FAILED, + OrderProgressBarStepName.REFUND_COMPLETED, + OrderProgressBarStepName.BRIDGING_FINISHED, +]) - const fillabilityDerived = Object.entries(pendingOrdersFillability).reduce( - (acc, [orderId, fillability]) => { - if (!fillability) { - return acc - } +export const EXECUTING_STEP_MIN_DISPLAY_TIME_MS = 1000 - const lacksBalance = fillability.hasEnoughBalance === false - const lacksAllowance = fillability.hasEnoughAllowance === false && !fillability.hasPermit +export function hasBlockingFillabilityIssue(fillability: OrderFillability | undefined): boolean { + if (!fillability) { + return false + } - if (lacksBalance || lacksAllowance) { - acc.push(orderId) - } + const lacksBalance = fillability.hasEnoughBalance === false + const lacksAllowance = fillability.hasEnoughAllowance === false && !fillability.hasPermit - return acc - }, - [], - ) + return lacksBalance || lacksAllowance +} - // An order can be flagged by both mechanisms; the Set keeps the list unique. - return Array.from(new Set([...priceDerived, ...fillabilityDerived])) +export function shouldShowUnfillableProgressStep( + isUnfillable: boolean, + fillability: OrderFillability | undefined, +): boolean { + return isUnfillable && !hasBlockingFillabilityIssue(fillability) +} + +export function computeUnfillableOrderIds( + marketOrders: OrderLike[], + pendingOrdersFillability: Record, +): string[] { + // `Price change` should only reflect true price-derived unfillable states. + // Temporary balance / allowance lag must keep the normal batching/searching flow. + return marketOrders + .filter((order) => shouldShowUnfillableProgressStep(!!order.isUnfillable, pendingOrdersFillability[order.id])) + .map((order) => order.id) } export function getNewlyFillableOrderIds(previous: Iterable, current: Iterable): string[] { @@ -46,3 +58,49 @@ export function getNewlyFillableOrderIds(previous: Iterable, current: It return newlyFillable } + +export function isCompletionStep(step: OrderProgressBarStepName | undefined): step is OrderProgressBarStepName { + return !!step && COMPLETION_STEPS.has(step) +} + +export function hasProgressBarLeftInitialStep(currentStep: OrderProgressBarStepName | undefined): boolean { + return !!currentStep && currentStep !== OrderProgressBarStepName.INITIAL +} + +export function shouldStageExecutingStep( + currentStep: OrderProgressBarStepName | undefined, + nextStep: OrderProgressBarStepName | undefined, + hasShownExecutingInCurrentAttempt?: boolean, +): boolean { + // Never synthesize `INITIAL -> EXECUTING`; fast fills should still pass through the + // competition/searching step. We only replay executing when the bar has already advanced + // within the current backend attempt and step 3 has not yet been shown for that attempt. + if ( + !currentStep || + currentStep === OrderProgressBarStepName.INITIAL || + currentStep === OrderProgressBarStepName.EXECUTING || + !isCompletionStep(nextStep) || + isCompletionStep(currentStep) + ) { + return false + } + + return !hasShownExecutingInCurrentAttempt +} + +export function getCompletionDelayMs( + currentStep: OrderProgressBarStepName | undefined, + nextStep: OrderProgressBarStepName | undefined, + lastTimeChangedSteps: number | undefined, + now = Date.now(), +): number { + if ( + currentStep !== OrderProgressBarStepName.EXECUTING || + !isCompletionStep(nextStep) || + lastTimeChangedSteps == null + ) { + return 0 + } + + return Math.max(EXECUTING_STEP_MIN_DISPLAY_TIME_MS - (now - lastTimeChangedSteps), 0) +} diff --git a/apps/cowswap-frontend/src/modules/trade/utils/addPendingOrderStep.test.ts b/apps/cowswap-frontend/src/modules/trade/utils/addPendingOrderStep.test.ts new file mode 100644 index 00000000000..68815c71698 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/utils/addPendingOrderStep.test.ts @@ -0,0 +1,40 @@ +import { USDC_MAINNET as USDC, USDT } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { CurrencyAmount } from '@cowprotocol/currency' + +import { addPendingOrder } from 'legacy/state/orders/actions' +import { generateOrder } from 'legacy/state/orders/mocks' + +import { addPendingOrderStep } from './addPendingOrderStep' + +const OWNER = '0x0000000000000000000000000000000000000001' + +describe('addPendingOrderStep', () => { + it('serializes bridge output amounts before persisting pending orders', () => { + const order = generateOrder({ owner: OWNER, sellToken: USDT, buyToken: USDC }) + const dispatch = jest.fn() + + order.bridgeOutputAmount = CurrencyAmount.fromRawAmount(USDC, '1234500') + + addPendingOrderStep( + { + id: order.id, + chainId: SupportedChainId.MAINNET, + order, + isSafeWallet: false, + }, + dispatch as never, + ) + + expect(dispatch).toHaveBeenCalledWith( + addPendingOrder({ + id: order.id, + chainId: SupportedChainId.MAINNET, + isSafeWallet: false, + order: expect.objectContaining({ + bridgeOutputAmount: { amount: '1234500' }, + }), + }), + ) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/trade/utils/addPendingOrderStep.ts b/apps/cowswap-frontend/src/modules/trade/utils/addPendingOrderStep.ts index 2f9b90f3938..dee769d313e 100644 --- a/apps/cowswap-frontend/src/modules/trade/utils/addPendingOrderStep.ts +++ b/apps/cowswap-frontend/src/modules/trade/utils/addPendingOrderStep.ts @@ -3,13 +3,14 @@ import { addPendingOrder, AddPendingOrderParams, SerializedOrder } from 'legacy/ import { AddUnserialisedPendingOrderParams } from 'legacy/state/orders/hooks' import { serializeToken } from 'legacy/state/user/hooks' -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function addPendingOrderStep(addOrderParams: AddUnserialisedPendingOrderParams, dispatch: AppDispatch) { +export function addPendingOrderStep(addOrderParams: AddUnserialisedPendingOrderParams, dispatch: AppDispatch): void { const serialisedSellToken = serializeToken(addOrderParams.order.inputToken) const serialisedBuyToken = serializeToken(addOrderParams.order.outputToken) const order: SerializedOrder = { ...addOrderParams.order, + bridgeOutputAmount: addOrderParams.order.bridgeOutputAmount + ? { amount: addOrderParams.order.bridgeOutputAmount.quotient.toString() } + : undefined, inputToken: serialisedSellToken, outputToken: serialisedBuyToken, } diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts new file mode 100644 index 00000000000..dca7f633dd2 --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts @@ -0,0 +1,232 @@ +import { SwapAndBridgeStatus } from 'modules/bridge' +import { OrderProgressBarStepName } from 'modules/orderProgressBar' + +import { + getDemoBridgeContext, + PLAYGROUND_ACTIVE_COUNTDOWN, + type ScenarioFrame, +} from './OrderProgressBarPlayground.demo.constants' + +const DEFAULT_FRAME_HOLD_MS = 1200 +const SEARCHING_FRAME_HOLD_MS = 1500 +const RETRY_FRAME_HOLD_MS = 1800 + +export type Scenario = { id: string; label: string; frames: [ScenarioFrame, ...ScenarioFrame[]] } +export type StaticPreview = { id: string; label: string; frame: ScenarioFrame } + +function getScenarioFrame( + stepName: OrderProgressBarStepName, + backendStatus: string, + holdMs: number, + extra: Partial> = {}, +): ScenarioFrame { + return { backendStatus, holdMs, stepName, ...extra } +} + +function getStaticPreview(id: string, label: string, frame: ScenarioFrame): StaticPreview { + return { id, label, frame } +} + +const getInitialFrame = (backendStatus = 'scheduled'): ScenarioFrame => + getScenarioFrame(OrderProgressBarStepName.INITIAL, backendStatus, DEFAULT_FRAME_HOLD_MS) + +function getSearchingFrame( + backendStatus = 'active', + holdMs = SEARCHING_FRAME_HOLD_MS, + extra: Partial> = {}, +): ScenarioFrame { + return getScenarioFrame(OrderProgressBarStepName.SOLVING, backendStatus, holdMs, { + countdown: PLAYGROUND_ACTIVE_COUNTDOWN, + ...extra, + }) +} + +const getDelayedFrame = (backendStatus = 'open', holdMs = SEARCHING_FRAME_HOLD_MS): ScenarioFrame => + getScenarioFrame(OrderProgressBarStepName.DELAYED, backendStatus, holdMs) + +const getExecutingFrame = (backendStatus = 'executing', holdMs = DEFAULT_FRAME_HOLD_MS): ScenarioFrame => + getScenarioFrame(OrderProgressBarStepName.EXECUTING, backendStatus, holdMs) + +const getFinishedFrame = (backendStatus = 'traded'): ScenarioFrame => + getScenarioFrame(OrderProgressBarStepName.FINISHED, backendStatus, 0) + +const getBridgeFrame = ( + stepName: OrderProgressBarStepName, + backendStatus: string, + holdMs: number, + swapAndBridgeContext: NonNullable, +): ScenarioFrame => getScenarioFrame(stepName, backendStatus, holdMs, { isBridgingTrade: true, swapAndBridgeContext }) + +const BRIDGE_PENDING_CONTEXT = getDemoBridgeContext(SwapAndBridgeStatus.PENDING) +const BRIDGE_DONE_CONTEXT = getDemoBridgeContext(SwapAndBridgeStatus.DONE) + +export const PLAYGROUND_SCENARIOS: Scenario[] = [ + { + id: 'happyPath', + label: 'Happy path: scheduled -> active -> executing -> traded', + frames: [getInitialFrame(), getSearchingFrame(), getExecutingFrame(), getFinishedFrame()], + }, + { + id: 'skipExecutingPoll', + label: 'Missed executing poll: scheduled -> active -> open -> traded', + frames: [ + getInitialFrame(), + getSearchingFrame(), + getDelayedFrame(), + getExecutingFrame('traded'), + getFinishedFrame(), + ], + }, + { + id: 'submissionRetry', + label: 'Submission retry: scheduled -> active -> executing -> open -> traded', + frames: [ + getInitialFrame(), + getSearchingFrame(), + getExecutingFrame(), + getScenarioFrame(OrderProgressBarStepName.SUBMISSION_FAILED, 'open', RETRY_FRAME_HOLD_MS), + getScenarioFrame(OrderProgressBarStepName.SOLVING, 'open', SEARCHING_FRAME_HOLD_MS), + getExecutingFrame('traded'), + getFinishedFrame(), + ], + }, + { + id: 'submissionRetryWithNotFound', + label: 'Retry with NotFound: active -> executing -> open -> notFound -> traded', + frames: [ + getInitialFrame(), + getSearchingFrame(), + getExecutingFrame(), + getScenarioFrame(OrderProgressBarStepName.SUBMISSION_FAILED, 'open', RETRY_FRAME_HOLD_MS), + getScenarioFrame(OrderProgressBarStepName.SOLVING, 'notFound', DEFAULT_FRAME_HOLD_MS), + getExecutingFrame('traded'), + getFinishedFrame(), + ], + }, + { + id: 'issue6642StartsUnfillable', + label: 'Issue #6642 fixed: approval lag stays on batching/searching', + frames: [ + getInitialFrame('scheduled + approval lag'), + getSearchingFrame(), + getDelayedFrame('open + allowance lag'), + getExecutingFrame('traded'), + getFinishedFrame(), + ], + }, + { + id: 'issue6881UnfillableToCancelling', + label: 'Issue #6881: price change -> cancelling', + frames: [ + getSearchingFrame(), + getScenarioFrame(OrderProgressBarStepName.UNFILLABLE, 'open + price out of market', DEFAULT_FRAME_HOLD_MS), + getScenarioFrame(OrderProgressBarStepName.CANCELLING, 'local cancelling', SEARCHING_FRAME_HOLD_MS), + getScenarioFrame(OrderProgressBarStepName.CANCELLED, 'cancelled', 0), + ], + }, + { + id: 'fastFillFromInitial', + label: 'Fast fill from initial: scheduled -> traded', + frames: [getInitialFrame(), getFinishedFrame()], + }, + { + id: 'reloadMissedFulfilledEvent', + label: 'Reload path: scheduled -> active -> traded', + frames: [getInitialFrame(), getSearchingFrame(), getExecutingFrame('traded'), getFinishedFrame()], + }, + { + id: 'bridgeContextReload', + label: 'Bridge context reload after fill', + frames: [ + getInitialFrame(), + getSearchingFrame(), + getExecutingFrame('traded'), + getBridgeFrame( + OrderProgressBarStepName.BRIDGING_IN_PROGRESS, + 'traded + bridge pending', + SEARCHING_FRAME_HOLD_MS, + BRIDGE_PENDING_CONTEXT, + ), + getBridgeFrame( + OrderProgressBarStepName.BRIDGING_IN_PROGRESS, + 'traded + bridge context missing', + SEARCHING_FRAME_HOLD_MS, + BRIDGE_PENDING_CONTEXT, + ), + getBridgeFrame(OrderProgressBarStepName.BRIDGING_FINISHED, 'traded + bridge done', 0, BRIDGE_DONE_CONTEXT), + ], + }, + { + id: 'cancellationRace', + label: 'Cancellation race: cancelling -> traded', + frames: [ + getSearchingFrame(), + getScenarioFrame(OrderProgressBarStepName.CANCELLING, 'local cancelling', SEARCHING_FRAME_HOLD_MS), + getScenarioFrame(OrderProgressBarStepName.CANCELLATION_FAILED, 'traded', 0), + ], + }, +] + +export const STATIC_PLAYGROUND_PREVIEWS: StaticPreview[] = [ + getStaticPreview('initial', 'Static: step 1 batching orders', getInitialFrame()), + getStaticPreview('solving', 'Static: step 2 competition started', getSearchingFrame('active', 0)), + getStaticPreview('delayed', 'Static: step 2 still searching', getDelayedFrame('open', 0)), + getStaticPreview('executing', 'Static: step 3 executing', getExecutingFrame('executing', 0)), + getStaticPreview( + 'submissionFailed', + 'Static: step 2 submission failed', + getScenarioFrame(OrderProgressBarStepName.SUBMISSION_FAILED, 'open', 0), + ), + getStaticPreview( + 'unfillable', + 'Static: price change', + getScenarioFrame(OrderProgressBarStepName.UNFILLABLE, 'local price-derived unfillable', 0), + ), + getStaticPreview( + 'cancelling', + 'Static: cancelling', + getScenarioFrame(OrderProgressBarStepName.CANCELLING, 'local cancelling', 0), + ), + getStaticPreview( + 'cancelled', + 'Static: cancelled', + getScenarioFrame(OrderProgressBarStepName.CANCELLED, 'cancelled', 0), + ), + getStaticPreview('expired', 'Static: expired', getScenarioFrame(OrderProgressBarStepName.EXPIRED, 'expired', 0)), + getStaticPreview( + 'cancellationFailed', + 'Static: cancellation failed', + getScenarioFrame(OrderProgressBarStepName.CANCELLATION_FAILED, 'traded', 0), + ), + getStaticPreview('finished', 'Static: transaction completed', getFinishedFrame()), + getStaticPreview( + 'bridgingInProgress', + 'Static: bridging in progress', + getBridgeFrame(OrderProgressBarStepName.BRIDGING_IN_PROGRESS, 'traded + bridge pending', 0, BRIDGE_PENDING_CONTEXT), + ), + getStaticPreview( + 'bridgingFailed', + 'Static: bridging failed', + getBridgeFrame( + OrderProgressBarStepName.BRIDGING_FAILED, + 'traded + bridge failed', + 0, + getDemoBridgeContext(SwapAndBridgeStatus.FAILED), + ), + ), + getStaticPreview( + 'refundCompleted', + 'Static: refund completed', + getBridgeFrame( + OrderProgressBarStepName.REFUND_COMPLETED, + 'traded + refund complete', + 0, + getDemoBridgeContext(SwapAndBridgeStatus.REFUND_COMPLETE), + ), + ), + getStaticPreview( + 'bridgingFinished', + 'Static: bridging finished', + getBridgeFrame(OrderProgressBarStepName.BRIDGING_FINISHED, 'traded + bridge done', 0, BRIDGE_DONE_CONTEXT), + ), +] diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.demo.constants.ts b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.demo.constants.ts new file mode 100644 index 00000000000..d9f4639ba3f --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.demo.constants.ts @@ -0,0 +1,114 @@ +import { COW_TOKEN_TO_CHAIN, TokenWithLogo, USDC } from '@cowprotocol/common-const' +import { OrderClass, OrderKind, SigningScheme, SupportedChainId } from '@cowprotocol/cow-sdk' +import { CurrencyAmount } from '@cowprotocol/currency' + +import { Order, OrderStatus } from 'legacy/state/orders/actions' + +import type { SwapAndBridgeContext } from 'modules/bridge' +import { OrderProgressBarProps, OrderProgressBarStepName } from 'modules/orderProgressBar' + +const CHAIN_ID = SupportedChainId.MAINNET +const INPUT_TOKEN = USDC[CHAIN_ID] +const OUTPUT_TOKEN = COW_TOKEN_TO_CHAIN[CHAIN_ID] ?? INPUT_TOKEN +const BRIDGE_TARGET_TOKEN = USDC[SupportedChainId.BASE] ?? INPUT_TOKEN +const DEMO_RECIPIENT = '0x1111111111111111111111111111111111111111' +const DEMO_OWNER = DEMO_RECIPIENT.replace('0x', '') +const EXECUTED_SELL_AMOUNT = '500000' +const EXECUTED_BUY_AMOUNT = '3350000000000000000' +const DEMO_SOLVERS = [ + { solver: 'baseline', displayName: 'Baseline' }, + { solver: 'barn', displayName: 'Barn' }, +] + +const DEMO_RECEIVED_AMOUNT = CurrencyAmount.fromRawAmount( + OUTPUT_TOKEN, + EXECUTED_BUY_AMOUNT, +) as CurrencyAmount + +const DEMO_ORDER_API_ADDITIONAL_INFO = { + executedSellAmount: EXECUTED_SELL_AMOUNT, + executedBuyAmount: EXECUTED_BUY_AMOUNT, +} as Order['apiAdditionalInfo'] + +const DEMO_ORDER: Order = { + id: '0xdebug-order', + owner: DEMO_OWNER, + status: OrderStatus.PENDING, + creationTime: new Date().toISOString(), + kind: OrderKind.SELL, + class: OrderClass.MARKET, + inputToken: INPUT_TOKEN, + outputToken: OUTPUT_TOKEN, + sellToken: INPUT_TOKEN.address.replace('0x', ''), + buyToken: OUTPUT_TOKEN.address.replace('0x', ''), + sellAmount: EXECUTED_SELL_AMOUNT, + sellAmountBeforeFee: EXECUTED_SELL_AMOUNT, + buyAmount: EXECUTED_BUY_AMOUNT, + validTo: Math.floor(Date.now() / 1000) + 3600, + appData: 'debug-playground', + feeAmount: '0', + partiallyFillable: false, + signature: '1'.repeat(130), + signingScheme: SigningScheme.EIP712, + receiver: DEMO_OWNER, + apiAdditionalInfo: DEMO_ORDER_API_ADDITIONAL_INFO, +} + +const DEMO_BRIDGE_PROVIDER = { + name: 'Across', + logoUrl: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=', +} as SwapAndBridgeContext['bridgeProvider'] + +export const PLAYGROUND_ACTIVE_COUNTDOWN = 15 + +export type ScenarioFrame = { + backendStatus: string + holdMs: number + stepName: OrderProgressBarStepName + countdown?: number | null + isBridgingTrade?: boolean + swapAndBridgeContext?: SwapAndBridgeContext +} + +export function getDemoBridgeContext(bridgingStatus: SwapAndBridgeContext['bridgingStatus']): SwapAndBridgeContext { + return { + bridgeProvider: DEMO_BRIDGE_PROVIDER, + bridgingStatus, + overview: { + sourceChainName: 'Ethereum', + sourceAmounts: { + sellAmount: CurrencyAmount.fromRawAmount(INPUT_TOKEN, '500000'), + buyAmount: DEMO_RECEIVED_AMOUNT, + }, + targetAmounts: { + sellAmount: DEMO_RECEIVED_AMOUNT, + buyAmount: CurrencyAmount.fromRawAmount(BRIDGE_TARGET_TOKEN, '3330000'), + }, + targetChainName: 'Base', + targetCurrency: BRIDGE_TARGET_TOKEN, + targetRecipient: DEMO_RECIPIENT, + }, + swapResultContext: { + intermediateToken: OUTPUT_TOKEN, + receivedAmount: DEMO_RECEIVED_AMOUNT, + receivedAmountUsd: null, + surplusAmount: CurrencyAmount.fromRawAmount(INPUT_TOKEN, '1000'), + surplusAmountUsd: null, + }, + } +} + +export function getProgressBarProps(frame: ScenarioFrame): OrderProgressBarProps { + return { + chainId: CHAIN_ID, + countdown: frame.countdown, + isBridgingTrade: !!frame.isBridgingTrade, + isProgressBarSetup: true, + order: DEMO_ORDER, + showCancellationModal: null, + solverCompetition: frame.stepName === OrderProgressBarStepName.FINISHED ? DEMO_SOLVERS : undefined, + stepName: frame.stepName, + swapAndBridgeContext: frame.swapAndBridgeContext, + totalSolvers: frame.stepName === OrderProgressBarStepName.FINISHED ? 49 : undefined, + } +} diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx new file mode 100644 index 00000000000..53224438835 --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx @@ -0,0 +1,215 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' + +import { OrderProgressBarPlaygroundPage } from './OrderProgressBarPlayground.page' + +jest.mock('modules/orderProgressBar', () => { + const actual = jest.requireActual('modules/orderProgressBar') + + return { + ...actual, + OrderProgressBar: ({ + countdown, + disableAnalytics, + stepName, + }: { + countdown?: number | null + disableAnalytics?: boolean + stepName?: string + }) => ( +
+ {stepName}:{countdown ?? 'none'} +
+ ), + } +}) + +describe('OrderProgressBarPlaygroundPage', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers() + }) + jest.useRealTimers() + }) + + it('replays the submission retry scenario from the dropdown', () => { + render() + + expect(screen.getByTestId('progress-bar-state').getAttribute('data-analytics')).toBe('off') + + act(() => { + fireEvent.change(screen.getByLabelText('Scenario'), { target: { value: 'submissionRetry' } }) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('initial:none') + + act(() => { + jest.advanceTimersByTime(1200) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('initial:none') + + act(() => { + jest.advanceTimersByTime(3800) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('solving:15') + + act(() => { + jest.advanceTimersByTime(5000) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('executing:none') + + act(() => { + jest.advanceTimersByTime(1200) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('submissionFailed:none') + + act(() => { + jest.advanceTimersByTime(5000) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('solving:none') + + act(() => { + jest.advanceTimersByTime(1500) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('executing:none') + + act(() => { + jest.advanceTimersByTime(1200) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('finished:none') + }) + + it('counts down during the solving frame instead of staying pinned at 15', () => { + render() + + act(() => { + jest.advanceTimersByTime(5000) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('solving:15') + + act(() => { + jest.advanceTimersByTime(2000) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('solving:13') + }) + + it('resets safely when switching from a longer scenario to a shorter one', () => { + render() + + act(() => { + fireEvent.change(screen.getByLabelText('Scenario'), { target: { value: 'submissionRetryWithNotFound' } }) + jest.advanceTimersByTime(5700) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('solving:15') + + act(() => { + fireEvent.change(screen.getByLabelText('Scenario'), { target: { value: 'fastFillFromInitial' } }) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('initial:none') + }) + + it('restarts the current scenario from the first frame when replaying', () => { + render() + + act(() => { + fireEvent.change(screen.getByLabelText('Scenario'), { target: { value: 'reloadMissedFulfilledEvent' } }) + jest.advanceTimersByTime(6500) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('executing:none') + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Replay scenario' })) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('initial:none') + }) + + it('replays the issue 6642 unfillable-start scenario', () => { + render() + + act(() => { + fireEvent.change(screen.getByLabelText('Scenario'), { target: { value: 'issue6642StartsUnfillable' } }) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('initial:none') + + act(() => { + jest.advanceTimersByTime(5000) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('solving:15') + + act(() => { + jest.advanceTimersByTime(5000) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('delayed:none') + + act(() => { + jest.advanceTimersByTime(1500) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('executing:none') + + act(() => { + jest.advanceTimersByTime(1200) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('finished:none') + }) + + it('replays the issue 6881 unfillable-to-cancelling scenario', () => { + render() + + act(() => { + fireEvent.change(screen.getByLabelText('Scenario'), { target: { value: 'issue6881UnfillableToCancelling' } }) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('solving:15') + + act(() => { + jest.advanceTimersByTime(5000) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('unfillable:none') + + act(() => { + jest.advanceTimersByTime(1200) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('cancelling:none') + + act(() => { + jest.advanceTimersByTime(1500) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('cancelled:none') + }) + + it('pins a single static step without progressing when static mode is enabled', () => { + render() + + act(() => { + fireEvent.change(screen.getByLabelText('Mode'), { target: { value: 'static' } }) + fireEvent.change(screen.getByLabelText('Static step'), { target: { value: 'executing' } }) + jest.advanceTimersByTime(20000) + }) + + expect(screen.getByTestId('progress-bar-state').textContent).toBe('executing:none') + }) +}) diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx new file mode 100644 index 00000000000..7e2d905a5f9 --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx @@ -0,0 +1,224 @@ +import { ChangeEvent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react' + +import { OrderProgressBar } from 'modules/orderProgressBar' + +import { PLAYGROUND_SCENARIOS, STATIC_PLAYGROUND_PREVIEWS } from './OrderProgressBarPlayground.constants' +import { getProgressBarProps } from './OrderProgressBarPlayground.demo.constants' +import * as styledEl from './OrderProgressBarPlayground.styled' +import { getScenarioFrameCountdown, getScenarioFrameDelayMs } from './OrderProgressBarPlayground.utils' +import { OrderProgressBarPlaygroundDetails } from './OrderProgressBarPlaygroundDetails' + +type PlaygroundMode = 'scenario' | 'static' + +type PlaygroundControlsProps = { + isStaticMode: boolean + mode: PlaygroundMode + onModeChange: (event: ChangeEvent) => void + onReplay: () => void + onScenarioChange: (event: ChangeEvent) => void + onStaticPreviewChange: (event: ChangeEvent) => void + scenarioId: string + staticPreviewId: string +} + +function PlaygroundControls({ + isStaticMode, + mode, + onModeChange, + onReplay, + onScenarioChange, + onStaticPreviewChange, + scenarioId, + staticPreviewId, +}: PlaygroundControlsProps): ReactNode { + return ( + + + Mode + + + + + + + + Scenario + + {PLAYGROUND_SCENARIOS.map((item) => ( + + ))} + + + + {isStaticMode ? ( + + Static step + + {STATIC_PLAYGROUND_PREVIEWS.map((item) => ( + + ))} + + + ) : ( + + Replay scenario + + )} + + ) +} + +function useScenarioPlayback( + frameDelays: number[], + isStaticMode: boolean, +): { frameElapsedMs: number; frameIndex: number; restartScenario: () => void } { + const [playbackKey, setPlaybackKey] = useState(0) + const [playbackElapsedMs, setPlaybackElapsedMs] = useState(0) + const [frameIndex, setFrameIndex] = useState(0) + + const restartScenario = useCallback((): void => { + setFrameIndex(0) + setPlaybackElapsedMs(0) + setPlaybackKey((value) => value + 1) + }, []) + + useEffect(() => { + if (isStaticMode) { + return + } + + setFrameIndex(0) + setPlaybackElapsedMs(0) + + const startedAt = Date.now() + let elapsedMs = 0 + const timers = frameDelays.map((delayMs, index) => { + elapsedMs += delayMs + + return window.setTimeout(() => { + setPlaybackElapsedMs(Date.now() - startedAt) + setFrameIndex(index + 1) + }, elapsedMs) + }) + const playbackTimer = window.setInterval(() => setPlaybackElapsedMs(Date.now() - startedAt), 1000) + + return () => { + timers.forEach((timer) => window.clearTimeout(timer)) + window.clearInterval(playbackTimer) + } + }, [frameDelays, isStaticMode, playbackKey]) + + return { frameElapsedMs: playbackElapsedMs, frameIndex, restartScenario } +} + +export function OrderProgressBarPlaygroundPage(): ReactNode { + const [mode, setMode] = useState('scenario') + const [scenarioId, setScenarioId] = useState(PLAYGROUND_SCENARIOS[0].id) + const [staticPreviewId, setStaticPreviewId] = useState(STATIC_PLAYGROUND_PREVIEWS[0].id) + const scenario = useMemo( + () => PLAYGROUND_SCENARIOS.find((item) => item.id === scenarioId) || PLAYGROUND_SCENARIOS[0], + [scenarioId], + ) + const staticPreview = useMemo( + () => STATIC_PLAYGROUND_PREVIEWS.find((item) => item.id === staticPreviewId) || STATIC_PLAYGROUND_PREVIEWS[0], + [staticPreviewId], + ) + const isStaticMode = mode === 'static' + const frameDelays = useMemo( + () => + scenario.frames.slice(0, -1).map((frame, index) => getScenarioFrameDelayMs(frame, scenario.frames[index + 1])), + [scenario], + ) + const frameStartOffsets = useMemo( + () => scenario.frames.map((_, index) => frameDelays.slice(0, index).reduce((acc, delayMs) => acc + delayMs, 0)), + [frameDelays, scenario.frames], + ) + const { frameElapsedMs, frameIndex, restartScenario } = useScenarioPlayback(frameDelays, isStaticMode) + const currentFrameIndex = isStaticMode + ? 0 + : scenario.frames.length > 1 + ? Math.min(frameIndex, scenario.frames.length - 1) + : 0 + const currentFrame = isStaticMode ? staticPreview.frame : (scenario.frames[currentFrameIndex] ?? scenario.frames[0]) + const currentFrameElapsedMs = isStaticMode + ? 0 + : Math.max(frameElapsedMs - (frameStartOffsets[currentFrameIndex] ?? 0), 0) + const currentFrameCountdown = isStaticMode + ? currentFrame.countdown + : getScenarioFrameCountdown(currentFrame, currentFrameElapsedMs) + const displayedFrames = isStaticMode ? [staticPreview.frame] : scenario.frames + const sequenceLabel = isStaticMode + ? currentFrame.backendStatus + : scenario.frames.map((frame) => frame.backendStatus).join(' -> ') + const detailsTitle = isStaticMode ? 'Static Preview' : 'Simulation' + + const handleScenarioChange = useCallback( + (event: ChangeEvent): void => { + setScenarioId(event.target.value) + restartScenario() + }, + [restartScenario], + ) + const handleModeChange = useCallback( + (event: ChangeEvent): void => setMode(event.target.value as PlaygroundMode), + [], + ) + const handleStaticPreviewChange = useCallback( + (event: ChangeEvent): void => setStaticPreviewId(event.target.value), + [], + ) + + return ( + + + Progress Bar Playground + + Select a canned backend sequence to replay the progress bar without placing a real order. + + + + + + + + + + + + + + ) +} diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.styled.ts b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.styled.ts new file mode 100644 index 00000000000..243fcfcd70f --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.styled.ts @@ -0,0 +1,228 @@ +import { UI } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +const COLORS = { + panelShadow: '0 14px 40px rgba(16, 43, 92, 0.12)', + accent: '#234c9b', + accentDark: '#173879', + backendBg: '#e8efff', + backendText: '#234c9b', + progressBg: '#e5f4eb', + progressText: '#1f6b44', +} as const + +export const Page = styled.div` + max-width: 1120px; + margin: 0 auto; + padding: 32px 24px 64px; + color: var(${UI.COLOR_TEXT}); +` + +export const Header = styled.div` + margin-bottom: 24px; +` + +export const Title = styled.h1` + margin: 0 0 8px; + font-size: 28px; + color: var(${UI.COLOR_TEXT}); +` + +export const Description = styled.p` + margin: 0; + color: var(${UI.COLOR_TEXT_OPACITY_70}); + line-height: 1.5; +` + +export const Controls = styled.div` + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: end; + margin-bottom: 24px; +` + +export const Field = styled.label` + display: flex; + flex-direction: column; + gap: 6px; + min-width: 280px; + font-weight: 600; + color: var(${UI.COLOR_TEXT}); +` + +export const Select = styled.select` + appearance: none; + min-height: 44px; + padding: 0 12px; + border-radius: 12px; + border: 1px solid var(${UI.COLOR_BORDER}); + background: var(${UI.COLOR_PAPER}); + color: var(${UI.COLOR_TEXT}); + box-shadow: 0 4px 12px rgba(35, 76, 155, 0.08); +` + +export const ReplayButton = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 18px; + border: 0; + border-radius: 12px; + background: ${COLORS.accent}; + color: white; + font-weight: 600; + cursor: pointer; + box-shadow: 0 10px 20px rgba(35, 76, 155, 0.24); + + &:hover { + background: ${COLORS.accentDark}; + } +` + +export const Layout = styled.div` + display: grid; + gap: 24px; + align-items: start; + grid-template-columns: minmax(320px, 520px) minmax(280px, 1fr); + + @media (max-width: 960px) { + grid-template-columns: 1fr; + } +` + +export const PreviewCard = styled.div` + padding: 24px; + border-radius: 24px; + background: var(${UI.COLOR_PAPER}); + border: 1px solid var(${UI.COLOR_BORDER}); + box-shadow: ${COLORS.panelShadow}; +` + +export const MetaCard = styled.div` + padding: 20px; + border-radius: 20px; + background: var(${UI.COLOR_PAPER}); + border: 1px solid var(${UI.COLOR_BORDER}); + box-shadow: ${COLORS.panelShadow}; +` + +export const MetaTitle = styled.h2` + margin: 0 0 12px; + font-size: 18px; + color: var(${UI.COLOR_TEXT}); +` + +export const MetaRow = styled.div` + margin-bottom: 12px; + color: var(${UI.COLOR_TEXT}); + line-height: 1.5; +` + +export const CurrentStatusGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; + + @media (max-width: 720px) { + grid-template-columns: 1fr; + } +` + +export const CurrentStatusCard = styled.div` + padding: 14px 16px; + border-radius: 16px; + background: linear-gradient(180deg, rgba(35, 76, 155, 0.08), var(${UI.COLOR_PAPER})); + border: 1px solid var(${UI.COLOR_BORDER}); +` + +export const CurrentStatusLabel = styled.div` + margin-bottom: 6px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(${UI.COLOR_TEXT_OPACITY_70}); +` + +export const CurrentStatusValue = styled.div` + display: inline-flex; + align-items: center; + min-height: 38px; + padding: 0 14px; + border-radius: 999px; + background: ${COLORS.accent}; + color: white; + font-size: 20px; + font-weight: 700; + box-shadow: 0 8px 18px rgba(35, 76, 155, 0.22); +` + +export const Timeline = styled.ol` + margin: 0; + padding-left: 20px; + display: grid; + gap: 10px; +` + +export const TimelineItem = styled.li<{ $active: boolean }>` + padding: 14px 16px; + border-radius: 16px; + border: 1px solid ${({ $active }) => ($active ? COLORS.accent : `var(${UI.COLOR_BORDER})`)}; + background: ${({ $active }) => + $active ? `linear-gradient(180deg, rgba(35, 76, 155, 0.08), var(${UI.COLOR_PAPER}))` : `var(${UI.COLOR_PAPER})`}; + color: ${({ $active }) => ($active ? `var(${UI.COLOR_TEXT})` : `var(${UI.COLOR_TEXT_OPACITY_70})`)}; + box-shadow: ${({ $active }) => ($active ? '0 12px 24px rgba(35, 76, 155, 0.16)' : 'none')}; +` + +export const TimelineHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +` + +export const TimelineTitle = styled.div` + font-weight: 700; + color: var(${UI.COLOR_TEXT}); +` + +export const TimelineCurrentBadge = styled.span` + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: ${COLORS.accent}; + color: white; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +` + +export const TimelineStatuses = styled.div` + display: flex; + flex-wrap: wrap; + gap: 10px; +` + +export const TimelineStatusPill = styled.span<{ $variant: 'backend' | 'progress' }>` + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + background: ${({ $variant }) => ($variant === 'backend' ? COLORS.backendBg : COLORS.progressBg)}; + color: ${({ $variant }) => ($variant === 'backend' ? COLORS.backendText : COLORS.progressText)}; + font-size: 14px; + font-weight: 700; +` + +export const TimelineStatusLabel = styled.span` + margin-right: 6px; + opacity: 0.72; +` diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.utils.test.ts b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.utils.test.ts new file mode 100644 index 00000000000..3799601d0fa --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.utils.test.ts @@ -0,0 +1,37 @@ +import { OrderProgressBarStepName } from 'modules/orderProgressBar' + +import { ScenarioFrame } from './OrderProgressBarPlayground.demo.constants' +import { getScenarioFrameDelayMs } from './OrderProgressBarPlayground.utils' + +function createFrame(stepName: OrderProgressBarStepName, backendStatus: string, holdMs: number): ScenarioFrame { + return { backendStatus, holdMs, stepName } +} + +describe('getScenarioFrameDelayMs', () => { + it('holds non-priority transitions for at least 5 seconds', () => { + const result = getScenarioFrameDelayMs( + createFrame(OrderProgressBarStepName.INITIAL, 'scheduled', 1200), + createFrame(OrderProgressBarStepName.SOLVING, 'active', 1500), + ) + + expect(result).toBe(5000) + }) + + it('lets priority retry screens interrupt immediately', () => { + const result = getScenarioFrameDelayMs( + createFrame(OrderProgressBarStepName.EXECUTING, 'executing', 1200), + createFrame(OrderProgressBarStepName.SUBMISSION_FAILED, 'open', 1800), + ) + + expect(result).toBe(1200) + }) + + it('replays completion-driven executing without adding the 5 second hold', () => { + const result = getScenarioFrameDelayMs( + createFrame(OrderProgressBarStepName.DELAYED, 'open', 1500), + createFrame(OrderProgressBarStepName.EXECUTING, 'traded', 1200), + ) + + expect(result).toBe(1500) + }) +}) diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.utils.ts b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.utils.ts new file mode 100644 index 00000000000..474f6d1795d --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.utils.ts @@ -0,0 +1,44 @@ +import { + getCompletionDelayMs, + MINIMUM_STEP_DISPLAY_TIME, + OrderProgressBarStepName, + shouldApplyStepNameImmediately, +} from 'modules/orderProgressBar' + +import type { ScenarioFrame } from './OrderProgressBarPlayground.demo.constants' + +function isCompletionDrivenExecutingFrame(frame: ScenarioFrame): boolean { + return frame.stepName === OrderProgressBarStepName.EXECUTING && frame.backendStatus.includes('traded') +} + +export function getScenarioFrameDelayMs(currentFrame: ScenarioFrame, nextFrame: ScenarioFrame): number { + const backendDelayMs = currentFrame.holdMs + + if (currentFrame.stepName === nextFrame.stepName) { + return backendDelayMs + } + + if (isCompletionDrivenExecutingFrame(nextFrame)) { + return backendDelayMs + } + + const remainingCompletionDelayMs = getCompletionDelayMs(currentFrame.stepName, nextFrame.stepName, 0, backendDelayMs) + + if (remainingCompletionDelayMs > 0) { + return backendDelayMs + remainingCompletionDelayMs + } + + if (shouldApplyStepNameImmediately(0, backendDelayMs, nextFrame.stepName)) { + return backendDelayMs + } + + return Math.max(backendDelayMs, MINIMUM_STEP_DISPLAY_TIME) +} + +export function getScenarioFrameCountdown(frame: ScenarioFrame, elapsedMs: number): number | null | undefined { + if (frame.countdown == null) { + return frame.countdown + } + + return Math.max(frame.countdown - Math.floor(elapsedMs / 1000), 0) +} diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlaygroundDetails.tsx b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlaygroundDetails.tsx new file mode 100644 index 00000000000..9d7aa373c77 --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlaygroundDetails.tsx @@ -0,0 +1,67 @@ +import { ReactNode } from 'react' + +import { ScenarioFrame } from './OrderProgressBarPlayground.demo.constants' +import * as styledEl from './OrderProgressBarPlayground.styled' + +type OrderProgressBarPlaygroundDetailsProps = { + currentFrame: ScenarioFrame + currentFrameIndex: number + frames: ScenarioFrame[] + sequenceLabel: string + title: string +} + +export function OrderProgressBarPlaygroundDetails({ + currentFrame, + currentFrameIndex, + frames, + sequenceLabel, + title, +}: OrderProgressBarPlaygroundDetailsProps): ReactNode { + return ( + + {title} + + + Current backend status + {currentFrame.backendStatus} + + + + Current progress step + {currentFrame.stepName} + + + + + Backend sequence: {sequenceLabel} + + + + {frames.map((frame, index) => ( + + + Step {index + 1} + {index === currentFrameIndex && Now} + + + + + Backend + {frame.backendStatus} + + + + Progress + {frame.stepName} + + + + ))} + + + ) +}