From 4f10d76d73024b324594f6b8e9bb67f4322a737d Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Fri, 10 Jul 2026 14:11:47 -0700 Subject: [PATCH 1/8] feat(perps): add usePerpsLiveMovers hook with change-detected live ranking --- .../feeds/perps/usePerpsLiveMovers.test.ts | 269 ++++++++++++++++++ .../feeds/perps/usePerpsLiveMovers.ts | 169 +++++++++++ 2 files changed, 438 insertions(+) create mode 100644 app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts create mode 100644 app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts new file mode 100644 index 00000000000..538190cf5cb --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts @@ -0,0 +1,269 @@ +/** + * usePerpsLiveMovers — unit tests + * + * Covers the behavior that makes each live-price tick cheap: + * 1. Initial ranking matches filterAndSortByPriceChangeDirection over the full base set (ranking correctness is preserved). + * 2. A push that doesn't change the displayed top-N triggers no re-render. + * 3. A push that changes the displayed top-N updates the result. + * 4. Items whose displayed value is unchanged keep their previous object identity (so memoized pill components skip re-rendering). + * 5. Disabling tears down the subscription and freezes the last result. + */ + +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { + type PerpsMarketData, + formatPercentage, +} from '@metamask/perps-controller'; +import { usePerpsLiveMovers } from './usePerpsLiveMovers'; +import { + filterAndSortByPriceChangeDirection, + type PerpsFeedItem, +} from './usePerpsFeed'; + +const mockSubscribeToSymbols = jest.fn(); + +jest.mock('../../../../UI/Perps/providers/PerpsStreamManager', () => ({ + usePerpsStream: jest.fn(() => ({ + prices: { + subscribeToSymbols: mockSubscribeToSymbols, + }, + })), +})); + +// Real market data always arrives with change24hPercent pre-formatted (the +// REST transform calls formatPercentage itself), so fixtures use the same +// formatting the live-price merge path produces — otherwise a live push +// with an unchanged numeric value would look like a change (raw "5" vs +// formatted "+5.00%") purely due to fixture shape, not hook behavior. +const makeMarket = (symbol: string, rawPercent: number): PerpsMarketData => + ({ + symbol, + name: symbol, + change24hPercent: formatPercentage(rawPercent), + }) as unknown as PerpsMarketData; + +const makeFeedItem = (symbol: string, rawPercent: number): PerpsFeedItem => ({ + market: makeMarket(symbol, rawPercent), + isWatchlisted: false, +}); + +type PriceUpdatePayload = Record; + +describe('usePerpsLiveMovers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('ranks the initial base set the same way filterAndSortByPriceChangeDirection does', async () => { + mockSubscribeToSymbols.mockReturnValue(jest.fn()); + const items = [ + makeFeedItem('LOSER', -3), + makeFeedItem('HIGH_GAINER', 5), + makeFeedItem('LOW_GAINER', 1), + ]; + + const { result } = renderHook(() => + usePerpsLiveMovers({ items, direction: 'gainers', maxCount: 12 }), + ); + + const expectedSymbols = filterAndSortByPriceChangeDirection( + items.map((item) => item.market), + 'gainers', + ).map((market) => market.symbol); + + await waitFor(() => { + expect(result.current.map((item) => item.market.symbol)).toEqual( + expectedSymbols, + ); + }); + }); + + it('slices to maxCount', async () => { + mockSubscribeToSymbols.mockReturnValue(jest.fn()); + const items = [ + makeFeedItem('A', 5), + makeFeedItem('B', 4), + makeFeedItem('C', 3), + ]; + + const { result } = renderHook(() => + usePerpsLiveMovers({ items, direction: 'gainers', maxCount: 2 }), + ); + + await waitFor(() => { + expect(result.current).toHaveLength(2); + }); + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'A', + 'B', + ]); + }); + + it('does not re-render when a push does not change the displayed top-N', async () => { + let capturedCallback: (prices: PriceUpdatePayload) => void = jest.fn(); + mockSubscribeToSymbols.mockImplementation((params) => { + capturedCallback = params.callback; + return jest.fn(); + }); + + const items = [ + makeFeedItem('HIGH_GAINER', 5), + makeFeedItem('LOW_GAINER', 1), + ]; + + let renderCount = 0; + const { result } = renderHook(() => { + renderCount++; + return usePerpsLiveMovers({ items, direction: 'gainers', maxCount: 12 }); + }); + + await waitFor(() => { + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'HIGH_GAINER', + 'LOW_GAINER', + ]); + }); + + const renderCountAfterMount = renderCount; + + // Push the exact same percent changes already reflected in the base data. + act(() => { + capturedCallback({ + HIGH_GAINER: { percentChange24h: '5' }, + LOW_GAINER: { percentChange24h: '1' }, + }); + }); + + expect(renderCount).toBe(renderCountAfterMount); + }); + + it('updates the displayed order when a push changes the ranking', async () => { + let capturedCallback: (prices: PriceUpdatePayload) => void = jest.fn(); + mockSubscribeToSymbols.mockImplementation((params) => { + capturedCallback = params.callback; + return jest.fn(); + }); + + const items = [ + makeFeedItem('HIGH_GAINER', 5), + makeFeedItem('LOW_GAINER', 1), + ]; + + const { result } = renderHook(() => + usePerpsLiveMovers({ items, direction: 'gainers', maxCount: 12 }), + ); + + await waitFor(() => { + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'HIGH_GAINER', + 'LOW_GAINER', + ]); + }); + + // LOW_GAINER overtakes HIGH_GAINER. + act(() => { + capturedCallback({ + HIGH_GAINER: { percentChange24h: '2' }, + LOW_GAINER: { percentChange24h: '9' }, + }); + }); + + await waitFor(() => { + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'LOW_GAINER', + 'HIGH_GAINER', + ]); + }); + }); + + it('reuses the previous item identity for symbols whose displayed value is unchanged', async () => { + let capturedCallback: (prices: PriceUpdatePayload) => void = jest.fn(); + mockSubscribeToSymbols.mockImplementation((params) => { + capturedCallback = params.callback; + return jest.fn(); + }); + + const items = [makeFeedItem('MOVES', 5), makeFeedItem('STAYS', 3)]; + + const { result } = renderHook(() => + usePerpsLiveMovers({ items, direction: 'gainers', maxCount: 12 }), + ); + + await waitFor(() => { + expect(result.current).toHaveLength(2); + }); + + const staysItemBefore = result.current.find( + (item) => item.market.symbol === 'STAYS', + ); + + // Only MOVES changes; STAYS keeps the same percent change. + act(() => { + capturedCallback({ + MOVES: { percentChange24h: '8' }, + STAYS: { percentChange24h: '3' }, + }); + }); + + await waitFor(() => { + const movesItem = result.current.find( + (item) => item.market.symbol === 'MOVES', + ); + expect(movesItem?.market.change24hPercent).toBe('+8.00%'); + }); + + const staysItemAfter = result.current.find( + (item) => item.market.symbol === 'STAYS', + ); + expect(staysItemAfter).toBe(staysItemBefore); + }); + + it('unsubscribes and freezes the displayed data when disabled', async () => { + const mockUnsubscribe = jest.fn(); + mockSubscribeToSymbols.mockReturnValue(mockUnsubscribe); + + const items = [ + makeFeedItem('HIGH_GAINER', 5), + makeFeedItem('LOW_GAINER', 1), + ]; + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + usePerpsLiveMovers({ + items, + direction: 'gainers', + maxCount: 12, + enabled, + }), + { initialProps: { enabled: true } }, + ); + + await waitFor(() => { + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'HIGH_GAINER', + 'LOW_GAINER', + ]); + }); + + const displayedBeforeDisable = result.current; + + act(() => { + rerender({ enabled: false }); + }); + + // The channel subscription is torn down (no more ticks are delivered to + // this hook) and the last computed slice stays exactly as it was. + expect(mockUnsubscribe).toHaveBeenCalled(); + expect(result.current).toBe(displayedBeforeDisable); + }); + + it('does not subscribe when there are no items', () => { + mockSubscribeToSymbols.mockReturnValue(jest.fn()); + + renderHook(() => + usePerpsLiveMovers({ items: [], direction: 'gainers', maxCount: 12 }), + ); + + expect(mockSubscribeToSymbols).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts new file mode 100644 index 00000000000..5c941a23e14 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { usePerpsStream } from '../../../../UI/Perps/providers/PerpsStreamManager'; +import { formatPercentage } from '../../../../UI/Perps/utils/formatUtils'; +import { + filterAndSortByPriceChangeDirection, + type PerpsFeedItem, + type PerpsPriceChangeDirection, +} from './usePerpsFeed'; + +export interface UsePerpsLiveMoversOptions { + /** Base feed items to rank, typically the full market set from `usePerpsFeed`. */ + items: PerpsFeedItem[]; + /** Which side of the 24h price-change spectrum to display. */ + direction: PerpsPriceChangeDirection; + /** Number of ranked items to keep — matches the pill strip's display cap. */ + maxCount: number; + /** Throttle delay for the underlying price subscription. @default 3000 */ + throttleMs?: number; + /** + * When false, the live subscription is torn down and the previously + * displayed items are frozen in place instead of updating. + * @default true + */ + enabled?: boolean; +} + +const EMPTY_ITEMS: PerpsFeedItem[] = []; + +/** + * Ranks perps markets by live 24h price-change percentage for a movers pill + * strip, without paying a re-render for every WebSocket tick. + * + * Correct gainers/losers ranking requires observing every market's live + * percent change — a market outside the currently displayed set can move + * into it. The WebSocket already delivers all-market updates regardless of + * what any component subscribes to, so this hook keeps the merge/filter/sort + * work on a ref between ticks and only commits React state when the + * *displayed* top `maxCount` slice actually changes (by symbol, order, or + * rounded percent). Idle ticks where the visible movers don't change cost + * zero renders. + * + * Items whose displayed value didn't change keep their previous object + * reference, so memoized pill components skip re-rendering too. + */ +export const usePerpsLiveMovers = ({ + items, + direction, + maxCount, + throttleMs = 3000, + enabled = true, +}: UsePerpsLiveMoversOptions): PerpsFeedItem[] => { + const stream = usePerpsStream(); + const [displayed, setDisplayed] = useState(EMPTY_ITEMS); + + const itemsRef = useRef(items); + const displayedRef = useRef(EMPTY_ITEMS); + const fingerprintRef = useRef(''); + // Latest live percent-change per symbol. A ref (not state) so ticks don't + // trigger a render by themselves — only the fingerprint check below does. + const livePercentsRef = useRef>({}); + + const symbols = useMemo( + () => items.map(({ market }) => market.symbol), + [items], + ); + // Memoized joined symbols to avoid resubscribing when the array reference + // changes but its contents don't (mirrors usePerpsLivePrices). + const symbolsKey = useMemo(() => symbols.join(','), [symbols]); + + const recompute = useCallback(() => { + const baseItems = itemsRef.current; + const merged = baseItems.map((item) => { + const livePercent = livePercentsRef.current[item.market.symbol]; + if (livePercent === undefined) return item.market; + return { + ...item.market, + change24hPercent: formatPercentage(livePercent), + }; + }); + const sorted = filterAndSortByPriceChangeDirection(merged, direction).slice( + 0, + maxCount, + ); + + const fingerprint = sorted + .map((market) => `${market.symbol}:${market.change24hPercent}`) + .join('|'); + if (fingerprint === fingerprintRef.current) { + return; + } + fingerprintRef.current = fingerprint; + + const baseBySymbol = new Map( + baseItems.map((item) => [item.market.symbol, item]), + ); + const previousBySymbol = new Map( + displayedRef.current.map((item) => [item.market.symbol, item]), + ); + + const next = sorted + .map((market) => { + const base = baseBySymbol.get(market.symbol); + if (!base) return undefined; + const previous = previousBySymbol.get(market.symbol); + // Reuse the previous item's identity when its displayed value is + // unchanged so React.memo'd pills relying on shallow prop + // comparison skip re-rendering too. + if ( + previous && + previous.market.change24hPercent === market.change24hPercent + ) { + return previous; + } + return { ...base, market }; + }) + .filter((item): item is PerpsFeedItem => item !== undefined); + + displayedRef.current = next; + setDisplayed(next); + }, [direction, maxCount]); + + // Latest recompute, readable from the subscription callback below without + // making the subscription effect resubscribe on every direction/maxCount + // change. + const recomputeRef = useRef(recompute); + useEffect(() => { + recomputeRef.current = recompute; + }, [recompute]); + + // Recompute from the base feed whenever it (or the ranking params) change + // — e.g. initial load, pull-to-refresh, or the gainers/losers toggle. + // Gated on `enabled` so a hidden/unfocused strip stays frozen rather than + // picking up REST refreshes in the background. + useEffect(() => { + if (!enabled) return; + itemsRef.current = items; + recompute(); + }, [enabled, recompute, items]); + + useEffect(() => { + if (!enabled || symbols.length === 0) return; + + const unsubscribe = stream.prices.subscribeToSymbols({ + symbols, + callback: (newPrices) => { + if (!newPrices) return; + for (const symbol of Object.keys(newPrices)) { + const percentChange24h = newPrices[symbol]?.percentChange24h; + if (!percentChange24h) continue; + const parsed = parseFloat(percentChange24h); + if (Number.isNaN(parsed)) continue; + livePercentsRef.current[symbol] = parsed; + } + recomputeRef.current(); + }, + throttleMs, + }); + + return () => { + unsubscribe(); + }; + // symbolsKey captures symbols changes via memoization, so symbols is + // intentionally omitted to prevent re-subscriptions when the array + // reference changes but its contents don't (mirrors usePerpsLivePrices). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stream, symbolsKey, throttleMs, enabled]); + + return displayed; +}; From 7dd76b0324e449f6beaa726d7f995f361c13e957 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Fri, 10 Jul 2026 14:23:39 -0700 Subject: [PATCH 2/8] perf: use usePerpsLiveMovers in Now-tab PerpsBlock --- .../Views/TrendingView/tabs/NowTab.test.tsx | 12 ++++- .../Views/TrendingView/tabs/NowTab.tsx | 54 +++++-------------- 2 files changed, 24 insertions(+), 42 deletions(-) diff --git a/app/components/Views/TrendingView/tabs/NowTab.test.tsx b/app/components/Views/TrendingView/tabs/NowTab.test.tsx index 041e4880df3..a3e8a4f9d2a 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.test.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.test.tsx @@ -46,8 +46,16 @@ jest.mock('../feeds/perps/usePerpsFeed', () => ({ usePerpsFeed: () => mockUsePerpsFeed(), })); -jest.mock('../../../UI/Perps/hooks/stream', () => ({ - usePerpsLivePrices: jest.fn(() => ({})), +// usePerpsLiveMovers (used by PerpsBlock) subscribes via the stream +// singleton — stub it so the hook's real ranking/fingerprint logic still +// runs (preserving the filter/sort assertions below) without needing a +// PerpsStreamProvider or real WebSocket. +jest.mock('../../../UI/Perps/providers/PerpsStreamManager', () => ({ + usePerpsStream: () => ({ + prices: { + subscribeToSymbols: jest.fn(() => jest.fn()), + }, + }), })); jest.mock('../feeds/perps/PerpsPillItem', () => { diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx index f145d08c47b..340f642da58 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.tsx @@ -12,8 +12,6 @@ import type { AppNavigationProp } from '../../../../core/NavigationService/types import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; import { selectPerpsEnabledFlag } from '../../../UI/Perps'; import { selectPredictEnabledFlag } from '../../../UI/Predict'; -import { usePerpsLivePrices } from '../../../UI/Perps/hooks/stream'; -import { formatPercentage } from '../../../UI/Perps/utils/formatUtils'; import Routes from '../../../../constants/navigation/Routes'; import { strings } from '../../../../../locales/i18n'; import { TrendingViewSelectorsIDs } from '../TrendingView.testIds'; @@ -25,12 +23,12 @@ import CryptoMoversSkeleton from '../feeds/tokens/CryptoMoversSkeleton'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; import { TimeOption } from '../../../UI/Trending/components/TrendingTokensBottomSheet'; import { - filterAndSortByPriceChangeDirection, PERPS_PRICE_CHANGE_SORT_DIRECTION, usePerpsFeed, type PerpsFeedItem, type PerpsPriceChangeDirection, } from '../feeds/perps/usePerpsFeed'; +import { usePerpsLiveMovers } from '../feeds/perps/usePerpsLiveMovers'; import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; import PerpsPillItem from '../feeds/perps/PerpsPillItem'; import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; @@ -71,6 +69,10 @@ interface PerpsBlockProps { navigation: NavigationProp; } +// Matches PillScrollList's default maxPills — PerpsBlock doesn't override it, +// so the movers hook should rank/display exactly as many as will be shown. +const PERPS_MOVERS_MAX_COUNT = 12; + const PerpsBlock: React.FC = ({ refresh, navigation }) => { const [activeMoverDirection, setActiveMoverDirection] = useState('gainers'); @@ -80,49 +82,21 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => { withTileExtras: false, }); - const symbols = useMemo( - () => perps.data.map(({ market }) => market.symbol), - [perps.data], - ); - const livePrices = usePerpsLivePrices({ symbols, throttleMs: 3000 }); - const handleMoverPillSelect = (key: string) => { if (key === 'gainers' || key === 'losers') { setActiveMoverDirection(key); } }; - const data = useMemo(() => { - const feedItemsBySymbol = new Map( - perps.data.map((item) => [item.market.symbol, item]), - ); - const marketsWithLivePrices = perps.data.map(({ market }) => { - const livePrice = livePrices[market.symbol]; - if (!livePrice?.percentChange24h) { - return market; - } - - const changePercent = parseFloat(livePrice.percentChange24h); - if (Number.isNaN(changePercent)) { - return market; - } - - return { - ...market, - change24hPercent: formatPercentage(changePercent), - }; - }); - const markets = filterAndSortByPriceChangeDirection( - marketsWithLivePrices, - activeMoverDirection, - ); - return markets - .map((market) => { - const item = feedItemsBySymbol.get(market.symbol); - return item ? { ...item, market } : undefined; - }) - .filter((item): item is PerpsFeedItem => item !== undefined); - }, [activeMoverDirection, livePrices, perps.data]); + // Observes live percent-change for every market on a ref between ticks and + // only commits state when the displayed top-N (matches PillScrollList's + // default maxPills) actually changes — see usePerpsLiveMovers for why this + // is cheap despite watching the whole market set. + const data = usePerpsLiveMovers({ + items: perps.data, + direction: activeMoverDirection, + maxCount: PERPS_MOVERS_MAX_COUNT, + }); const pillData = data.length === 0 && perps.data.length > 0 && From ed81a0202bd7ae28d2e535499f7ce1465b4d3d8c Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Fri, 10 Jul 2026 14:38:53 -0700 Subject: [PATCH 3/8] perf: pause Now-tab movers subscription when unfocused or tab inactive --- .../ExploreActiveTabContext.test.tsx | 48 +++++++ .../TrendingView/ExploreActiveTabContext.tsx | 39 ++++++ .../Views/TrendingView/TrendingView.tsx | 123 ++++++++++-------- .../Views/TrendingView/tabs/NowTab.test.tsx | 71 +++++++++- .../Views/TrendingView/tabs/NowTab.tsx | 17 ++- 5 files changed, 237 insertions(+), 61 deletions(-) create mode 100644 app/components/Views/TrendingView/ExploreActiveTabContext.test.tsx create mode 100644 app/components/Views/TrendingView/ExploreActiveTabContext.tsx diff --git a/app/components/Views/TrendingView/ExploreActiveTabContext.test.tsx b/app/components/Views/TrendingView/ExploreActiveTabContext.test.tsx new file mode 100644 index 00000000000..cc701e48902 --- /dev/null +++ b/app/components/Views/TrendingView/ExploreActiveTabContext.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { + ExploreActiveTabProvider, + useExploreActiveTab, +} from './ExploreActiveTabContext'; + +const ActiveTabProbe: React.FC = () => { + const activeTab = useExploreActiveTab(); + return {activeTab}; +}; + +describe('ExploreActiveTabContext', () => { + it('defaults to "Now" when rendered outside a provider', () => { + render(); + + expect(screen.getByTestId('active-tab')).toHaveTextContent('Now'); + }); + + it('exposes the provided active tab to descendants', () => { + render( + + + , + ); + + expect(screen.getByTestId('active-tab')).toHaveTextContent('Macro'); + }); + + it('updates descendants when the active tab changes', () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByTestId('active-tab')).toHaveTextContent('Now'); + + rerender( + + + , + ); + + expect(screen.getByTestId('active-tab')).toHaveTextContent('Sports'); + }); +}); diff --git a/app/components/Views/TrendingView/ExploreActiveTabContext.tsx b/app/components/Views/TrendingView/ExploreActiveTabContext.tsx new file mode 100644 index 00000000000..81e916b9fab --- /dev/null +++ b/app/components/Views/TrendingView/ExploreActiveTabContext.tsx @@ -0,0 +1,39 @@ +import React, { + createContext, + useContext, + type PropsWithChildren, +} from 'react'; +import type { ExploreTabName } from './search/analytics'; + +const DEFAULT_ACTIVE_TAB: ExploreTabName = 'Now'; + +const ExploreActiveTabContext = + createContext(DEFAULT_ACTIVE_TAB); + +export interface ExploreActiveTabProviderProps extends PropsWithChildren { + activeTab: ExploreTabName; +} + +/** + * Exposes the currently active Explore tab to descendants without threading + * it through `TabProps`. `TabsList` keeps every tab mounted simultaneously, + * so passing the active tab as a prop would give all six tabs a changing + * prop value on every switch, busting memoization for feeds that don't care + * which tab is active. Consumers that do care (e.g. `PerpsBlock` pausing its + * live subscription while hidden) read it via `useExploreActiveTab` instead. + */ +export const ExploreActiveTabProvider: React.FC< + ExploreActiveTabProviderProps +> = ({ activeTab, children }) => ( + + {children} + +); + +/** + * The currently active Explore tab name (e.g. `'Now'`, `'Macro'`). Falls + * back to `'Now'` when rendered outside a provider, matching the tabs list's + * default first tab. + */ +export const useExploreActiveTab = (): ExploreTabName => + useContext(ExploreActiveTabContext); diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index 5f02335840c..68672a28bf6 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { TouchableOpacity } from 'react-native'; import { RouteProp, @@ -30,6 +30,7 @@ import { selectBasicFunctionalityEnabled } from '../../../selectors/settings'; import BasicFunctionalityEmptyState from '../../UI/BasicFunctionality/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState'; import TrendingFeedSessionManager from '../../UI/Trending/services/TrendingFeedSessionManager'; import ExploreSearchBar from './components/ExploreSearchBar/ExploreSearchBar'; +import { ExploreActiveTabProvider } from './ExploreActiveTabContext'; import { useExploreRefresh } from './hooks/useExploreRefresh'; import NowTab from './tabs/NowTab'; import MacroTab from './tabs/MacroTab'; @@ -134,6 +135,7 @@ export const ExploreFeed: React.FC = () => { const sessionManager = TrendingFeedSessionManager.getInstance(); const previousTabRef = useRef('Now'); const pendingExploreEntrySourceRef = useRef(undefined); + const [activeTab, setActiveTab] = useState('Now'); const handleTabChange = useCallback(({ i }: { i: number }) => { const destinationTab = TAB_NAMES[i]; @@ -147,6 +149,7 @@ export const ExploreFeed: React.FC = () => { ...(source ? { source } : {}), }); previousTabRef.current = destinationTab; + setActiveTab(destinationTab); }, []); // Handle tab navigation from route params @@ -237,63 +240,69 @@ export const ExploreFeed: React.FC = () => { {!isBasicFunctionalityEnabled ? ( ) : ( - - - - - - - - - - - - - - - - - + - - - + + + + + + + + + + + + + + + + + + + + )} diff --git a/app/components/Views/TrendingView/tabs/NowTab.test.tsx b/app/components/Views/TrendingView/tabs/NowTab.test.tsx index a3e8a4f9d2a..81dadd2d288 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.test.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.test.tsx @@ -3,10 +3,12 @@ import { render, screen, fireEvent } from '@testing-library/react-native'; import { NavigationContainer } from '@react-navigation/native'; const mockNavigate = jest.fn(); +const mockUseIsFocused = jest.fn(() => true); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ navigate: mockNavigate }), + useIsFocused: () => mockUseIsFocused(), })); jest.mock('react-redux', () => ({ @@ -50,10 +52,13 @@ jest.mock('../feeds/perps/usePerpsFeed', () => ({ // singleton — stub it so the hook's real ranking/fingerprint logic still // runs (preserving the filter/sort assertions below) without needing a // PerpsStreamProvider or real WebSocket. +const mockSubscribeToSymbols = jest.fn(() => jest.fn()); jest.mock('../../../UI/Perps/providers/PerpsStreamManager', () => ({ usePerpsStream: () => ({ prices: { - subscribeToSymbols: jest.fn(() => jest.fn()), + subscribeToSymbols: ( + ...args: Parameters + ) => mockSubscribeToSymbols(...args), }, }), })); @@ -198,6 +203,8 @@ import { selectPredictEnabledFlag } from '../../../UI/Predict'; import { selectWhatsHappeningEnabled } from '../../../../selectors/featureFlagController/whatsHappening'; import WhatsHappeningSection from '../../../UI/WhatsHappening'; import NowTab from './NowTab'; +import { ExploreActiveTabProvider } from '../ExploreActiveTabContext'; +import type { ExploreTabName } from '../search/analytics'; import type { RefreshConfig } from '../hooks/useExploreRefresh'; import { useTokensFeed } from '../feeds/tokens/useTokensFeed'; import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; @@ -246,6 +253,7 @@ const createMockSelectorImpl = const arrangeMocks = () => { jest.clearAllMocks(); + mockUseIsFocused.mockReturnValue(true); const mockUseSelector = useSelector as jest.MockedFunction< typeof useSelector @@ -328,10 +336,15 @@ const arrangeMocks = () => { }; }; -const renderNowTab = (props = defaultTabProps) => +const renderNowTab = ( + props = defaultTabProps, + { activeTab = 'Now' as ExploreTabName } = {}, +) => render( - + + + , ); @@ -523,6 +536,58 @@ describe('NowTab — Perps Movers "View All" navigation', () => { expect(screen.queryByTestId('section-header-view-all-perps')).toBeNull(); }); + + describe('live movers subscription gating', () => { + it('subscribes to live prices when the Now tab is active and the screen is focused', () => { + arrangePerpsMoversMocks(); + + renderNowTab(defaultTabProps, { activeTab: 'Now' }); + + expect(mockSubscribeToSymbols).toHaveBeenCalled(); + }); + + it('does not subscribe to live prices when a different Explore tab is active', () => { + arrangePerpsMoversMocks(); + + renderNowTab(defaultTabProps, { activeTab: 'Macro' }); + + expect(mockSubscribeToSymbols).not.toHaveBeenCalled(); + }); + + it('does not subscribe to live prices when the Explore screen is unfocused', () => { + arrangePerpsMoversMocks(); + mockUseIsFocused.mockReturnValue(false); + + renderNowTab(defaultTabProps, { activeTab: 'Now' }); + + expect(mockSubscribeToSymbols).not.toHaveBeenCalled(); + }); + + it('stops establishing new live subscriptions once the tab becomes inactive', () => { + arrangePerpsMoversMocks([ + { market: { symbol: 'BTC', change24hPercent: '5' } }, + ]); + + const { rerender } = renderNowTab(defaultTabProps, { + activeTab: 'Now', + }); + + expect(mockSubscribeToSymbols).toHaveBeenCalled(); + const callsWhileActive = mockSubscribeToSymbols.mock.calls.length; + + rerender( + + + + + , + ); + + // No further subscriptions should be established once disabled — the + // count should stay exactly where it was when the tab went inactive. + expect(mockSubscribeToSymbols.mock.calls.length).toBe(callsWhileActive); + }); + }); }); describe('NowTab — Predictions navigation', () => { diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx index 340f642da58..2f88ef26db2 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.tsx @@ -1,5 +1,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { + useIsFocused, + useNavigation, + NavigationProp, +} from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box, @@ -29,6 +33,7 @@ import { type PerpsPriceChangeDirection, } from '../feeds/perps/usePerpsFeed'; import { usePerpsLiveMovers } from '../feeds/perps/usePerpsLiveMovers'; +import { useExploreActiveTab } from '../ExploreActiveTabContext'; import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; import PerpsPillItem from '../feeds/perps/PerpsPillItem'; import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; @@ -88,6 +93,15 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => { } }; + // Pause the live subscription when the Explore screen isn't focused (e.g. + // user navigated to another bottom tab) or the Now tab isn't the active + // one (TabsList keeps every tab mounted, so switching tabs doesn't + // unmount PerpsBlock on its own). + const isScreenFocused = useIsFocused(); + const activeExploreTab = useExploreActiveTab(); + const isMoversSubscriptionEnabled = + isScreenFocused && activeExploreTab === 'Now'; + // Observes live percent-change for every market on a ref between ticks and // only commits state when the displayed top-N (matches PillScrollList's // default maxPills) actually changes — see usePerpsLiveMovers for why this @@ -96,6 +110,7 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => { items: perps.data, direction: activeMoverDirection, maxCount: PERPS_MOVERS_MAX_COUNT, + enabled: isMoversSubscriptionEnabled, }); const pillData = data.length === 0 && From caebfdf94ea193267a869b93e7458607356229b3 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Fri, 10 Jul 2026 14:51:11 -0700 Subject: [PATCH 4/8] perf: gate perps price subscription on focus --- .../useWhatsHappeningAssetPrices.test.ts | 67 +++++++++++++++++++ .../hooks/useWhatsHappeningAssetPrices.ts | 14 +++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.test.ts b/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.test.ts index 13d496661a6..a017c0ddb94 100644 --- a/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.test.ts +++ b/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.test.ts @@ -12,6 +12,12 @@ jest.mock('../../../UI/Perps/hooks/stream', () => ({ mockUsePerpsLivePrices(options), })); +const mockUseIsFocused = jest.fn(() => true); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => mockUseIsFocused(), +})); + // ── Test data ────────────────────────────────────────────────────────────────── const tslaAsset: RelatedAsset = { @@ -43,6 +49,7 @@ describe('useWhatsHappeningAssetPrices', () => { beforeEach(() => { jest.clearAllMocks(); mockUsePerpsLivePrices.mockReturnValue({}); + mockUseIsFocused.mockReturnValue(true); }); describe('perps live price subscription', () => { @@ -129,4 +136,64 @@ describe('useWhatsHappeningAssetPrices', () => { throttleMs: 3000, }); }); + + describe('focus gating', () => { + it('subscribes with the real symbols while focused', () => { + mockUseIsFocused.mockReturnValue(true); + + renderHook(() => useWhatsHappeningAssetPrices([tslaAsset])); + + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: ['xyz:TSLA'], + throttleMs: 3000, + }); + }); + + it('passes an empty symbol list to pause the subscription while unfocused', () => { + mockUseIsFocused.mockReturnValue(false); + + renderHook(() => useWhatsHappeningAssetPrices([tslaAsset])); + + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: [], + throttleMs: 3000, + }); + }); + + it('keeps the last known prices, keyed by the full symbol set, while unfocused', () => { + mockUsePerpsLivePrices.mockReturnValue({ + 'xyz:TSLA': { price: '172.50', percentChange24h: '3.45' }, + }); + mockUseIsFocused.mockReturnValue(false); + + const { result } = renderHook(() => + useWhatsHappeningAssetPrices([tslaAsset]), + ); + + expect(result.current.perpsPriceBySymbol['xyz:TSLA']).toEqual({ + price: 172.5, + percentChange24h: 3.45, + }); + }); + + it('re-subscribes with the real symbols once focus is regained', () => { + mockUseIsFocused.mockReturnValue(false); + + const { rerender } = renderHook(() => + useWhatsHappeningAssetPrices([tslaAsset]), + ); + expect(mockUsePerpsLivePrices).toHaveBeenLastCalledWith({ + symbols: [], + throttleMs: 3000, + }); + + mockUseIsFocused.mockReturnValue(true); + rerender(() => useWhatsHappeningAssetPrices([tslaAsset])); + + expect(mockUsePerpsLivePrices).toHaveBeenLastCalledWith({ + symbols: ['xyz:TSLA'], + throttleMs: 3000, + }); + }); + }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.ts b/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.ts index 9416d62e8d7..b86c334dd5c 100644 --- a/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.ts +++ b/app/components/Views/WhatsHappeningDetailView/hooks/useWhatsHappeningAssetPrices.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { useIsFocused } from '@react-navigation/native'; import type { RelatedAsset } from '@metamask/ai-controllers'; import { usePerpsLivePrices } from '../../../UI/Perps/hooks/stream'; @@ -12,6 +13,8 @@ export interface UseWhatsHappeningAssetPricesResult { perpsPriceBySymbol: Record; } +const NO_SYMBOLS: string[] = []; + /** * Returns live price data for every perps market referenced by the given * related assets. Prices come from the WebSocket stream via @@ -22,13 +25,22 @@ export interface UseWhatsHappeningAssetPricesResult { export function useWhatsHappeningAssetPrices( assets: RelatedAsset[], ): UseWhatsHappeningAssetPricesResult { + const isFocused = useIsFocused(); const perpsSymbols = useMemo( () => [...new Set(assets.flatMap((a) => a.hlPerpsMarket ?? []))], [assets], ); + // Pass an empty symbol list instead of an `enabled` flag so the shared + // `usePerpsLivePrices` hook (used by call sites that always want to stay + // subscribed, e.g. order views) doesn't need its own gating support. This + // freezes the last known prices in place rather than clearing them, so + // the detail view doesn't blank out if it stays mounted below a pushed + // screen while unfocused. + const subscribedSymbols = isFocused ? perpsSymbols : NO_SYMBOLS; + const livePerpsPrices = usePerpsLivePrices({ - symbols: perpsSymbols, + symbols: subscribedSymbols, throttleMs: 3000, }); From 24bd24795b13d3f811ce08e2f4eca65b90950f9c Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Mon, 13 Jul 2026 15:23:03 -0700 Subject: [PATCH 5/8] chore: add further debounce configuration --- .../feeds/perps/usePerpsLiveMovers.test.ts | 180 ++++++++++++++++++ .../feeds/perps/usePerpsLiveMovers.ts | 70 ++++++- .../Views/TrendingView/tabs/NowTab.tsx | 6 + 3 files changed, 252 insertions(+), 4 deletions(-) diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts index 538190cf5cb..a702c340c80 100644 --- a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts @@ -266,4 +266,184 @@ describe('usePerpsLiveMovers', () => { expect(mockSubscribeToSymbols).not.toHaveBeenCalled(); }); + + describe('recomputeIntervalMs', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('batches multiple ticks inside the interval into a single recompute using the freshest percents', async () => { + let capturedCallback: (prices: PriceUpdatePayload) => void = jest.fn(); + mockSubscribeToSymbols.mockImplementation((params) => { + capturedCallback = params.callback; + return jest.fn(); + }); + + const items = [ + makeFeedItem('HIGH_GAINER', 5), + makeFeedItem('LOW_GAINER', 1), + ]; + + let renderCount = 0; + const { result } = renderHook(() => { + renderCount++; + return usePerpsLiveMovers({ + items, + direction: 'gainers', + maxCount: 12, + recomputeIntervalMs: 10000, + }); + }); + + await waitFor(() => { + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'HIGH_GAINER', + 'LOW_GAINER', + ]); + }); + + jest.useFakeTimers(); + const renderCountBeforeTicks = renderCount; + + // First tick schedules a recompute ~10s out; the values it carries + // should NOT be what ends up displayed, since a second tick arrives + // before the timer fires. + act(() => { + capturedCallback({ + HIGH_GAINER: { percentChange24h: '2' }, + LOW_GAINER: { percentChange24h: '9' }, + }); + }); + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'HIGH_GAINER', + 'LOW_GAINER', + ]); + + // Second tick lands inside the same interval — it only updates the + // ref, it must not schedule a second timer/commit. + act(() => { + capturedCallback({ + HIGH_GAINER: { percentChange24h: '6' }, + LOW_GAINER: { percentChange24h: '3' }, + }); + }); + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'HIGH_GAINER', + 'LOW_GAINER', + ]); + expect(renderCount).toBe(renderCountBeforeTicks); + + act(() => { + jest.advanceTimersByTime(10000); + }); + + // Exactly one additional render for two batched ticks, reflecting the + // most recently accumulated percents (from the second tick). + expect(renderCount).toBe(renderCountBeforeTicks + 1); + expect( + result.current.map((item) => item.market.change24hPercent), + ).toEqual(['+6.00%', '+3.00%']); + }); + + it('cancels a pending recompute when disabled', async () => { + let capturedCallback: (prices: PriceUpdatePayload) => void = jest.fn(); + const mockUnsubscribe = jest.fn(); + mockSubscribeToSymbols.mockImplementation((params) => { + capturedCallback = params.callback; + return mockUnsubscribe; + }); + + const items = [ + makeFeedItem('HIGH_GAINER', 5), + makeFeedItem('LOW_GAINER', 1), + ]; + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + usePerpsLiveMovers({ + items, + direction: 'gainers', + maxCount: 12, + recomputeIntervalMs: 10000, + enabled, + }), + { initialProps: { enabled: true } }, + ); + + await waitFor(() => { + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'HIGH_GAINER', + 'LOW_GAINER', + ]); + }); + + jest.useFakeTimers(); + + // Schedule a pending recompute, then disable before it fires. + act(() => { + capturedCallback({ + HIGH_GAINER: { percentChange24h: '2' }, + LOW_GAINER: { percentChange24h: '9' }, + }); + }); + + const displayedBeforeDisable = result.current; + + act(() => { + rerender({ enabled: false }); + }); + + expect(mockUnsubscribe).toHaveBeenCalled(); + + // Advancing time must not resurrect the cancelled recompute — the + // strip stays exactly as it was frozen. + act(() => { + jest.advanceTimersByTime(10000); + }); + expect(result.current).toBe(displayedBeforeDisable); + }); + + it('recomputes immediately on a direction change even with a recompute pending', async () => { + let capturedCallback: (prices: PriceUpdatePayload) => void = jest.fn(); + mockSubscribeToSymbols.mockImplementation((params) => { + capturedCallback = params.callback; + return jest.fn(); + }); + + const items = [makeFeedItem('GAINER', 5), makeFeedItem('LOSER', -3)]; + + const { result, rerender } = renderHook( + ({ direction }: { direction: 'gainers' | 'losers' }) => + usePerpsLiveMovers({ + items, + direction, + maxCount: 12, + recomputeIntervalMs: 10000, + }), + { initialProps: { direction: 'gainers' as const } }, + ); + + await waitFor(() => { + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'GAINER', + ]); + }); + + jest.useFakeTimers(); + + // Schedule a pending recompute that won't fire for 10s. + act(() => { + capturedCallback({ GAINER: { percentChange24h: '5' } }); + }); + + // The direction toggle (user-initiated) must not wait for that timer. + act(() => { + rerender({ direction: 'losers' }); + }); + + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'LOSER', + ]); + }); + }); }); diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts index 5c941a23e14..f9fbf95bfcd 100644 --- a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts @@ -16,6 +16,16 @@ export interface UsePerpsLiveMoversOptions { maxCount: number; /** Throttle delay for the underlying price subscription. @default 3000 */ throttleMs?: number; + /** + * Minimum time between recomputes of the rank/filter/sort/fingerprint work + * (a full pass over every market). Ticks arriving inside the interval + * still merge into the ref for free; only the recompute itself is batched + * to at most once per interval, using the most recently accumulated + * percents when it runs. `0` recomputes on every tick (still gated by + * `throttleMs` above). + * @default 0 + */ + recomputeIntervalMs?: number; /** * When false, the live subscription is torn down and the previously * displayed items are frozen in place instead of updating. @@ -41,12 +51,17 @@ const EMPTY_ITEMS: PerpsFeedItem[] = []; * * Items whose displayed value didn't change keep their previous object * reference, so memoized pill components skip re-rendering too. + * + * `recomputeIntervalMs` batches the recompute itself (not just the state + * commit) so callers with a large market set can bound how often the full + * pass runs, independent of the subscription's own `throttleMs`. */ export const usePerpsLiveMovers = ({ items, direction, maxCount, throttleMs = 3000, + recomputeIntervalMs = 0, enabled = true, }: UsePerpsLiveMoversOptions): PerpsFeedItem[] => { const stream = usePerpsStream(); @@ -127,15 +142,54 @@ export const usePerpsLiveMovers = ({ recomputeRef.current = recompute; }, [recompute]); + // Wall-clock instant of the last recompute pass, used to schedule the next + // one no sooner than recomputeIntervalMs after it. + const lastRecomputeAtRef = useRef(0); + const pendingRecomputeTimerRef = useRef>(); + + const clearPendingRecompute = useCallback(() => { + if (pendingRecomputeTimerRef.current !== undefined) { + clearTimeout(pendingRecomputeTimerRef.current); + pendingRecomputeTimerRef.current = undefined; + } + }, []); + + // Trailing-throttle (not debounce) the recompute pass: a strict debounce + // would never fire under a continuous stream of ticks. At most one + // recompute runs per recomputeIntervalMs, always using the freshest + // accumulated ref data by the time it fires, so batched ticks are never + // lost — just coalesced into fewer full passes. + const scheduleRecompute = useCallback(() => { + if (recomputeIntervalMs <= 0) { + lastRecomputeAtRef.current = Date.now(); + recomputeRef.current(); + return; + } + + if (pendingRecomputeTimerRef.current !== undefined) return; + + const elapsed = Date.now() - lastRecomputeAtRef.current; + const delay = Math.max(recomputeIntervalMs - elapsed, 0); + + pendingRecomputeTimerRef.current = setTimeout(() => { + pendingRecomputeTimerRef.current = undefined; + lastRecomputeAtRef.current = Date.now(); + recomputeRef.current(); + }, delay); + }, [recomputeIntervalMs]); + // Recompute from the base feed whenever it (or the ranking params) change // — e.g. initial load, pull-to-refresh, or the gainers/losers toggle. // Gated on `enabled` so a hidden/unfocused strip stays frozen rather than - // picking up REST refreshes in the background. + // picking up REST refreshes in the background. Runs immediately (bypassing + // recomputeIntervalMs) since these are user-initiated, not tick-driven. useEffect(() => { if (!enabled) return; itemsRef.current = items; + clearPendingRecompute(); + lastRecomputeAtRef.current = Date.now(); recompute(); - }, [enabled, recompute, items]); + }, [enabled, recompute, items, clearPendingRecompute]); useEffect(() => { if (!enabled || symbols.length === 0) return; @@ -151,19 +205,27 @@ export const usePerpsLiveMovers = ({ if (Number.isNaN(parsed)) continue; livePercentsRef.current[symbol] = parsed; } - recomputeRef.current(); + scheduleRecompute(); }, throttleMs, }); return () => { unsubscribe(); + clearPendingRecompute(); }; // symbolsKey captures symbols changes via memoization, so symbols is // intentionally omitted to prevent re-subscriptions when the array // reference changes but its contents don't (mirrors usePerpsLivePrices). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stream, symbolsKey, throttleMs, enabled]); + }, [ + stream, + symbolsKey, + throttleMs, + enabled, + scheduleRecompute, + clearPendingRecompute, + ]); return displayed; }; diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx index 2f88ef26db2..2692f243cfe 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.tsx @@ -78,6 +78,11 @@ interface PerpsBlockProps { // so the movers hook should rank/display exactly as many as will be shown. const PERPS_MOVERS_MAX_COUNT = 12; +// Pills only ever show a 24h percent change, so re-ranking the top-N more +// often than this is wasted work — batch the ranking pass to at most once +// per interval regardless of how often price ticks arrive. +const PERPS_MOVERS_RECOMPUTE_INTERVAL_MS = 10_000; + const PerpsBlock: React.FC = ({ refresh, navigation }) => { const [activeMoverDirection, setActiveMoverDirection] = useState('gainers'); @@ -110,6 +115,7 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => { items: perps.data, direction: activeMoverDirection, maxCount: PERPS_MOVERS_MAX_COUNT, + recomputeIntervalMs: PERPS_MOVERS_RECOMPUTE_INTERVAL_MS, enabled: isMoversSubscriptionEnabled, }); const pillData = From b6e93fbb4661576fff7d7e3e973b7666b9f9e8ab Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Mon, 13 Jul 2026 15:24:12 -0700 Subject: [PATCH 6/8] fix: include design system fix --- package.json | 2 +- yarn.lock | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index adf085fedd9..294c0e3e33c 100644 --- a/package.json +++ b/package.json @@ -266,7 +266,7 @@ "@metamask/core-backend": "^6.3.0", "@metamask/delegation-controller": "^2.0.2", "@metamask/delegation-deployments": "^1.0.0", - "@metamask/design-system-react-native": "^0.34.0", + "@metamask/design-system-react-native": "^0.35.0", "@metamask/design-system-twrnc-preset": "^0.7.0", "@metamask/design-tokens": "^8.7.0", "@metamask/earn-controller": "^12.1.0", diff --git a/yarn.lock b/yarn.lock index 2134f2b986f..87c31c06fca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8572,11 +8572,11 @@ __metadata: languageName: node linkType: hard -"@metamask/design-system-react-native@npm:^0.34.0": - version: 0.34.0 - resolution: "@metamask/design-system-react-native@npm:0.34.0" +"@metamask/design-system-react-native@npm:^0.35.0": + version: 0.35.0 + resolution: "@metamask/design-system-react-native@npm:0.35.0" dependencies: - "@metamask/design-system-shared": "npm:^0.28.0" + "@metamask/design-system-shared": "npm:^0.29.0" react-native-jazzicon: "npm:^0.1.2" peerDependencies: "@metamask/design-system-twrnc-preset": ^0.7.0 @@ -8589,18 +8589,18 @@ __metadata: react-native-reanimated: ">=4.2.0" react-native-safe-area-context: ">=5.0.0" react-native-worklets: ">=0.7.4" - checksum: 10/c9538ebb2deb857fb02ba5ff60a7c14ee6d850803f535674981dba8d82ca4dde0aa9aaef932d362e41c0d2ec9e77a74cddcc21801c6b6b1ac8c6563794c42e48 + checksum: 10/be185dd814064829e7ee382f804960a619b1d59b9a3c8b1952e9f3b07167820c3dd70e6106861060bb77c26f1832843f254ed4665e043fe94c4bd350fe87c26c languageName: node linkType: hard -"@metamask/design-system-shared@npm:^0.28.0": - version: 0.28.0 - resolution: "@metamask/design-system-shared@npm:0.28.0" +"@metamask/design-system-shared@npm:^0.29.0": + version: 0.29.0 + resolution: "@metamask/design-system-shared@npm:0.29.0" dependencies: "@metamask/utils": "npm:^11.11.0" peerDependencies: react: ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10/5a478a1e7da1648a8ecc9ed94d74ebbbc4ff34921f93d3bdd0f64fbe2bd048fe3f6a93ce7d8be8f928471b5630486899eccd0d83d1dfacef6a0087174ab34f52 + checksum: 10/01d579341e7700d000e406535ff18a2185c69f96cfa0677acf362d42160fc89adec5dc956bde983153380d8c5e07da2f0c97557a80940cde50b7d3396e0e7721 languageName: node linkType: hard @@ -35837,7 +35837,7 @@ __metadata: "@metamask/core-backend": "npm:^6.3.0" "@metamask/delegation-controller": "npm:^2.0.2" "@metamask/delegation-deployments": "npm:^1.0.0" - "@metamask/design-system-react-native": "npm:^0.34.0" + "@metamask/design-system-react-native": "npm:^0.35.0" "@metamask/design-system-twrnc-preset": "npm:^0.7.0" "@metamask/design-tokens": "npm:^8.7.0" "@metamask/earn-controller": "npm:^12.1.0" From c51eef096b6ef03c1bdb10f4d3a6d15e5c96933f Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Mon, 13 Jul 2026 15:47:01 -0700 Subject: [PATCH 7/8] fix: lint tsc --- .../TrendingView/feeds/perps/usePerpsLiveMovers.test.ts | 6 +++++- .../Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts index a702c340c80..cb384eb67be 100644 --- a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts @@ -420,7 +420,11 @@ describe('usePerpsLiveMovers', () => { maxCount: 12, recomputeIntervalMs: 10000, }), - { initialProps: { direction: 'gainers' as const } }, + { + initialProps: { direction: 'gainers' } as { + direction: 'gainers' | 'losers'; + }, + }, ); await waitFor(() => { diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts index f9fbf95bfcd..173ef460354 100644 --- a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts @@ -145,7 +145,9 @@ export const usePerpsLiveMovers = ({ // Wall-clock instant of the last recompute pass, used to schedule the next // one no sooner than recomputeIntervalMs after it. const lastRecomputeAtRef = useRef(0); - const pendingRecomputeTimerRef = useRef>(); + const pendingRecomputeTimerRef = useRef< + ReturnType | undefined + >(undefined); const clearPendingRecompute = useCallback(() => { if (pendingRecomputeTimerRef.current !== undefined) { From ee983bd3fd84c74d50ffced93ace409176869f2f Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Mon, 13 Jul 2026 16:39:20 -0700 Subject: [PATCH 8/8] fix: bugbots --- .../feeds/perps/usePerpsLiveMovers.test.ts | 161 +++++++++++ .../feeds/perps/usePerpsLiveMovers.ts | 265 +++++++++++------- 2 files changed, 331 insertions(+), 95 deletions(-) diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts index cb384eb67be..52a20cef4cd 100644 --- a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.test.ts @@ -99,6 +99,116 @@ describe('usePerpsLiveMovers', () => { ]); }); + describe('no deferred empty/stale frame on input changes', () => { + it('reflects new items on the same render, without waiting for an effect', () => { + mockSubscribeToSymbols.mockReturnValue(jest.fn()); + + const { result, rerender } = renderHook( + ({ items }: { items: PerpsFeedItem[] }) => + usePerpsLiveMovers({ items, direction: 'gainers', maxCount: 12 }), + { initialProps: { items: [] as PerpsFeedItem[] } }, + ); + + expect(result.current).toEqual([]); + + const itemsV2 = [makeFeedItem('A', 5), makeFeedItem('B', 3)]; + rerender({ items: itemsV2 }); + + // No waitFor / follow-up act needed — the ranked slice must already + // reflect the new items on the very render triggered by the prop + // change, so a "loading finished" frame never shows stale/empty data. + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'A', + 'B', + ]); + }); + + it('reflects a direction change on the same render with no extra effect-driven render', () => { + mockSubscribeToSymbols.mockReturnValue(jest.fn()); + const items = [makeFeedItem('GAINER', 5), makeFeedItem('LOSER', -3)]; + + let renderCount = 0; + const { result, rerender } = renderHook( + ({ direction }: { direction: 'gainers' | 'losers' }) => { + renderCount++; + return usePerpsLiveMovers({ items, direction, maxCount: 12 }); + }, + { + initialProps: { direction: 'gainers' } as { + direction: 'gainers' | 'losers'; + }, + }, + ); + + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'GAINER', + ]); + + const renderCountBeforeToggle = renderCount; + rerender({ direction: 'losers' }); + + // Exactly the render `rerender` itself causes — no follow-up + // effect-driven render lagging behind it. + expect(renderCount).toBe(renderCountBeforeToggle + 1); + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'LOSER', + ]); + }); + + it('resumes with the last-known live percents immediately on re-enable, without waiting for a fresh tick', () => { + const mockUnsubscribe = jest.fn(); + let capturedCallback: (prices: PriceUpdatePayload) => void = jest.fn(); + mockSubscribeToSymbols.mockImplementation((params) => { + capturedCallback = params.callback; + return mockUnsubscribe; + }); + + const items = [ + makeFeedItem('HIGH_GAINER', 5), + makeFeedItem('LOW_GAINER', 1), + ]; + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + usePerpsLiveMovers({ + items, + direction: 'gainers', + maxCount: 12, + enabled, + }), + { initialProps: { enabled: true } }, + ); + + // LOW_GAINER overtakes HIGH_GAINER while still enabled. + act(() => { + capturedCallback({ + HIGH_GAINER: { percentChange24h: '2' }, + LOW_GAINER: { percentChange24h: '9' }, + }); + }); + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'LOW_GAINER', + 'HIGH_GAINER', + ]); + + act(() => { + rerender({ enabled: false }); + }); + + act(() => { + rerender({ enabled: true }); + }); + + // Re-subscribing delivers no cached tick synchronously in this mock, + // yet the ranking already reflects the live percents merged before + // the pause — no empty/stale frame while waiting for a fresh tick. + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'LOW_GAINER', + 'HIGH_GAINER', + ]); + }); + }); + it('does not re-render when a push does not change the displayed top-N', async () => { let capturedCallback: (prices: PriceUpdatePayload) => void = jest.fn(); mockSubscribeToSymbols.mockImplementation((params) => { @@ -345,6 +455,57 @@ describe('usePerpsLiveMovers', () => { ).toEqual(['+6.00%', '+3.00%']); }); + it('recomputes the first tick after subscribe promptly instead of waiting a full interval', async () => { + let capturedCallback: (prices: PriceUpdatePayload) => void = jest.fn(); + mockSubscribeToSymbols.mockImplementation((params) => { + capturedCallback = params.callback; + return jest.fn(); + }); + + const items = [ + makeFeedItem('HIGH_GAINER', 5), + makeFeedItem('LOW_GAINER', 1), + ]; + + const { result } = renderHook(() => + usePerpsLiveMovers({ + items, + direction: 'gainers', + maxCount: 12, + recomputeIntervalMs: 10000, + }), + ); + + await waitFor(() => { + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'HIGH_GAINER', + 'LOW_GAINER', + ]); + }); + + jest.useFakeTimers(); + + // The first delivery after (re)subscribe carries the stream's cached + // snapshot — here LOW_GAINER has overtaken HIGH_GAINER. + act(() => { + capturedCallback({ + HIGH_GAINER: { percentChange24h: '2' }, + LOW_GAINER: { percentChange24h: '9' }, + }); + }); + + // Advancing far less than the interval must already surface it: the + // first tick is not held behind the items effect's fresh timestamp. + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(result.current.map((item) => item.market.symbol)).toEqual([ + 'LOW_GAINER', + 'HIGH_GAINER', + ]); + }); + it('cancels a pending recompute when disabled', async () => { let capturedCallback: (prices: PriceUpdatePayload) => void = jest.fn(); const mockUnsubscribe = jest.fn(); diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts index 173ef460354..e4f6a808c3d 100644 --- a/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsLiveMovers.ts @@ -17,12 +17,14 @@ export interface UsePerpsLiveMoversOptions { /** Throttle delay for the underlying price subscription. @default 3000 */ throttleMs?: number; /** - * Minimum time between recomputes of the rank/filter/sort/fingerprint work + * Minimum time between throttled recomputes triggered by live ticks alone * (a full pass over every market). Ticks arriving inside the interval - * still merge into the ref for free; only the recompute itself is batched - * to at most once per interval, using the most recently accumulated - * percents when it runs. `0` recomputes on every tick (still gated by - * `throttleMs` above). + * still merge into the ref for free; only the resulting re-render is + * batched to at most once per interval, using the most recently + * accumulated percents when it fires. Base data changes (new `items`, + * a `direction`/`maxCount` change, or `enabled` toggling) always render + * immediately — this interval only throttles tick-driven updates. + * `0` re-renders on every tick (still gated by `throttleMs` above). * @default 0 */ recomputeIntervalMs?: number; @@ -36,6 +38,68 @@ export interface UsePerpsLiveMoversOptions { const EMPTY_ITEMS: PerpsFeedItem[] = []; +interface RankedMovers { + items: PerpsFeedItem[]; + fingerprint: string; +} + +/** + * Pure rank/filter/sort/slice pass, extracted so both the render-time memo + * below and the off-render tick-throttling logic share one implementation. + * + * Reuses `previousItems`' object identity for any symbol whose displayed + * value is unchanged, so memoized pill components relying on shallow prop + * comparison skip re-rendering too. + */ +const rankMovers = ( + baseItems: PerpsFeedItem[], + direction: PerpsPriceChangeDirection, + maxCount: number, + livePercents: Record, + previousItems: PerpsFeedItem[], +): RankedMovers => { + const merged = baseItems.map((item) => { + const livePercent = livePercents[item.market.symbol]; + if (livePercent === undefined) return item.market; + return { + ...item.market, + change24hPercent: formatPercentage(livePercent), + }; + }); + const sorted = filterAndSortByPriceChangeDirection(merged, direction).slice( + 0, + maxCount, + ); + + const fingerprint = sorted + .map((market) => `${market.symbol}:${market.change24hPercent}`) + .join('|'); + + const baseBySymbol = new Map( + baseItems.map((item) => [item.market.symbol, item]), + ); + const previousBySymbol = new Map( + previousItems.map((item) => [item.market.symbol, item]), + ); + + const rankedItems = sorted + .map((market) => { + const base = baseBySymbol.get(market.symbol); + if (!base) return undefined; + const previous = previousBySymbol.get(market.symbol); + if ( + previous && + previous.market.change24hPercent === market.change24hPercent + ) { + return previous; + } + return { ...base, market }; + }) + .filter((item): item is PerpsFeedItem => item !== undefined); + + return { items: rankedItems, fingerprint }; +}; + /** * Ranks perps markets by live 24h price-change percentage for a movers pill * strip, without paying a re-render for every WebSocket tick. @@ -43,18 +107,22 @@ const EMPTY_ITEMS: PerpsFeedItem[] = []; * Correct gainers/losers ranking requires observing every market's live * percent change — a market outside the currently displayed set can move * into it. The WebSocket already delivers all-market updates regardless of - * what any component subscribes to, so this hook keeps the merge/filter/sort - * work on a ref between ticks and only commits React state when the - * *displayed* top `maxCount` slice actually changes (by symbol, order, or - * rounded percent). Idle ticks where the visible movers don't change cost - * zero renders. + * what any component subscribes to, so live ticks are merged onto a ref + * (`livePercentsRef`) that doesn't itself trigger a render. * - * Items whose displayed value didn't change keep their previous object - * reference, so memoized pill components skip re-rendering too. + * The ranked slice is derived with `useMemo` directly from `items`, + * `direction`, `maxCount`, and `enabled` — so a base-data load, a + * gainers/losers toggle, or a resume-from-pause is reflected on the very + * same render, using whatever live percents are already available. There is + * no one-frame gap where the caller sees stale or empty data while loading + * has already finished. * - * `recomputeIntervalMs` batches the recompute itself (not just the state - * commit) so callers with a large market set can bound how often the full - * pass runs, independent of the subscription's own `throttleMs`. + * Live ticks alone don't change those memo inputs, so between ticks the + * memo doesn't re-run any work. A throttled check (bounded by + * `recomputeIntervalMs`) runs off the render path and only requests a + * render (by bumping a counter, which *is* a memo input) when the visible + * top-`maxCount` slice actually changed. Idle ticks where the visible + * movers don't change cost zero renders. */ export const usePerpsLiveMovers = ({ items, @@ -65,14 +133,22 @@ export const usePerpsLiveMovers = ({ enabled = true, }: UsePerpsLiveMoversOptions): PerpsFeedItem[] => { const stream = usePerpsStream(); - const [displayed, setDisplayed] = useState(EMPTY_ITEMS); - const itemsRef = useRef(items); - const displayedRef = useRef(EMPTY_ITEMS); - const fingerprintRef = useRef(''); + // Bumped only when the throttled tick check below finds the visible + // fingerprint changed — forces the memo to re-run even though + // items/direction/maxCount haven't. + const [tickVersion, setTickVersion] = useState(0); + // Latest live percent-change per symbol. A ref (not state) so ticks don't - // trigger a render by themselves — only the fingerprint check below does. + // trigger a render by themselves — only a fingerprint change does, via + // tickVersion. const livePercentsRef = useRef>({}); + // The last actually-rendered slice/fingerprint. Synced *after* each commit + // (in an effect below) rather than written during the memo itself, so the + // memo stays a pure read of its inputs. Used both for identity reuse and + // as the basis the tick throttle compares fresh ticks against. + const previousItemsRef = useRef(EMPTY_ITEMS); + const renderedFingerprintRef = useRef(''); const symbols = useMemo( () => items.map(({ market }) => market.symbol), @@ -82,68 +158,44 @@ export const usePerpsLiveMovers = ({ // changes but its contents don't (mirrors usePerpsLivePrices). const symbolsKey = useMemo(() => symbols.join(','), [symbols]); - const recompute = useCallback(() => { - const baseItems = itemsRef.current; - const merged = baseItems.map((item) => { - const livePercent = livePercentsRef.current[item.market.symbol]; - if (livePercent === undefined) return item.market; + const { items: displayed, fingerprint } = useMemo(() => { + // Disabled: keep whatever was last rendered frozen, and skip the + // rank/sort pass entirely rather than churning on background data + // (e.g. a REST refresh) the caller isn't showing anyway. + if (!enabled) { return { - ...item.market, - change24hPercent: formatPercentage(livePercent), + items: previousItemsRef.current, + fingerprint: renderedFingerprintRef.current, }; - }); - const sorted = filterAndSortByPriceChangeDirection(merged, direction).slice( - 0, + } + return rankMovers( + items, + direction, maxCount, + livePercentsRef.current, + previousItemsRef.current, ); + // tickVersion is intentionally a dependency purely to force a re-run + // when a live tick (which doesn't otherwise change any of these inputs) + // changes the visible ranking. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, direction, maxCount, enabled, tickVersion]); - const fingerprint = sorted - .map((market) => `${market.symbol}:${market.change24hPercent}`) - .join('|'); - if (fingerprint === fingerprintRef.current) { - return; - } - fingerprintRef.current = fingerprint; - - const baseBySymbol = new Map( - baseItems.map((item) => [item.market.symbol, item]), - ); - const previousBySymbol = new Map( - displayedRef.current.map((item) => [item.market.symbol, item]), - ); + useEffect(() => { + previousItemsRef.current = displayed; + renderedFingerprintRef.current = fingerprint; + }, [displayed, fingerprint]); - const next = sorted - .map((market) => { - const base = baseBySymbol.get(market.symbol); - if (!base) return undefined; - const previous = previousBySymbol.get(market.symbol); - // Reuse the previous item's identity when its displayed value is - // unchanged so React.memo'd pills relying on shallow prop - // comparison skip re-rendering too. - if ( - previous && - previous.market.change24hPercent === market.change24hPercent - ) { - return previous; - } - return { ...base, market }; - }) - .filter((item): item is PerpsFeedItem => item !== undefined); - - displayedRef.current = next; - setDisplayed(next); - }, [direction, maxCount]); - - // Latest recompute, readable from the subscription callback below without - // making the subscription effect resubscribe on every direction/maxCount - // change. - const recomputeRef = useRef(recompute); + // Latest ranking params, readable from the subscription callback below + // without making the subscription effect resubscribe on every + // direction/maxCount change. Declared (and thus committed) ahead of the + // subscription effect so a synchronous cached delivery on (re)subscribe + // always sees the freshest params. + const paramsRef = useRef({ items, direction, maxCount }); useEffect(() => { - recomputeRef.current = recompute; - }, [recompute]); + paramsRef.current = { items, direction, maxCount }; + }, [items, direction, maxCount]); - // Wall-clock instant of the last recompute pass, used to schedule the next - // one no sooner than recomputeIntervalMs after it. const lastRecomputeAtRef = useRef(0); const pendingRecomputeTimerRef = useRef< ReturnType | undefined @@ -156,15 +208,44 @@ export const usePerpsLiveMovers = ({ } }, []); - // Trailing-throttle (not debounce) the recompute pass: a strict debounce - // would never fire under a continuous stream of ticks. At most one - // recompute runs per recomputeIntervalMs, always using the freshest - // accumulated ref data by the time it fires, so batched ticks are never - // lost — just coalesced into fewer full passes. + // Off-render-path check: does the freshest accumulated live data actually + // change the *rendered* fingerprint? Only if so is a render requested. + // This is the one place that still pays for a full rank/filter/sort pass + // outside of a render — but bounded to at most once per + // recomputeIntervalMs, not once per tick. + const maybeRequestRender = useCallback(() => { + const { + items: baseItems, + direction: dir, + maxCount: max, + } = paramsRef.current; + const { fingerprint: nextFingerprint } = rankMovers( + baseItems, + dir, + max, + livePercentsRef.current, + previousItemsRef.current, + ); + if (nextFingerprint === renderedFingerprintRef.current) return; + setTickVersion((version) => version + 1); + }, []); + + // Latest maybeRequestRender, readable from the subscription callback + // below without making the subscription effect resubscribe. + const maybeRequestRenderRef = useRef(maybeRequestRender); + useEffect(() => { + maybeRequestRenderRef.current = maybeRequestRender; + }, [maybeRequestRender]); + + // Trailing-throttle (not debounce) the tick-driven render request: a + // strict debounce would never fire under a continuous stream of ticks. At + // most one check-and-request runs per recomputeIntervalMs, always using + // the freshest accumulated ref data by the time it fires, so batched ticks + // are never lost — just coalesced into fewer full passes. const scheduleRecompute = useCallback(() => { if (recomputeIntervalMs <= 0) { lastRecomputeAtRef.current = Date.now(); - recomputeRef.current(); + maybeRequestRenderRef.current(); return; } @@ -176,26 +257,20 @@ export const usePerpsLiveMovers = ({ pendingRecomputeTimerRef.current = setTimeout(() => { pendingRecomputeTimerRef.current = undefined; lastRecomputeAtRef.current = Date.now(); - recomputeRef.current(); + maybeRequestRenderRef.current(); }, delay); }, [recomputeIntervalMs]); - // Recompute from the base feed whenever it (or the ranking params) change - // — e.g. initial load, pull-to-refresh, or the gainers/losers toggle. - // Gated on `enabled` so a hidden/unfocused strip stays frozen rather than - // picking up REST refreshes in the background. Runs immediately (bypassing - // recomputeIntervalMs) since these are user-initiated, not tick-driven. - useEffect(() => { - if (!enabled) return; - itemsRef.current = items; - clearPendingRecompute(); - lastRecomputeAtRef.current = Date.now(); - recompute(); - }, [enabled, recompute, items, clearPendingRecompute]); - useEffect(() => { if (!enabled || symbols.length === 0) return; + // The stream delivers its cached snapshot synchronously on (re)subscribe. + // Reset the throttle anchor so that first cached delivery — mount, tab + // resume, or a symbol-set change — is checked promptly instead of + // waiting up to a full recomputeIntervalMs behind a stale timestamp. + lastRecomputeAtRef.current = 0; + clearPendingRecompute(); + const unsubscribe = stream.prices.subscribeToSymbols({ symbols, callback: (newPrices) => { @@ -203,7 +278,7 @@ export const usePerpsLiveMovers = ({ for (const symbol of Object.keys(newPrices)) { const percentChange24h = newPrices[symbol]?.percentChange24h; if (!percentChange24h) continue; - const parsed = parseFloat(percentChange24h); + const parsed = Number.parseFloat(percentChange24h); if (Number.isNaN(parsed)) continue; livePercentsRef.current[symbol] = parsed; }