diff --git a/apps/cowswap-frontend/src/entities/surplusModal/index.ts b/apps/cowswap-frontend/src/entities/surplusModal/index.ts index 7834b2611c..bbc6fb15c4 100644 --- a/apps/cowswap-frontend/src/entities/surplusModal/index.ts +++ b/apps/cowswap-frontend/src/entities/surplusModal/index.ts @@ -11,6 +11,8 @@ const initialState: OrdersToDisplayModal = { const surplusModalAtom = atom(initialState) +const surplusModalOrderIdsAtom = atom((get) => get(surplusModalAtom).orderIds) + const addSurplusOrderAtom = atom(null, (get, set, orderId: string) => set(surplusModalAtom, () => { const state = get(surplusModalAtom) @@ -52,3 +54,7 @@ export function useOrderIdForSurplusModal(): string | undefined { export function useRemoveOrderFromSurplusQueue(): (orderId: string) => void { return useSetAtom(removeSurplusOrderAtom) } + +export function useSurplusQueueOrderIds(): string[] { + return useAtomValue(surplusModalOrderIdsAtom) +} diff --git a/apps/cowswap-frontend/src/modules/application/utils/faviconAnimation/controller.test.ts b/apps/cowswap-frontend/src/modules/application/utils/faviconAnimation/controller.test.ts new file mode 100644 index 0000000000..94c410fca5 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/application/utils/faviconAnimation/controller.test.ts @@ -0,0 +1,106 @@ +jest.mock('common/favicon', () => { + const mockPlay = jest.fn() + + class MockFaviconAnimator { + play(_frames: string[], options?: { onComplete?: () => void }): void { + mockPlay(_frames) + options?.onComplete?.() + } + stop(): void {} + resetToDefault(): void {} + setDefaultFrame(): void {} + isAnimationRunning(): boolean { + return false + } + } + + return { + FaviconAnimator: MockFaviconAnimator, + frameDurations: { + solving: 100, + completed: 100, + completedHold: 100, + backToDefault: 100, + }, + __mockPlay__: mockPlay, + } +}) + +import { OrderProgressBarStepName } from 'modules/orderProgressBar' +import { OrderProgressBarState } from 'modules/orderProgressBar/types' + +import { FaviconAnimationController } from './controller' + +const { __mockPlay__: mockPlay } = jest.requireMock('common/favicon') as { __mockPlay__: jest.Mock } + +describe('FaviconAnimationController', () => { + const frameSet = { + defaultFrame: 'default.ico', + completedFrames: ['completed-1', 'completed-2'], + completedHoldFrame: 'completed-hold', + backToDefaultFrames: ['back-1'], + solvingFrames: ['solving-1'], + } + + const NOW = 10_000 + + function successState(): OrderProgressBarState { + return { + progressBarStepName: OrderProgressBarStepName.FINISHED, + lastTimeChangedSteps: NOW, + } + } + + function solvingState(): OrderProgressBarState { + return { + progressBarStepName: OrderProgressBarStepName.SOLVING, + lastTimeChangedSteps: NOW, + countdown: 5, + } + } + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW) + mockPlay.mockClear() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + function countCompletionPlays(): number { + return mockPlay.mock.calls.filter(([frames]) => frames === frameSet.completedFrames).length + } + + it('only queues a completion animation once for the same finished state', () => { + const controller = new FaviconAnimationController(frameSet) + + controller.update({ orderA: successState() }) + controller.update({}) // state pruned temporarily + controller.update({ orderA: successState() }) // re-added while still success + + expect(countCompletionPlays()).toBe(1) + }) + + it('queues another completion when an order returns to solving and finishes again', () => { + const controller = new FaviconAnimationController(frameSet) + + controller.update({ orderA: successState() }) + controller.update({ orderA: solvingState() }) + controller.update({ orderA: successState() }) + + expect(countCompletionPlays()).toBe(2) + }) + + it('ignores completion animations triggered while another order is still in progress', () => { + const controller = new FaviconAnimationController(frameSet) + + controller.update({ orderA: successState(), orderB: solvingState() }) + // Completing while another order is still solving should not enqueue anything. + expect(countCompletionPlays()).toBe(0) + + controller.update({ orderB: successState() }) + + expect(countCompletionPlays()).toBe(1) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/application/utils/faviconAnimation/controller.ts b/apps/cowswap-frontend/src/modules/application/utils/faviconAnimation/controller.ts index 1f8155ceb4..0ed8aa11dc 100644 --- a/apps/cowswap-frontend/src/modules/application/utils/faviconAnimation/controller.ts +++ b/apps/cowswap-frontend/src/modules/application/utils/faviconAnimation/controller.ts @@ -14,6 +14,8 @@ export class FaviconAnimationController { private currentCompletion: string | null = null private isInitialized = false private frameSet: FrameSet + private completedOrders = new Set() + private hasInProgress = false constructor(frameSet: FrameSet) { this.frameSet = frameSet @@ -24,6 +26,7 @@ export class FaviconAnimationController { const entries = Object.entries(state) const suppressQueue = !this.isInitialized const hasInProgress = entries.some(([, value]) => shouldAnimateInProgress(value)) + this.hasInProgress = hasInProgress this.enqueueCompleted(entries, suppressQueue) this.isInitialized = true if (hasInProgress) { @@ -80,23 +83,56 @@ export class FaviconAnimationController { private enqueueCompleted(entries: Array<[string, OrderProgressBarState]>, suppressQueue: boolean): void { const nextSteps: Record = {} + const seenOrderIds = new Set() for (const [orderId, state] of entries) { const currentStep = state.progressBarStepName nextSteps[orderId] = currentStep + seenOrderIds.add(orderId) - if ( - !suppressQueue && - isSuccess(currentStep) && - this.previousSteps[orderId] !== currentStep && - isRecentStateChange(state) - ) { - if (!this.completionQueue.includes(orderId)) this.completionQueue.push(orderId) - } + this.updateCompletionCache(orderId, currentStep) + this.tryQueueCompletion(orderId, state, currentStep, suppressQueue) } + + this.cleanupCompletedOrders(seenOrderIds) this.previousSteps = nextSteps } + private updateCompletionCache(orderId: string, currentStep: OrderProgressBarStepName | undefined): void { + if (!isSuccess(currentStep)) { + this.completedOrders.delete(orderId) + } + } + + private tryQueueCompletion( + orderId: string, + state: OrderProgressBarState, + currentStep: OrderProgressBarStepName | undefined, + suppressQueue: boolean, + ): void { + if ( + suppressQueue || + !isSuccess(currentStep) || + this.completedOrders.has(orderId) || + this.previousSteps[orderId] === currentStep || + this.hasInProgress || + !isRecentStateChange(state) + ) { + return + } + + if (!this.completionQueue.includes(orderId)) this.completionQueue.push(orderId) + this.completedOrders.add(orderId) + } + + private cleanupCompletedOrders(seenOrderIds: Set): void { + for (const orderId of Array.from(this.completedOrders)) { + if (!seenOrderIds.has(orderId)) { + this.completedOrders.delete(orderId) + } + } + } + private handleInProgress(): void { if (this.mode === 'completing' && this.currentCompletion) this.unshiftCurrentCompletion() if (!this.frameSet.solvingFrames.length) { diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts index 5e83293f30..d87cca7872 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/hooks/useOrderProgressBarProps.ts @@ -133,7 +133,7 @@ function useOrderBaseProgressBarProps(params: UseOrderProgressBarPropsParams): U // Do not build progress bar data when these conditions are set const disableProgressBar = widgetDisabled || isCreating || isFailed || isPresignaturePending || featureFlagDisabled - const orderId = order?.id || '' + const orderId = order?.id const getCancelOrder = useCancelOrder() const showCancellationModal = order && getCancelOrder ? getCancelOrder(order) : null @@ -254,9 +254,9 @@ function getDoNotQueryStatusEndpoint( const DEFAULT_STATE = {} -function useGetExecutingOrderState(orderId: string): OrderProgressBarState { +function useGetExecutingOrderState(orderId?: string): OrderProgressBarState { const fullState = useAtomValue(ordersProgressBarStateAtom) - const singleState = fullState[orderId] + const singleState = orderId ? fullState[orderId] : undefined return useMemo(() => singleState || DEFAULT_STATE, [singleState]) } @@ -281,7 +281,7 @@ function useSetExecutingOrderProgressBarStepNameCallback(): (orderId: string, va // local updaters function useCountdownStartUpdater( - orderId: string, + orderId: string | undefined, countdown: OrderProgressBarState['countdown'], backendApiStatus: OrderProgressBarState['backendApiStatus'], shouldDisableCountdown: boolean, @@ -289,6 +289,10 @@ function useCountdownStartUpdater( const setCountdown = useSetExecutingOrderCountdownCallback() useEffect(() => { + if (!orderId) { + return + } + if (shouldDisableCountdown) { // Loose `!= null` on purpose: both null and undefined should reset the countdown, but 0 must stay; strict `!== null` would let undefined slip through if (countdown != null) { @@ -308,17 +312,21 @@ function useCountdownStartUpdater( }, [backendApiStatus, setCountdown, countdown, orderId, shouldDisableCountdown]) } -function useCancellingOrderUpdater(orderId: string, isCancelling: boolean): void { +function useCancellingOrderUpdater(orderId: string | undefined, isCancelling: boolean): void { const setCancellationTriggered = useSetAtom(setOrderProgressBarCancellationTriggered) useEffect(() => { - if (isCancelling) setCancellationTriggered(orderId) + if (!orderId || !isCancelling) { + return + } + + setCancellationTriggered(orderId) }, [orderId, isCancelling, setCancellationTriggered]) } // TODO: Break down this large function into smaller functions function useProgressBarStepNameUpdater( - orderId: string, + orderId: string | undefined, isUnfillable: boolean, isCancelled: boolean, isExpired: boolean, @@ -352,8 +360,14 @@ function useProgressBarStepNameUpdater( // Update state with new step name useEffect(() => { + if (!orderId) { + return + } + + const ensuredOrderId = orderId + function updateStepName(name: OrderProgressBarStepName): void { - setProgressBarStepName(orderId, name || DEFAULT_STEP_NAME) + setProgressBarStepName(ensuredOrderId, name || DEFAULT_STEP_NAME) } let timer: NodeJS.Timeout | undefined @@ -487,7 +501,11 @@ const BACKEND_TYPE_TO_PROGRESS_BAR_STEP_NAME: Record { expect(store.get(ordersProgressBarStateAtom)).toBe(initialState) }) + }) describe('updateOrderProgressBarCountdown', () => { @@ -124,3 +126,16 @@ describe('updateOrderProgressBarCountdown', () => { expect(store.get(ordersProgressBarStateAtom)).toEqual({}) }) }) + +describe('cancellationTrackedOrderIdsAtom', () => { + it('returns ids with cancellationTriggered flag set', () => { + const store = createStore() + store.set(ordersProgressBarStateAtom, { + a: { cancellationTriggered: true }, + b: {}, + c: { cancellationTriggered: true }, + }) + + expect(store.get(cancellationTrackedOrderIdsAtom)).toEqual(['a', 'c']) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.ts b/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.ts index 3e58de5551..8c279872e8 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.ts +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/state/atoms.ts @@ -32,7 +32,7 @@ export const pruneOrdersProgressBarState = atom(null, (get, set, trackedOrderIds return acc }, {}) - if (!changed && trackedOrderIds.every((orderId) => orderId in fullState)) { + if (!changed) { return } @@ -213,3 +213,11 @@ export const setOrderProgressBarCancellationTriggered = atom(null, (get, set, or set(ordersProgressBarStateAtom, { ...fullState, [orderId]: singleState }) }) + +export const cancellationTrackedOrderIdsAtom = atom((get) => { + const fullState = get(ordersProgressBarStateAtom) + + return Object.entries(fullState) + .filter(([, state]) => state?.cancellationTriggered) + .map(([orderId]) => orderId) +}) 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 09f1224531..11c122002d 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.test.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.test.tsx @@ -4,10 +4,13 @@ import { OrderClass } from '@cowprotocol/cow-sdk' import { useWalletInfo } from '@cowprotocol/wallet' import { render } from '@testing-library/react' +import { useSurplusQueueOrderIds } from 'entities/surplusModal' import type { Order } from 'legacy/state/orders/actions' import { useOnlyPendingOrders } from 'legacy/state/orders/hooks' +import { useTradeConfirmState } from 'modules/trade' + import { OrderProgressStateUpdater } from './OrderProgressStateUpdater' import { useOrderProgressBarProps } from '../hooks/useOrderProgressBarProps' @@ -24,7 +27,16 @@ jest.mock('../hooks/useOrderProgressBarProps', () => ({ useOrderProgressBarProps: jest.fn(), })) +jest.mock('entities/surplusModal', () => ({ + useSurplusQueueOrderIds: jest.fn(), +})) + +jest.mock('modules/trade', () => ({ + useTradeConfirmState: jest.fn(), +})) + const mockPruneOrders = jest.fn() +const mockCancellationIds = jest.fn() jest.mock('jotai', () => { const actual = jest.requireActual('jotai') @@ -40,12 +52,23 @@ jest.mock('jotai', () => { return actual.useSetAtom(atom) }), + useAtomValue: jest.fn((atom: PrimitiveAtom) => { + const { cancellationTrackedOrderIdsAtom } = jest.requireActual('../state/atoms') + + if (atom === cancellationTrackedOrderIdsAtom) { + return mockCancellationIds() + } + + return actual.useAtomValue(atom) + }), } }) const useWalletInfoMock = useWalletInfo as jest.MockedFunction const useOnlyPendingOrdersMock = useOnlyPendingOrders as jest.MockedFunction const useOrderProgressBarPropsMock = useOrderProgressBarProps as jest.MockedFunction +const useSurplusQueueOrderIdsMock = useSurplusQueueOrderIds as jest.MockedFunction +const useTradeConfirmStateMock = useTradeConfirmState as jest.MockedFunction type WalletInfo = ReturnType @@ -57,6 +80,9 @@ describe('OrderProgressStateUpdater', () => { props: {} as never, activityDerivedState: null, }) + useSurplusQueueOrderIdsMock.mockReturnValue([]) + useTradeConfirmStateMock.mockReturnValue({ transactionHash: null } as never) + mockCancellationIds.mockReturnValue([]) }) afterEach(() => { @@ -96,4 +122,60 @@ describe('OrderProgressStateUpdater', () => { expect(useOrderProgressBarPropsMock).not.toHaveBeenCalled() expect(mockPruneOrders).toHaveBeenLastCalledWith([]) }) + + it('keeps state for orders queued for the surplus modal', () => { + useWalletInfoMock.mockReturnValue({ + chainId: undefined, + account: undefined, + } as unknown as WalletInfo) + useOnlyPendingOrdersMock.mockReturnValue([]) + useSurplusQueueOrderIdsMock.mockReturnValue(['queued-order', 'next-order']) + + render() + + expect(mockPruneOrders).toHaveBeenLastCalledWith(['queued-order', 'next-order']) + }) + + it('keeps state for the order currently displayed in the confirmation modal', () => { + useWalletInfoMock.mockReturnValue({ + chainId: undefined, + account: undefined, + } as unknown as WalletInfo) + useOnlyPendingOrdersMock.mockReturnValue([]) + useTradeConfirmStateMock.mockReturnValue({ transactionHash: '0xorder' } as never) + + render() + + expect(mockPruneOrders).toHaveBeenLastCalledWith(['0xorder']) + }) + + it('tracks the union of pending, queued, and displayed orders without duplicates', () => { + useWalletInfoMock.mockReturnValue({ + chainId: 1, + account: '0xabc', + } as unknown as WalletInfo) + useOnlyPendingOrdersMock.mockReturnValue([ + stubOrder({ id: '1', class: OrderClass.MARKET }), + stubOrder({ id: '2', class: OrderClass.MARKET }), + ]) + useSurplusQueueOrderIdsMock.mockReturnValue(['2', '3']) + useTradeConfirmStateMock.mockReturnValue({ transactionHash: '2' } as never) + + render() + + expect(mockPruneOrders).toHaveBeenLastCalledWith(['1', '2', '3']) + }) + + it('keeps cancellation-triggered orders even if they are not pending anymore', () => { + useWalletInfoMock.mockReturnValue({ + chainId: undefined, + account: undefined, + } as unknown as WalletInfo) + useOnlyPendingOrdersMock.mockReturnValue([]) + mockCancellationIds.mockReturnValue(['abc']) + + render() + + expect(mockPruneOrders).toHaveBeenLastCalledWith(['abc']) + }) }) diff --git a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx index 57bcb6431a..1498667b7f 100644 --- a/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/orderProgressBar/updaters/OrderProgressStateUpdater.tsx @@ -1,14 +1,18 @@ -import { useSetAtom } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import { ReactNode, useEffect, useMemo } from 'react' import { OrderClass, SupportedChainId } from '@cowprotocol/cow-sdk' import { useWalletInfo } from '@cowprotocol/wallet' +import { useSurplusQueueOrderIds } from 'entities/surplusModal' + import { Order } from 'legacy/state/orders/actions' import { useOnlyPendingOrders } from 'legacy/state/orders/hooks' +import { useTradeConfirmState } from 'modules/trade' + import { useOrderProgressBarProps } from '../hooks/useOrderProgressBarProps' -import { pruneOrdersProgressBarState } from '../state/atoms' +import { cancellationTrackedOrderIdsAtom, pruneOrdersProgressBarState } from '../state/atoms' function OrderProgressStateObserver({ chainId, order }: { chainId: SupportedChainId; order: Order }): null { useOrderProgressBarProps(chainId, order) @@ -18,6 +22,9 @@ function OrderProgressStateObserver({ chainId, order }: { chainId: SupportedChai export function OrderProgressStateUpdater(): ReactNode { const { chainId, account } = useWalletInfo() const pruneProgressState = useSetAtom(pruneOrdersProgressBarState) + const { transactionHash } = useTradeConfirmState() + const surplusQueueOrderIds = useSurplusQueueOrderIds() + const cancellationTrackedOrderIds = useAtomValue(cancellationTrackedOrderIdsAtom) const pendingOrders = useOnlyPendingOrders(chainId as SupportedChainId, account) const marketOrders = useMemo( @@ -26,9 +33,32 @@ export function OrderProgressStateUpdater(): ReactNode { ) useEffect(() => { - const trackedIds = account && chainId ? marketOrders.map((order) => order.id) : [] - pruneProgressState(trackedIds) - }, [account, chainId, marketOrders, pruneProgressState]) + 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)) + + pruneProgressState(Array.from(trackedIdsSet)) + }, [ + account, + cancellationTrackedOrderIds, + chainId, + marketOrders, + pruneProgressState, + surplusQueueOrderIds, + transactionHash, + ]) if (!chainId || !account) { return null