diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx index 1fef385dea2..7dcc0b4c590 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx @@ -80,6 +80,7 @@ const PredictGameDetailsContent: React.FC = ({ marketId: market.id, childMarketIds: market.childMarketIds, claimable: false, + livePriceUpdates: true, }); const { data: claimablePositions = [] } = usePredictPositions({ marketId: market.id, diff --git a/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx b/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx index 5753c9818ec..9a2c259a40e 100644 --- a/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx +++ b/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx @@ -42,7 +42,7 @@ const PredictHomePositions = forwardRef< refetch, isLoading: isActiveLoading, error: activeError, - } = usePredictPositions({ claimable: false }); + } = usePredictPositions({ claimable: false, livePriceUpdates: true }); const { data: claimablePositions = [], diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx index c673a6d1b98..005c6b2e767 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx @@ -45,13 +45,6 @@ jest.mock('../../hooks/usePredictCashOut', () => ({ usePredictCashOut: () => ({ onCashOut: mockOnCashOut }), })); -jest.mock('../../hooks/usePredictLivePositions', () => ({ - usePredictLivePositions: jest.fn((positions: unknown[]) => ({ - livePositions: positions ?? [], - isConnected: false, - lastUpdateTime: null, - })), -})); jest.mock('../../utils/format'); const mockUseSelector = useSelector as jest.MockedFunction; diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx index 0d560fdda2d..47568ad316a 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx @@ -1,7 +1,6 @@ import { Box } from '@metamask/design-system-react-native'; import React from 'react'; import { useSelector } from 'react-redux'; -import { usePredictLivePositions } from '../../hooks/usePredictLivePositions'; import { usePredictCashOut } from '../../hooks/usePredictCashOut'; import { PredictMarket, @@ -29,7 +28,6 @@ const PredictPicks: React.FC = ({ claimablePositions, testID = PREDICT_PICKS_TEST_ID, }) => { - const { livePositions } = usePredictLivePositions(positions); const { onCashOut } = usePredictCashOut({ market, callerName: 'PredictPicks', @@ -43,7 +41,7 @@ const PredictPicks: React.FC = ({ if (usePositionDetail) { return ( - {livePositions.map((position) => ( + {positions.map((position) => ( = ({ return ( - {livePositions.map((position) => ( + {positions.map((position) => ( ({ - usePredictLivePositions: jest.fn((positions: unknown[]) => ({ - livePositions: positions ?? [], - isConnected: false, - lastUpdateTime: null, - })), -})); jest.mock('../../utils/format'); const mockUsePredictPositions = usePredictPositions as jest.Mock; @@ -327,12 +320,12 @@ describe('PredictPicksForCard', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith({ marketId: 'specific-market-456', - refetchInterval: 10000, enabled: true, + livePriceUpdates: true, }); }); - it('passes refetchInterval of 10000ms to hook when no positions prop', () => { + it('enables livePriceUpdates when no positions prop', () => { mockUsePredictPositions.mockReturnValue({ data: [], isLoading: false, @@ -345,7 +338,7 @@ describe('PredictPicksForCard', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith( expect.objectContaining({ - refetchInterval: 10000, + livePriceUpdates: true, }), ); }); @@ -362,8 +355,8 @@ describe('PredictPicksForCard', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith({ marketId: 'market-1', - refetchInterval: undefined, enabled: false, + livePriceUpdates: false, }); }); }); diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx index f2049d1248a..92e5ac875e3 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Box } from '@metamask/design-system-react-native'; import { usePredictPositions } from '../../hooks/usePredictPositions'; -import { usePredictLivePositions } from '../../hooks/usePredictLivePositions'; import type { PredictPosition } from '../../types'; import PredictPicksForCardItem from './PredictPicksForCardItem'; import { @@ -33,14 +32,13 @@ const PredictPicksForCard: React.FC = ({ }) => { const { data: fetchedPositions = [] } = usePredictPositions({ marketId, - refetchInterval: positionsProp ? undefined : 10000, enabled: !positionsProp, + livePriceUpdates: !positionsProp, }); const basePositions = positionsProp ?? fetchedPositions; - const { livePositions } = usePredictLivePositions(basePositions); - if (livePositions.length === 0) { + if (basePositions.length === 0) { return null; } @@ -52,7 +50,7 @@ const PredictPicksForCard: React.FC = ({ twClassName="h-px bg-border-muted my-2" /> )} - {livePositions.map((position) => ( + {basePositions.map((position) => ( ({ })); const mockRefetchClaimablePositions = jest.fn(); -jest.mock('../../hooks/usePredictPositions', () => ({ - usePredictPositions: () => ({ - data: [{ id: 'position-1' }], - isLoading: false, - error: null, - refetch: mockRefetchClaimablePositions, - }), -})); +let mockActivePositions: PredictPosition[] = []; +let mockClaimablePositions: PredictPosition[] = []; +jest.mock('../../hooks/usePredictPositions'); const mockClaim = jest.fn(); jest.mock('../../hooks/usePredictClaim', () => ({ @@ -127,36 +123,13 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); -function createTestState( - _availableBalance?: number, - claimableAmount?: number, - privacyMode = false, -) { +function createTestState(_availableBalance?: number, privacyMode = false) { const testAddress = '0x1234567890123456789012345678901234567890'; const testAccountId = 'test-account-id'; - const claimablePositions = claimableAmount - ? ([ - { - id: 'position-1', - status: PredictPositionStatus.WON, - cashPnl: claimableAmount, - currentValue: claimableAmount, - marketId: 'market-1', - title: 'Test Market', - outcome: 'Yes', - }, - ] as unknown as PredictPosition[]) - : []; - return { engine: { backgroundState: { - PredictController: { - claimablePositions: { - [testAddress]: claimablePositions, - }, - }, AccountsController: { internalAccounts: { selectedAccount: testAccountId, @@ -185,11 +158,16 @@ describe('MarketsWonCard', () => { const mockUseUnrealizedPnL = useUnrealizedPnL as jest.MockedFunction< typeof useUnrealizedPnL >; + const mockUsePredictPositions = usePredictPositions as jest.MockedFunction< + typeof usePredictPositions + >; beforeEach(() => { jest.clearAllMocks(); mockBalanceResult.data = 100.5; mockBalanceResult.isLoading = false; + mockActivePositions = [{ id: 'position-1' } as PredictPosition]; + mockClaimablePositions = []; mockUseUnrealizedPnL.mockReturnValue({ data: { @@ -201,13 +179,35 @@ describe('MarketsWonCard', () => { isFetching: false, error: null, } as unknown as ReturnType); + mockUsePredictPositions.mockImplementation( + ({ claimable }: { claimable?: boolean } = {}) => + ({ + data: claimable ? mockClaimablePositions : mockActivePositions, + isLoading: false, + error: null, + refetch: mockRefetchClaimablePositions, + }) as unknown as ReturnType, + ); }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('rendering', () => { + it('does not enable live updates for active position count query', () => { + const state = createTestState(100.5); + + renderWithProvider(, { state }); + + const activePositionsCall = mockUsePredictPositions.mock.calls.find( + ([options]) => options?.claimable === false, + ); + + expect(activePositionsCall?.[0]).toMatchObject({ claimable: false }); + expect(activePositionsCall?.[0]?.livePriceUpdates).toBeUndefined(); + }); + it('displays available balance and unrealized P&L', () => { const state = createTestState(100.5); @@ -230,7 +230,18 @@ describe('MarketsWonCard', () => { }); it('hides monetary values when privacy mode is enabled', () => { - const state = createTestState(100.5, 24.66, true); + mockClaimablePositions = [ + { + id: 'position-1', + status: PredictPositionStatus.WON, + cashPnl: 24.66, + currentValue: 24.66, + marketId: 'market-1', + title: 'Test Market', + outcome: 'Yes', + } as PredictPosition, + ]; + const state = createTestState(100.5, true); renderWithProvider(, { state }); @@ -238,7 +249,7 @@ describe('MarketsWonCard', () => { expect(screen.queryByText('+$8.63 (+3.9%)')).toBeNull(); expect(screen.queryByText('Claim $24.66')).toBeNull(); expect(screen.getByText('••••••••••••')).toBeOnTheScreen(); - expect(screen.getByText('•••••••••')).toBeOnTheScreen(); + expect(screen.getAllByText('•••••••••').length).toBeGreaterThan(0); }); }); diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx index 6bca1d097b8..6d37d864d30 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx @@ -46,8 +46,7 @@ import { usePredictDeposit } from '../../hooks/usePredictDeposit'; import { useUnrealizedPnL } from '../../hooks/useUnrealizedPnL'; import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; import { usePredictPositions } from '../../hooks/usePredictPositions'; -import { selectPredictWonPositions } from '../../selectors/predictController'; -import { PredictPosition } from '../../types'; +import { PredictPosition, PredictPositionStatus } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { formatPercentage, @@ -95,12 +94,20 @@ const PredictPositionsHeader = forwardRef< const evmAccount = getEvmAccountFromSelectedAccountGroup(); const selectedAddress = evmAccount?.address ?? '0x0'; const { isDepositPending } = usePredictDeposit(); - const wonPositions = useSelector( - selectPredictWonPositions({ address: selectedAddress }), - ); - - const { data: activePositions } = usePredictPositions({ claimable: false }); + const { data: activePositions } = usePredictPositions({ + claimable: false, + }); + const { data: claimablePositions = [] } = usePredictPositions({ + claimable: true, + }); const hasPositions = (activePositions?.length ?? 0) > 0; + const wonPositions = useMemo( + () => + claimablePositions.filter( + (position) => position.status === PredictPositionStatus.WON, + ), + [claimablePositions], + ); const { data: pnlData, diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx index 02d2100f1ca..bd700a62a77 100644 --- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx +++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx @@ -306,7 +306,7 @@ describe('PredictSportCardFooter', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith({ marketId: 'specific-market-123', claimable: false, - refetchInterval: 10000, + livePriceUpdates: true, }); }); diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx index 0f3b6af467a..69792b7c999 100644 --- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx +++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx @@ -53,7 +53,7 @@ const PredictSportCardFooter: React.FC = ({ const { data: positions = [], isLoading } = usePredictPositions({ marketId: market.id, claimable: false, - refetchInterval: 10000, + livePriceUpdates: true, }); const { data: claimablePositions = [] } = usePredictPositions({ diff --git a/app/components/UI/Predict/hooks/index.ts b/app/components/UI/Predict/hooks/index.ts index 1515de44b18..e22c2dda532 100644 --- a/app/components/UI/Predict/hooks/index.ts +++ b/app/components/UI/Predict/hooks/index.ts @@ -13,12 +13,6 @@ export { type UseLiveMarketPricesResult, } from './useLiveMarketPrices'; -export { - usePredictLivePositions, - type UseLivePositionsOptions, - type UseLivePositionsResult, -} from './usePredictLivePositions'; - export { usePredictTabs, type FeedTab, diff --git a/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts b/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts index b091e2f8e79..91ec1f57b18 100644 --- a/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts +++ b/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts @@ -1,10 +1,19 @@ -import { renderHook } from '@testing-library/react-native'; +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { usePredictLivePositions } from './usePredictLivePositions'; import { useLiveMarketPrices } from './useLiveMarketPrices'; import { PredictPosition, PredictPositionStatus, PriceUpdate } from '../types'; +import { predictQueries } from '../queries'; import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; jest.mock('./useLiveMarketPrices'); +const mockUseIsFocused = jest.fn(() => true); +jest.mock('@react-navigation/native', () => ({ + useIsFocused: () => mockUseIsFocused(), +})); + +const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; const createMockPosition = ( overrides: Partial = {}, @@ -42,11 +51,52 @@ const createMockPriceUpdate = ( ...overrides, }); +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, cacheTime: Infinity } }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + return { Wrapper, queryClient }; +}; + +const renderLivePositionsHook = ( + positions: PredictPosition[], + options?: Parameters[1], + cachedPositions?: PredictPosition[], +) => { + const { Wrapper, queryClient } = createWrapper(); + if (cachedPositions) { + queryClient.setQueryData( + predictQueries.positions.keys.byAddress(MOCK_ADDRESS), + cachedPositions, + ); + } + const renderResult = renderHook( + () => usePredictLivePositions(positions, options), + { + wrapper: Wrapper, + }, + ); + + return { + ...renderResult, + queryClient, + }; +}; + describe('usePredictLivePositions', () => { const mockUseLiveMarketPrices = useLiveMarketPrices as jest.Mock; + const getCachedPositions = (queryClient: QueryClient) => + queryClient.getQueryData( + predictQueries.positions.keys.byAddress(MOCK_ADDRESS), + ); + beforeEach(() => { jest.clearAllMocks(); + mockUseIsFocused.mockReturnValue(true); mockUseLiveMarketPrices.mockReturnValue({ prices: new Map(), isConnected: false, @@ -61,7 +111,7 @@ describe('usePredictLivePositions', () => { createMockPosition({ id: 'position-2', outcomeTokenId: 'token-2' }), ]; - renderHook(() => usePredictLivePositions(positions)); + renderLivePositionsHook(positions); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith( ['token-1', 'token-2'], @@ -70,7 +120,7 @@ describe('usePredictLivePositions', () => { }); it('disables subscription when positions array is empty', () => { - renderHook(() => usePredictLivePositions([])); + renderLivePositionsHook([]); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith([], { enabled: false, @@ -80,7 +130,7 @@ describe('usePredictLivePositions', () => { it('disables subscription when enabled option is false', () => { const positions = [createMockPosition()]; - renderHook(() => usePredictLivePositions(positions, { enabled: false })); + renderLivePositionsHook(positions, { enabled: false }); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(['token-1'], { enabled: false, @@ -90,16 +140,37 @@ describe('usePredictLivePositions', () => { it('passes enabled true when positions exist and enabled is not specified', () => { const positions = [createMockPosition()]; - renderHook(() => usePredictLivePositions(positions)); + renderLivePositionsHook(positions); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(['token-1'], { enabled: true, }); }); + + it('skips claimable positions when building live subscriptions', () => { + const positions = [createMockPosition({ claimable: true })]; + + renderLivePositionsHook(positions); + + expect(mockUseLiveMarketPrices).toHaveBeenCalledWith([], { + enabled: false, + }); + }); + + it('disables subscription when the screen is not focused', () => { + mockUseIsFocused.mockReturnValue(false); + const positions = [createMockPosition()]; + + renderLivePositionsHook(positions); + + expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(['token-1'], { + enabled: false, + }); + }); }); describe('live position calculation', () => { - it('returns original positions when no price updates are available', () => { + it('preserves cached positions when no price updates are available', async () => { const positions = [createMockPosition({ currentValue: 100, cashPnl: 0 })]; mockUseLiveMarketPrices.mockReturnValue({ prices: new Map(), @@ -107,13 +178,18 @@ describe('usePredictLivePositions', () => { lastUpdateTime: null, }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); - expect(result.current.livePositions[0].currentValue).toBe(100); - expect(result.current.livePositions[0].cashPnl).toBe(0); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(positions); + }); }); - it('calculates currentValue as size multiplied by bestBid', () => { + it('calculates currentValue as size multiplied by bestBid', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -129,12 +205,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].currentValue).toBe(120); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(120); + }); }); - it('calculates cashPnl as currentValue minus initialValue', () => { + it('calculates cashPnl as currentValue minus initialValue', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -150,12 +233,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].cashPnl).toBe(20); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].cashPnl).toBe(20); + }); }); - it('calculates percentPnl correctly for positive gains', () => { + it('calculates percentPnl correctly for positive gains', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -171,12 +261,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].percentPnl).toBe(20); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].percentPnl).toBe(20); + }); }); - it('calculates percentPnl correctly for negative losses', () => { + it('calculates percentPnl correctly for negative losses', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -192,14 +289,21 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].currentValue).toBe(80); - expect(result.current.livePositions[0].cashPnl).toBe(-20); - expect(result.current.livePositions[0].percentPnl).toBe(-20); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(80); + expect(cached?.[0].cashPnl).toBe(-20); + expect(cached?.[0].percentPnl).toBe(-20); + }); }); - it('returns zero percentPnl when initialValue is zero', () => { + it('returns zero percentPnl when initialValue is zero', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -215,12 +319,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].percentPnl).toBe(0); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].percentPnl).toBe(0); + }); }); - it('updates price field with bestBid value', () => { + it('updates price field with bestBid value', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', price: 0.5, @@ -235,14 +346,21 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].price).toBe(0.65); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].price).toBe(0.65); + }); }); }); describe('multiple positions', () => { - it('updates only positions with matching price updates', () => { + it('updates only positions with matching price updates', async () => { const positions = [ createMockPosition({ id: 'position-1', @@ -269,15 +387,22 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); - expect(result.current.livePositions[0].currentValue).toBe(70); - expect(result.current.livePositions[0].cashPnl).toBe(20); - expect(result.current.livePositions[1].currentValue).toBe(100); - expect(result.current.livePositions[1].cashPnl).toBe(0); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(70); + expect(cached?.[0].cashPnl).toBe(20); + expect(cached?.[1].currentValue).toBe(100); + expect(cached?.[1].cashPnl).toBe(0); + }); }); - it('updates all positions when all have price updates', () => { + it('updates all positions when all have price updates', async () => { const positions = [ createMockPosition({ id: 'position-1', @@ -308,122 +433,265 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); - expect(result.current.livePositions[0].currentValue).toBe(60); - expect(result.current.livePositions[1].currentValue).toBe(160); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(60); + expect(cached?.[1].currentValue).toBe(160); + }); }); }); - describe('connection status', () => { - it('returns isConnected from useLiveMarketPrices', () => { + describe('cache synchronization', () => { + it('syncs live values into the address cache for passed active positions', async () => { + const activePosition = createMockPosition({ + id: 'active-position', + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const untouchedPosition = createMockPosition({ + id: 'untouched-position', + outcomeTokenId: 'token-2', + currentValue: 55, + cashPnl: 5, + percentPnl: 10, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); + mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), isConnected: true, - lastUpdateTime: null, + lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([])); + const { queryClient } = renderLivePositionsHook( + [activePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + [activePosition, untouchedPosition], + ); - expect(result.current.isConnected).toBe(true); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toEqual([ + expect.objectContaining({ + id: activePosition.id, + currentValue: 120, + cashPnl: 20, + percentPnl: 20, + price: 0.6, + }), + untouchedPosition, + ]); + }); }); - it('returns false for isConnected when disconnected', () => { - mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), - isConnected: false, - lastUpdateTime: null, + it('ignores claimable positions when syncing cache', async () => { + const claimablePosition = createMockPosition({ + id: 'claimable-position', + claimable: true, + currentValue: 80, + cashPnl: 30, + percentPnl: 60, }); + const { queryClient } = renderLivePositionsHook( + [claimablePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + [claimablePosition], + ); - const { result } = renderHook(() => usePredictLivePositions([])); - - expect(result.current.isConnected).toBe(false); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toEqual([claimablePosition]); + }); }); - it('returns lastUpdateTime from useLiveMarketPrices', () => { - const timestamp = 1704067200000; + it('does not rewrite cache when live values are unchanged', async () => { + const livePosition = createMockPosition({ + currentValue: 120, + cashPnl: 20, + percentPnl: 20, + price: 0.6, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: livePosition.outcomeTokenId, + bestBid: 0.6, + }); mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), + prices: new Map([[livePosition.outcomeTokenId, priceUpdate]]), isConnected: true, - lastUpdateTime: timestamp, + lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([])); + const cachedPositions = [livePosition]; + const { queryClient } = renderLivePositionsHook( + [livePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + cachedPositions, + ); - expect(result.current.lastUpdateTime).toBe(timestamp); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); - it('returns null lastUpdateTime when no updates received', () => { + it('disables cache sync when enabled is false', async () => { + const activePosition = createMockPosition({ + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), isConnected: true, - lastUpdateTime: null, + lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([])); + const cachedPositions = [activePosition]; + const { queryClient } = renderLivePositionsHook( + [activePosition], + { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }, + cachedPositions, + ); - expect(result.current.lastUpdateTime).toBeNull(); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); - }); - describe('empty state', () => { - it('returns empty array for empty positions input', () => { - const { result } = renderHook(() => usePredictLivePositions([])); + it('disables cache sync when the screen is not focused', async () => { + mockUseIsFocused.mockReturnValue(false); + const activePosition = createMockPosition({ + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); + mockUseLiveMarketPrices.mockReturnValue({ + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), + isConnected: true, + lastUpdateTime: Date.now(), + }); - expect(result.current.livePositions).toEqual([]); + const cachedPositions = [activePosition]; + const { queryClient } = renderLivePositionsHook( + [activePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + cachedPositions, + ); + + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); - it('preserves position order in output', () => { - const positions = [ - createMockPosition({ id: 'first', outcomeTokenId: 'token-1' }), - createMockPosition({ id: 'second', outcomeTokenId: 'token-2' }), - createMockPosition({ id: 'third', outcomeTokenId: 'token-3' }), - ]; + it('disables cache sync when cacheAddress is missing', async () => { + const activePosition = createMockPosition({ + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); + mockUseLiveMarketPrices.mockReturnValue({ + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), + isConnected: true, + lastUpdateTime: Date.now(), + }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const cachedPositions = [activePosition]; + const { queryClient } = renderLivePositionsHook( + [activePosition], + undefined, + cachedPositions, + ); - expect(result.current.livePositions[0].id).toBe('first'); - expect(result.current.livePositions[1].id).toBe('second'); - expect(result.current.livePositions[2].id).toBe('third'); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); }); - describe('position data preservation', () => { - it('preserves all original position fields not related to value calculation', () => { - const position = createMockPosition({ - id: 'test-id', - providerId: 'test-provider', - marketId: 'test-market', - outcomeId: 'test-outcome', - outcome: 'Test Outcome', - title: 'Test Title', - icon: 'test-icon', - status: PredictPositionStatus.OPEN, - claimable: true, - endDate: '2025-06-15', - negRisk: true, - }); - const priceUpdate = createMockPriceUpdate({ tokenId: 'token-1' }); + describe('empty state', () => { + it('preserves position order in cache output', async () => { + const positions = [ + createMockPosition({ + id: 'first', + outcomeTokenId: 'token-1', + size: 100, + initialValue: 50, + }), + createMockPosition({ + id: 'second', + outcomeTokenId: 'token-2', + size: 200, + initialValue: 100, + }), + createMockPosition({ + id: 'third', + outcomeTokenId: 'token-3', + size: 300, + initialValue: 150, + }), + ]; + const pricesMap = new Map([ + [ + 'token-1', + createMockPriceUpdate({ tokenId: 'token-1', bestBid: 0.6 }), + ], + [ + 'token-2', + createMockPriceUpdate({ tokenId: 'token-2', bestBid: 0.7 }), + ], + [ + 'token-3', + createMockPriceUpdate({ tokenId: 'token-3', bestBid: 0.8 }), + ], + ]); mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map([['token-1', priceUpdate]]), + prices: pricesMap, isConnected: true, lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); - - const livePosition = result.current.livePositions[0]; - expect(livePosition.id).toBe('test-id'); - expect(livePosition.providerId).toBe('test-provider'); - expect(livePosition.marketId).toBe('test-market'); - expect(livePosition.outcomeId).toBe('test-outcome'); - expect(livePosition.outcome).toBe('Test Outcome'); - expect(livePosition.title).toBe('Test Title'); - expect(livePosition.icon).toBe('test-icon'); - expect(livePosition.status).toBe(PredictPositionStatus.OPEN); - expect(livePosition.claimable).toBe(true); - expect(livePosition.endDate).toBe('2025-06-15'); - expect(livePosition.negRisk).toBe(true); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); + + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].id).toBe('first'); + expect(cached?.[1].id).toBe('second'); + expect(cached?.[2].id).toBe('third'); + }); }); }); }); diff --git a/app/components/UI/Predict/hooks/usePredictLivePositions.ts b/app/components/UI/Predict/hooks/usePredictLivePositions.ts index 80530359d25..7878b3d39f0 100644 --- a/app/components/UI/Predict/hooks/usePredictLivePositions.ts +++ b/app/components/UI/Predict/hooks/usePredictLivePositions.ts @@ -1,64 +1,74 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useIsFocused } from '@react-navigation/native'; import { PredictPosition } from '../types'; +import { predictQueries } from '../queries'; import { useLiveMarketPrices } from './useLiveMarketPrices'; +/** + * Stable empty Map reference to avoid unnecessary useEffect cycles. + * When livePositionUpdates computes an empty Map, returning this constant + * preserves referential equality and prevents the cache-sync effect from firing. + */ +const EMPTY_POSITION_UPDATES = new Map< + string, + Pick +>(); + export interface UseLivePositionsOptions { /** * Whether to enable live price updates * @default true */ enabled?: boolean; -} - -export interface UseLivePositionsResult { - /** - * Positions with live-updated values based on current market prices - */ - livePositions: PredictPosition[]; - /** - * Whether the WebSocket connection is active - */ - isConnected: boolean; /** - * Timestamp of the last price update + * Address-scoped positions cache to sync live values into + * @internal */ - lastUpdateTime: number | null; + cacheAddress?: string; } /** - * Hook that takes positions and returns live-updated positions based on real-time market prices. - * - * Uses the bestBid price from live market data to calculate: - * - currentValue: size * bestBid (what you can sell for right now) - * - cashPnl: currentValue - initialValue (profit/loss) - * - percentPnl: ((currentValue - initialValue) / initialValue) * 100 + * Side-effect hook that subscribes to live market prices and syncs + * computed position values (currentValue, cashPnl, percentPnl, price) + * into the address-scoped positions query cache. * * @param positions - Array of positions to track (from usePredictPositions) - * @param options - Configuration options (enabled: boolean) - * @returns Live-updated positions, connection status, and last update timestamp + * @param options - Configuration options + * @internal Only consumed by usePredictPositions */ export const usePredictLivePositions = ( positions: PredictPosition[], options: UseLivePositionsOptions = {}, -): UseLivePositionsResult => { - const { enabled = true } = options; +): void => { + const { enabled = true, cacheAddress } = options; + const queryClient = useQueryClient(); + const isScreenFocused = useIsFocused(); const tokenIds = useMemo( - () => positions.map((position) => position.outcomeTokenId), + () => + positions + .filter((position) => !position.claimable) + .map((position) => position.outcomeTokenId), [positions], ); - const { prices, isConnected, lastUpdateTime } = useLiveMarketPrices( - tokenIds, - { enabled: enabled && positions.length > 0 }, - ); + const { prices } = useLiveMarketPrices(tokenIds, { + enabled: enabled && isScreenFocused && tokenIds.length > 0, + }); const livePositions = useMemo(() => { if (positions.length === 0) { return []; } - return positions.map((position) => { + let hasChanges = false; + + const nextPositions = positions.map((position) => { + if (position.claimable) { + return position; + } + const priceUpdate = prices.get(position.outcomeTokenId); if (!priceUpdate) { @@ -75,6 +85,17 @@ export const usePredictLivePositions = ( 100 : 0; + if ( + position.currentValue === liveCurrentValue && + position.cashPnl === liveCashPnl && + position.percentPnl === livePercentPnl && + position.price === bestBid + ) { + return position; + } + + hasChanges = true; + return { ...position, currentValue: liveCurrentValue, @@ -83,11 +104,90 @@ export const usePredictLivePositions = ( price: bestBid, }; }); + + return hasChanges ? nextPositions : positions; }, [positions, prices]); - return { - livePositions, - isConnected, - lastUpdateTime, - }; + const livePositionUpdates = useMemo(() => { + const updates = new Map< + string, + Pick + >(); + + livePositions.forEach((livePosition, index) => { + const originalPosition = positions[index]; + + if ( + !originalPosition || + originalPosition.id !== livePosition.id || + originalPosition === livePosition || + livePosition.claimable + ) { + return; + } + + updates.set(livePosition.id, { + currentValue: livePosition.currentValue, + cashPnl: livePosition.cashPnl, + percentPnl: livePosition.percentPnl, + price: livePosition.price, + }); + }); + + return updates.size > 0 ? updates : EMPTY_POSITION_UPDATES; + }, [livePositions, positions]); + + useEffect(() => { + if ( + !enabled || + !isScreenFocused || + !cacheAddress || + livePositionUpdates.size === 0 + ) { + return; + } + + queryClient.setQueryData( + predictQueries.positions.keys.byAddress(cacheAddress), + (cachedPositions) => { + if (!cachedPositions || cachedPositions.length === 0) { + return cachedPositions; + } + + let hasChanges = false; + + const nextPositions = cachedPositions.map((cachedPosition) => { + const livePositionUpdate = livePositionUpdates.get(cachedPosition.id); + + if (!livePositionUpdate || cachedPosition.claimable) { + return cachedPosition; + } + + if ( + cachedPosition.currentValue === livePositionUpdate.currentValue && + cachedPosition.cashPnl === livePositionUpdate.cashPnl && + cachedPosition.percentPnl === livePositionUpdate.percentPnl && + cachedPosition.price === livePositionUpdate.price + ) { + return cachedPosition; + } + + hasChanges = true; + + return { + ...cachedPosition, + ...livePositionUpdate, + }; + }); + + return hasChanges ? nextPositions : cachedPositions; + }, + ); + }, [ + cacheAddress, + enabled, + isScreenFocused, + livePositionUpdates, + queryClient, + ]); }; diff --git a/app/components/UI/Predict/hooks/usePredictPositions.test.ts b/app/components/UI/Predict/hooks/usePredictPositions.test.ts index 4c80ce6de69..5d94210e9f6 100644 --- a/app/components/UI/Predict/hooks/usePredictPositions.test.ts +++ b/app/components/UI/Predict/hooks/usePredictPositions.test.ts @@ -34,6 +34,10 @@ jest.mock('react-redux', () => ({ useSelector: (selector: () => unknown) => selector(), })); +jest.mock('./usePredictLivePositions', () => ({ + usePredictLivePositions: jest.fn(), +})); + const mockGetPositions = jest.fn< Promise, [{ address: string }] @@ -91,11 +95,16 @@ const createWrapper = () => { return { Wrapper, queryClient }; }; +const mockUsePredictLivePositions = jest.requireMock( + './usePredictLivePositions', +).usePredictLivePositions as jest.Mock; + describe('usePredictPositions', () => { beforeEach(() => { jest.clearAllMocks(); mockEnsurePolygonNetworkExists.mockResolvedValue(undefined); mockGetPositions.mockResolvedValue([]); + mockUsePredictLivePositions.mockImplementation(() => undefined); }); it('returns empty positions when query returns no positions', async () => { @@ -154,6 +163,13 @@ describe('usePredictPositions', () => { }); expect(result.current.data).toEqual([activePosition]); + expect(mockUsePredictLivePositions).toHaveBeenLastCalledWith( + [activePosition], + { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }, + ); }); it('returns only claimable positions when claimable is true', async () => { @@ -176,6 +192,13 @@ describe('usePredictPositions', () => { }); expect(result.current.data).toEqual([claimablePosition]); + expect(mockUsePredictLivePositions).toHaveBeenLastCalledWith( + [claimablePosition], + { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }, + ); }); it('filters positions by marketId', async () => { @@ -220,6 +243,10 @@ describe('usePredictPositions', () => { expect(mockGetPositions).not.toHaveBeenCalled(); expect(result.current.data).toBeUndefined(); expect(result.current.isFetching).toBe(false); + expect(mockUsePredictLivePositions).toHaveBeenLastCalledWith([], { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }); }); it('returns query error message when query fails', async () => { @@ -371,4 +398,94 @@ describe('usePredictPositions', () => { expect(result.current.data).toEqual([parentPosition]); }); }); + + it('updates returned data through cache sync while keeping claimable rows unchanged', async () => { + const { Wrapper } = createWrapper(); + const activePosition = createPosition('active-cache-sync', { + claimable: false, + currentValue: 100, + cashPnl: 8, + percentPnl: 12, + }); + const claimablePosition = createPosition('claimable-cache-sync', { + claimable: true, + currentValue: 40, + cashPnl: 30, + percentPnl: 300, + marketId: 'market-claimable', + }); + mockGetPositions.mockResolvedValue([activePosition, claimablePosition]); + + mockUsePredictLivePositions.mockImplementation( + ( + positions: PredictPosition[], + options?: { enabled?: boolean; cacheAddress?: string }, + ) => { + const { useEffect } = jest.requireActual('react'); + const { useQueryClient } = jest.requireActual('@tanstack/react-query'); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!options?.enabled || !options.cacheAddress) { + return; + } + + queryClient.setQueryData( + ['predict', 'positions', options.cacheAddress], + (cachedPositions: PredictPosition[] | undefined) => { + if (!cachedPositions) { + return cachedPositions; + } + + let hasChanges = false; + + const nextPositions = cachedPositions.map( + (position: PredictPosition) => { + if (position.id !== activePosition.id || position.claimable) { + return position; + } + + if ( + position.currentValue === 150 && + position.cashPnl === 58 && + position.percentPnl === 63 + ) { + return position; + } + + hasChanges = true; + + return { + ...position, + currentValue: 150, + cashPnl: 58, + percentPnl: 63, + }; + }, + ); + + return hasChanges ? nextPositions : cachedPositions; + }, + ); + }, [options?.cacheAddress, options?.enabled, queryClient, positions]); + }, + ); + + const { result } = renderHook( + () => usePredictPositions({ livePriceUpdates: true }), + { wrapper: Wrapper }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual([ + expect.objectContaining({ + id: activePosition.id, + currentValue: 150, + cashPnl: 58, + percentPnl: 63, + }), + claimablePosition, + ]); + }); + }); }); diff --git a/app/components/UI/Predict/hooks/usePredictPositions.ts b/app/components/UI/Predict/hooks/usePredictPositions.ts index ca1ce9fd36e..ac3e7ca5ef8 100644 --- a/app/components/UI/Predict/hooks/usePredictPositions.ts +++ b/app/components/UI/Predict/hooks/usePredictPositions.ts @@ -3,11 +3,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import type { PredictPosition } from '../types'; import { usePredictNetworkManagement } from './usePredictNetworkManagement'; +import { usePredictLivePositions } from './usePredictLivePositions'; import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts'; import { predictQueries } from '../queries'; import { selectSelectedAccountGroupId } from '../../../../selectors/multichainAccounts/accountTreeController'; const OPTIMISTIC_POLL_INTERVAL = 2_000; +const EMPTY_POSITIONS: PredictPosition[] = []; interface UsePredictPositionsOptions { enabled?: boolean; @@ -15,6 +17,7 @@ interface UsePredictPositionsOptions { claimable?: boolean; marketId?: string; childMarketIds?: string[]; + livePriceUpdates?: boolean; } function buildSelect( @@ -51,6 +54,7 @@ export function usePredictPositions(options: UsePredictPositionsOptions = {}) { claimable, marketId, childMarketIds, + livePriceUpdates = false, } = options; const { ensurePolygonNetworkExists } = usePredictNetworkManagement(); @@ -74,7 +78,7 @@ export function usePredictPositions(options: UsePredictPositionsOptions = {}) { (p: PredictPosition) => p.optimistic, ); - return useQuery({ + const query = useQuery({ ...queryOpts, enabled, refetchInterval: hasOptimistic @@ -82,4 +86,11 @@ export function usePredictPositions(options: UsePredictPositionsOptions = {}) { : (refetchInterval ?? false), select: buildSelect(claimable, marketId, childMarketIds), }); + + usePredictLivePositions(query.data ?? EMPTY_POSITIONS, { + enabled: enabled && livePriceUpdates, + cacheAddress: address, + }); + + return query; } diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts index 9ded1b0bd71..775b51b8b02 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts @@ -525,6 +525,58 @@ describe('WebSocketManager', () => { }), ); }); + + it('does not unsubscribe overlapping tokens still needed by another subscription', () => { + const manager = WebSocketManager.getInstance(); + const homepageCallback = jest.fn(); + const marketDetailsCallback = jest.fn(); + + manager.subscribeToMarketPrices(['token1', 'token2'], homepageCallback); + const unsubscribeMarketDetails = manager.subscribeToMarketPrices( + ['token1'], + marketDetailsCallback, + ); + mockWebSocketInstances[0].simulateOpen(); + mockWebSocketInstances[0].send.mockClear(); + + unsubscribeMarketDetails(); + + expect(mockWebSocketInstances[0].send).not.toHaveBeenCalledWith( + JSON.stringify({ + operation: 'unsubscribe', + assets_ids: ['token1'], + }), + ); + }); + + it('only unsubscribes tokens no longer needed by remaining subscriptions', () => { + const manager = WebSocketManager.getInstance(); + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + manager.subscribeToMarketPrices(['token1', 'token2'], callback1); + const unsubscribe = manager.subscribeToMarketPrices( + ['token2', 'token3'], + callback2, + ); + mockWebSocketInstances[0].simulateOpen(); + mockWebSocketInstances[0].send.mockClear(); + + unsubscribe(); + + expect(mockWebSocketInstances[0].send).toHaveBeenCalledWith( + JSON.stringify({ + operation: 'unsubscribe', + assets_ids: ['token3'], + }), + ); + expect(mockWebSocketInstances[0].send).not.toHaveBeenCalledWith( + JSON.stringify({ + operation: 'unsubscribe', + assets_ids: ['token2', 'token3'], + }), + ); + }); }); describe('crypto price subscriptions', () => { diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts index 32854b837a1..935e7c2628d 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts @@ -306,7 +306,14 @@ export class WebSocketManager { callbacks.delete(callback); if (callbacks.size === 0) { this.priceSubscriptions.delete(subscriptionKey); - this.sendMarketUnsubscribe(tokenIds); + const remainingTokenIds = this.getSubscribedMarketTokenIds(); + const tokenIdsToUnsubscribe = tokenIds.filter( + (tokenId) => !remainingTokenIds.has(tokenId), + ); + + if (tokenIdsToUnsubscribe.length > 0) { + this.sendMarketUnsubscribe(tokenIdsToUnsubscribe); + } } } @@ -449,12 +456,23 @@ export class WebSocketManager { ); } - private resubscribeAllMarkets(): void { - const allTokenIds = new Set(); + private getSubscribedMarketTokenIds(): Set { + const subscribedTokenIds = new Set(); + this.priceSubscriptions.forEach((_, key) => { - key.split(',').forEach((id) => allTokenIds.add(id)); + key.split(',').forEach((tokenId) => { + if (tokenId) { + subscribedTokenIds.add(tokenId); + } + }); }); + return subscribedTokenIds; + } + + private resubscribeAllMarkets(): void { + const allTokenIds = this.getSubscribedMarketTokenIds(); + if (allTokenIds.size > 0) { this.sendMarketSubscribe(Array.from(allTokenIds)); } diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 851ce1c2098..de562391a54 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -211,6 +211,10 @@ jest.mock('../../hooks/usePredictPositions', () => ({ })), })); +jest.mock('../../hooks/usePredictLivePositions', () => ({ + usePredictLivePositions: jest.fn(), +})); + jest.mock('../../hooks/usePredictBalance', () => ({ usePredictBalance: jest.fn(() => ({ data: 100, @@ -562,6 +566,9 @@ function setupPredictMarketDetailsTest( const { usePredictPositions } = jest.requireMock( '../../hooks/usePredictPositions', ); + const { usePredictLivePositions } = jest.requireMock( + '../../hooks/usePredictLivePositions', + ); const { usePredictEligibility } = jest.requireMock( '../../hooks/usePredictEligibility', ); @@ -624,6 +631,7 @@ function setupPredictMarketDetailsTest( ({ claimable }: { claimable?: boolean }) => claimable ? claimablePositionsHook : activePositionsHook, ); + usePredictLivePositions.mockImplementation(() => undefined); // Set up usePredictOrderPreview mock to return preview data matching position currentValue mockUsePredictOrderPreview.mockImplementation( diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 334846bcc7d..75999c08629 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -129,6 +129,7 @@ const PredictMarketDetails: React.FC = () => { childMarketIds: market?.childMarketIds, claimable: false, enabled: !isMarketLoading && Boolean(resolvedMarketId), + livePriceUpdates: true, }); // "claimable" positions diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx index 51afe896083..2e72d424395 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx @@ -314,6 +314,41 @@ describe('PredictionsSection', () => { }); }); + it('renders the current active position values from the hook data', async () => { + mockUsePredictPositionsForHomepage.mockImplementation( + ({ + claimable = false, + }: { maxPositions?: number; claimable?: boolean } = {}) => ({ + positions: claimable + ? [] + : [ + { + ...mockActivePositions[0], + currentValue: 99, + percentPnl: 890, + }, + mockActivePositions[1], + ], + isLoading: false, + error: null, + totalClaimableValue: 0, + refetch: jest.fn(), + }), + ); + + renderWithProvider( + , + ); + + await waitFor(() => { + expect(screen.getByText('Test Position 1')).toBeOnTheScreen(); + }); + + expect(screen.getByText('$99')).toBeOnTheScreen(); + expect(screen.getByText('890%')).toBeOnTheScreen(); + expect(screen.queryByText('$12')).not.toBeOnTheScreen(); + }); + it('shows position skeletons when loading positions', () => { mockUsePredictPositionsForHomepage.mockImplementation( ({ diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts index b70836a0ed3..87a7e68db8f 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts @@ -3,6 +3,7 @@ import { usePredictPositionsForHomepage } from './usePredictPositionsForHomepage import type { PredictPosition } from '../../../../../UI/Predict/types'; const mockRefetch = jest.fn().mockResolvedValue(undefined); +const mockUsePredictPositions = jest.fn(); let mockUsePredictPositionsReturn: { data: PredictPosition[] | undefined; isLoading: boolean; @@ -16,7 +17,12 @@ let mockUsePredictPositionsReturn: { }; jest.mock('../../../../../UI/Predict/hooks/usePredictPositions', () => ({ - usePredictPositions: () => mockUsePredictPositionsReturn, + usePredictPositions: ( + ...args: Parameters + ) => { + mockUsePredictPositions(...args); + return mockUsePredictPositionsReturn; + }, })); const createMockPosition = (id: string, currentValue = 12): PredictPosition => @@ -36,6 +42,7 @@ const createMockPosition = (id: string, currentValue = 12): PredictPosition => describe('usePredictPositionsForHomepage', () => { beforeEach(() => { jest.clearAllMocks(); + mockUsePredictPositions.mockClear(); mockUsePredictPositionsReturn = { data: [ createMockPosition('1'), @@ -157,6 +164,28 @@ describe('usePredictPositionsForHomepage', () => { expect(result.current.totalClaimableValue).toBe(0); }); + it('enables live updates for active positions', () => { + renderHook(() => usePredictPositionsForHomepage({ claimable: false })); + + expect(mockUsePredictPositions).toHaveBeenCalledWith( + expect.objectContaining({ + claimable: false, + livePriceUpdates: true, + }), + ); + }); + + it('disables live updates for claimable positions', () => { + renderHook(() => usePredictPositionsForHomepage({ claimable: true })); + + expect(mockUsePredictPositions).toHaveBeenCalledWith( + expect.objectContaining({ + claimable: true, + livePriceUpdates: false, + }), + ); + }); + it('treats undefined currentValue as 0 in totalClaimableValue sum', () => { mockUsePredictPositionsReturn.data = [ { diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts index 8b47633ad2b..2967e5343d0 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts @@ -32,6 +32,7 @@ export const usePredictPositionsForHomepage = ( const { data, isLoading, error, refetch } = usePredictPositions({ claimable, enabled, + livePriceUpdates: !claimable, }); const allPositions = useMemo(() => data ?? [], [data]); diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts index 73694f2bb1e..345e9c7b445 100644 --- a/tests/component-view/mocks.ts +++ b/tests/component-view/mocks.ts @@ -187,6 +187,8 @@ jest.mock('../../app/core/Engine', () => { getBalance: jest.fn().mockResolvedValue(0), getPositions: jest.fn().mockResolvedValue([]), getPrices: jest.fn().mockResolvedValue({ providerId: '', results: [] }), + subscribeToMarketPrices: jest.fn(() => () => undefined), + getConnectionStatus: jest.fn(() => ({ marketConnected: false })), trackFeedViewed: jest.fn(), trackTabChanged: jest.fn(), trackMarketDetailsOpened: jest.fn(),