diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx index c0ed5f16d3b4..60d0f235accf 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx @@ -13,6 +13,7 @@ import { defaultPerpsClosePositionValidationMock, defaultPerpsEventTrackingMock, defaultPerpsLivePricesMock, + defaultPerpsTopOfBookMock, defaultPerpsOrderFeesMock, defaultPerpsPositionMock, defaultPerpsRewardsMock, @@ -51,6 +52,7 @@ jest.mock('../../hooks', () => ({ jest.mock('../../hooks/stream', () => ({ usePerpsLivePrices: jest.fn(), + usePerpsTopOfBook: jest.fn(), })); jest.mock('../../hooks/usePerpsEventTracking', () => ({ @@ -120,6 +122,9 @@ describe('PerpsClosePositionView', () => { const usePerpsLivePricesMock = jest.mocked( jest.requireMock('../../hooks/stream').usePerpsLivePrices, ); + const usePerpsTopOfBookMock = jest.mocked( + jest.requireMock('../../hooks/stream').usePerpsTopOfBook, + ); const usePerpsOrderFeesMock = jest.mocked( jest.requireMock('../../hooks').usePerpsOrderFees, ); @@ -162,6 +167,7 @@ describe('PerpsClosePositionView', () => { // Setup hook mocks with default values usePerpsLivePricesMock.mockReturnValue(defaultPerpsLivePricesMock); + usePerpsTopOfBookMock.mockReturnValue(defaultPerpsTopOfBookMock); usePerpsOrderFeesMock.mockReturnValue(defaultPerpsOrderFeesMock); usePerpsClosePositionValidationMock.mockReturnValue( defaultPerpsClosePositionValidationMock, diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index d1e6f2f06f25..2c4a0062be23 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -46,7 +46,7 @@ import { usePerpsToasts, usePerpsMarketData, } from '../../hooks'; -import { usePerpsLivePrices } from '../../hooks/stream'; +import { usePerpsLivePrices, usePerpsTopOfBook } from '../../hooks/stream'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { @@ -115,6 +115,11 @@ const PerpsClosePositionView: React.FC = () => { ? parseFloat(priceData[position.coin].price) : parseFloat(position.entryPrice); + // Get top of book data for maker/taker fee determination + const currentTopOfBook = usePerpsTopOfBook({ + symbol: position.coin, + }); + // Determine position direction const isLong = parseFloat(position.size) > 0; const absSize = Math.abs(parseFloat(position.size)); @@ -185,8 +190,6 @@ const PerpsClosePositionView: React.FC = () => { [closingValue], ); - const positionPriceData = priceData[position.coin]; - const feeResults = usePerpsOrderFees({ orderType, amount: closingValueString, @@ -194,11 +197,11 @@ const PerpsClosePositionView: React.FC = () => { isClosing: true, limitPrice, direction: isLong ? 'short' : 'long', - currentAskPrice: positionPriceData?.bestAsk - ? Number.parseFloat(positionPriceData.bestAsk) + currentAskPrice: currentTopOfBook?.bestAsk + ? Number.parseFloat(currentTopOfBook.bestAsk) : undefined, - currentBidPrice: positionPriceData?.bestBid - ? Number.parseFloat(positionPriceData.bestBid) + currentBidPrice: currentTopOfBook?.bestBid + ? Number.parseFloat(currentTopOfBook.bestBid) : undefined, }); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 24fe83a168b5..92a432092d90 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -571,6 +571,28 @@ const createMockStreamManager = () => { account: { subscribe: jest.fn(() => jest.fn()), }, + topOfBook: { + subscribeToSymbol: ({ + symbol: _symbol, + callback, + }: { + symbol: string; + callback: (data: unknown) => void; + }) => { + const id = Math.random().toString(); + subscribers.set(id, callback); + // Immediately provide mock top of book data + const mockTopOfBook = { + bestBid: '2999', + bestAsk: '3001', + spread: '2', + }; + callback(mockTopOfBook); + return () => { + subscribers.delete(id); + }; + }, + }, marketData: { subscribe: jest.fn(() => jest.fn()), getMarkets: jest.fn(), diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 4bfa9d60b5ed..3e1226b5d525 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -85,7 +85,11 @@ import { usePerpsToasts, usePerpsTrading, } from '../../hooks'; -import { usePerpsLiveAccount, usePerpsLivePrices } from '../../hooks/stream'; +import { + usePerpsLiveAccount, + usePerpsLivePrices, + usePerpsTopOfBook, +} from '../../hooks/stream'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { @@ -271,6 +275,11 @@ const PerpsOrderViewContentBase: React.FC = () => { }); const currentPrice = prices[orderForm.asset]; + // Get top of book data for maker/taker fee determination + const currentTopOfBook = usePerpsTopOfBook({ + symbol: orderForm.asset, + }); + // Track screen load with unified hook usePerpsMeasurement({ traceName: TraceName.PerpsOrderView, @@ -299,11 +308,11 @@ const PerpsOrderViewContentBase: React.FC = () => { isClosing: false, limitPrice: orderForm.limitPrice, direction: orderForm.direction, - currentAskPrice: currentPrice?.bestAsk - ? Number.parseFloat(currentPrice.bestAsk) + currentAskPrice: currentTopOfBook?.bestAsk + ? Number.parseFloat(currentTopOfBook.bestAsk) : undefined, - currentBidPrice: currentPrice?.bestBid - ? Number.parseFloat(currentPrice.bestBid) + currentBidPrice: currentTopOfBook?.bestBid + ? Number.parseFloat(currentTopOfBook.bestBid) : undefined, }); diff --git a/app/components/UI/Perps/__mocks__/perpsHooksMocks.ts b/app/components/UI/Perps/__mocks__/perpsHooksMocks.ts index 946920052255..ac41b290197e 100644 --- a/app/components/UI/Perps/__mocks__/perpsHooksMocks.ts +++ b/app/components/UI/Perps/__mocks__/perpsHooksMocks.ts @@ -11,6 +11,12 @@ export const defaultPerpsLivePricesMock = { BTC: { price: '45000.00', change24h: 1.2 }, }; +export const defaultPerpsTopOfBookMock = { + bestBid: '2999.00', + bestAsk: '3001.00', + spread: '2.00', +}; + export const defaultPerpsOrderFeesMock = { totalFee: 45, protocolFee: 45, diff --git a/app/components/UI/Perps/hooks/stream/index.ts b/app/components/UI/Perps/hooks/stream/index.ts index 9727c3ba78c9..836a23d11c0b 100644 --- a/app/components/UI/Perps/hooks/stream/index.ts +++ b/app/components/UI/Perps/hooks/stream/index.ts @@ -4,6 +4,7 @@ export { usePerpsLiveOrders } from './usePerpsLiveOrders'; export { usePerpsLivePositions } from './usePerpsLivePositions'; export { usePerpsLiveFills } from './usePerpsLiveFills'; export { usePerpsLiveAccount } from './usePerpsLiveAccount'; +export { usePerpsTopOfBook } from './usePerpsTopOfBook'; // Export types for convenience export type { UsePerpsLivePricesOptions } from './usePerpsLivePrices'; @@ -14,6 +15,10 @@ export type { UsePerpsLiveAccountOptions, UsePerpsLiveAccountReturn, } from './usePerpsLiveAccount'; +export type { + UsePerpsTopOfBookOptions, + TopOfBookData, +} from './usePerpsTopOfBook'; // Re-export types from controllers export type { diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts b/app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts index 1907f0128e26..1921d081e91f 100644 --- a/app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts +++ b/app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { usePerpsStream } from '../../providers/PerpsStreamManager'; import type { PriceUpdate } from '../../controllers/types'; @@ -28,6 +28,9 @@ export function usePerpsLivePrices( const stream = usePerpsStream(); const [prices, setPrices] = useState>({}); + // Memoize joined symbols to prevent unnecessary effect re-runs + const symbolsKey = useMemo(() => symbols.join(','), [symbols]); + useEffect(() => { if (symbols.length === 0) return; @@ -45,8 +48,10 @@ export function usePerpsLivePrices( return () => { unsubscribe(); }; + // symbolsKey captures symbols changes via memoization, so symbols is intentionally omitted + // to prevent re-subscriptions when array reference changes but content is the same // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stream, symbols.join(','), throttleMs]); + }, [stream, symbolsKey, throttleMs]); return prices; } diff --git a/app/components/UI/Perps/hooks/stream/usePerpsTopOfBook.test.ts b/app/components/UI/Perps/hooks/stream/usePerpsTopOfBook.test.ts new file mode 100644 index 000000000000..8bfc50165c95 --- /dev/null +++ b/app/components/UI/Perps/hooks/stream/usePerpsTopOfBook.test.ts @@ -0,0 +1,61 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePerpsTopOfBook } from './usePerpsTopOfBook'; +import { usePerpsStream } from '../../providers/PerpsStreamManager'; + +jest.mock('../../providers/PerpsStreamManager'); + +describe('usePerpsTopOfBook', () => { + const mockUnsubscribe = jest.fn(); + const mockSubscribeToSymbol = jest.fn(() => mockUnsubscribe); + + beforeEach(() => { + jest.clearAllMocks(); + (usePerpsStream as jest.Mock).mockReturnValue({ + topOfBook: { + subscribeToSymbol: mockSubscribeToSymbol, + }, + }); + }); + + it('subscribes to top of book for provided symbol', () => { + const symbol = 'BTC'; + + renderHook(() => usePerpsTopOfBook({ symbol })); + + expect(mockSubscribeToSymbol).toHaveBeenCalledWith({ + symbol, + callback: expect.any(Function), + }); + }); + + it('returns undefined initially', () => { + const { result } = renderHook(() => usePerpsTopOfBook({ symbol: 'BTC' })); + + expect(result.current).toBeUndefined(); + }); + + it('does not subscribe when symbol is empty', () => { + renderHook(() => usePerpsTopOfBook({ symbol: '' })); + + expect(mockSubscribeToSymbol).not.toHaveBeenCalled(); + }); + + it('calls unsubscribe on unmount', () => { + const { unmount } = renderHook(() => usePerpsTopOfBook({ symbol: 'BTC' })); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it('resubscribes when symbol changes', () => { + const { rerender } = renderHook( + ({ symbol }) => usePerpsTopOfBook({ symbol }), + { initialProps: { symbol: 'BTC' } }, + ); + + rerender({ symbol: 'ETH' }); + + expect(mockSubscribeToSymbol).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/components/UI/Perps/hooks/stream/usePerpsTopOfBook.ts b/app/components/UI/Perps/hooks/stream/usePerpsTopOfBook.ts new file mode 100644 index 000000000000..7f92494f1ded --- /dev/null +++ b/app/components/UI/Perps/hooks/stream/usePerpsTopOfBook.ts @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; +import { usePerpsStream } from '../../providers/PerpsStreamManager'; + +/** + * Top of book data (best bid/ask only) + */ +export interface TopOfBookData { + bestBid?: string; + bestAsk?: string; + spread?: string; +} + +export interface UsePerpsTopOfBookOptions { + /** Symbol to subscribe to top of book data */ + symbol: string; +} + +/** + * Hook for top of book subscriptions (best bid/ask prices only) + * + * **Important**: Only use this hook when you need top of book data for fee calculations + * (maker/taker determination). Most views should use `usePerpsLivePrices` instead. + * + * Top of book data is used to determine if a limit order will be maker or taker: + * - Limit buy BELOW bestAsk = maker (provides liquidity, 0.015% fee) + * - Limit buy AT/ABOVE bestAsk = taker (removes liquidity, 0.045% fee) + * + * Currently needed by: + * - PerpsOrderView (for fee calculations when placing orders) + * - PerpsClosePositionView (for fee calculations when closing positions) + * + * @param options - Configuration options for the hook + * @returns Top of book data for the specified symbol with real-time updates + * + * @example + * ```typescript + * // In OrderView - need both prices and top of book + * const prices = usePerpsLivePrices({ symbols: ['BTC'] }); + * const topOfBook = usePerpsTopOfBook({ symbol: 'BTC' }); + * + * const currentPrice = prices['BTC']; + * + * // Use top of book for maker/taker determination + * const isMaker = topOfBook?.bestAsk && limitPrice < Number(topOfBook.bestAsk); + * ``` + */ +export function usePerpsTopOfBook( + options: UsePerpsTopOfBookOptions, +): TopOfBookData | undefined { + const { symbol } = options; + const stream = usePerpsStream(); + const [topOfBook, setTopOfBook] = useState( + undefined, + ); + + useEffect(() => { + if (!symbol) return; + + const unsubscribe = stream.topOfBook.subscribeToSymbol({ + symbol, + callback: (newTopOfBook) => { + setTopOfBook(newTopOfBook); + }, + }); + + return () => { + unsubscribe(); + }; + }, [stream, symbol]); + + return topOfBook; +} diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index 2235d25e5bc9..b92b91e22059 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -521,7 +521,6 @@ describe('PerpsStreamManager', () => { expect(mockSubscribeToPrices).toHaveBeenCalledWith({ symbols: ['BTC-PERP'], callback: expect.any(Function), - includeOrderBook: true, }); }); }); @@ -1135,7 +1134,6 @@ describe('PerpsStreamManager', () => { expect(mockSubscribeToPrices).toHaveBeenCalledWith({ symbols: ['BTC-PERP', 'ETH-PERP'], callback: expect.any(Function), - includeOrderBook: true, }); }); }); @@ -1415,4 +1413,69 @@ describe('PerpsStreamManager', () => { expect(mockLogger.error).toBeDefined(); }); }); + + describe('TopOfBookStreamChannel', () => { + it('subscribes to top of book with includeOrderBook flag', () => { + const callback = jest.fn(); + + testStreamManager.topOfBook.subscribeToSymbol({ + symbol: 'BTC', + callback, + }); + + expect(mockSubscribeToPrices).toHaveBeenCalledWith({ + symbols: ['BTC'], + includeOrderBook: true, + callback: expect.any(Function), + }); + }); + + it('provides top of book data for subscribed symbol', () => { + const callback = jest.fn(); + + testStreamManager.topOfBook.subscribeToSymbol({ + symbol: 'BTC', + callback, + }); + + const priceCallback = mockSubscribeToPrices.mock.calls[0][0].callback; + priceCallback([ + { coin: 'BTC', bestBid: '50000', bestAsk: '50001' }, + { coin: 'ETH', bestBid: '3000', bestAsk: '3001' }, + ]); + + expect(callback).toHaveBeenCalledWith({ + bestBid: '50000', + bestAsk: '50001', + spread: undefined, + }); + }); + + it('does not subscribe when symbol is empty', () => { + const callback = jest.fn(); + + testStreamManager.topOfBook.subscribeToSymbol({ + symbol: '', + callback, + }); + + expect(mockSubscribeToPrices).not.toHaveBeenCalled(); + }); + + it('clears top of book cache when clearCache called', () => { + const callback = jest.fn(); + + testStreamManager.topOfBook.subscribeToSymbol({ + symbol: 'BTC', + callback, + }); + + const priceCallback = mockSubscribeToPrices.mock.calls[0][0].callback; + priceCallback([{ coin: 'BTC', bestBid: '50000', bestAsk: '50001' }]); + + testStreamManager.topOfBook.clearCache(); + + expect(callback).toHaveBeenCalledWith(undefined); + }); + }); }); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index ca68c2462811..798ee55e2bef 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -70,14 +70,17 @@ abstract class StreamChannel { // Store pending update subscriber.pendingUpdate = updates; - // Only set timer if one isn't already running + // Throttle pattern: Only set timer if one isn't already running + // This ensures callbacks fire at most once per throttleMs interval + // WITHOUT resetting the countdown on every update (which would be debouncing) + // The conditional check prevents timer accumulation - no memory leaks if (!subscriber.timer) { subscriber.timer = setTimeout(() => { if (subscriber.pendingUpdate) { subscriber.callback(subscriber.pendingUpdate); subscriber.pendingUpdate = undefined; - subscriber.timer = undefined; } + subscriber.timer = undefined; }, subscriber.throttleMs); } }); @@ -107,10 +110,12 @@ abstract class StreamChannel { // Ensure WebSocket connected this.connect(); + // Return unsubscribe function return () => { const sub = this.subscribers.get(id); if (sub?.timer) { clearTimeout(sub.timer); + sub.timer = undefined; } this.subscribers.delete(id); @@ -126,6 +131,15 @@ abstract class StreamChannel { } public disconnect() { + // This prevents orphaned timers from continuing to run after disconnect + this.subscribers.forEach((subscriber) => { + if (subscriber.timer) { + clearTimeout(subscriber.timer); + subscriber.timer = undefined; + } + subscriber.pendingUpdate = undefined; + }); + if (this.wsSubscription) { this.wsSubscription(); this.wsSubscription = null; @@ -157,6 +171,16 @@ abstract class StreamChannel { } public clearCache(): void { + // This ensures no timers are orphaned during the disconnect/reconnect cycle + this.subscribers.forEach((subscriber) => { + // Clear any pending updates and timers + if (subscriber.timer) { + clearTimeout(subscriber.timer); + subscriber.timer = undefined; + } + subscriber.pendingUpdate = undefined; + }); + // Disconnect the old WebSocket subscription to stop receiving old account data if (this.wsSubscription) { this.disconnect(); @@ -172,12 +196,6 @@ abstract class StreamChannel { // Notify subscribers with cleared data to trigger loading state // Using getClearedData() ensures type safety while maintaining loading semantics this.subscribers.forEach((subscriber) => { - // Clear any pending updates and timers - if (subscriber.timer) { - clearTimeout(subscriber.timer); - subscriber.timer = undefined; - } - subscriber.pendingUpdate = undefined; // Send cleared data to indicate "no data yet" (loading state) subscriber.callback(this.getClearedData()); }); @@ -228,7 +246,6 @@ class PriceStreamChannel extends StreamChannel> { this.wsSubscription = Engine.context.PerpsController.subscribeToPrices({ symbols: allSymbols, - includeOrderBook: true, // include bid/ask data from L2 book callback: (updates: PriceUpdate[]) => { // Update cache and build price map const priceMap: Record = {}; @@ -335,7 +352,6 @@ class PriceStreamChannel extends StreamChannel> { // Subscribe to all market prices this.prewarmUnsubscribe = controller.subscribeToPrices({ symbols: this.allMarketSymbols, - includeOrderBook: true, // include bid/ask data from L2 book callback: (updates: PriceUpdate[]) => { // Update cache and build price map const priceMap: Record = {}; @@ -831,6 +847,97 @@ class AccountStreamChannel extends StreamChannel { } } +// Top of book channel for best bid/ask data +class TopOfBookStreamChannel extends StreamChannel< + { bestBid?: string; bestAsk?: string; spread?: string } | undefined +> { + private currentSymbol: string | null = null; + private cachedTopOfBook: + | { bestBid?: string; bestAsk?: string; spread?: string } + | undefined = undefined; + + protected connect() { + if (!this.currentSymbol || this.wsSubscription) { + return; + } + + DevLogger.log(`TopOfBookStreamChannel: Subscribing to top of book`, { + symbol: this.currentSymbol, + }); + + this.wsSubscription = Engine.context.PerpsController.subscribeToPrices({ + symbols: [this.currentSymbol], + includeOrderBook: true, + callback: (updates: PriceUpdate[]) => { + const update = updates.find((u) => u.coin === this.currentSymbol); + if (update) { + const topOfBook = { + bestBid: update.bestBid, + bestAsk: update.bestAsk, + spread: update.spread, + }; + this.cachedTopOfBook = topOfBook; + this.notifySubscribers(topOfBook); + } + }, + }); + } + + protected getCachedData(): + | { bestBid?: string; bestAsk?: string; spread?: string } + | undefined { + return this.cachedTopOfBook; + } + + protected getClearedData(): + | { bestBid?: string; bestAsk?: string; spread?: string } + | undefined { + return undefined; + } + + public clearCache(): void { + this.cachedTopOfBook = undefined; + super.clearCache(); + } + + subscribeToSymbol(params: { + symbol: string; + callback: ( + orderBook: + | { bestBid?: string; bestAsk?: string; spread?: string } + | undefined, + ) => void; + }): () => void { + if (this.currentSymbol && this.currentSymbol !== params.symbol) { + DevLogger.log( + 'TopOfBookStreamChannel: Warning - different symbol requested, staying on current', + { + currentSymbol: this.currentSymbol, + requestedSymbol: params.symbol, + }, + ); + + // Force disconnect to clear old symbol + this.disconnect(); + + // Set new symbol + this.currentSymbol = params.symbol; + } else if (!this.currentSymbol) { + this.currentSymbol = params.symbol; + } + + return this.subscribe({ + callback: params.callback, + }); + } + + public disconnect() { + this.currentSymbol = null; + this.cachedTopOfBook = undefined; + super.disconnect(); + } +} + // Market data channel for caching market list data class MarketDataChannel extends StreamChannel { private lastFetchTime = 0; @@ -1001,10 +1108,10 @@ export class PerpsStreamManager { public readonly fills = new FillStreamChannel(); public readonly account = new AccountStreamChannel(); public readonly marketData = new MarketDataChannel(); + public readonly topOfBook = new TopOfBookStreamChannel(); // Future channels can be added here: // public readonly funding = new FundingStreamChannel(); - // public readonly orderBook = new OrderBookStreamChannel(); // public readonly trades = new TradeStreamChannel(); } diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index 03d6e654b8e9..08c4ad1be828 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -1227,17 +1227,47 @@ export class HyperLiquidSubscriptionService { wsMetrics.messagesReceived++; wsMetrics.lastMessageTime = Date.now(); - // Update cache for ALL available symbols - Object.entries(data.mids).forEach(([symbol, price]) => { - this.cachedPriceData ??= new Map(); + // Initialize cache if needed + this.cachedPriceData ??= new Map(); - const priceUpdate = this.createPriceUpdate(symbol, price.toString()); + const subscribedSymbols = new Set(); + + // Collect all symbols that have subscribers + for (const [symbol, subscriberSet] of this.priceSubscribers.entries()) { + if (subscriberSet.size > 0) { + subscribedSymbols.add(symbol); + } + } + + // Track if any subscribed symbol was updated + let hasUpdates = false; + + // Only process symbols that are actually subscribed to + for (const symbol in data.mids) { + // Skip if nobody is subscribed to this symbol + if (!subscribedSymbols.has(symbol)) { + continue; + } + + const price = data.mids[symbol].toString(); + const cachedPrice = this.cachedPriceData.get(symbol); + + // Skip if price hasn't changed + if (cachedPrice && cachedPrice.price === price) { + continue; + } + + // Price changed or new symbol - update cache + const priceUpdate = this.createPriceUpdate(symbol, price); this.cachedPriceData.set(symbol, priceUpdate); - }); + hasUpdates = true; + } - // Always notify price subscribers when we receive price data - // This ensures subscribers get updates and the UI can display current prices - this.notifyAllPriceSubscribers(); + // Only notify subscribers if we actually have updates + // This prevents unnecessary React re-renders when prices haven't changed + if (hasUpdates) { + this.notifyAllPriceSubscribers(); + } }) .then((sub) => { this.globalAllMidsSubscription = sub; diff --git a/e2e/specs/perps/perps-add-funds.spec.ts b/e2e/specs/perps/perps-add-funds.spec.ts index 1bec023c5e22..d8553572f4c8 100644 --- a/e2e/specs/perps/perps-add-funds.spec.ts +++ b/e2e/specs/perps/perps-add-funds.spec.ts @@ -26,7 +26,7 @@ describe(SmokePerps('Perps - Add funds (has funds, not first time)'), () => { jest.setTimeout(150000); }); - it('deposits $80 from Add funds and verifies updated balance', async () => { + it.skip('deposits $80 from Add funds and verifies updated balance', async () => { await withFixtures( { fixture: new FixtureBuilder()