From ee3d7ff73d82292e6cdf449be73aae8106c50af9 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:24:30 +0100 Subject: [PATCH 01/16] fix: enhance progress bar logic with completion delays and step staging --- .../hooks/useOrderProgressBarProps.test.ts | 82 ++++++++- .../hooks/useOrderProgressBarProps.ts | 93 +++++++--- ...ProgressEventsUpdater.integration.test.tsx | 158 +++++++++++++++++ .../OrderProgressEventsUpdater.test.ts | 92 ++++++++-- .../updaters/OrderProgressEventsUpdater.tsx | 160 ++++++++++++------ .../orderProgressBar/updaters/utils.ts | 45 +++++ 6 files changed, 536 insertions(+), 94 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx 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..d5f496e2162 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts @@ -1,3 +1,5 @@ +import { SwapAndBridgeStatus } from 'modules/bridge' + import { getProgressBarStepName } from './useOrderProgressBarProps' import { OrderProgressBarStepName } from '../constants' @@ -9,14 +11,24 @@ const EXECUTING_STATUS = 'executing' as OrderProgressBarState['backendApiStatus' describe('getProgressBarStepName', () => { function callGetProgressBarStepName({ isUnfillable = false, + isConfirmed = false, + countdown = null, + currentStepName, backendApiStatus, previousStepName = OrderProgressBarStepName.SOLVING, previousBackendApiStatus, + bridgingStatus, + isBridgingTrade = false, }: { isUnfillable?: boolean + isConfirmed?: boolean + countdown?: OrderProgressBarState['countdown'] + currentStepName?: OrderProgressBarStepName | undefined backendApiStatus?: OrderProgressBarState['backendApiStatus'] previousStepName?: OrderProgressBarStepName | undefined previousBackendApiStatus?: OrderProgressBarState['previousBackendApiStatus'] + bridgingStatus?: SwapAndBridgeStatus | undefined + isBridgingTrade?: boolean }): OrderProgressBarStepName { return getProgressBarStepName( isUnfillable, @@ -24,13 +36,14 @@ describe('getProgressBarStepName', () => { false, // isExpired false, // isCancelling undefined, // cancellationTriggered - false, // isConfirmed - null, // countdown + isConfirmed, + countdown, + currentStepName, backendApiStatus, previousBackendApiStatus, previousStepName, - undefined, // bridgingStatus - false, // isBridgingTrade + bridgingStatus, + isBridgingTrade, ) } @@ -60,4 +73,65 @@ 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('does not restage executing after a submission retry path', () => { + const result = callGetProgressBarStepName({ + currentStepName: OrderProgressBarStepName.SUBMISSION_FAILED, + previousStepName: OrderProgressBarStepName.EXECUTING, + 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) + }) }) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts index 79f364c31ff..9c53925a36c 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts @@ -36,6 +36,7 @@ import { updateOrderProgressBarStepName, } from '../state/atoms' import { OrderProgressBarProps, OrderProgressBarState } from '../types' +import { getCompletionDelayMs, shouldStageExecutingStep } from '../updaters/utils' export type UseOrderProgressBarResult = Pick & { stepName: Exclude @@ -217,6 +218,7 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U cancellationTriggered, isConfirmed, countdown, + progressBarStepName, backendApiStatus, previousBackendApiStatus, lastTimeChangedSteps, @@ -269,6 +271,7 @@ export function getProgressBarStepName( cancellationTriggered: undefined | true, isConfirmed: boolean, countdown: OrderProgressBarState['countdown'], + currentStepName: OrderProgressBarState['progressBarStepName'], backendApiStatus: OrderProgressBarState['backendApiStatus'], previousBackendApiStatus: OrderProgressBarState['previousBackendApiStatus'], previousStepName: OrderProgressBarState['previousStepName'], @@ -276,23 +279,15 @@ export function getProgressBarStepName( isBridgingTrade: boolean, ): OrderProgressBarStepName { const isTradedOrConfirmed = backendApiStatus === CompetitionOrderStatus.type.TRADED || isConfirmed + const hasMovedPastInitialStep = !!currentStepName && currentStepName !== OrderProgressBarStepName.INITIAL + const bridgingStepName = getBridgingStepName(bridgingStatus) - if (bridgingStatus) { - 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 (shouldStageExecutingStep(currentStepName, previousStepName, bridgingStepName)) { + return OrderProgressBarStepName.EXECUTING + } - if (bridgingStatus && [SwapAndBridgeStatus.PENDING, SwapAndBridgeStatus.DEFAULT].includes(bridgingStatus)) { - return OrderProgressBarStepName.BRIDGING_IN_PROGRESS - } + if (bridgingStepName) { + return bridgingStepName } if (isTradedOrConfirmed && isBridgingTrade && !bridgingStatus) { @@ -310,6 +305,16 @@ export function getProgressBarStepName( return OrderProgressBarStepName.CANCELLATION_FAILED } else if (isConfirmed) { // already traded + if (shouldStageExecutingStep(currentStepName, previousStepName, OrderProgressBarStepName.FINISHED)) { + return OrderProgressBarStepName.EXECUTING + } + + return OrderProgressBarStepName.FINISHED + } else if (backendApiStatus === CompetitionOrderStatus.type.TRADED) { + if (shouldStageExecutingStep(currentStepName, previousStepName, OrderProgressBarStepName.FINISHED)) { + return OrderProgressBarStepName.EXECUTING + } + return OrderProgressBarStepName.FINISHED } else if ( previousBackendApiStatus === CompetitionOrderStatus.type.EXECUTING && @@ -337,8 +342,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 @@ -350,6 +354,30 @@ export function getProgressBarStepName( return OrderProgressBarStepName.INITIAL } +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 useCancellingOrderUpdater(orderId: string | undefined, isCancelling: boolean): void { const setCancellationTriggered = useSetAtom(setOrderProgressBarCancellationTriggered) @@ -413,6 +441,7 @@ function useProgressBarStepNameUpdater( cancellationTriggered: undefined | true, isConfirmed: boolean, countdown: OrderProgressBarState['countdown'], + currentStepName: OrderProgressBarState['progressBarStepName'], backendApiStatus: OrderProgressBarState['backendApiStatus'], previousBackendApiStatus: OrderProgressBarState['previousBackendApiStatus'], lastTimeChangedSteps: OrderProgressBarState['lastTimeChangedSteps'], @@ -430,6 +459,7 @@ function useProgressBarStepNameUpdater( cancellationTriggered, isConfirmed, countdown, + currentStepName, backendApiStatus, previousBackendApiStatus, previousStepName, @@ -452,15 +482,11 @@ function useProgressBarStepNameUpdater( let timer: NodeJS.Timeout | undefined const timeSinceLastChange = lastTimeChangedSteps ? Date.now() - lastTimeChangedSteps : 0 + const completionDelayMs = getCompletionDelayMs(currentStepName, stepName, lastTimeChangedSteps) - if ( - lastTimeChangedSteps === undefined || - timeSinceLastChange >= MINIMUM_STEP_DISPLAY_TIME || - stepName === OrderProgressBarStepName.FINISHED || - stepName === OrderProgressBarStepName.CANCELLATION_FAILED || - stepName === OrderProgressBarStepName.CANCELLED || - stepName === OrderProgressBarStepName.EXPIRED - ) { + if (completionDelayMs > 0) { + timer = setTimeout(() => updateStepName(stepName), completionDelayMs) + } else if (shouldApplyStepNameImmediately(lastTimeChangedSteps, timeSinceLastChange, stepName)) { updateStepName(stepName) // schedule update for temporary steps @@ -475,7 +501,22 @@ function useProgressBarStepNameUpdater( return () => { if (timer) clearTimeout(timer) } - }, [orderId, stepName, lastTimeChangedSteps, setProgressBarStepName]) + }, [currentStepName, lastTimeChangedSteps, orderId, setProgressBarStepName, stepName]) +} + +function shouldApplyStepNameImmediately( + lastTimeChangedSteps: OrderProgressBarState['lastTimeChangedSteps'], + timeSinceLastChange: number, + stepName: OrderProgressBarStepName, +): boolean { + return ( + lastTimeChangedSteps === undefined || + timeSinceLastChange >= MINIMUM_STEP_DISPLAY_TIME || + stepName === OrderProgressBarStepName.FINISHED || + stepName === OrderProgressBarStepName.CANCELLATION_FAILED || + stepName === OrderProgressBarStepName.CANCELLED || + stepName === OrderProgressBarStepName.EXPIRED + ) } function useSetExecutingOrderCountdownCallback(): (orderId: string, value: number | null) => void { 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..ba3736961ff --- /dev/null +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx @@ -0,0 +1,158 @@ +import { Provider as JotaiProvider } from 'jotai' +import { createStore } from 'jotai/vanilla' +import { ReactNode } from 'react' + +import { type EnrichedOrder, SupportedChainId } from '@cowprotocol/cow-sdk' +import { CowWidgetEvents, 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 { 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) +} + +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() + }) +}) 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..ee3b6b0b936 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, + 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_OK = createFillability({}) +const FILLABILITY_LACKING_BALANCE = createFillability({ hasEnoughBalance: false }) +const FILLABILITY_LACKING_ALLOWANCE = createFillability({ hasEnoughAllowance: false }) describe('computeUnfillableOrderIds', () => { it('includes orders flagged as unfillable by price', () => { @@ -94,3 +100,59 @@ 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.INITIAL, + OrderProgressBarStepName.FINISHED, + ) + + expect(result).toBe(true) + }) + + it('does not stage executing again once it has already been shown', () => { + const result = shouldStageExecutingStep( + OrderProgressBarStepName.SUBMISSION_FAILED, + OrderProgressBarStepName.EXECUTING, + OrderProgressBarStepName.FINISHED, + ) + + expect(result).toBe(false) + }) + + it('does not move backwards from a completion step', () => { + const result = shouldStageExecutingStep( + OrderProgressBarStepName.FINISHED, + OrderProgressBarStepName.SOLVING, + 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.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx index 22f8c677d88..f0a3c79aeed 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 { MutableRefObject, useCallback, useEffect, useMemo, useRef } from 'react' import { OrderClass, SupportedChainId } from '@cowprotocol/cow-sdk' import { @@ -18,7 +18,13 @@ 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, + shouldStageExecutingStep, +} from './utils' import { OrderProgressBarStepName } from '../constants' import { @@ -26,6 +32,7 @@ import { updateOrderProgressBarCountdown, updateOrderProgressBarStepName, } from '../state/atoms' +import { OrdersProgressBarState } from '../types' function useUnfillableOrderIds(): string[] { const { chainId, account } = useWalletInfo() @@ -42,59 +49,35 @@ 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 - - if (typeof currentCountdown !== 'undefined' && currentCountdown !== null) { - setCountdown({ orderId: orderUid, value: null }) - } - }, - [setCountdown, setStepName], - ) +function useCompletionTimersRef(): MutableRefObject> { + const completionTimersRef = useRef>({}) useEffect(() => { - const previousUnfillable = previousUnfillableRef.current - const currentUnfillable = new Set(unfillableIds) - const currentProgressState = ordersProgressStateRef.current + const completionTimers = completionTimersRef.current - const newlyFillable = getNewlyFillableOrderIds(previousUnfillable, currentUnfillable) - - newlyFillable.forEach((orderId) => { - const currentStep = currentProgressState[orderId]?.progressBarStepName - - if (currentStep && currentStep !== OrderProgressBarStepName.UNFILLABLE) { - return - } - - finalizeOrderStep(orderId, OrderProgressBarStepName.SOLVING) - }) - currentUnfillable.forEach((orderId) => finalizeOrderStep(orderId, OrderProgressBarStepName.UNFILLABLE)) + return () => { + Object.values(completionTimers).forEach((timer) => clearTimeout(timer)) + } + }, []) - // Persist for the next diff so we only reset orders that actually recovered. - previousUnfillableRef.current = currentUnfillable - }, [unfillableIds, finalizeOrderStep]) + return completionTimersRef +} +function useOrderProgressEventListeners( + finalizeOrderStep: (orderUid: string, step: OrderProgressBarStepName) => void, +): void { useEffect(() => { const listeners: CowWidgetEventListener[] = [ { @@ -110,22 +93,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 +116,88 @@ export function OrderProgressEventsUpdater(): null { listeners.forEach((listener) => WIDGET_EVENT_EMITTER.off(listener)) } }, [finalizeOrderStep]) +} + +export function OrderProgressEventsUpdater(): null { + const ordersProgressState = useAtomValue(ordersProgressBarStateAtom) + const setCountdown = useSetAtom(updateOrderProgressBarCountdown) + const setStepName = useSetAtom(updateOrderProgressBarStepName) + const unfillableIds = useUnfillableOrderIds() + const previousUnfillableRef = useRef>(new Set()) + const ordersProgressStateRef = useOrdersProgressStateRef(ordersProgressState) + const completionTimersRef = useCompletionTimersRef() + + const finalizeOrderStep = useCallback( + (orderUid: string, step: OrderProgressBarStepName) => { + const currentState = ordersProgressStateRef.current[orderUid] + const currentStep = currentState?.progressBarStepName + + if (currentStep === step) { + return + } + + const currentCountdown = currentState?.countdown + + if (typeof currentCountdown !== 'undefined' && currentCountdown !== null) { + setCountdown({ orderId: orderUid, value: null }) + } + + const existingTimer = completionTimersRef.current[orderUid] + + if (existingTimer) { + clearTimeout(existingTimer) + delete completionTimersRef.current[orderUid] + } + + if (shouldStageExecutingStep(currentStep, currentState?.previousStepName, step)) { + setStepName({ orderId: orderUid, value: OrderProgressBarStepName.EXECUTING }) + completionTimersRef.current[orderUid] = setTimeout(() => { + setStepName({ orderId: orderUid, value: step }) + delete completionTimersRef.current[orderUid] + }, EXECUTING_STEP_MIN_DISPLAY_TIME_MS) + + return + } + + const completionDelayMs = getCompletionDelayMs(currentStep, step, currentState?.lastTimeChangedSteps) + + if (completionDelayMs > 0) { + completionTimersRef.current[orderUid] = setTimeout(() => { + setStepName({ orderId: orderUid, value: step }) + delete completionTimersRef.current[orderUid] + }, completionDelayMs) + + return + } + + setStepName({ orderId: orderUid, value: step }) + }, + [completionTimersRef, ordersProgressStateRef, setCountdown, setStepName], + ) + + 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 + } + + finalizeOrderStep(orderId, OrderProgressBarStepName.SOLVING) + }) + currentUnfillable.forEach((orderId) => finalizeOrderStep(orderId, OrderProgressBarStepName.UNFILLABLE)) + + // Persist for the next diff so we only reset orders that actually recovered. + previousUnfillableRef.current = currentUnfillable + }, [unfillableIds, finalizeOrderStep, ordersProgressStateRef]) + + useOrderProgressEventListeners(finalizeOrderStep) return null } diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts index 3fb7a65a04c..1b03f83c8d7 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts @@ -1,10 +1,22 @@ import { type OrderFillability } from 'modules/ordersTable' +import { OrderProgressBarStepName } from '../constants' + type OrderLike = { id: string isUnfillable?: boolean } +const COMPLETION_STEPS = new Set([ + OrderProgressBarStepName.FINISHED, + OrderProgressBarStepName.BRIDGING_IN_PROGRESS, + OrderProgressBarStepName.BRIDGING_FAILED, + OrderProgressBarStepName.REFUND_COMPLETED, + OrderProgressBarStepName.BRIDGING_FINISHED, +]) + +export const EXECUTING_STEP_MIN_DISPLAY_TIME_MS = 1000 + export function computeUnfillableOrderIds( marketOrders: OrderLike[], pendingOrdersFillability: Record, @@ -46,3 +58,36 @@ 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 shouldStageExecutingStep( + currentStep: OrderProgressBarStepName | undefined, + previousStep: OrderProgressBarStepName | undefined, + nextStep: OrderProgressBarStepName | undefined, +): boolean { + if (!currentStep || !isCompletionStep(nextStep) || isCompletionStep(currentStep)) { + return false + } + + return currentStep !== OrderProgressBarStepName.EXECUTING && previousStep !== OrderProgressBarStepName.EXECUTING +} + +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) +} From 0b7b1e454f94237d372e4851cc80d2c22ddd8fad Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:47:20 +0100 Subject: [PATCH 02/16] fix: refactor order progress bar logic to improve step handling and completion scheduling --- .../hooks/useOrderProgressBarProps.test.ts | 32 ++++- .../hooks/useOrderProgressBarProps.ts | 125 +++++++++++++++--- ...ProgressEventsUpdater.integration.test.tsx | 62 +++++++++ .../updaters/OrderProgressEventsUpdater.tsx | 35 +++-- 4 files changed, 229 insertions(+), 25 deletions(-) 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 d5f496e2162..6795f0e4167 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts @@ -1,6 +1,6 @@ import { SwapAndBridgeStatus } from 'modules/bridge' -import { getProgressBarStepName } from './useOrderProgressBarProps' +import { getProgressBarStepName, shouldApplyCompletionDrivenExecutingImmediately } from './useOrderProgressBarProps' import { OrderProgressBarStepName } from '../constants' import { OrderProgressBarState } from '../types' @@ -135,3 +135,33 @@ describe('getProgressBarStepName', () => { expect(result).toBe(OrderProgressBarStepName.EXECUTING) }) }) + +describe('shouldApplyCompletionDrivenExecutingImmediately', () => { + it('bypasses the 5s hold when executing is only a staging step before completion', () => { + const result = shouldApplyCompletionDrivenExecutingImmediately( + OrderProgressBarStepName.EXECUTING, + OrderProgressBarStepName.SOLVING, + OrderProgressBarStepName.INITIAL, + 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, + OrderProgressBarStepName.INITIAL, + false, + EXECUTING_STATUS, + undefined, + false, + ) + + 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 9c53925a36c..cacb127c03c 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts @@ -354,6 +354,52 @@ 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'], + previousStepName: OrderProgressBarState['previousStepName'], + 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, previousStepName, completionTargetStepName)) { + return true + } + + const isTradedOrConfirmed = backendApiStatus === CompetitionOrderStatus.type.TRADED || isConfirmed + + return isTradedOrConfirmed && isBridgingTrade && !bridgingStatus +} + function getBridgingStepName(bridgingStatus: SwapAndBridgeStatus | undefined): OrderProgressBarStepName | undefined { if (!bridgingStatus) { return undefined @@ -431,7 +477,6 @@ function useGetExecutingOrderState(orderId?: string): OrderProgressBarState { return useMemo(() => singleState || DEFAULT_STATE, [singleState]) } -// TODO: Break down this large function into smaller functions function useProgressBarStepNameUpdater( orderId: string | undefined, isUnfillable: boolean, @@ -449,8 +494,7 @@ function useProgressBarStepNameUpdater( bridgingStatus: SwapAndBridgeStatus | undefined, isBridgingTrade: boolean, ): void { - const setProgressBarStepName = useSetExecutingOrderProgressBarStepNameCallback() - + const updateStepName = useUpdateProgressBarStepName(orderId) const stepName = getProgressBarStepName( isUnfillable, isCancelled, @@ -467,41 +511,77 @@ function useProgressBarStepNameUpdater( 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 const completionDelayMs = getCompletionDelayMs(currentStepName, stepName, lastTimeChangedSteps) + const shouldApplyStepNow = shouldApplyStepNameNow( + lastTimeChangedSteps, + timeSinceLastChange, + stepName, + currentStepName, + previousStepName, + isConfirmed, + backendApiStatus, + bridgingStatus, + isBridgingTrade, + ) if (completionDelayMs > 0) { timer = setTimeout(() => updateStepName(stepName), completionDelayMs) - } else if (shouldApplyStepNameImmediately(lastTimeChangedSteps, timeSinceLastChange, stepName)) { + } else if (shouldApplyStepNow) { 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) } return () => { if (timer) clearTimeout(timer) } - }, [currentStepName, lastTimeChangedSteps, orderId, setProgressBarStepName, stepName]) + }, [ + backendApiStatus, + bridgingStatus, + currentStepName, + isBridgingTrade, + isConfirmed, + lastTimeChangedSteps, + orderId, + previousStepName, + stepName, + updateStepName, + ]) +} + +function shouldApplyStepNameNow( + lastTimeChangedSteps: OrderProgressBarState['lastTimeChangedSteps'], + timeSinceLastChange: number, + stepName: OrderProgressBarStepName, + currentStepName: OrderProgressBarState['progressBarStepName'], + previousStepName: OrderProgressBarState['previousStepName'], + isConfirmed: boolean, + backendApiStatus: OrderProgressBarState['backendApiStatus'], + bridgingStatus: SwapAndBridgeStatus | undefined, + isBridgingTrade: boolean, +): boolean { + return ( + shouldApplyCompletionDrivenExecutingImmediately( + stepName, + currentStepName, + previousStepName, + isConfirmed, + backendApiStatus, + bridgingStatus, + isBridgingTrade, + ) || shouldApplyStepNameImmediately(lastTimeChangedSteps, timeSinceLastChange, stepName) + ) } function shouldApplyStepNameImmediately( @@ -536,6 +616,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/updaters/OrderProgressEventsUpdater.integration.test.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx index ba3736961ff..b74d171d33e 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx @@ -155,4 +155,66 @@ describe('OrderProgressEventsUpdater', () => { 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() + }) }) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx index f0a3c79aeed..0d2d1931fb1 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx @@ -127,6 +127,29 @@ export function OrderProgressEventsUpdater(): null { const ordersProgressStateRef = useOrdersProgressStateRef(ordersProgressState) const completionTimersRef = useCompletionTimersRef() + const scheduleCompletionStep = useCallback( + (orderUid: string, step: OrderProgressBarStepName, delayMs: number) => { + const scheduledTimer = setTimeout(() => { + if (completionTimersRef.current[orderUid] !== scheduledTimer) { + return + } + + delete completionTimersRef.current[orderUid] + + const latestState = ordersProgressStateRef.current[orderUid] + + if (!latestState || latestState.progressBarStepName !== OrderProgressBarStepName.EXECUTING) { + return + } + + setStepName({ orderId: orderUid, value: step }) + }, delayMs) + + completionTimersRef.current[orderUid] = scheduledTimer + }, + [completionTimersRef, ordersProgressStateRef, setStepName], + ) + const finalizeOrderStep = useCallback( (orderUid: string, step: OrderProgressBarStepName) => { const currentState = ordersProgressStateRef.current[orderUid] @@ -151,10 +174,7 @@ export function OrderProgressEventsUpdater(): null { if (shouldStageExecutingStep(currentStep, currentState?.previousStepName, step)) { setStepName({ orderId: orderUid, value: OrderProgressBarStepName.EXECUTING }) - completionTimersRef.current[orderUid] = setTimeout(() => { - setStepName({ orderId: orderUid, value: step }) - delete completionTimersRef.current[orderUid] - }, EXECUTING_STEP_MIN_DISPLAY_TIME_MS) + scheduleCompletionStep(orderUid, step, EXECUTING_STEP_MIN_DISPLAY_TIME_MS) return } @@ -162,17 +182,14 @@ export function OrderProgressEventsUpdater(): null { const completionDelayMs = getCompletionDelayMs(currentStep, step, currentState?.lastTimeChangedSteps) if (completionDelayMs > 0) { - completionTimersRef.current[orderUid] = setTimeout(() => { - setStepName({ orderId: orderUid, value: step }) - delete completionTimersRef.current[orderUid] - }, completionDelayMs) + scheduleCompletionStep(orderUid, step, completionDelayMs) return } setStepName({ orderId: orderUid, value: step }) }, - [completionTimersRef, ordersProgressStateRef, setCountdown, setStepName], + [completionTimersRef, ordersProgressStateRef, scheduleCompletionStep, setCountdown, setStepName], ) useEffect(() => { From ff5695b0edfd428ee3ce806eb45b033091bbeef9 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:20:19 +0100 Subject: [PATCH 03/16] fix: add logic to persist bridging step names in order progress bar --- .../hooks/useOrderProgressBarProps.test.ts | 23 +++++++++++++++ .../hooks/useOrderProgressBarProps.ts | 21 ++++++++++++++ ...ProgressEventsUpdater.integration.test.tsx | 28 +++++++++++++++++++ .../OrderProgressEventsUpdater.test.ts | 10 +++++++ .../orderProgressBar/updaters/utils.ts | 7 ++++- 5 files changed, 88 insertions(+), 1 deletion(-) 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 6795f0e4167..8bf548701a8 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts @@ -7,6 +7,7 @@ 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({ @@ -134,6 +135,28 @@ describe('getProgressBarStepName', () => { 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', () => { diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts index cacb127c03c..e4c29bfead7 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts @@ -291,6 +291,12 @@ export function getProgressBarStepName( } if (isTradedOrConfirmed && isBridgingTrade && !bridgingStatus) { + const persistedBridgingStepName = getPersistedBridgingStepName(currentStepName) + + if (persistedBridgingStepName) { + return persistedBridgingStepName + } + return OrderProgressBarStepName.EXECUTING } @@ -424,6 +430,21 @@ function getBridgingStepName(bridgingStatus: SwapAndBridgeStatus | undefined): O 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) 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 index b74d171d33e..1d47ae813f3 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx @@ -156,6 +156,34 @@ describe('OrderProgressEventsUpdater', () => { 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() 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 ee3b6b0b936..b1c0bcc0897 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts @@ -122,6 +122,16 @@ describe('shouldStageExecutingStep', () => { expect(result).toBe(false) }) + it('does not stage executing while the progress bar is still on the initial step', () => { + const result = shouldStageExecutingStep( + OrderProgressBarStepName.INITIAL, + undefined, + OrderProgressBarStepName.FINISHED, + ) + + expect(result).toBe(false) + }) + it('does not move backwards from a completion step', () => { const result = shouldStageExecutingStep( OrderProgressBarStepName.FINISHED, diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts index 1b03f83c8d7..9fd3d9793ba 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts @@ -68,7 +68,12 @@ export function shouldStageExecutingStep( previousStep: OrderProgressBarStepName | undefined, nextStep: OrderProgressBarStepName | undefined, ): boolean { - if (!currentStep || !isCompletionStep(nextStep) || isCompletionStep(currentStep)) { + if ( + !currentStep || + currentStep === OrderProgressBarStepName.INITIAL || + !isCompletionStep(nextStep) || + isCompletionStep(currentStep) + ) { return false } From 91be91de7cda8dc9116d93e9c76050037126a2fb Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:07:40 +0100 Subject: [PATCH 04/16] fix: update order progress bar logic to restage executing step after submission retry --- .../hooks/useOrderProgressBarProps.test.ts | 12 ++++++- ...ProgressEventsUpdater.integration.test.tsx | 32 +++++++++++++++++++ .../OrderProgressEventsUpdater.test.ts | 4 +-- .../orderProgressBar/updaters/utils.ts | 4 +++ 4 files changed, 49 insertions(+), 3 deletions(-) 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 8bf548701a8..60470f2282b 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts @@ -115,13 +115,23 @@ describe('getProgressBarStepName', () => { expect(result).toBe(OrderProgressBarStepName.FINISHED) }) - it('does not restage executing after a submission retry path', () => { + 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('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) }) 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 index 1d47ae813f3..1c96acc8495 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx @@ -245,4 +245,36 @@ describe('OrderProgressEventsUpdater', () => { unmount() }) + + it('restages executing before finishing after a submission retry path', () => { + const orderUid = '0xretry-order' + const { store, TestComponent } = getWrapper() + + store.set(ordersProgressBarStateAtom, { + [orderUid]: { + progressBarStepName: OrderProgressBarStepName.SUBMISSION_FAILED, + previousStepName: OrderProgressBarStepName.EXECUTING, + }, + }) + + const { unmount } = render(, { wrapper: TestComponent }) + + act(() => emitFulfilledOrder(orderUid)) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ + previousStepName: OrderProgressBarStepName.SUBMISSION_FAILED, + progressBarStepName: OrderProgressBarStepName.EXECUTING, + }) + + act(() => { + jest.advanceTimersByTime(EXECUTING_STEP_MIN_DISPLAY_TIME_MS) + }) + + expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ + previousStepName: OrderProgressBarStepName.EXECUTING, + progressBarStepName: OrderProgressBarStepName.FINISHED, + }) + + 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 b1c0bcc0897..99e67492bef 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts @@ -112,14 +112,14 @@ describe('shouldStageExecutingStep', () => { expect(result).toBe(true) }) - it('does not stage executing again once it has already been shown', () => { + it('restages executing after a submission retry path before finishing', () => { const result = shouldStageExecutingStep( OrderProgressBarStepName.SUBMISSION_FAILED, OrderProgressBarStepName.EXECUTING, OrderProgressBarStepName.FINISHED, ) - expect(result).toBe(false) + expect(result).toBe(true) }) it('does not stage executing while the progress bar is still on the initial step', () => { diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts index 9fd3d9793ba..b56ddbc94d9 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts @@ -77,6 +77,10 @@ export function shouldStageExecutingStep( return false } + if (currentStep === OrderProgressBarStepName.SUBMISSION_FAILED) { + return true + } + return currentStep !== OrderProgressBarStepName.EXECUTING && previousStep !== OrderProgressBarStepName.EXECUTING } From fb82f69e51382d723b721d66b3abd8277721ac9a Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:46:04 +0100 Subject: [PATCH 05/16] fix: add shouldApplyStepNameImmediately function to enhance order progress bar step handling --- .../hooks/useOrderProgressBarProps.test.ts | 20 ++++++++++++++++++- .../hooks/useOrderProgressBarProps.ts | 3 ++- 2 files changed, 21 insertions(+), 2 deletions(-) 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 60470f2282b..aa2f9287bd0 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts @@ -1,6 +1,10 @@ import { SwapAndBridgeStatus } from 'modules/bridge' -import { getProgressBarStepName, shouldApplyCompletionDrivenExecutingImmediately } from './useOrderProgressBarProps' +import { + getProgressBarStepName, + shouldApplyCompletionDrivenExecutingImmediately, + shouldApplyStepNameImmediately, +} from './useOrderProgressBarProps' import { OrderProgressBarStepName } from '../constants' import { OrderProgressBarState } from '../types' @@ -198,3 +202,17 @@ describe('shouldApplyCompletionDrivenExecutingImmediately', () => { expect(result).toBe(false) }) }) + +describe('shouldApplyStepNameImmediately', () => { + 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('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 e4c29bfead7..c5e1b1e56fa 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts @@ -605,7 +605,7 @@ function shouldApplyStepNameNow( ) } -function shouldApplyStepNameImmediately( +export function shouldApplyStepNameImmediately( lastTimeChangedSteps: OrderProgressBarState['lastTimeChangedSteps'], timeSinceLastChange: number, stepName: OrderProgressBarStepName, @@ -613,6 +613,7 @@ function shouldApplyStepNameImmediately( return ( lastTimeChangedSteps === undefined || timeSinceLastChange >= MINIMUM_STEP_DISPLAY_TIME || + stepName === OrderProgressBarStepName.SUBMISSION_FAILED || stepName === OrderProgressBarStepName.FINISHED || stepName === OrderProgressBarStepName.CANCELLATION_FAILED || stepName === OrderProgressBarStepName.CANCELLED || From 09a2653a5f938deaea6e22c22aab178eaf74c4ae Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:32:42 +0100 Subject: [PATCH 06/16] fix: enhance order progress bar logic to track execution attempts and improve step replay handling --- .../hooks/useOrderProgressBarProps.test.ts | 31 ++- .../hooks/useOrderProgressBarProps.ts | 144 +++++++---- .../orderProgressBar/state/atoms.test.ts | 82 ++++++ .../modules/orderProgressBar/state/atoms.ts | 23 ++ .../src/modules/orderProgressBar/types.ts | 1 + ...ProgressEventsUpdater.integration.test.tsx | 7 +- .../OrderProgressEventsUpdater.test.ts | 31 +-- .../updaters/OrderProgressEventsUpdater.tsx | 2 +- .../OrderProgressStateUpdater.test.tsx | 112 +++++++-- .../updaters/OrderProgressStateUpdater.tsx | 235 +++++++++++++++--- .../orderProgressBar/updaters/utils.ts | 12 +- 11 files changed, 544 insertions(+), 136 deletions(-) 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 aa2f9287bd0..d5b375b4421 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts @@ -22,6 +22,7 @@ describe('getProgressBarStepName', () => { backendApiStatus, previousStepName = OrderProgressBarStepName.SOLVING, previousBackendApiStatus, + hasShownExecutingInCurrentAttempt, bridgingStatus, isBridgingTrade = false, }: { @@ -32,6 +33,7 @@ describe('getProgressBarStepName', () => { backendApiStatus?: OrderProgressBarState['backendApiStatus'] previousStepName?: OrderProgressBarStepName | undefined previousBackendApiStatus?: OrderProgressBarState['previousBackendApiStatus'] + hasShownExecutingInCurrentAttempt?: true bridgingStatus?: SwapAndBridgeStatus | undefined isBridgingTrade?: boolean }): OrderProgressBarStepName { @@ -47,6 +49,7 @@ describe('getProgressBarStepName', () => { backendApiStatus, previousBackendApiStatus, previousStepName, + hasShownExecutingInCurrentAttempt, bridgingStatus, isBridgingTrade, ) @@ -129,6 +132,16 @@ describe('getProgressBarStepName', () => { 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, @@ -178,7 +191,7 @@ describe('shouldApplyCompletionDrivenExecutingImmediately', () => { const result = shouldApplyCompletionDrivenExecutingImmediately( OrderProgressBarStepName.EXECUTING, OrderProgressBarStepName.SOLVING, - OrderProgressBarStepName.INITIAL, + undefined, true, undefined, undefined, @@ -192,7 +205,7 @@ describe('shouldApplyCompletionDrivenExecutingImmediately', () => { const result = shouldApplyCompletionDrivenExecutingImmediately( OrderProgressBarStepName.EXECUTING, OrderProgressBarStepName.SOLVING, - OrderProgressBarStepName.INITIAL, + undefined, false, EXECUTING_STATUS, undefined, @@ -201,6 +214,20 @@ describe('shouldApplyCompletionDrivenExecutingImmediately', () => { 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', () => { diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts index c5e1b1e56fa..1087b160d3d 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts @@ -173,6 +173,7 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U previousStepName, lastTimeChangedSteps, cancellationTriggered, + hasShownExecutingInCurrentAttempt, } = useGetExecutingOrderState(orderId) const solversInfo = useSolversInfo(chainId) @@ -223,6 +224,7 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U previousBackendApiStatus, lastTimeChangedSteps, previousStepName, + hasShownExecutingInCurrentAttempt, bridgingStatus, isBridgingTrade, ) @@ -260,8 +262,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, @@ -275,14 +304,21 @@ export function getProgressBarStepName( 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 (shouldStageExecutingStep(currentStepName, previousStepName, bridgingStepName)) { + if (shouldStageExecutingStep(currentStepName, bridgingStepName, hasShownExecutingInCurrentAttempt)) { return OrderProgressBarStepName.EXECUTING } @@ -309,26 +345,11 @@ export function getProgressBarStepName( } else if (cancellationTriggered && isTradedOrConfirmed) { // Was cancelling, but got executed in the meantime return OrderProgressBarStepName.CANCELLATION_FAILED - } else if (isConfirmed) { - // already traded - if (shouldStageExecutingStep(currentStepName, previousStepName, OrderProgressBarStepName.FINISHED)) { - return OrderProgressBarStepName.EXECUTING - } - - return OrderProgressBarStepName.FINISHED - } else if (backendApiStatus === CompetitionOrderStatus.type.TRADED) { - if (shouldStageExecutingStep(currentStepName, previousStepName, OrderProgressBarStepName.FINISHED)) { - return OrderProgressBarStepName.EXECUTING - } - - 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 @@ -381,7 +402,7 @@ function getCompletionTargetStepName( export function shouldApplyCompletionDrivenExecutingImmediately( stepName: OrderProgressBarStepName, currentStepName: OrderProgressBarState['progressBarStepName'], - previousStepName: OrderProgressBarState['previousStepName'], + hasShownExecutingInCurrentAttempt: OrderProgressBarState['hasShownExecutingInCurrentAttempt'], isConfirmed: boolean, backendApiStatus: OrderProgressBarState['backendApiStatus'], bridgingStatus: SwapAndBridgeStatus | undefined, @@ -397,7 +418,7 @@ export function shouldApplyCompletionDrivenExecutingImmediately( const completionTargetStepName = getCompletionTargetStepName(isConfirmed, backendApiStatus, bridgingStatus) - if (shouldStageExecutingStep(currentStepName, previousStepName, completionTargetStepName)) { + if (shouldStageExecutingStep(currentStepName, completionTargetStepName, hasShownExecutingInCurrentAttempt)) { return true } @@ -498,6 +519,48 @@ function useGetExecutingOrderState(orderId?: string): OrderProgressBarState { return useMemo(() => singleState || DEFAULT_STATE, [singleState]) } +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, @@ -512,6 +575,7 @@ function useProgressBarStepNameUpdater( previousBackendApiStatus: OrderProgressBarState['previousBackendApiStatus'], lastTimeChangedSteps: OrderProgressBarState['lastTimeChangedSteps'], previousStepName: OrderProgressBarState['previousStepName'], + hasShownExecutingInCurrentAttempt: OrderProgressBarState['hasShownExecutingInCurrentAttempt'], bridgingStatus: SwapAndBridgeStatus | undefined, isBridgingTrade: boolean, ): void { @@ -528,6 +592,7 @@ function useProgressBarStepNameUpdater( backendApiStatus, previousBackendApiStatus, previousStepName, + hasShownExecutingInCurrentAttempt, bridgingStatus, isBridgingTrade, ) @@ -536,34 +601,18 @@ function useProgressBarStepNameUpdater( if (!orderId) { return } - let timer: NodeJS.Timeout | undefined - - const timeSinceLastChange = lastTimeChangedSteps ? Date.now() - lastTimeChangedSteps : 0 - const completionDelayMs = getCompletionDelayMs(currentStepName, stepName, lastTimeChangedSteps) - const shouldApplyStepNow = shouldApplyStepNameNow( - lastTimeChangedSteps, - timeSinceLastChange, + const timer = getStepNameUpdateTimer( + updateStepName, stepName, currentStepName, - previousStepName, + hasShownExecutingInCurrentAttempt, + lastTimeChangedSteps, isConfirmed, backendApiStatus, bridgingStatus, isBridgingTrade, ) - if (completionDelayMs > 0) { - timer = setTimeout(() => updateStepName(stepName), completionDelayMs) - } else if (shouldApplyStepNow) { - updateStepName(stepName) - - if (stepName === OrderProgressBarStepName.SUBMISSION_FAILED) { - timer = setTimeout(() => updateStepName(OrderProgressBarStepName.SOLVING), MINIMUM_STEP_DISPLAY_TIME) - } - } else { - timer = setTimeout(() => updateStepName(stepName), MINIMUM_STEP_DISPLAY_TIME - timeSinceLastChange) - } - return () => { if (timer) clearTimeout(timer) } @@ -576,6 +625,7 @@ function useProgressBarStepNameUpdater( lastTimeChangedSteps, orderId, previousStepName, + hasShownExecutingInCurrentAttempt, stepName, updateStepName, ]) @@ -586,7 +636,7 @@ function shouldApplyStepNameNow( timeSinceLastChange: number, stepName: OrderProgressBarStepName, currentStepName: OrderProgressBarState['progressBarStepName'], - previousStepName: OrderProgressBarState['previousStepName'], + hasShownExecutingInCurrentAttempt: OrderProgressBarState['hasShownExecutingInCurrentAttempt'], isConfirmed: boolean, backendApiStatus: OrderProgressBarState['backendApiStatus'], bridgingStatus: SwapAndBridgeStatus | undefined, @@ -596,7 +646,7 @@ function shouldApplyStepNameNow( shouldApplyCompletionDrivenExecutingImmediately( stepName, currentStepName, - previousStepName, + hasShownExecutingInCurrentAttempt, isConfirmed, backendApiStatus, bridgingStatus, 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..18aa8545ffa 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 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 index 1c96acc8495..76d8298d8a5 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx @@ -246,13 +246,13 @@ describe('OrderProgressEventsUpdater', () => { unmount() }) - it('restages executing before finishing after a submission retry path', () => { + it('replays executing before finishing after a submission retry path', () => { const orderUid = '0xretry-order' const { store, TestComponent } = getWrapper() store.set(ordersProgressBarStateAtom, { [orderUid]: { - progressBarStepName: OrderProgressBarStepName.SUBMISSION_FAILED, + progressBarStepName: OrderProgressBarStepName.SOLVING, previousStepName: OrderProgressBarStepName.EXECUTING, }, }) @@ -262,8 +262,9 @@ describe('OrderProgressEventsUpdater', () => { act(() => emitFulfilledOrder(orderUid)) expect(store.get(ordersProgressBarStateAtom)[orderUid]).toMatchObject({ - previousStepName: OrderProgressBarStepName.SUBMISSION_FAILED, + previousStepName: OrderProgressBarStepName.SOLVING, progressBarStepName: OrderProgressBarStepName.EXECUTING, + hasShownExecutingInCurrentAttempt: true, }) act(() => { 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 99e67492bef..29928db58f5 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts @@ -103,11 +103,7 @@ describe('getNewlyFillableOrderIds', () => { describe('shouldStageExecutingStep', () => { it('requires executing before the final step when the order finishes from step 2', () => { - const result = shouldStageExecutingStep( - OrderProgressBarStepName.SOLVING, - OrderProgressBarStepName.INITIAL, - OrderProgressBarStepName.FINISHED, - ) + const result = shouldStageExecutingStep(OrderProgressBarStepName.SOLVING, OrderProgressBarStepName.FINISHED) expect(result).toBe(true) }) @@ -115,29 +111,36 @@ describe('shouldStageExecutingStep', () => { it('restages executing after a submission retry path before finishing', () => { const result = shouldStageExecutingStep( OrderProgressBarStepName.SUBMISSION_FAILED, - OrderProgressBarStepName.EXECUTING, OrderProgressBarStepName.FINISHED, ) expect(result).toBe(true) }) - it('does not stage executing while the progress bar is still on the initial step', () => { + it('replays executing after a retry even if the UI already moved back to solving', () => { const result = shouldStageExecutingStep( - OrderProgressBarStepName.INITIAL, - undefined, + 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.SOLVING, - OrderProgressBarStepName.FINISHED, - ) + const result = shouldStageExecutingStep(OrderProgressBarStepName.FINISHED, OrderProgressBarStepName.FINISHED) expect(result).toBe(false) }) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx index 0d2d1931fb1..9cfd8ad03f9 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx @@ -172,7 +172,7 @@ export function OrderProgressEventsUpdater(): null { delete completionTimersRef.current[orderUid] } - if (shouldStageExecutingStep(currentStep, currentState?.previousStepName, step)) { + if (shouldStageExecutingStep(currentStep, step, currentState?.hasShownExecutingInCurrentAttempt)) { setStepName({ orderId: orderUid, value: OrderProgressBarStepName.EXECUTING }) scheduleCompletionStep(orderUid, step, EXECUTING_STEP_MIN_DISPLAY_TIME_MS) 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..ee46a05ea21 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.test.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.test.tsx @@ -3,7 +3,7 @@ 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' @@ -14,6 +14,7 @@ import { useTradeConfirmState } from 'modules/trade' import { OrderProgressStateUpdater } from './OrderProgressStateUpdater' import { useOrderProgressBarProps } from '../hooks/useOrderProgressBarProps' +import { OrderProgressBarStepName } from '../types' jest.mock('@cowprotocol/wallet', () => ({ useWalletInfo: jest.fn(), @@ -37,28 +38,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) }), } @@ -83,11 +113,14 @@ describe('OrderProgressStateUpdater', () => { 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', () => { @@ -95,15 +128,12 @@ describe('OrderProgressStateUpdater', () => { 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(useOrderProgressBarPropsMock).toHaveBeenCalledTimes(2) expect(useOrderProgressBarPropsMock).toHaveBeenNthCalledWith(1, 1, expect.objectContaining({ id: '1' })) expect(useOrderProgressBarPropsMock).toHaveBeenNthCalledWith(2, 1, expect.objectContaining({ id: '3' })) @@ -116,9 +146,7 @@ describe('OrderProgressStateUpdater', () => { account: undefined, } as unknown as WalletInfo) useOnlyPendingOrdersMock.mockReturnValue([]) - render() - expect(useOrderProgressBarPropsMock).not.toHaveBeenCalled() expect(mockPruneOrders).toHaveBeenLastCalledWith([]) }) @@ -130,9 +158,7 @@ describe('OrderProgressStateUpdater', () => { } as unknown as WalletInfo) useOnlyPendingOrdersMock.mockReturnValue([]) useSurplusQueueOrderIdsMock.mockReturnValue(['queued-order', 'next-order']) - render() - expect(mockPruneOrders).toHaveBeenLastCalledWith(['queued-order', 'next-order']) }) @@ -143,9 +169,7 @@ describe('OrderProgressStateUpdater', () => { } as unknown as WalletInfo) useOnlyPendingOrdersMock.mockReturnValue([]) useTradeConfirmStateMock.mockReturnValue({ transactionHash: '0xorder' } as never) - render() - expect(mockPruneOrders).toHaveBeenLastCalledWith(['0xorder']) }) @@ -160,9 +184,7 @@ describe('OrderProgressStateUpdater', () => { ]) useSurplusQueueOrderIdsMock.mockReturnValue(['2', '3']) useTradeConfirmStateMock.mockReturnValue({ transactionHash: '2' } as never) - render() - expect(mockPruneOrders).toHaveBeenLastCalledWith(['1', '2', '3']) }) @@ -173,9 +195,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..d36bdcd5245 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' @@ -12,63 +12,224 @@ import { useOnlyPendingOrders } from 'legacy/state/orders/hooks' import { useTradeConfirmState } from 'modules/trade' import { useOrderProgressBarProps } from '../hooks/useOrderProgressBarProps' -import { cancellationTrackedOrderIdsAtom, pruneOrdersProgressBarState } from '../state/atoms' +import { + cancellationTrackedOrderIdsAtom, + ordersProgressBarStateAtom, + pruneOrdersProgressBarState, +} from '../state/atoms' + +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, order }: { chainId: SupportedChainId; order: Order }): null { useOrderProgressBarProps(chainId, order) 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 + } + + clearTimeout(pruneTimerRef.current) + pruneTimerRef.current = undefined + } - // 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)) + 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 + } + + // Re-run when the next retained stale entry expires, even if nothing else rerenders. + pruneTimerRef.current = setTimeout(runPruneCycle, nextGracePeriodDelayMs) + } finally { + isPruningRef.current = false + } } - surplusQueueOrderIds.forEach((orderId) => trackedIdsSet.add(orderId)) + const unsubscribe = store.sub(ordersProgressBarStateAtom, runPruneCycle) + runPruneCycle() - if (transactionHash) { - trackedIdsSet.add(transactionHash) + return () => { + unsubscribe() + clearPruneTimer() } + }, [account, cancellationTrackedOrderIds, chainId, marketOrders, store, surplusQueueOrderIds, transactionHash]) - cancellationTrackedOrderIds.forEach((orderId) => trackedIdsSet.add(orderId)) + return null +} - pruneProgressState(Array.from(trackedIdsSet)) - }, [ - account, - cancellationTrackedOrderIds, - chainId, - marketOrders, - pruneProgressState, - surplusQueueOrderIds, - transactionHash, - ]) +function OrderProgressStateObservers({ + chainId, + marketOrders, +}: { + chainId: SupportedChainId + marketOrders: Order[] +}): 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], + ) if (!chainId || !account) { - return null + return ( + + ) } return ( <> - {marketOrders.map((order) => ( - - ))} + + ) } diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts index b56ddbc94d9..287394cfb68 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts @@ -65,23 +65,23 @@ export function isCompletionStep(step: OrderProgressBarStepName | undefined): st export function shouldStageExecutingStep( currentStep: OrderProgressBarStepName | undefined, - previousStep: 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 } - if (currentStep === OrderProgressBarStepName.SUBMISSION_FAILED) { - return true - } - - return currentStep !== OrderProgressBarStepName.EXECUTING && previousStep !== OrderProgressBarStepName.EXECUTING + return !hasShownExecutingInCurrentAttempt } export function getCompletionDelayMs( From b7b2cdc09a693fa43b8ef3d964bddb3700b1a130 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:30:27 +0100 Subject: [PATCH 07/16] feat: orderprogressbar playground --- .../src/common/constants/routes.ts | 1 + .../application/containers/App/RoutesApp.tsx | 4 + .../src/modules/orderProgressBar/index.ts | 3 + .../OrderProgressBarPlayground.constants.ts | 227 +++++++++++++++++ .../OrderProgressBarPlayground.page.test.tsx | 109 +++++++++ .../debug/OrderProgressBarPlayground.page.tsx | 147 +++++++++++ .../OrderProgressBarPlayground.styled.ts | 228 ++++++++++++++++++ 7 files changed, 719 insertions(+) create mode 100644 apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts create mode 100644 apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx create mode 100644 apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx create mode 100644 apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.styled.ts diff --git a/apps/cowswap-frontend/src/common/constants/routes.ts b/apps/cowswap-frontend/src/common/constants/routes.ts index 562f6e9ba1c..73e223ce475 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/modules/application/containers/App/RoutesApp.tsx b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx index f8e890a24d5..2e628ba9bd6 100644 --- a/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx +++ b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx @@ -27,6 +27,7 @@ import { import { Routes as RoutesEnum, RoutesValues } from 'common/constants/routes' import Account, { AccountOverview } from 'pages/Account' import { AdvancedOrdersPage } from 'pages/AdvancedOrders/AdvancedOrders.page' +import { OrderProgressBarPlaygroundPage } from 'pages/debug/OrderProgressBarPlayground.page' import AnySwapAffectedUsers from 'pages/error/AnySwapAffectedUsers' import { HooksPage } from 'pages/Hooks' import { LimitOrdersPage } from 'pages/LimitOrders/LimitOrders.page' @@ -114,6 +115,9 @@ export function RoutesApp(): ReactNode { } /> } /> } /> + {process.env.NODE_ENV === 'development' && ( + } /> + )} {lazyRoutes.map((item, key) => LazyRoute({ ...item, key }))} diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts index c55bdf66e80..5407e5d2933 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts @@ -10,6 +10,9 @@ export { SurplusModalSetup } from './containers/SurplusModalSetup' // Hooks export { OrderSubmittedContent } from './containers/OrderSubmittedContent' +// Pure components +export { OrderProgressBar } from './pure/OrderProgressBar' + export * from './types' // State atoms 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..13c2dc22177 --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts @@ -0,0 +1,227 @@ +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 { SwapAndBridgeStatus } 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_RECEIVED_AMOUNT = CurrencyAmount.fromRawAmount( + OUTPUT_TOKEN, + EXECUTED_BUY_AMOUNT, +) as unknown as CurrencyAmount + +const DEMO_ORDER_API_ADDITIONAL_INFO = { + executedSellAmount: EXECUTED_SELL_AMOUNT, + executedBuyAmount: EXECUTED_BUY_AMOUNT, +} as Order['apiAdditionalInfo'] + +export type ScenarioFrame = { + backendStatus: string + holdMs: number + stepName: OrderProgressBarStepName + countdown?: number | null + isBridgingTrade?: boolean + swapAndBridgeContext?: SwapAndBridgeContext +} + +export type Scenario = { + id: string + label: string + frames: [ScenarioFrame, ...ScenarioFrame[]] +} + +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_SOLVERS = [ + { solver: 'baseline', displayName: 'Baseline' }, + { solver: 'barn', displayName: 'Barn' }, +] + +const DEMO_BRIDGE_PROVIDER = { + name: 'Across', + logoUrl: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=', +} as SwapAndBridgeContext['bridgeProvider'] + +function getDemoBridgeContext(status: SwapAndBridgeStatus): SwapAndBridgeContext { + return { + bridgeProvider: DEMO_BRIDGE_PROVIDER, + bridgingStatus: status, + 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 const PLAYGROUND_SCENARIOS: Scenario[] = [ + { + id: 'happyPath', + label: 'Happy path: scheduled -> active -> executing -> traded', + frames: [ + { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, + { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'executing', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, + ], + }, + { + id: 'skipExecutingPoll', + label: 'Missed executing poll: scheduled -> active -> open -> traded', + frames: [ + { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, + { stepName: OrderProgressBarStepName.DELAYED, backendStatus: 'open', holdMs: 1500 }, + { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'traded', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, + ], + }, + { + id: 'submissionRetry', + label: 'Submission retry: scheduled -> active -> executing -> open -> traded', + frames: [ + { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, + { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'executing', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.SUBMISSION_FAILED, backendStatus: 'open', holdMs: 1800 }, + { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'open', holdMs: 1500 }, + { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'traded', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, + ], + }, + { + id: 'submissionRetryWithNotFound', + label: 'Retry with NotFound: active -> executing -> open -> notFound -> traded', + frames: [ + { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, + { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'executing', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.SUBMISSION_FAILED, backendStatus: 'open', holdMs: 1800 }, + { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'notFound', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'traded', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, + ], + }, + { + id: 'fastFillFromInitial', + label: 'Fast fill from initial: scheduled -> traded', + frames: [ + { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, + ], + }, + { + id: 'reloadMissedFulfilledEvent', + label: 'Reload path: scheduled -> active -> traded', + frames: [ + { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, + { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'traded', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, + ], + }, + { + id: 'bridgeContextReload', + label: 'Bridge context reload after fill', + frames: [ + { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, + { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, + { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'traded', holdMs: 1200 }, + { + stepName: OrderProgressBarStepName.BRIDGING_IN_PROGRESS, + backendStatus: 'traded + bridge pending', + holdMs: 1500, + isBridgingTrade: true, + swapAndBridgeContext: getDemoBridgeContext(SwapAndBridgeStatus.PENDING), + }, + { + stepName: OrderProgressBarStepName.BRIDGING_IN_PROGRESS, + backendStatus: 'traded + bridge context missing', + holdMs: 1500, + isBridgingTrade: true, + swapAndBridgeContext: getDemoBridgeContext(SwapAndBridgeStatus.PENDING), + }, + { + stepName: OrderProgressBarStepName.BRIDGING_FINISHED, + backendStatus: 'traded + bridge done', + holdMs: 0, + isBridgingTrade: true, + swapAndBridgeContext: getDemoBridgeContext(SwapAndBridgeStatus.DONE), + }, + ], + }, + { + id: 'cancellationRace', + label: 'Cancellation race: cancelling -> traded', + frames: [ + { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, + { stepName: OrderProgressBarStepName.CANCELLING, backendStatus: 'local cancelling', holdMs: 1500 }, + { stepName: OrderProgressBarStepName.CANCELLATION_FAILED, backendStatus: 'traded', holdMs: 0 }, + ], + }, +] + +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..7b68c501a34 --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx @@ -0,0 +1,109 @@ +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, stepName }: { countdown?: number | null; 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() + + 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('solving:9') + + 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('submissionFailed:none') + + act(() => { + jest.advanceTimersByTime(1800) + }) + + 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('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:none') + + 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(3000) + }) + + 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') + }) +}) 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..7b51f14de11 --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx @@ -0,0 +1,147 @@ +import { ChangeEvent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react' + +import { OrderProgressBar } from 'modules/orderProgressBar' + +import { + getProgressBarProps, + PLAYGROUND_SCENARIOS, + Scenario, + ScenarioFrame, +} from './OrderProgressBarPlayground.constants' +import * as styledEl from './OrderProgressBarPlayground.styled' + +interface ScenarioSimulationCardProps { + currentFrame: ScenarioFrame + currentFrameIndex: number + scenario: Scenario +} + +function ScenarioSimulationCard({ currentFrame, currentFrameIndex, scenario }: ScenarioSimulationCardProps): ReactNode { + return ( + + Simulation + + + Current backend status + {currentFrame.backendStatus} + + + + Current progress step + {currentFrame.stepName} + + + + + Backend sequence: {scenario.frames.map((frame) => frame.backendStatus).join(' -> ')} + + + + {scenario.frames.map((frame, index) => ( + + + Step {index + 1} + {index === currentFrameIndex && Now} + + + + + Backend + {frame.backendStatus} + + + + Progress + {frame.stepName} + + + + ))} + + + ) +} + +export function OrderProgressBarPlaygroundPage(): ReactNode { + const [scenarioId, setScenarioId] = useState(PLAYGROUND_SCENARIOS[0].id) + const [playbackKey, setPlaybackKey] = useState(0) + const [frameIndex, setFrameIndex] = useState(0) + + const scenario = useMemo( + () => PLAYGROUND_SCENARIOS.find((item) => item.id === scenarioId) || PLAYGROUND_SCENARIOS[0], + [scenarioId], + ) + const currentFrameIndex = scenario.frames.length > 1 ? Math.min(frameIndex, scenario.frames.length - 1) : 0 + const currentFrame = scenario.frames[currentFrameIndex] ?? scenario.frames[0] + + const restartScenario = useCallback((): void => { + setFrameIndex(0) + setPlaybackKey((value) => value + 1) + }, []) + + const handleReplay = useCallback((): void => { + restartScenario() + }, [restartScenario]) + + const handleScenarioChange = useCallback( + (event: ChangeEvent): void => { + setScenarioId(event.target.value) + restartScenario() + }, + [restartScenario], + ) + + useEffect(() => { + setFrameIndex(0) + + let elapsedMs = 0 + const timers = scenario.frames.slice(0, -1).map((frame, index) => { + elapsedMs += frame.holdMs + + return window.setTimeout(() => setFrameIndex(index + 1), elapsedMs) + }) + + return () => timers.forEach((timer) => window.clearTimeout(timer)) + }, [playbackKey, scenario]) + + return ( + + + Progress Bar Playground + + Select a canned backend sequence to replay the progress bar without placing a real order. + + + + + + Scenario + + {PLAYGROUND_SCENARIOS.map((item) => ( + + ))} + + + + + Replay scenario + + + + + + + + + + + + ) +} 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..68b57eab822 --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.styled.ts @@ -0,0 +1,228 @@ +import styled from 'styled-components/macro' + +const COLORS = { + text: '#16356f', + muted: '#4f6794', + border: '#b8c8ef', + panel: '#ffffff', + panelShadow: '0 14px 40px rgba(16, 43, 92, 0.12)', + accent: '#234c9b', + accentDark: '#173879', + accentSoft: 'linear-gradient(180deg, rgba(35, 76, 155, 0.10), rgba(35, 76, 155, 0.03))', + 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: ${COLORS.text}; +` + +export const Header = styled.div` + margin-bottom: 24px; +` + +export const Title = styled.h1` + margin: 0 0 8px; + font-size: 28px; + color: ${COLORS.text}; +` + +export const Description = styled.p` + margin: 0; + color: ${COLORS.muted}; + 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: ${COLORS.text}; +` + +export const Select = styled.select` + appearance: none; + min-height: 44px; + padding: 0 12px; + border-radius: 12px; + border: 1px solid ${COLORS.accent}; + background: ${COLORS.panel}; + color: ${COLORS.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: ${COLORS.panel}; + box-shadow: ${COLORS.panelShadow}; +` + +export const MetaCard = styled.div` + padding: 20px; + border-radius: 20px; + background: ${COLORS.panel}; + box-shadow: ${COLORS.panelShadow}; +` + +export const MetaTitle = styled.h2` + margin: 0 0 12px; + font-size: 18px; + color: ${COLORS.text}; +` + +export const MetaRow = styled.div` + margin-bottom: 12px; + color: ${COLORS.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: ${COLORS.accentSoft}; + border: 1px solid ${COLORS.border}; +` + +export const CurrentStatusLabel = styled.div` + margin-bottom: 6px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: ${COLORS.muted}; +` + +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 : COLORS.border)}; + background: ${({ $active }) => ($active ? COLORS.accentSoft : COLORS.panel)}; + color: ${({ $active }) => ($active ? COLORS.text : COLORS.muted)}; + 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: ${COLORS.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; +` From 120c1dff6fa1ac1794e6c1c6cbcb9da7188a8e41 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:41:00 +0100 Subject: [PATCH 08/16] feat: implement debug progress bar route functionality --- .../application/containers/App/RoutesApp.tsx | 8 ++++++-- .../containers/App/RoutesApp.utils.test.ts | 14 ++++++++++++++ .../application/containers/App/RoutesApp.utils.ts | 9 +++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.utils.test.ts create mode 100644 apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.utils.ts 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 2e628ba9bd6..67893ca9292 100644 --- a/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx +++ b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx @@ -11,7 +11,7 @@ import { } from '@cowprotocol/common-const' import { useFeatureFlags } from '@cowprotocol/common-hooks' -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' @@ -34,6 +34,8 @@ 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')) @@ -84,6 +86,8 @@ const lazyRoutes: LazyRouteProps[] = [ export function RoutesApp(): ReactNode { const { isAffiliateProgramEnabled } = useFeatureFlags() + const { search } = useLocation() + const isDebugProgressBarEnabled = isDebugProgressBarRouteEnabled(search, process.env.NODE_ENV) return ( @@ -115,7 +119,7 @@ export function RoutesApp(): ReactNode { } /> } /> } /> - {process.env.NODE_ENV === 'development' && ( + {isDebugProgressBarEnabled && ( } /> )} 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' +} From a4b04b621323650bf6c1667f2e52b09de4ed07bc Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:28:27 +0100 Subject: [PATCH 09/16] feat: enhance order progress bar functionality with new hooks and tests --- .../hooks/useOrderProgressBarProps.test.ts | 28 ++ .../hooks/useOrderProgressBarProps.ts | 27 +- .../src/modules/orderProgressBar/index.ts | 2 + ...ProgressEventsUpdater.integration.test.tsx | 114 +++++- .../OrderProgressEventsUpdater.test.ts | 25 +- .../updaters/OrderProgressEventsUpdater.tsx | 71 ++-- .../orderProgressBar/updaters/utils.ts | 50 +-- .../OrderProgressBarPlayground.constants.ts | 331 +++++++++--------- ...derProgressBarPlayground.demo.constants.ts | 114 ++++++ .../OrderProgressBarPlayground.page.test.tsx | 62 +++- .../debug/OrderProgressBarPlayground.page.tsx | 251 +++++++------ .../OrderProgressBarPlayground.utils.test.ts | 37 ++ .../debug/OrderProgressBarPlayground.utils.ts | 36 ++ .../OrderProgressBarPlaygroundDetails.tsx | 67 ++++ 14 files changed, 880 insertions(+), 335 deletions(-) create mode 100644 apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.demo.constants.ts create mode 100644 apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.utils.test.ts create mode 100644 apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.utils.ts create mode 100644 apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlaygroundDetails.tsx 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 d5b375b4421..2a430511baf 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts @@ -72,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, @@ -231,6 +253,12 @@ describe('shouldApplyCompletionDrivenExecutingImmediately', () => { }) 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) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts index 1087b160d3d..9ed0a0cd768 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 { usePendingOrdersFillability } from 'modules/ordersTable' import { getOrderCompetitionStatus } from 'api/cowProtocol/api' import { useCancelOrder } from 'common/hooks/useCancelOrder' @@ -36,7 +37,12 @@ import { updateOrderProgressBarStepName, } from '../state/atoms' import { OrderProgressBarProps, OrderProgressBarState } from '../types' -import { getCompletionDelayMs, shouldStageExecutingStep } from '../updaters/utils' +import { + getCompletionDelayMs, + hasProgressBarLeftInitialStep, + shouldShowUnfillableProgressStep, + shouldStageExecutingStep, +} from '../updaters/utils' export type UseOrderProgressBarResult = Pick & { stepName: Exclude @@ -52,7 +58,7 @@ type UseOrderProgressBarPropsParams = { 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 /** @@ -175,6 +181,9 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U cancellationTriggered, hasShownExecutingInCurrentAttempt, } = useGetExecutingOrderState(orderId) + const pendingOrdersFillability = usePendingOrdersFillability(OrderClass.MARKET) + const currentOrderFillability = orderId ? pendingOrdersFillability[orderId] : undefined + const shouldShowUnfillableStep = shouldShowUnfillableProgressStep(isUnfillable, currentOrderFillability) const solversInfo = useSolversInfo(chainId) const totalSolvers = Object.keys(solversInfo).length @@ -212,7 +221,7 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U useBackendApiStatusUpdater(chainId, orderId, doNotQuery) useProgressBarStepNameUpdater( orderId, - isUnfillable, + shouldShowUnfillableStep, isCancelled, isExpired, isCancelling, @@ -233,7 +242,7 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U orderId, countdown, backendApiStatus, - isUnfillable || isCancelled || isCancelling || isExpired, + shouldShowUnfillableStep || isCancelled || isCancelling || isExpired, ) return useMemo(() => { @@ -352,8 +361,11 @@ export function getProgressBarStepName( // 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 || @@ -663,6 +675,7 @@ export function shouldApplyStepNameImmediately( return ( lastTimeChangedSteps === undefined || timeSinceLastChange >= MINIMUM_STEP_DISPLAY_TIME || + stepName === OrderProgressBarStepName.CANCELLING || stepName === OrderProgressBarStepName.SUBMISSION_FAILED || stepName === OrderProgressBarStepName.FINISHED || stepName === OrderProgressBarStepName.CANCELLATION_FAILED || diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts index 5407e5d2933..ee671328633 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/index.ts @@ -9,9 +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/updaters/OrderProgressEventsUpdater.integration.test.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx index 76d8298d8a5..5a5aa575f8a 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx @@ -2,7 +2,7 @@ import { Provider as JotaiProvider } from 'jotai' import { createStore } from 'jotai/vanilla' import { ReactNode } from 'react' -import { type EnrichedOrder, SupportedChainId } from '@cowprotocol/cow-sdk' +import { type EnrichedOrder, OrderClass, SupportedChainId } from '@cowprotocol/cow-sdk' import { CowWidgetEvents, type OnFulfilledOrderPayload } from '@cowprotocol/events' import { type BridgeOrderDataSerialized } from '@cowprotocol/types' import { useWalletInfo } from '@cowprotocol/wallet' @@ -19,6 +19,7 @@ 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', () => ({ @@ -278,4 +279,115 @@ describe('OrderProgressEventsUpdater', () => { 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 29928db58f5..bf8b8b602f4 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.test.ts @@ -5,6 +5,7 @@ import { EXECUTING_STEP_MIN_DISPLAY_TIME_MS, getCompletionDelayMs, getNewlyFillableOrderIds, + shouldShowUnfillableProgressStep, shouldStageExecutingStep, } from './utils' @@ -27,7 +28,6 @@ function createFillability(overrides: Omit, 'order'>): } } -const FILLABILITY_OK = createFillability({}) const FILLABILITY_LACKING_BALANCE = createFillability({ hasEnoughBalance: false }) const FILLABILITY_LACKING_ALLOWANCE = createFillability({ hasEnoughAllowance: false }) @@ -40,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']) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx index 9cfd8ad03f9..919cc22be01 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx @@ -23,10 +23,12 @@ import { EXECUTING_STEP_MIN_DISPLAY_TIME_MS, getCompletionDelayMs, getNewlyFillableOrderIds, + hasProgressBarLeftInitialStep, shouldStageExecutingStep, } from './utils' import { OrderProgressBarStepName } from '../constants' +import { MINIMUM_STEP_DISPLAY_TIME, shouldApplyStepNameImmediately } from '../hooks/useOrderProgressBarProps' import { ordersProgressBarStateAtom, updateOrderProgressBarCountdown, @@ -61,18 +63,18 @@ function useOrdersProgressStateRef( return ordersProgressStateRef } -function useCompletionTimersRef(): MutableRefObject> { - const completionTimersRef = useRef>({}) +function useStepTimersRef(): MutableRefObject> { + const stepTimersRef = useRef>({}) useEffect(() => { - const completionTimers = completionTimersRef.current + const stepTimers = stepTimersRef.current return () => { - Object.values(completionTimers).forEach((timer) => clearTimeout(timer)) + Object.values(stepTimers).forEach((timer) => clearTimeout(timer)) } }, []) - return completionTimersRef + return stepTimersRef } function useOrderProgressEventListeners( @@ -125,29 +127,34 @@ export function OrderProgressEventsUpdater(): null { const unfillableIds = useUnfillableOrderIds() const previousUnfillableRef = useRef>(new Set()) const ordersProgressStateRef = useOrdersProgressStateRef(ordersProgressState) - const completionTimersRef = useCompletionTimersRef() - - const scheduleCompletionStep = useCallback( - (orderUid: string, step: OrderProgressBarStepName, delayMs: number) => { + const stepTimersRef = useStepTimersRef() + + const scheduleStepUpdate = useCallback( + ( + orderUid: string, + step: OrderProgressBarStepName, + delayMs: number, + expectedCurrentStep: OrderProgressBarStepName | undefined, + ) => { const scheduledTimer = setTimeout(() => { - if (completionTimersRef.current[orderUid] !== scheduledTimer) { + if (stepTimersRef.current[orderUid] !== scheduledTimer) { return } - delete completionTimersRef.current[orderUid] + delete stepTimersRef.current[orderUid] const latestState = ordersProgressStateRef.current[orderUid] - if (!latestState || latestState.progressBarStepName !== OrderProgressBarStepName.EXECUTING) { + if (!latestState || latestState.progressBarStepName !== expectedCurrentStep) { return } setStepName({ orderId: orderUid, value: step }) }, delayMs) - completionTimersRef.current[orderUid] = scheduledTimer + stepTimersRef.current[orderUid] = scheduledTimer }, - [completionTimersRef, ordersProgressStateRef, setStepName], + [ordersProgressStateRef, setStepName, stepTimersRef], ) const finalizeOrderStep = useCallback( @@ -165,16 +172,16 @@ export function OrderProgressEventsUpdater(): null { setCountdown({ orderId: orderUid, value: null }) } - const existingTimer = completionTimersRef.current[orderUid] + const existingTimer = stepTimersRef.current[orderUid] if (existingTimer) { clearTimeout(existingTimer) - delete completionTimersRef.current[orderUid] + delete stepTimersRef.current[orderUid] } if (shouldStageExecutingStep(currentStep, step, currentState?.hasShownExecutingInCurrentAttempt)) { setStepName({ orderId: orderUid, value: OrderProgressBarStepName.EXECUTING }) - scheduleCompletionStep(orderUid, step, EXECUTING_STEP_MIN_DISPLAY_TIME_MS) + scheduleStepUpdate(orderUid, step, EXECUTING_STEP_MIN_DISPLAY_TIME_MS, OrderProgressBarStepName.EXECUTING) return } @@ -182,37 +189,55 @@ export function OrderProgressEventsUpdater(): null { const completionDelayMs = getCompletionDelayMs(currentStep, step, currentState?.lastTimeChangedSteps) if (completionDelayMs > 0) { - scheduleCompletionStep(orderUid, step, completionDelayMs) + 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 }) }, - [completionTimersRef, ordersProgressStateRef, scheduleCompletionStep, setCountdown, setStepName], + [ordersProgressStateRef, scheduleStepUpdate, setCountdown, setStepName, stepTimersRef], ) useEffect(() => { const previousUnfillable = previousUnfillableRef.current const currentUnfillable = new Set(unfillableIds) - const currentProgressState = ordersProgressStateRef.current + const currentProgressState = ordersProgressState const newlyFillable = getNewlyFillableOrderIds(previousUnfillable, currentUnfillable) newlyFillable.forEach((orderId) => { const currentStep = currentProgressState[orderId]?.progressBarStepName - if (currentStep && currentStep !== OrderProgressBarStepName.UNFILLABLE) { + if (currentStep !== OrderProgressBarStepName.UNFILLABLE) { return } finalizeOrderStep(orderId, OrderProgressBarStepName.SOLVING) }) - currentUnfillable.forEach((orderId) => finalizeOrderStep(orderId, OrderProgressBarStepName.UNFILLABLE)) + currentUnfillable.forEach((orderId) => { + const currentStep = currentProgressState[orderId]?.progressBarStepName + + if (!hasProgressBarLeftInitialStep(currentStep)) { + return + } + + finalizeOrderStep(orderId, OrderProgressBarStepName.UNFILLABLE) + }) // Persist for the next diff so we only reset orders that actually recovered. previousUnfillableRef.current = currentUnfillable - }, [unfillableIds, finalizeOrderStep, ordersProgressStateRef]) + }, [unfillableIds, finalizeOrderStep, ordersProgressState]) useOrderProgressEventListeners(finalizeOrderStep) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts index 287394cfb68..fb73f35e2c3 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/utils.ts @@ -17,33 +17,33 @@ const COMPLETION_STEPS = new Set([ export const EXECUTING_STEP_MIN_DISPLAY_TIME_MS = 1000 -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 fillabilityDerived = Object.entries(pendingOrdersFillability).reduce( - (acc, [orderId, fillability]) => { - if (!fillability) { - return acc - } +export function hasBlockingFillabilityIssue(fillability: OrderFillability | undefined): boolean { + if (!fillability) { + return false + } - const lacksBalance = fillability.hasEnoughBalance === false - const lacksAllowance = fillability.hasEnoughAllowance === false && !fillability.hasPermit + const lacksBalance = fillability.hasEnoughBalance === false + const lacksAllowance = fillability.hasEnoughAllowance === false && !fillability.hasPermit - if (lacksBalance || lacksAllowance) { - acc.push(orderId) - } + return lacksBalance || lacksAllowance +} - return acc - }, - [], - ) +export function shouldShowUnfillableProgressStep( + isUnfillable: boolean, + fillability: OrderFillability | undefined, +): boolean { + return isUnfillable && !hasBlockingFillabilityIssue(fillability) +} - // An order can be flagged by both mechanisms; the Set keeps the list unique. - return Array.from(new Set([...priceDerived, ...fillabilityDerived])) +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[] { @@ -63,6 +63,10 @@ export function isCompletionStep(step: OrderProgressBarStepName | undefined): st 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, diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts index 13c2dc22177..368b9081f07 100644 --- a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts @@ -1,227 +1,222 @@ -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 { SwapAndBridgeStatus } from 'modules/bridge' -import { OrderProgressBarProps, OrderProgressBarStepName } from 'modules/orderProgressBar' +import { 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' +import { + getDemoBridgeContext, + PLAYGROUND_ACTIVE_COUNTDOWN, + type ScenarioFrame, +} from './OrderProgressBarPlayground.demo.constants' -const DEMO_RECEIVED_AMOUNT = CurrencyAmount.fromRawAmount( - OUTPUT_TOKEN, - EXECUTED_BUY_AMOUNT, -) as unknown as CurrencyAmount +const DEFAULT_FRAME_HOLD_MS = 1200 +const SEARCHING_FRAME_HOLD_MS = 1500 +const RETRY_FRAME_HOLD_MS = 1800 -const DEMO_ORDER_API_ADDITIONAL_INFO = { - executedSellAmount: EXECUTED_SELL_AMOUNT, - executedBuyAmount: EXECUTED_BUY_AMOUNT, -} as Order['apiAdditionalInfo'] +export type Scenario = { id: string; label: string; frames: [ScenarioFrame, ...ScenarioFrame[]] } +export type StaticPreview = { id: string; label: string; frame: ScenarioFrame } -export type ScenarioFrame = { - backendStatus: string - holdMs: number - stepName: OrderProgressBarStepName - countdown?: number | null - isBridgingTrade?: boolean - swapAndBridgeContext?: SwapAndBridgeContext +function getScenarioFrame( + stepName: OrderProgressBarStepName, + backendStatus: string, + holdMs: number, + extra: Partial> = {}, +): ScenarioFrame { + return { backendStatus, holdMs, stepName, ...extra } } -export type Scenario = { - id: string - label: string - frames: [ScenarioFrame, ...ScenarioFrame[]] +function getStaticPreview(id: string, label: string, frame: ScenarioFrame): StaticPreview { + return { id, label, frame } } -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 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 DEMO_SOLVERS = [ - { solver: 'baseline', displayName: 'Baseline' }, - { solver: 'barn', displayName: 'Barn' }, -] +const getDelayedFrame = (backendStatus = 'open', holdMs = SEARCHING_FRAME_HOLD_MS): ScenarioFrame => + getScenarioFrame(OrderProgressBarStepName.DELAYED, backendStatus, holdMs) -const DEMO_BRIDGE_PROVIDER = { - name: 'Across', - logoUrl: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=', -} as SwapAndBridgeContext['bridgeProvider'] +const getExecutingFrame = (backendStatus = 'executing', holdMs = DEFAULT_FRAME_HOLD_MS): ScenarioFrame => + getScenarioFrame(OrderProgressBarStepName.EXECUTING, backendStatus, holdMs) -function getDemoBridgeContext(status: SwapAndBridgeStatus): SwapAndBridgeContext { - return { - bridgeProvider: DEMO_BRIDGE_PROVIDER, - bridgingStatus: status, - 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, - }, - } -} +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: [ - { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, - { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'executing', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, - ], + frames: [getInitialFrame(), getSearchingFrame(), getExecutingFrame(), getFinishedFrame()], }, { id: 'skipExecutingPoll', label: 'Missed executing poll: scheduled -> active -> open -> traded', frames: [ - { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, - { stepName: OrderProgressBarStepName.DELAYED, backendStatus: 'open', holdMs: 1500 }, - { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'traded', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, + getInitialFrame(), + getSearchingFrame(), + getDelayedFrame(), + getExecutingFrame('traded'), + getFinishedFrame(), ], }, { id: 'submissionRetry', label: 'Submission retry: scheduled -> active -> executing -> open -> traded', frames: [ - { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, - { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'executing', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.SUBMISSION_FAILED, backendStatus: 'open', holdMs: 1800 }, - { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'open', holdMs: 1500 }, - { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'traded', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, + 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: [ - { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, - { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'executing', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.SUBMISSION_FAILED, backendStatus: 'open', holdMs: 1800 }, - { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'notFound', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'traded', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, + getInitialFrame(), + getSearchingFrame(), + getExecutingFrame(), + getScenarioFrame(OrderProgressBarStepName.SUBMISSION_FAILED, 'open', RETRY_FRAME_HOLD_MS), + getScenarioFrame(OrderProgressBarStepName.SOLVING, 'notFound', DEFAULT_FRAME_HOLD_MS), + getExecutingFrame('traded'), + getFinishedFrame(), ], }, { - id: 'fastFillFromInitial', - label: 'Fast fill from initial: scheduled -> traded', + id: 'issue6642StartsUnfillable', + label: 'Issue #6642 fixed: approval lag stays on batching/searching', frames: [ - { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, + getInitialFrame('scheduled + approval lag'), + getSearchingFrame(), + getDelayedFrame('open + allowance lag'), + getExecutingFrame('traded'), + getFinishedFrame(), ], }, + { + id: 'fastFillFromInitial', + label: 'Fast fill from initial: scheduled -> traded', + frames: [getInitialFrame(), getFinishedFrame()], + }, { id: 'reloadMissedFulfilledEvent', label: 'Reload path: scheduled -> active -> traded', - frames: [ - { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, - { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'traded', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.FINISHED, backendStatus: 'traded', holdMs: 0 }, - ], + frames: [getInitialFrame(), getSearchingFrame(), getExecutingFrame('traded'), getFinishedFrame()], }, { id: 'bridgeContextReload', label: 'Bridge context reload after fill', frames: [ - { stepName: OrderProgressBarStepName.INITIAL, backendStatus: 'scheduled', holdMs: 1200 }, - { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, - { stepName: OrderProgressBarStepName.EXECUTING, backendStatus: 'traded', holdMs: 1200 }, - { - stepName: OrderProgressBarStepName.BRIDGING_IN_PROGRESS, - backendStatus: 'traded + bridge pending', - holdMs: 1500, - isBridgingTrade: true, - swapAndBridgeContext: getDemoBridgeContext(SwapAndBridgeStatus.PENDING), - }, - { - stepName: OrderProgressBarStepName.BRIDGING_IN_PROGRESS, - backendStatus: 'traded + bridge context missing', - holdMs: 1500, - isBridgingTrade: true, - swapAndBridgeContext: getDemoBridgeContext(SwapAndBridgeStatus.PENDING), - }, - { - stepName: OrderProgressBarStepName.BRIDGING_FINISHED, - backendStatus: 'traded + bridge done', - holdMs: 0, - isBridgingTrade: true, - swapAndBridgeContext: getDemoBridgeContext(SwapAndBridgeStatus.DONE), - }, + 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: [ - { stepName: OrderProgressBarStepName.SOLVING, backendStatus: 'active', countdown: 9, holdMs: 1500 }, - { stepName: OrderProgressBarStepName.CANCELLING, backendStatus: 'local cancelling', holdMs: 1500 }, - { stepName: OrderProgressBarStepName.CANCELLATION_FAILED, backendStatus: 'traded', holdMs: 0 }, + getSearchingFrame(), + getScenarioFrame(OrderProgressBarStepName.CANCELLING, 'local cancelling', SEARCHING_FRAME_HOLD_MS), + getScenarioFrame(OrderProgressBarStepName.CANCELLATION_FAILED, 'traded', 0), ], }, ] -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, - } -} +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 index 7b68c501a34..33578667d54 100644 --- a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx @@ -40,10 +40,16 @@ describe('OrderProgressBarPlaygroundPage', () => { jest.advanceTimersByTime(1200) }) - expect(screen.getByTestId('progress-bar-state').textContent).toBe('solving:9') + expect(screen.getByTestId('progress-bar-state').textContent).toBe('initial:none') act(() => { - jest.advanceTimersByTime(1500) + 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') @@ -55,7 +61,7 @@ describe('OrderProgressBarPlaygroundPage', () => { expect(screen.getByTestId('progress-bar-state').textContent).toBe('submissionFailed:none') act(() => { - jest.advanceTimersByTime(1800) + jest.advanceTimersByTime(5000) }) expect(screen.getByTestId('progress-bar-state').textContent).toBe('solving:none') @@ -81,7 +87,7 @@ describe('OrderProgressBarPlaygroundPage', () => { jest.advanceTimersByTime(5700) }) - expect(screen.getByTestId('progress-bar-state').textContent).toBe('solving:none') + expect(screen.getByTestId('progress-bar-state').textContent).toBe('solving:15') act(() => { fireEvent.change(screen.getByLabelText('Scenario'), { target: { value: 'fastFillFromInitial' } }) @@ -95,7 +101,7 @@ describe('OrderProgressBarPlaygroundPage', () => { act(() => { fireEvent.change(screen.getByLabelText('Scenario'), { target: { value: 'reloadMissedFulfilledEvent' } }) - jest.advanceTimersByTime(3000) + jest.advanceTimersByTime(6500) }) expect(screen.getByTestId('progress-bar-state').textContent).toBe('executing:none') @@ -106,4 +112,50 @@ describe('OrderProgressBarPlaygroundPage', () => { 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('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 index 7b51f14de11..0ead475a6ef 100644 --- a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx @@ -2,107 +2,165 @@ import { ChangeEvent, ReactNode, useCallback, useEffect, useMemo, useState } fro import { OrderProgressBar } from 'modules/orderProgressBar' -import { - getProgressBarProps, - PLAYGROUND_SCENARIOS, - Scenario, - ScenarioFrame, -} from './OrderProgressBarPlayground.constants' +import { PLAYGROUND_SCENARIOS, STATIC_PLAYGROUND_PREVIEWS } from './OrderProgressBarPlayground.constants' +import { getProgressBarProps } from './OrderProgressBarPlayground.demo.constants' import * as styledEl from './OrderProgressBarPlayground.styled' - -interface ScenarioSimulationCardProps { - currentFrame: ScenarioFrame - currentFrameIndex: number - scenario: Scenario +import { 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 ScenarioSimulationCard({ currentFrame, currentFrameIndex, scenario }: ScenarioSimulationCardProps): ReactNode { +function PlaygroundControls({ + isStaticMode, + mode, + onModeChange, + onReplay, + onScenarioChange, + onStaticPreviewChange, + scenarioId, + staticPreviewId, +}: PlaygroundControlsProps): ReactNode { return ( - - Simulation - - - Current backend status - {currentFrame.backendStatus} - - - - Current progress step - {currentFrame.stepName} - - - - - Backend sequence: {scenario.frames.map((frame) => frame.backendStatus).join(' -> ')} - - - - {scenario.frames.map((frame, index) => ( - - - Step {index + 1} - {index === currentFrameIndex && Now} - - - - - Backend - {frame.backendStatus} - - - - Progress - {frame.stepName} - - - - ))} - - + + + Mode + + + + + + + + Scenario + + {PLAYGROUND_SCENARIOS.map((item) => ( + + ))} + + + + {isStaticMode ? ( + + Static step + + {STATIC_PLAYGROUND_PREVIEWS.map((item) => ( + + ))} + + + ) : ( + + Replay scenario + + )} + ) } -export function OrderProgressBarPlaygroundPage(): ReactNode { - const [scenarioId, setScenarioId] = useState(PLAYGROUND_SCENARIOS[0].id) +function useScenarioPlayback( + frameDelays: number[], + isStaticMode: boolean, +): { frameIndex: number; restartScenario: () => void } { const [playbackKey, setPlaybackKey] = useState(0) const [frameIndex, setFrameIndex] = useState(0) - const scenario = useMemo( - () => PLAYGROUND_SCENARIOS.find((item) => item.id === scenarioId) || PLAYGROUND_SCENARIOS[0], - [scenarioId], - ) - const currentFrameIndex = scenario.frames.length > 1 ? Math.min(frameIndex, scenario.frames.length - 1) : 0 - const currentFrame = scenario.frames[currentFrameIndex] ?? scenario.frames[0] - const restartScenario = useCallback((): void => { setFrameIndex(0) setPlaybackKey((value) => value + 1) }, []) - const handleReplay = useCallback((): void => { - restartScenario() - }, [restartScenario]) - - const handleScenarioChange = useCallback( - (event: ChangeEvent): void => { - setScenarioId(event.target.value) - restartScenario() - }, - [restartScenario], - ) - useEffect(() => { + if (isStaticMode) { + return + } + setFrameIndex(0) let elapsedMs = 0 - const timers = scenario.frames.slice(0, -1).map((frame, index) => { - elapsedMs += frame.holdMs + const timers = frameDelays.map((delayMs, index) => { + elapsedMs += delayMs return window.setTimeout(() => setFrameIndex(index + 1), elapsedMs) }) return () => timers.forEach((timer) => window.clearTimeout(timer)) - }, [playbackKey, scenario]) + }, [frameDelays, isStaticMode, playbackKey]) + + return { 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 { 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 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 ( @@ -113,34 +171,29 @@ export function OrderProgressBarPlaygroundPage(): ReactNode { - - - Scenario - - {PLAYGROUND_SCENARIOS.map((item) => ( - - ))} - - - - - Replay scenario - - + - + ) 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..13b935daf3c --- /dev/null +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.utils.ts @@ -0,0 +1,36 @@ +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) +} 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} + + + + ))} + + + ) +} From 8937fd88a8a3bcc215c2dd397f8e38d03bd8655e Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:45:43 +0100 Subject: [PATCH 10/16] refactor: update OrderProgressBarPlayground styles to use UI variables for consistency --- .../OrderProgressBarPlayground.styled.ts | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.styled.ts b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.styled.ts index 68b57eab822..243fcfcd70f 100644 --- a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.styled.ts +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.styled.ts @@ -1,14 +1,11 @@ +import { UI } from '@cowprotocol/ui' + import styled from 'styled-components/macro' const COLORS = { - text: '#16356f', - muted: '#4f6794', - border: '#b8c8ef', - panel: '#ffffff', panelShadow: '0 14px 40px rgba(16, 43, 92, 0.12)', accent: '#234c9b', accentDark: '#173879', - accentSoft: 'linear-gradient(180deg, rgba(35, 76, 155, 0.10), rgba(35, 76, 155, 0.03))', backendBg: '#e8efff', backendText: '#234c9b', progressBg: '#e5f4eb', @@ -19,7 +16,7 @@ export const Page = styled.div` max-width: 1120px; margin: 0 auto; padding: 32px 24px 64px; - color: ${COLORS.text}; + color: var(${UI.COLOR_TEXT}); ` export const Header = styled.div` @@ -29,12 +26,12 @@ export const Header = styled.div` export const Title = styled.h1` margin: 0 0 8px; font-size: 28px; - color: ${COLORS.text}; + color: var(${UI.COLOR_TEXT}); ` export const Description = styled.p` margin: 0; - color: ${COLORS.muted}; + color: var(${UI.COLOR_TEXT_OPACITY_70}); line-height: 1.5; ` @@ -52,7 +49,7 @@ export const Field = styled.label` gap: 6px; min-width: 280px; font-weight: 600; - color: ${COLORS.text}; + color: var(${UI.COLOR_TEXT}); ` export const Select = styled.select` @@ -60,9 +57,9 @@ export const Select = styled.select` min-height: 44px; padding: 0 12px; border-radius: 12px; - border: 1px solid ${COLORS.accent}; - background: ${COLORS.panel}; - color: ${COLORS.text}; + 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); ` @@ -99,26 +96,28 @@ export const Layout = styled.div` export const PreviewCard = styled.div` padding: 24px; border-radius: 24px; - background: ${COLORS.panel}; + 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: ${COLORS.panel}; + 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: ${COLORS.text}; + color: var(${UI.COLOR_TEXT}); ` export const MetaRow = styled.div` margin-bottom: 12px; - color: ${COLORS.text}; + color: var(${UI.COLOR_TEXT}); line-height: 1.5; ` @@ -136,8 +135,8 @@ export const CurrentStatusGrid = styled.div` export const CurrentStatusCard = styled.div` padding: 14px 16px; border-radius: 16px; - background: ${COLORS.accentSoft}; - border: 1px solid ${COLORS.border}; + 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` @@ -146,7 +145,7 @@ export const CurrentStatusLabel = styled.div` font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; - color: ${COLORS.muted}; + color: var(${UI.COLOR_TEXT_OPACITY_70}); ` export const CurrentStatusValue = styled.div` @@ -172,9 +171,10 @@ export const Timeline = styled.ol` export const TimelineItem = styled.li<{ $active: boolean }>` padding: 14px 16px; border-radius: 16px; - border: 1px solid ${({ $active }) => ($active ? COLORS.accent : COLORS.border)}; - background: ${({ $active }) => ($active ? COLORS.accentSoft : COLORS.panel)}; - color: ${({ $active }) => ($active ? COLORS.text : COLORS.muted)}; + 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')}; ` @@ -188,7 +188,7 @@ export const TimelineHeader = styled.div` export const TimelineTitle = styled.div` font-weight: 700; - color: ${COLORS.text}; + color: var(${UI.COLOR_TEXT}); ` export const TimelineCurrentBadge = styled.span` From 98c31e7c4dd8267e1819c08342d297e81aee28fe Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:57:12 +0100 Subject: [PATCH 11/16] feat: add tests for FullSizeLottie component and enhance scaling functionality for large phones --- .../pure/LottieContainer.test.tsx | 39 +++++++++++++++++++ .../orderProgressBar/pure/LottieContainer.tsx | 28 +++++++++++-- .../orderProgressBar/pure/TopSections.tsx | 4 +- 3 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/orderProgressBar/pure/LottieContainer.test.tsx 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? */} + return } interface SolvingTopSectionProps { @@ -107,7 +107,7 @@ export function ExecutingTopSection({ stepName }: BaseTopSectionProps): ReactNod return ( - + ) } From d685cdc7ac460893257614f2295bcc430b25df58 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:52:37 +0100 Subject: [PATCH 12/16] feat: add tests for cancelling scenarios in OrderProgressBar and enhance countdown functionality --- ...seOrderProgressBarProps.cancelling.test.ts | 29 ++++++++++++ .../OrderProgressBarPlayground.constants.ts | 10 +++++ .../OrderProgressBarPlayground.page.test.tsx | 44 +++++++++++++++++++ .../debug/OrderProgressBarPlayground.page.tsx | 35 ++++++++++++--- .../debug/OrderProgressBarPlayground.utils.ts | 8 ++++ 5 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.cancelling.test.ts 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/pages/debug/OrderProgressBarPlayground.constants.ts b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts index 368b9081f07..dca7f633dd2 100644 --- a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.constants.ts @@ -114,6 +114,16 @@ export const PLAYGROUND_SCENARIOS: Scenario[] = [ 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', diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx index 33578667d54..1b38c2329a7 100644 --- a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx @@ -79,6 +79,22 @@ describe('OrderProgressBarPlaygroundPage', () => { 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() @@ -147,6 +163,34 @@ describe('OrderProgressBarPlaygroundPage', () => { 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() diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx index 0ead475a6ef..8f162adf89a 100644 --- a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx @@ -5,7 +5,7 @@ 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 { getScenarioFrameDelayMs } from './OrderProgressBarPlayground.utils' +import { getScenarioFrameCountdown, getScenarioFrameDelayMs } from './OrderProgressBarPlayground.utils' import { OrderProgressBarPlaygroundDetails } from './OrderProgressBarPlaygroundDetails' type PlaygroundMode = 'scenario' | 'static' @@ -86,12 +86,14 @@ function PlaygroundControls({ function useScenarioPlayback( frameDelays: number[], isStaticMode: boolean, -): { frameIndex: number; restartScenario: () => void } { +): { 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) }, []) @@ -101,18 +103,27 @@ function useScenarioPlayback( } setFrameIndex(0) + setPlaybackElapsedMs(0) + const startedAt = Date.now() let elapsedMs = 0 const timers = frameDelays.map((delayMs, index) => { elapsedMs += delayMs - return window.setTimeout(() => setFrameIndex(index + 1), elapsedMs) + 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)) + return () => { + timers.forEach((timer) => window.clearTimeout(timer)) + window.clearInterval(playbackTimer) + } }, [frameDelays, isStaticMode, playbackKey]) - return { frameIndex, restartScenario } + return { frameElapsedMs: playbackElapsedMs, frameIndex, restartScenario } } export function OrderProgressBarPlaygroundPage(): ReactNode { @@ -133,13 +144,23 @@ export function OrderProgressBarPlaygroundPage(): ReactNode { scenario.frames.slice(0, -1).map((frame, index) => getScenarioFrameDelayMs(frame, scenario.frames[index + 1])), [scenario], ) - const { frameIndex, restartScenario } = useScenarioPlayback(frameDelays, isStaticMode) + 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 @@ -184,7 +205,7 @@ export function OrderProgressBarPlaygroundPage(): ReactNode { - + Date: Thu, 9 Apr 2026 16:17:13 +0100 Subject: [PATCH 13/16] feat: add disableAnalytics prop to OrderProgressBar and enhance order fillability handling --- .../hooks/useOrderProgressBarProps.ts | 22 ++- .../pure/OrderProgressBar/index.tsx | 14 +- .../src/modules/orderProgressBar/types.ts | 1 + ...OrderProgressEventsUpdater.timers.test.tsx | 157 ++++++++++++++++++ .../updaters/OrderProgressEventsUpdater.tsx | 155 ++++++++++++----- .../OrderProgressStateUpdater.test.tsx | 43 ++--- .../updaters/OrderProgressStateUpdater.tsx | 31 +++- .../OrderProgressBarPlayground.page.test.tsx | 14 +- .../debug/OrderProgressBarPlayground.page.tsx | 5 +- 9 files changed, 360 insertions(+), 82 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.timers.test.tsx diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts index 9ed0a0cd768..eae777e7c42 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts @@ -16,7 +16,7 @@ import { Order, OrderStatus } from 'legacy/state/orders/actions' import { type SwapAndBridgeContext, SwapAndBridgeStatus } from 'modules/bridge' import { useInjectedWidgetParams } from 'modules/injectedWidget' -import { usePendingOrdersFillability } from 'modules/ordersTable' +import { type OrderFillability, usePendingOrdersFillability } from 'modules/ordersTable' import { getOrderCompetitionStatus } from 'api/cowProtocol/api' import { useCancelOrder } from 'common/hooks/useCancelOrder' @@ -55,6 +55,7 @@ export type UseOrderProgressBarResult = Pick type UseOrderProgressBarPropsParams = { activityDerivedState: ActivityDerivedState | null chainId: SupportedChainId + currentOrderFillability?: OrderFillability isBridgingTrade: boolean } @@ -71,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 @@ -81,6 +96,7 @@ export function useOrderProgressBarProps( const progressBarProps = useOrderBaseProgressBarProps({ chainId, activityDerivedState, + currentOrderFillability, isBridgingTrade, }) @@ -144,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, @@ -181,8 +197,6 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U cancellationTriggered, hasShownExecutingInCurrentAttempt, } = useGetExecutingOrderState(orderId) - const pendingOrdersFillability = usePendingOrdersFillability(OrderClass.MARKET) - const currentOrderFillability = orderId ? pendingOrdersFillability[orderId] : undefined const shouldShowUnfillableStep = shouldShowUnfillableProgressStep(isUnfillable, currentOrderFillability) const solversInfo = useSolversInfo(chainId) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/pure/OrderProgressBar/index.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/pure/OrderProgressBar/index.tsx index 98d3ae81ef4..4a3100a84ca 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/pure/OrderProgressBar/index.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/pure/OrderProgressBar/index.tsx @@ -12,7 +12,7 @@ import { OrderProgressStepFactory } from '../steps/stepsRegistry' const IS_DEBUG_MODE = false export function OrderProgressBar(props: OrderProgressBarProps): ReactNode { - const { stepName = OrderProgressBarStepName.INITIAL, debugMode = IS_DEBUG_MODE } = props + const { stepName = OrderProgressBarStepName.INITIAL, debugMode = IS_DEBUG_MODE, disableAnalytics = false } = props const [debugStep, setDebugStep] = useState(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/types.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/types.ts index 18aa8545ffa..b8c28f8d1de 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/types.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/types.ts @@ -38,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.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 919cc22be01..bac17683231 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.tsx @@ -1,4 +1,4 @@ -import { useAtomValue, useSetAtom } from 'jotai' +import { useAtomValue, useSetAtom, useStore } from 'jotai' import { MutableRefObject, useCallback, useEffect, useMemo, useRef } from 'react' import { OrderClass, SupportedChainId } from '@cowprotocol/cow-sdk' @@ -24,6 +24,7 @@ import { getCompletionDelayMs, getNewlyFillableOrderIds, hasProgressBarLeftInitialStep, + isCompletionStep, shouldStageExecutingStep, } from './utils' @@ -63,14 +64,20 @@ function useOrdersProgressStateRef( return ordersProgressStateRef } -function useStepTimersRef(): MutableRefObject> { - const stepTimersRef = useRef>({}) +type ScheduledStepTimer = { + expectedCurrentStep: OrderProgressBarStepName | undefined + step: OrderProgressBarStepName + timer: NodeJS.Timeout +} + +function useStepTimersRef(): MutableRefObject> { + const stepTimersRef = useRef>({}) useEffect(() => { const stepTimers = stepTimersRef.current return () => { - Object.values(stepTimers).forEach((timer) => clearTimeout(timer)) + Object.values(stepTimers).forEach(({ timer }) => clearTimeout(timer)) } }, []) @@ -120,14 +127,95 @@ function useOrderProgressEventListeners( }, [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 previousUnfillableRef = useRef>(new Set()) 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( ( @@ -136,8 +224,8 @@ export function OrderProgressEventsUpdater(): null { delayMs: number, expectedCurrentStep: OrderProgressBarStepName | undefined, ) => { - const scheduledTimer = setTimeout(() => { - if (stepTimersRef.current[orderUid] !== scheduledTimer) { + const timerRef = setTimeout(() => { + if (stepTimersRef.current[orderUid]?.timer !== timerRef) { return } @@ -152,7 +240,11 @@ export function OrderProgressEventsUpdater(): null { setStepName({ orderId: orderUid, value: step }) }, delayMs) - stepTimersRef.current[orderUid] = scheduledTimer + stepTimersRef.current[orderUid] = { + expectedCurrentStep, + step, + timer: timerRef, + } }, [ordersProgressStateRef, setStepName, stepTimersRef], ) @@ -161,6 +253,7 @@ export function OrderProgressEventsUpdater(): null { (orderUid: string, step: OrderProgressBarStepName) => { const currentState = ordersProgressStateRef.current[orderUid] const currentStep = currentState?.progressBarStepName + clearStepTimer(orderUid) if (currentStep === step) { return @@ -172,13 +265,6 @@ export function OrderProgressEventsUpdater(): null { setCountdown({ orderId: orderUid, value: null }) } - const existingTimer = stepTimersRef.current[orderUid] - - if (existingTimer) { - clearTimeout(existingTimer) - delete stepTimersRef.current[orderUid] - } - if (shouldStageExecutingStep(currentStep, step, currentState?.hasShownExecutingInCurrentAttempt)) { setStepName({ orderId: orderUid, value: OrderProgressBarStepName.EXECUTING }) scheduleStepUpdate(orderUid, step, EXECUTING_STEP_MIN_DISPLAY_TIME_MS, OrderProgressBarStepName.EXECUTING) @@ -206,39 +292,16 @@ export function OrderProgressEventsUpdater(): null { setStepName({ orderId: orderUid, value: step }) }, - [ordersProgressStateRef, scheduleStepUpdate, setCountdown, setStepName, stepTimersRef], + [clearStepTimer, ordersProgressStateRef, scheduleStepUpdate, setCountdown, setStepName], ) - useEffect(() => { - const previousUnfillable = previousUnfillableRef.current - const currentUnfillable = new Set(unfillableIds) - const currentProgressState = ordersProgressState - - const newlyFillable = getNewlyFillableOrderIds(previousUnfillable, currentUnfillable) - - newlyFillable.forEach((orderId) => { - const currentStep = currentProgressState[orderId]?.progressBarStepName - - if (currentStep !== OrderProgressBarStepName.UNFILLABLE) { - return - } - - finalizeOrderStep(orderId, OrderProgressBarStepName.SOLVING) - }) - currentUnfillable.forEach((orderId) => { - const currentStep = currentProgressState[orderId]?.progressBarStepName - - if (!hasProgressBarLeftInitialStep(currentStep)) { - return - } - - finalizeOrderStep(orderId, OrderProgressBarStepName.UNFILLABLE) - }) - - // Persist for the next diff so we only reset orders that actually recovered. - previousUnfillableRef.current = currentUnfillable - }, [unfillableIds, finalizeOrderStep, ordersProgressState]) - + 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 ee46a05ea21..a091e11a9fc 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.test.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.test.tsx @@ -9,11 +9,12 @@ 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', () => ({ @@ -24,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', () => ({ @@ -96,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 @@ -106,10 +116,8 @@ 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([]) @@ -124,27 +132,23 @@ describe('OrderProgressStateUpdater', () => { }) 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() @@ -152,10 +156,7 @@ describe('OrderProgressStateUpdater', () => { }) 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() diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx index d36bdcd5245..07784bd88a7 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx @@ -9,9 +9,10 @@ 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 { useOrderProgressBarPropsWithFillability } from '../hooks/useOrderProgressBarProps' import { cancellationTrackedOrderIdsAtom, ordersProgressBarStateAtom, @@ -101,8 +102,16 @@ function getTrackedOrderIds({ return trackedIdsSet } -function OrderProgressStateObserver({ chainId, order }: { chainId: SupportedChainId; order: Order }): null { - useOrderProgressBarProps(chainId, order) +function OrderProgressStateObserver({ + chainId, + currentOrderFillability, + order, +}: { + chainId: SupportedChainId + currentOrderFillability: OrderFillability | undefined + order: Order +}): null { + useOrderProgressBarPropsWithFillability(chainId, order, currentOrderFillability) return null } @@ -184,14 +193,21 @@ function OrderProgressStatePruner({ function OrderProgressStateObservers({ chainId, marketOrders, + pendingOrdersFillability, }: { chainId: SupportedChainId marketOrders: Order[] + pendingOrdersFillability: Record }): ReactNode { return ( <> {marketOrders.map((order) => ( - + ))} ) @@ -207,6 +223,7 @@ export function OrderProgressStateUpdater(): ReactNode { () => pendingOrders.filter((order) => order.class === OrderClass.MARKET), [pendingOrders], ) + const pendingOrdersFillability = usePendingOrdersFillability(OrderClass.MARKET) if (!chainId || !account) { return ( @@ -229,7 +246,11 @@ export function OrderProgressStateUpdater(): ReactNode { surplusQueueOrderIds={surplusQueueOrderIds} transactionHash={transactionHash} /> - + ) } diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx index 1b38c2329a7..53224438835 100644 --- a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.test.tsx @@ -7,8 +7,16 @@ jest.mock('modules/orderProgressBar', () => { return { ...actual, - OrderProgressBar: ({ countdown, stepName }: { countdown?: number | null; stepName?: string }) => ( -
+ OrderProgressBar: ({ + countdown, + disableAnalytics, + stepName, + }: { + countdown?: number | null + disableAnalytics?: boolean + stepName?: string + }) => ( +
{stepName}:{countdown ?? 'none'}
), @@ -30,6 +38,8 @@ describe('OrderProgressBarPlaygroundPage', () => { 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' } }) }) diff --git a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx index 8f162adf89a..7e2d905a5f9 100644 --- a/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx +++ b/apps/cowswap-frontend/src/pages/debug/OrderProgressBarPlayground.page.tsx @@ -205,7 +205,10 @@ export function OrderProgressBarPlaygroundPage(): ReactNode { - + Date: Fri, 10 Apr 2026 11:42:50 +0100 Subject: [PATCH 14/16] fix: bypass 5s min bridge state --- .../application/containers/App/RoutesApp.tsx | 15 +++++++++++++-- .../hooks/useOrderProgressBarProps.test.ts | 6 ++++++ .../hooks/useOrderProgressBarProps.ts | 1 + 3 files changed, 20 insertions(+), 2 deletions(-) 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 67893ca9292..acbf1d91db2 100644 --- a/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx +++ b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx @@ -27,7 +27,6 @@ import { import { Routes as RoutesEnum, RoutesValues } from 'common/constants/routes' import Account, { AccountOverview } from 'pages/Account' import { AdvancedOrdersPage } from 'pages/AdvancedOrders/AdvancedOrders.page' -import { OrderProgressBarPlaygroundPage } from 'pages/debug/OrderProgressBarPlayground.page' import AnySwapAffectedUsers from 'pages/error/AnySwapAffectedUsers' import { HooksPage } from 'pages/Hooks' import { LimitOrdersPage } from 'pages/LimitOrders/LimitOrders.page' @@ -40,6 +39,11 @@ import { isDebugProgressBarRouteEnabled } from './RoutesApp.utils' 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 = @@ -120,7 +124,14 @@ export function RoutesApp(): ReactNode { } /> } /> {isDebugProgressBarEnabled && ( - } /> + }> + + + } + /> )} {lazyRoutes.map((item, key) => LazyRoute({ ...item, key }))} 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 2a430511baf..e626f0b6cea 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.test.ts @@ -265,6 +265,12 @@ describe('shouldApplyStepNameImmediately', () => { 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) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts index eae777e7c42..ec61cfcec01 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts @@ -691,6 +691,7 @@ export function shouldApplyStepNameImmediately( 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 || From 4b82ba7789b8434834690a4151356faa7f1ca7dd Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:57:33 +0100 Subject: [PATCH 15/16] feat: add bridging status test --- ...ProgressEventsUpdater.integration.test.tsx | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) 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 index 5a5aa575f8a..a279ff38018 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressEventsUpdater.integration.test.tsx @@ -3,7 +3,7 @@ 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 { CowWidgetEvents, type OnBridgingSuccessPayload, type OnFulfilledOrderPayload } from '@cowprotocol/events' import { type BridgeOrderDataSerialized } from '@cowprotocol/types' import { useWalletInfo } from '@cowprotocol/wallet' @@ -65,6 +65,15 @@ function emitFulfilledOrder(orderUid: string, bridgeOrder?: BridgeOrderDataSeria 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() @@ -157,6 +166,38 @@ describe('OrderProgressEventsUpdater', () => { 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() From 90db86abb75b1a3492d7ce1020938bb29a7ffed8 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:53:50 +0100 Subject: [PATCH 16/16] fix: bridge progress bar crash --- .../src/legacy/state/orders/actions.ts | 5 ++ .../src/legacy/state/orders/hooks.ts | 5 +- .../src/legacy/state/orders/utils.ts | 3 +- .../orders/utils/deserializeOrder.test.ts | 61 +++++++++++++++++++ .../state/orders/utils/deserializeOrder.ts | 37 +++++++++-- .../trade/utils/addPendingOrderStep.test.ts | 40 ++++++++++++ .../trade/utils/addPendingOrderStep.ts | 7 ++- 7 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 apps/cowswap-frontend/src/legacy/state/orders/utils/deserializeOrder.test.ts create mode 100644 apps/cowswap-frontend/src/modules/trade/utils/addPendingOrderStep.test.ts diff --git a/apps/cowswap-frontend/src/legacy/state/orders/actions.ts b/apps/cowswap-frontend/src/legacy/state/orders/actions.ts index 98bc47fd02e..e0f0850186d 100644 --- a/apps/cowswap-frontend/src/legacy/state/orders/actions.ts +++ b/apps/cowswap-frontend/src/legacy/state/orders/actions.ts @@ -110,6 +110,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 @@ -117,6 +121,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 679f3a3235a..d256f6bfed4 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' @@ -305,6 +305,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 4b8af9b7250..a34bf34972e 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/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, }