diff --git a/app/components/UI/DeleteWalletModal/__snapshots__/index.test.tsx.snap b/app/components/UI/DeleteWalletModal/__snapshots__/index.test.tsx.snap index 4da7e2010ac1..abca7831202a 100644 --- a/app/components/UI/DeleteWalletModal/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/DeleteWalletModal/__snapshots__/index.test.tsx.snap @@ -447,8 +447,9 @@ exports[`DeleteWalletModal bottom sheet renders matching snapshot 1`] = ` style={ { "color": "#121314", - "fontFamily": "Geist Bold", + "fontFamily": "CentraNo1-Bold", "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 24, "marginLeft": "auto", @@ -532,8 +533,9 @@ exports[`DeleteWalletModal bottom sheet renders matching snapshot 1`] = ` style={ { "color": "#121314", - "fontFamily": "Geist Bold", + "fontFamily": "CentraNo1-Bold", "fontSize": 16, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 24, } @@ -589,8 +591,9 @@ exports[`DeleteWalletModal bottom sheet renders matching snapshot 1`] = ` style={ { "color": "#121314", - "fontFamily": "Geist Bold", + "fontFamily": "CentraNo1-Bold", "fontSize": 16, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 24, } diff --git a/app/components/UI/DeleteWalletModal/index.tsx b/app/components/UI/DeleteWalletModal/index.tsx index 25d5294b5924..719ddba3d593 100644 --- a/app/components/UI/DeleteWalletModal/index.tsx +++ b/app/components/UI/DeleteWalletModal/index.tsx @@ -172,6 +172,7 @@ const DeleteWalletModal: React.FC = () => { {strings('login.forgot_password_point_1_bold')} {' '} @@ -193,6 +194,7 @@ const DeleteWalletModal: React.FC = () => { {strings('login.forgot_password_point_2_bold')}{' '} diff --git a/app/components/UI/DeleteWalletModal/styles.ts b/app/components/UI/DeleteWalletModal/styles.ts index 2e18ad9d0e66..c7a88e77a943 100644 --- a/app/components/UI/DeleteWalletModal/styles.ts +++ b/app/components/UI/DeleteWalletModal/styles.ts @@ -71,6 +71,7 @@ export const createStyles = (colors: any) => width: '80%', marginLeft: 'auto', marginRight: 'auto', + ...fontStyles.bold, }, red: { marginHorizontal: 24, @@ -79,6 +80,7 @@ export const createStyles = (colors: any) => warningText: { textAlign: 'left', width: '100%', + fontWeight: '500', }, warningTextContainer: { flexDirection: 'column', diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 9e32d9141436..49179bfce77f 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -35,6 +35,7 @@ import { usePerpsPerformance } from '../../hooks'; import ButtonIcon, { ButtonIconSizes, } from '../../../../../component-library/components/Buttons/ButtonIcon'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; const PerpsMarketRowItemSkeleton = () => { const { styles } = useStyles(styleSheet, {}); @@ -201,16 +202,21 @@ const PerpsMarketListView = ({ // Track screen load performance const hasTrackedMarketsView = useRef(false); - const hasTrackedSkeletonDisplay = useRef(false); + const hasTrackedDataDisplay = useRef(false); - // Track skeleton display immediately + // Track when actual market data is displayed (not just skeleton) useEffect(() => { - if (isLoadingMarkets && !hasTrackedSkeletonDisplay.current) { - // Measure time to skeleton display (should be instant) - endMeasure(PerpsMeasurementName.MARKETS_SCREEN_LOADED); - hasTrackedSkeletonDisplay.current = true; + if (filteredMarkets.length > 0 && !hasTrackedDataDisplay.current) { + // End measurement when actual data is displayed + const loadTime = endMeasure(PerpsMeasurementName.MARKETS_SCREEN_LOADED); + DevLogger.log('PerpsMarketListView: Market data displayed', { + marketCount: filteredMarkets.length, + loadTimeMs: loadTime, + targetMs: 200, + }); + hasTrackedDataDisplay.current = true; } - }, [isLoadingMarkets, endMeasure]); + }, [filteredMarkets.length, endMeasure]); useEffect(() => { // Track markets screen viewed event - only once when data is loaded diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx index d53c6bdfe98d..4b792cb40311 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx @@ -708,6 +708,118 @@ describe('PerpsTabViewWithProvider', () => { }); }); + describe('Visibility Callback Tests', () => { + it('should register visibility callback when onVisibilityChange is provided', () => { + const mockOnVisibilityChange = jest.fn(); + + render( + , + ); + + // Verify callback was registered + expect(mockOnVisibilityChange).toHaveBeenCalledTimes(1); + expect(mockOnVisibilityChange).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should update visibility state when callback is invoked', () => { + let visibilityCallback: ((visible: boolean) => void) | null = null; + const mockOnVisibilityChange = jest.fn((callback) => { + visibilityCallback = callback; + }); + + render( + , + ); + + // Simulate parent calling the visibility callback + act(() => { + visibilityCallback?.(true); + }); + + // The callback should have been invoked + expect(visibilityCallback).toBeTruthy(); + }); + + it('should not register callback when onVisibilityChange is not provided', () => { + const { rerender } = render(); + + // Component should render without errors + expect(screen.getByTestId('manage-balance-button')).toBeOnTheScreen(); + + // Rerender with different visibility + rerender(); + + // Should still render without errors + expect(screen.getByTestId('manage-balance-button')).toBeOnTheScreen(); + }); + + it('should use initial visibility value', () => { + const mockOnVisibilityChange = jest.fn(); + + // Test with initial visible = true + const { unmount: unmount1 } = render( + , + ); + + expect(screen.getByTestId('manage-balance-button')).toBeOnTheScreen(); + unmount1(); + + // Test with initial visible = false + const { unmount: unmount2 } = render( + , + ); + + expect(screen.getByTestId('manage-balance-button')).toBeOnTheScreen(); + unmount2(); + }); + + it('should handle multiple visibility changes', () => { + let visibilityCallback: ((visible: boolean) => void) | null = null; + let callCount = 0; + const mockOnVisibilityChange = jest.fn((callback) => { + visibilityCallback = callback; + }); + + render( + , + ); + + // Simulate multiple visibility changes + act(() => { + visibilityCallback?.(true); + callCount++; + }); + + act(() => { + visibilityCallback?.(false); + callCount++; + }); + + act(() => { + visibilityCallback?.(true); + callCount++; + }); + + // Verify callback was called for each change + expect(callCount).toBe(3); + }); + }); + describe('Edge Cases', () => { it('should handle component unmounting gracefully', () => { const { unmount } = render(); diff --git a/app/components/UI/Perps/Views/PerpsTabView/index.tsx b/app/components/UI/Perps/Views/PerpsTabView/index.tsx index aeafe0a3295f..45d4027bf4ea 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/index.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/index.tsx @@ -1,27 +1,49 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { NavigationProp, ParamListBase } from '@react-navigation/native'; import { PerpsConnectionProvider } from '../../providers/PerpsConnectionProvider'; import { PerpsStreamProvider } from '../../providers/PerpsStreamManager'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; import PerpsTabView from './PerpsTabView'; interface PerpsTabViewWithProviderProps { navigation?: NavigationProp; tabLabel?: string; + isVisible?: boolean; + onVisibilityChange?: (callback: (visible: boolean) => void) => void; } /** * PerpsTabView wrapped with both PerpsConnectionProvider and PerpsStreamProvider * This ensures the usePerpsConnection and usePerpsStream hooks work properly when used in the main wallet tab view + * Visibility is managed internally with updates from parent via callback */ const PerpsTabViewWithProvider: React.FC = ( props, -) => ( - - - - - -); +) => { + const { isVisible: initialVisible = false, onVisibilityChange } = props; + const [isVisible, setIsVisible] = useState(initialVisible); + + // Register callback with parent + useEffect(() => { + if (onVisibilityChange) { + onVisibilityChange((visible: boolean) => { + DevLogger.log('PerpsTabView: Visibility updated via callback', { + visible, + timestamp: new Date().toISOString(), + }); + setIsVisible(visible); + }); + } + }, [onVisibilityChange]); + + return ( + + + + + + ); +}; // Export the wrapped version as default export default PerpsTabViewWithProvider; diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index c91a4e24f814..e9619d18ceb5 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -5,6 +5,7 @@ export const PERPS_CONSTANTS = { FEATURE_FLAG_KEY: 'perpsEnabled', WEBSOCKET_TIMEOUT: 5000, // 5 seconds WEBSOCKET_CLEANUP_DELAY: 1000, // 1 second + BACKGROUND_DISCONNECT_DELAY: 20_000, // 20 seconds delay before disconnecting when app is backgrounded DEFAULT_ASSET_PREVIEW_LIMIT: 5, DEFAULT_MAX_LEVERAGE: 3 as number, // Default fallback max leverage when market data is unavailable - conservative default FALLBACK_PRICE_DISPLAY: '$---', // Display when price data is unavailable @@ -67,6 +68,10 @@ export const PERFORMANCE_CONFIG = { // Order validation debounce delay (milliseconds) // Prevents excessive validation calls during rapid form input changes VALIDATION_DEBOUNCE_MS: 1000, + + // Market data cache duration (milliseconds) + // How long to cache market list data before fetching fresh data + MARKET_DATA_CACHE_DURATION_MS: 5 * 60 * 1000, // 5 minutes } as const; /** diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts index 648a24bfdb50..711f4c1eb4cc 100644 --- a/app/components/UI/Perps/hooks/index.ts +++ b/app/components/UI/Perps/hooks/index.ts @@ -8,6 +8,7 @@ export { usePerpsDepositStatus } from './usePerpsDepositStatus'; // Connection management hooks export { usePerpsConnection } from '../providers/PerpsConnectionProvider'; +export { usePerpsConnectionLifecycle } from './usePerpsConnectionLifecycle'; // State hooks (Redux selectors) export { usePerpsAccount } from './usePerpsAccount'; diff --git a/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.test.ts b/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.test.ts new file mode 100644 index 000000000000..79b69840dd32 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.test.ts @@ -0,0 +1,427 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { AppState } from 'react-native'; +import BackgroundTimer from 'react-native-background-timer'; +import Device from '../../../../util/device'; +import { usePerpsConnectionLifecycle } from './usePerpsConnectionLifecycle'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig'; + +// Mock dependencies +jest.mock('react-native', () => ({ + AppState: { + currentState: 'active', + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + }, +})); + +jest.mock('react-native-background-timer', () => ({ + setTimeout: jest.fn(), + clearTimeout: jest.fn(), + start: jest.fn(), + stop: jest.fn(), +})); + +jest.mock('../../../../util/device'); +jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ + DevLogger: { + log: jest.fn(), + }, +})); + +describe('usePerpsConnectionLifecycle', () => { + let mockOnConnect: jest.Mock; + let mockOnDisconnect: jest.Mock; + let mockOnError: jest.Mock; + let mockAppStateListener: ((state: string) => void) | null = null; + + const mockIsIos = Device.isIos as jest.MockedFunction; + const mockIsAndroid = Device.isAndroid as jest.MockedFunction< + typeof Device.isAndroid + >; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockOnConnect = jest.fn().mockResolvedValue(undefined); + mockOnDisconnect = jest.fn(); + mockOnError = jest.fn(); + + // Default to iOS + mockIsIos.mockReturnValue(true); + mockIsAndroid.mockReturnValue(false); + + // Reset AppState current state + (AppState as { currentState: string }).currentState = 'active'; + + // Capture the AppState listener + (AppState.addEventListener as jest.Mock).mockImplementation( + (event, handler) => { + if (event === 'change') { + mockAppStateListener = handler; + } + return { remove: jest.fn() }; + }, + ); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + mockAppStateListener = null; + }); + + describe('Tab Visibility Changes', () => { + it('should connect when tab becomes visible', async () => { + const { rerender } = renderHook( + ({ isVisible }) => + usePerpsConnectionLifecycle({ + isVisible, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + }), + { initialProps: { isVisible: false } }, + ); + + expect(mockOnConnect).not.toHaveBeenCalled(); + + // Change to visible + rerender({ isVisible: true }); + + // Wait for the 500ms delay + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(mockOnConnect).toHaveBeenCalledTimes(1); + expect(mockOnDisconnect).not.toHaveBeenCalled(); + }); + + it('should disconnect immediately when tab becomes hidden', () => { + const { rerender } = renderHook( + ({ isVisible }) => + usePerpsConnectionLifecycle({ + isVisible, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + }), + { initialProps: { isVisible: true } }, + ); + + // Initial connection + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(mockOnConnect).toHaveBeenCalledTimes(1); + + // Change to hidden + rerender({ isVisible: false }); + + expect(mockOnDisconnect).toHaveBeenCalledTimes(1); + }); + + it('should not manage connection when visibility is undefined', () => { + renderHook(() => + usePerpsConnectionLifecycle({ + isVisible: undefined, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + }), + ); + + // Should still connect initially + expect(mockOnConnect).toHaveBeenCalledTimes(1); + + // But visibility changes should not affect it + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(mockOnDisconnect).not.toHaveBeenCalled(); + }); + }); + + describe('App State Changes - iOS', () => { + beforeEach(() => { + mockIsIos.mockReturnValue(true); + mockIsAndroid.mockReturnValue(false); + }); + + it('should schedule disconnection when app goes to background on iOS', () => { + renderHook(() => + usePerpsConnectionLifecycle({ + isVisible: true, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + }), + ); + + // Initial connection + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(mockOnConnect).toHaveBeenCalledTimes(1); + + // Simulate app going to background + act(() => { + mockAppStateListener?.('background'); + }); + + expect(BackgroundTimer.start).toHaveBeenCalled(); + expect(mockOnDisconnect).not.toHaveBeenCalled(); + + // Advance time to trigger disconnection + act(() => { + jest.advanceTimersByTime(PERPS_CONSTANTS.BACKGROUND_DISCONNECT_DELAY); + }); + + expect(mockOnDisconnect).toHaveBeenCalledTimes(1); + expect(BackgroundTimer.stop).toHaveBeenCalled(); + }); + + it('should cancel disconnection when app returns to foreground quickly on iOS', () => { + renderHook(() => + usePerpsConnectionLifecycle({ + isVisible: true, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + }), + ); + + // Initial connection + act(() => { + jest.runOnlyPendingTimers(); + }); + + // Simulate app going to background + act(() => { + mockAppStateListener?.('background'); + }); + + expect(BackgroundTimer.start).toHaveBeenCalled(); + + // Return to foreground before timer expires + act(() => { + jest.advanceTimersByTime(5000); // 5 seconds + mockAppStateListener?.('active'); + }); + + expect(BackgroundTimer.stop).toHaveBeenCalled(); + expect(mockOnDisconnect).not.toHaveBeenCalled(); + + // Verify timer doesn't fire later + act(() => { + jest.advanceTimersByTime(PERPS_CONSTANTS.BACKGROUND_DISCONNECT_DELAY); + }); + expect(mockOnDisconnect).not.toHaveBeenCalled(); + }); + }); + + describe('App State Changes - Android', () => { + beforeEach(() => { + mockIsIos.mockReturnValue(false); + mockIsAndroid.mockReturnValue(true); + }); + + it('should schedule disconnection when app goes to background on Android', () => { + renderHook(() => + usePerpsConnectionLifecycle({ + isVisible: true, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + }), + ); + + // Initial connection + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(mockOnConnect).toHaveBeenCalledTimes(1); + + // Simulate app going to background + act(() => { + mockAppStateListener?.('background'); + }); + + expect(BackgroundTimer.setTimeout).toHaveBeenCalledWith( + expect.any(Function), + PERPS_CONSTANTS.BACKGROUND_DISCONNECT_DELAY, + ); + }); + + it('should cancel disconnection when app returns to foreground quickly on Android', () => { + const mockTimerId = 123; + (BackgroundTimer.setTimeout as jest.Mock).mockReturnValue(mockTimerId); + + renderHook(() => + usePerpsConnectionLifecycle({ + isVisible: true, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + }), + ); + + // Initial connection + act(() => { + jest.runOnlyPendingTimers(); + }); + + // Simulate app going to background + act(() => { + mockAppStateListener?.('background'); + }); + + // Return to foreground before timer expires + act(() => { + mockAppStateListener?.('active'); + }); + + expect(BackgroundTimer.clearTimeout).toHaveBeenCalledWith(mockTimerId); + expect(mockOnDisconnect).not.toHaveBeenCalled(); + }); + }); + + describe('Interaction between visibility and app state', () => { + it('should not reconnect when app comes to foreground if tab is not visible', () => { + const { rerender } = renderHook( + ({ isVisible }) => + usePerpsConnectionLifecycle({ + isVisible, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + }), + { initialProps: { isVisible: true } }, + ); + + // Initial connection + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(mockOnConnect).toHaveBeenCalledTimes(1); + + // Hide tab + rerender({ isVisible: false }); + expect(mockOnDisconnect).toHaveBeenCalledTimes(1); + + mockOnConnect.mockClear(); + + // App goes to background and returns + act(() => { + mockAppStateListener?.('background'); + jest.advanceTimersByTime(1000); + mockAppStateListener?.('active'); + }); + + // Should not reconnect because tab is not visible + expect(mockOnConnect).not.toHaveBeenCalled(); + }); + + it('should cancel background timer when tab becomes hidden', () => { + mockIsIos.mockReturnValue(true); + const { rerender } = renderHook( + ({ isVisible }) => + usePerpsConnectionLifecycle({ + isVisible, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + }), + { initialProps: { isVisible: true } }, + ); + + // App goes to background + act(() => { + mockAppStateListener?.('background'); + }); + + expect(BackgroundTimer.start).toHaveBeenCalled(); + + // Tab becomes hidden before timer expires + rerender({ isVisible: false }); + + expect(BackgroundTimer.stop).toHaveBeenCalled(); + expect(mockOnDisconnect).toHaveBeenCalledTimes(1); + }); + }); + + describe('Error handling', () => { + it('should call onError when connection fails', async () => { + const error = new Error('Connection failed'); + mockOnConnect.mockRejectedValueOnce(error); + + renderHook(() => + usePerpsConnectionLifecycle({ + isVisible: true, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + onError: mockOnError, + }), + ); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + expect(mockOnError).toHaveBeenCalledWith('Connection failed'); + }); + + it('should handle non-Error objects in connection failure', async () => { + mockOnConnect.mockRejectedValueOnce('String error'); + + renderHook(() => + usePerpsConnectionLifecycle({ + isVisible: true, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + onError: mockOnError, + }), + ); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + expect(mockOnError).toHaveBeenCalledWith('Unknown connection error'); + }); + }); + + describe('Cleanup', () => { + it('should disconnect and clean up on unmount', () => { + const { unmount } = renderHook(() => + usePerpsConnectionLifecycle({ + isVisible: true, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + }), + ); + + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(mockOnConnect).toHaveBeenCalledTimes(1); + + unmount(); + + expect(mockOnDisconnect).toHaveBeenCalledTimes(1); + }); + + it('should clean up background timer on unmount', () => { + mockIsIos.mockReturnValue(true); + const { unmount } = renderHook(() => + usePerpsConnectionLifecycle({ + isVisible: true, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + }), + ); + + // Start background timer + act(() => { + mockAppStateListener?.('background'); + }); + + expect(BackgroundTimer.start).toHaveBeenCalled(); + + unmount(); + + expect(BackgroundTimer.stop).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.ts b/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.ts new file mode 100644 index 000000000000..228aea0a1c96 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.ts @@ -0,0 +1,195 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; +import BackgroundTimer from 'react-native-background-timer'; +import Device from '../../../../util/device'; +import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig'; + +interface UsePerpsConnectionLifecycleParams { + isVisible?: boolean; + onConnect: () => Promise; + onDisconnect: () => void | Promise; + onError?: (error: string) => void; +} + +interface UsePerpsConnectionLifecycleReturn { + hasConnected: boolean; +} + +/** + * Hook that manages the Perps WebSocket connection lifecycle based on: + * - Tab visibility (connect when visible, disconnect when hidden) + * - App state (disconnect after 20s when backgrounded) + * + * This hook ensures optimal battery and network usage by: + * - Immediately disconnecting when tab is not visible + * - Delaying disconnection by 20s when app is backgrounded (for quick returns) + * - Using BackgroundTimer to ensure timers run even when app is suspended + */ +export function usePerpsConnectionLifecycle({ + isVisible, + onConnect, + onDisconnect, + onError, +}: UsePerpsConnectionLifecycleParams): UsePerpsConnectionLifecycleReturn { + const hasConnected = useRef(false); + const lastAppState = useRef(AppState.currentState); + const backgroundDisconnectTimer = useRef(null); + + // Helper to cancel background timer + const cancelBackgroundTimer = useCallback(() => { + if (backgroundDisconnectTimer.current) { + if (Device.isAndroid()) { + BackgroundTimer.clearTimeout(backgroundDisconnectTimer.current); + } else { + clearTimeout(backgroundDisconnectTimer.current); + BackgroundTimer.stop(); + } + backgroundDisconnectTimer.current = null; + } + }, []); + + // Helper to schedule background disconnection + const scheduleBackgroundDisconnection = useCallback(() => { + // Cancel any existing timer to prevent multiple timers + cancelBackgroundTimer(); + + DevLogger.log( + `usePerpsConnectionLifecycle: Scheduling disconnection in ${PERPS_CONSTANTS.BACKGROUND_DISCONNECT_DELAY}ms`, + ); + + if (Device.isIos()) { + // iOS: Start background timer, schedule with setTimeout, then stop immediately + BackgroundTimer.start(); + backgroundDisconnectTimer.current = setTimeout(() => { + hasConnected.current = false; + onDisconnect(); + backgroundDisconnectTimer.current = null; + }, PERPS_CONSTANTS.BACKGROUND_DISCONNECT_DELAY) as unknown as number; + // Stop immediately after scheduling (not in the callback) + BackgroundTimer.stop(); + } else if (Device.isAndroid()) { + // Android uses BackgroundTimer.setTimeout directly + backgroundDisconnectTimer.current = BackgroundTimer.setTimeout(() => { + hasConnected.current = false; + onDisconnect(); + backgroundDisconnectTimer.current = null; + }, PERPS_CONSTANTS.BACKGROUND_DISCONNECT_DELAY); + } + }, [onDisconnect, cancelBackgroundTimer]); + + // Handle connection based on current state + const handleConnection = useCallback(async () => { + if (!hasConnected.current) { + hasConnected.current = true; + try { + await onConnect(); + } catch (err) { + hasConnected.current = false; + const errorMessage = + err instanceof Error ? err.message : 'Unknown connection error'; + onError?.(errorMessage); + } + } + }, [onConnect, onError]); + + // Handle disconnection + const handleDisconnection = useCallback(() => { + if (hasConnected.current) { + hasConnected.current = false; + onDisconnect(); + } + }, [onDisconnect]); + + // Handle tab visibility changes + useEffect(() => { + if (isVisible === undefined) { + // If visibility is not provided (e.g., in modal stack), don't manage based on visibility + return; + } + + if (isVisible === false && hasConnected.current) { + // Tab is not visible - disconnect immediately + cancelBackgroundTimer(); // Cancel any pending background timer + handleDisconnection(); + } else if ( + isVisible === true && + !hasConnected.current && + lastAppState.current === 'active' + ) { + // Tab is visible and app is active - connect + // Add small delay to allow any pending disconnection to complete + const timer = setTimeout(() => { + if (isVisible === true && !hasConnected.current) { + handleConnection(); + } + }, 500); + return () => clearTimeout(timer); + } + }, [isVisible, cancelBackgroundTimer, handleConnection, handleDisconnection]); + + // Handle app state changes (background/foreground) + useEffect(() => { + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if ( + lastAppState.current === 'active' && + nextAppState.match(/inactive|background/) && + hasConnected.current + ) { + // App going to background - schedule disconnection + scheduleBackgroundDisconnection(); + } else if ( + lastAppState.current.match(/inactive|background/) && + nextAppState === 'active' + ) { + // App coming to foreground + cancelBackgroundTimer(); + + // Reconnect if needed and visible + if ( + !hasConnected.current && + (isVisible === true || isVisible === undefined) + ) { + handleConnection(); + } + } + + lastAppState.current = nextAppState; + }; + + const subscription = AppState.addEventListener( + 'change', + handleAppStateChange, + ); + return () => { + subscription.remove(); + cancelBackgroundTimer(); + }; + }, [ + isVisible, + scheduleBackgroundDisconnection, + cancelBackgroundTimer, + handleConnection, + ]); + + // Initial connection on mount (if visible) + useEffect(() => { + if (!hasConnected.current && isVisible !== false) { + handleConnection(); + } + + // Cleanup on unmount + return () => { + if (hasConnected.current) { + hasConnected.current = false; + onDisconnect(); + } + cancelBackgroundTimer(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Intentionally only run on mount/unmount + + return { + hasConnected: hasConnected.current, + }; +} diff --git a/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts b/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts index 0bba2965777f..2d5a0411bb8e 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts @@ -1,40 +1,28 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { waitFor } from '@testing-library/react-native'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; -import Engine from '../../../../core/Engine'; import { usePerpsMarkets } from './usePerpsMarkets'; -import type { PerpsMarketData, IPerpsProvider } from '../controllers/types'; +import type { PerpsMarketData } from '../controllers/types'; // Mock dependencies jest.mock('../../../../core/SDKConnect/utils/DevLogger'); -jest.mock('../../../../core/Engine', () => ({ - context: { - PerpsController: { - getActiveProvider: jest.fn(), - }, - }, -})); -// Mock PerpsConnectionProvider -jest.mock('../providers/PerpsConnectionProvider', () => ({ - usePerpsConnection: jest.fn(() => ({ - isConnected: true, - isConnecting: false, - isInitialized: true, - error: null, - connect: jest.fn(), - disconnect: jest.fn(), - resetError: jest.fn(), - })), -})); +// Mock PerpsStreamManager +const mockSubscribe = jest.fn(); +const mockRefresh = jest.fn(); +const mockMarketData = { + subscribe: mockSubscribe, + refresh: mockRefresh, +}; -// Mock stream hooks -jest.mock('./stream', () => ({ - usePerpsLivePrices: jest.fn(() => ({})), +jest.mock('../providers/PerpsStreamManager', () => ({ + usePerpsStream: jest.fn(() => ({ + marketData: mockMarketData, + })), })); // Mock data -const mockMarketData: PerpsMarketData[] = [ +const mockMarketDataArray: PerpsMarketData[] = [ { symbol: 'BTC', name: 'Bitcoin', @@ -51,67 +39,10 @@ const mockMarketData: PerpsMarketData[] = [ price: '$3,000.00', change24h: '-1.2%', change24hPercent: '-1.2', - volume: '$800M', + volume: '$900M', }, ]; -const mockProvider = { - protocolId: 'hyperliquid' as const, - getMarketDataWithPrices: jest.fn(), - getDepositRoutes: jest.fn(), - getWithdrawalRoutes: jest.fn(), - placeOrder: jest.fn(), - editOrder: jest.fn(), - cancelOrder: jest.fn(), - closePosition: jest.fn(), - getPositions: jest.fn(), - getAccountState: jest.fn(), - getMarkets: jest.fn(), - withdraw: jest.fn(), - subscribeToPrices: jest.fn(), - subscribeToPositions: jest.fn(), - subscribeToOrderFills: jest.fn(), - setLiveDataConfig: jest.fn(), - disconnect: jest.fn(), - toggleTestnet: jest.fn(), - initialize: jest.fn(), - isReadyToTrade: jest.fn(), - validateDeposit: jest.fn(), - calculateLiquidationPrice: jest.fn(), - calculateMaintenanceMargin: jest.fn(), - getMaxLeverage: jest.fn(), - calculateFees: jest.fn().mockResolvedValue({ - feeRate: 0.00045, - feeAmount: 45, - }), - updatePositionTPSL: jest.fn().mockResolvedValue({ - success: true, - orderId: '123', - }), - checkWithdrawalStatus: jest.fn().mockResolvedValue({ - status: 'pending', - metadata: {}, - }), - validateOrder: jest.fn().mockResolvedValue({ isValid: true }), - validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), - validateWithdrawal: jest.fn().mockResolvedValue({ isValid: true }), - getBlockExplorerUrl: jest.fn(), - getOrderFills: jest.fn(), - getOrders: jest.fn(), - getOpenOrders: jest.fn(), - getFunding: jest.fn(), - getIsFirstTimeUser: jest.fn(), - subscribeToOrders: jest.fn(), - unsubscribeFromOrders: jest.fn(), - unsubscribeFromPrices: jest.fn(), - unsubscribeFromPositions: jest.fn(), - unsubscribeFromOrderFills: jest.fn(), - subscribeToAccount: jest.fn(() => jest.fn()), -}; - -const mockPerpsController = Engine.context.PerpsController as jest.Mocked< - typeof Engine.context.PerpsController ->; const mockLogger = DevLogger as jest.Mocked; describe('usePerpsMarkets', () => { @@ -119,11 +50,14 @@ describe('usePerpsMarkets', () => { jest.clearAllMocks(); jest.useFakeTimers(); - // Set up default mocks - mockPerpsController.getActiveProvider.mockReturnValue( - mockProvider as IPerpsProvider, - ); - mockProvider.getMarketDataWithPrices.mockResolvedValue(mockMarketData); + // Set up default mock behavior + mockSubscribe.mockImplementation(({ callback }) => { + // Simulate immediate callback with data + setTimeout(() => callback(mockMarketDataArray), 0); + // Return unsubscribe function + return jest.fn(); + }); + mockRefresh.mockResolvedValue(undefined); }); afterEach(() => { @@ -132,6 +66,9 @@ describe('usePerpsMarkets', () => { describe('Initial state', () => { it('returns initial state with empty markets and loading true', () => { + // Setup to not call the callback immediately + mockSubscribe.mockImplementation(() => jest.fn()); + // Act const { result } = renderHook(() => usePerpsMarkets()); @@ -167,16 +104,20 @@ describe('usePerpsMarkets', () => { expect(result.current.isLoading).toBe(false); }); - // Assert - expect(result.current.markets).toEqual(mockMarketData); + // Assert - markets should be sorted by volume (BTC first due to $1.2B vs $900M) + expect(result.current.markets).toHaveLength(2); + expect(result.current.markets[0].symbol).toBe('BTC'); + expect(result.current.markets[1].symbol).toBe('ETH'); expect(result.current.error).toBeNull(); - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); - expect(mockLogger.log).toHaveBeenCalledWith( - 'Perps: Fetching market data from active provider...', - ); + expect(mockSubscribe).toHaveBeenCalledTimes(1); expect(mockLogger.log).toHaveBeenCalledWith( - 'Perps: Successfully fetched and transformed market data', - { marketCount: 2 }, + 'Perps: Market data received (first load)', + expect.objectContaining({ + marketCount: 2, + cacheHit: expect.any(Boolean), + source: expect.any(String), + timeToDataMs: expect.any(Number), + }), ); }); @@ -194,7 +135,7 @@ describe('usePerpsMarkets', () => { // Assert expect(result.current.isLoading).toBe(false); expect(result.current.markets).toEqual([]); - expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); + expect(mockSubscribe).not.toHaveBeenCalled(); }); it('updates markets when data changes', async () => { @@ -217,382 +158,315 @@ describe('usePerpsMarkets', () => { }, ]; - mockProvider.getMarketDataWithPrices.mockResolvedValue(newMarketData); + // Simulate data update by calling the callback again + const subscriberCallback = mockSubscribe.mock.calls[0][0].callback; // Act - await act(async () => { - await result.current.refresh(); + act(() => { + subscriberCallback(newMarketData); }); // Assert expect(result.current.markets).toEqual(newMarketData); - expect(result.current.error).toBeNull(); }); - }); - describe('Error handling', () => { - it('handles fetch errors with empty markets', async () => { + it('handles empty market data', async () => { // Arrange - const errorMessage = 'Network error'; - mockProvider.getMarketDataWithPrices.mockRejectedValue( - new Error(errorMessage), - ); + mockSubscribe.mockImplementation(({ callback }) => { + setTimeout(() => callback([]), 0); + return jest.fn(); + }); // Act const { result } = renderHook(() => usePerpsMarkets()); + // Wait for async operations await waitFor(() => { expect(result.current.isLoading).toBe(false); }); // Assert expect(result.current.markets).toEqual([]); - expect(result.current.error).toBe(errorMessage); - expect(mockLogger.log).toHaveBeenCalledWith( - 'Perps: Failed to fetch market data', - expect.any(Error), - ); + expect(result.current.error).toBeNull(); }); + }); - it('handles fetch errors with existing markets', async () => { + describe('Manual refresh', () => { + it('refreshes market data when refresh is called', async () => { // Arrange const { result } = renderHook(() => usePerpsMarkets()); - // Wait for initial successful fetch await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.markets).toEqual(mockMarketData); - - // Set up error for next call - const errorMessage = 'Network error'; - mockProvider.getMarketDataWithPrices.mockRejectedValue( - new Error(errorMessage), - ); - // Act await act(async () => { await result.current.refresh(); }); - // Assert - should keep existing data on error - expect(result.current.markets).toEqual(mockMarketData); - expect(result.current.error).toBe(errorMessage); - }); - - it('handles unknown error types', async () => { - // Arrange - mockProvider.getMarketDataWithPrices.mockRejectedValue('String error'); - - // Act - const { result } = renderHook(() => usePerpsMarkets()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - // Assert - expect(result.current.error).toBe('Unknown error occurred'); + expect(mockRefresh).toHaveBeenCalledTimes(1); + expect(result.current.isRefreshing).toBe(false); + expect(mockLogger.log).toHaveBeenCalledWith( + 'Perps: Manual refresh completed', + ); }); - }); - describe('Refresh functionality', () => { - it('refreshes data when refresh function is called', async () => { + it('sets isRefreshing state during refresh', async () => { // Arrange - const { result } = renderHook(() => usePerpsMarkets()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); + let resolveRefresh: () => void; + const refreshPromise = new Promise((resolve) => { + resolveRefresh = resolve; }); + mockRefresh.mockReturnValue(refreshPromise); - // Clear previous calls - mockProvider.getMarketDataWithPrices.mockClear(); - - // Act - await act(async () => { - await result.current.refresh(); - }); - - // Assert - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); - expect(result.current.markets).toEqual(mockMarketData); - }); - - it('sets isRefreshing to true during refresh', async () => { - // Arrange const { result } = renderHook(() => usePerpsMarkets()); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - // Make the next call hang to test refreshing state - let resolvePromise: (value: PerpsMarketData[]) => void; - const hangingPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - mockProvider.getMarketDataWithPrices.mockReturnValue(hangingPromise); - - // Act - act(() => { - result.current.refresh(); - }); + // Act - start refresh + const refreshCall = act(() => result.current.refresh()); // Assert - should be refreshing expect(result.current.isRefreshing).toBe(true); - expect(result.current.isLoading).toBe(false); - // Complete the promise - act(() => { - resolvePromise(mockMarketData); + // Complete refresh + await act(async () => { + resolveRefresh?.(); + await refreshCall; }); - await waitFor(() => { - expect(result.current.isRefreshing).toBe(false); - }); + // Assert - no longer refreshing + expect(result.current.isRefreshing).toBe(false); }); - it('clears error on successful refresh', async () => { + it('handles refresh errors gracefully', async () => { // Arrange - mockProvider.getMarketDataWithPrices.mockRejectedValueOnce( - new Error('Initial error'), - ); + const refreshError = new Error('Failed to fetch markets'); + mockRefresh.mockRejectedValue(refreshError); const { result } = renderHook(() => usePerpsMarkets()); await waitFor(() => { - expect(result.current.error).toBe('Initial error'); + expect(result.current.isLoading).toBe(false); }); - // Set up successful response for refresh - mockProvider.getMarketDataWithPrices.mockResolvedValue(mockMarketData); - // Act await act(async () => { await result.current.refresh(); }); // Assert - expect(result.current.error).toBeNull(); - expect(result.current.markets).toEqual(mockMarketData); + expect(result.current.error).toBe('Failed to fetch markets'); + expect(result.current.isRefreshing).toBe(false); + expect(mockLogger.log).toHaveBeenCalledWith( + 'Perps: Failed to refresh market data', + refreshError, + ); }); }); - describe('Polling functionality', () => { - it('does not poll when enablePolling is false', async () => { + describe('Polling', () => { + it('polls for updates when enablePolling is true', async () => { // Arrange - const { result } = renderHook(() => - usePerpsMarkets({ enablePolling: false }), + const pollingInterval = 5000; + + // Act + renderHook(() => + usePerpsMarkets({ + enablePolling: true, + pollingInterval, + }), ); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); + // Wait for initial load + await act(async () => { + jest.advanceTimersByTime(100); }); - mockProvider.getMarketDataWithPrices.mockClear(); + // Clear previous calls + mockRefresh.mockClear(); - // Act - advance time beyond default polling interval - act(() => { - jest.advanceTimersByTime(65000); + // Advance time to trigger polling + await act(async () => { + jest.advanceTimersByTime(pollingInterval); }); // Assert - expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); - }); + expect(mockRefresh).toHaveBeenCalledTimes(1); - it('polls at default interval when enablePolling is true', async () => { - // Arrange - const { result } = renderHook(() => - usePerpsMarkets({ enablePolling: true }), - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - mockProvider.getMarketDataWithPrices.mockClear(); - - // Act - advance time to trigger polling - act(() => { - jest.advanceTimersByTime(60000); + // Advance time again + await act(async () => { + jest.advanceTimersByTime(pollingInterval); }); // Assert - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + expect(mockRefresh).toHaveBeenCalledTimes(2); }); - it('polls at custom interval when specified', async () => { - // Arrange - const customInterval = 30000; - const { result } = renderHook(() => + it('does not poll when enablePolling is false', async () => { + // Act + renderHook(() => usePerpsMarkets({ - enablePolling: true, - pollingInterval: customInterval, + enablePolling: false, + pollingInterval: 5000, }), ); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); + // Wait for initial load + await act(async () => { + jest.advanceTimersByTime(100); }); - mockProvider.getMarketDataWithPrices.mockClear(); + // Clear previous calls + mockRefresh.mockClear(); - // Act - advance time to custom interval - act(() => { - jest.advanceTimersByTime(customInterval); + // Advance time + await act(async () => { + jest.advanceTimersByTime(10000); }); - // Assert - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + // Assert - refresh should not have been called + expect(mockRefresh).not.toHaveBeenCalled(); }); - it('continues polling multiple times', async () => { + it('cleans up polling interval on unmount', async () => { // Arrange - const { result } = renderHook(() => - usePerpsMarkets({ enablePolling: true }), + const { unmount } = renderHook(() => + usePerpsMarkets({ + enablePolling: true, + pollingInterval: 5000, + }), ); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - mockProvider.getMarketDataWithPrices.mockClear(); - - // Act - advance time for multiple intervals - act(() => { - jest.advanceTimersByTime(60000); - }); - act(() => { - jest.advanceTimersByTime(60000); + // Wait for initial load + await act(async () => { + jest.advanceTimersByTime(100); }); - // Assert - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(2); - }); - - it('clears polling interval on unmount', async () => { - // Arrange - const { result, unmount } = renderHook(() => - usePerpsMarkets({ enablePolling: true }), - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + // Clear previous calls + mockRefresh.mockClear(); - // Act + // Act - unmount unmount(); - mockProvider.getMarketDataWithPrices.mockClear(); - - act(() => { - jest.advanceTimersByTime(60000); - }); - - // Assert - expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); - }); - }); - - describe('Options configuration', () => { - it('applies default options when none provided', () => { - // Act - const { result } = renderHook(() => usePerpsMarkets()); - - // Assert - should start loading (default skipInitialFetch: false) - expect(result.current.isLoading).toBe(true); - }); - it('applies custom options correctly', async () => { - // Arrange - const options = { - enablePolling: true, - pollingInterval: 45000, - skipInitialFetch: true, - }; - - // Act - const { result } = renderHook(() => usePerpsMarkets(options)); - - // Assert - expect(result.current.isLoading).toBe(false); - expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); - - // Test polling with custom interval - mockProvider.getMarketDataWithPrices.mockClear(); - act(() => { - jest.advanceTimersByTime(45000); + // Advance time after unmount + await act(async () => { + jest.advanceTimersByTime(10000); }); - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + // Assert - refresh should not have been called after unmount + expect(mockRefresh).not.toHaveBeenCalled(); }); - }); - describe('Loading states', () => { - it('maintains correct loading state during initial fetch', async () => { + it('handles polling errors gracefully', async () => { // Arrange - let resolvePromise: (value: PerpsMarketData[]) => void; - const hangingPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - mockProvider.getMarketDataWithPrices.mockReturnValue(hangingPromise); + mockRefresh.mockRejectedValue(new Error('Polling failed')); // Act - const { result } = renderHook(() => usePerpsMarkets()); - - // Assert - should be loading - expect(result.current.isLoading).toBe(true); - expect(result.current.isRefreshing).toBe(false); - - // Complete the promise - act(() => { - resolvePromise(mockMarketData); - }); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - }); - - it('maintains correct loading state during refresh', async () => { - // Arrange - const { result } = renderHook(() => usePerpsMarkets()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - let resolvePromise: (value: PerpsMarketData[]) => void; - const hangingPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - mockProvider.getMarketDataWithPrices.mockReturnValue(hangingPromise); + renderHook(() => + usePerpsMarkets({ + enablePolling: true, + pollingInterval: 5000, + }), + ); - // Act - act(() => { - result.current.refresh(); + // Wait for initial load + await act(async () => { + jest.advanceTimersByTime(100); }); - // Assert - should be refreshing, not loading - expect(result.current.isLoading).toBe(false); - expect(result.current.isRefreshing).toBe(true); + // Clear logs + mockLogger.log.mockClear(); - // Complete the promise - act(() => { - resolvePromise(mockMarketData); + // Advance time to trigger polling + await act(async () => { + jest.advanceTimersByTime(5000); }); - await waitFor(() => { - expect(result.current.isRefreshing).toBe(false); - }); + // Assert - error should be logged but not crash + expect(mockLogger.log).toHaveBeenCalledWith( + 'Perps: Polling refresh failed', + expect.any(Error), + ); }); }); - describe('Edge cases', () => { - it('handles provider not available', async () => { - // Arrange - mockPerpsController.getActiveProvider.mockImplementation(() => { - throw new Error('Provider not available'); + describe('Volume sorting', () => { + it('sorts markets by volume correctly', async () => { + // Arrange - markets with various volume formats + const unsortedMarkets: PerpsMarketData[] = [ + { + symbol: 'A', + name: 'A', + maxLeverage: '1x', + price: '$1', + change24h: '+0%', + change24hPercent: '0', + volume: '$100K', + }, + { + symbol: 'B', + name: 'B', + maxLeverage: '1x', + price: '$1', + change24h: '+0%', + change24hPercent: '0', + volume: '$1.5B', + }, + { + symbol: 'C', + name: 'C', + maxLeverage: '1x', + price: '$1', + change24h: '+0%', + change24hPercent: '0', + volume: '$<1', + }, + { + symbol: 'D', + name: 'D', + maxLeverage: '1x', + price: '$1', + change24h: '+0%', + change24hPercent: '0', + volume: '$500M', + }, + { + symbol: 'E', + name: 'E', + maxLeverage: '1x', + price: '$1', + change24h: '+0%', + change24hPercent: '0', + volume: '—', + }, + { + symbol: 'F', + name: 'F', + maxLeverage: '1x', + price: '$1', + change24h: '+0%', + change24hPercent: '0', + volume: '$0', + }, + { + symbol: 'G', + name: 'G', + maxLeverage: '1x', + price: '$1', + change24h: '+0%', + change24hPercent: '0', + volume: '—', + }, + ]; + + mockSubscribe.mockImplementation(({ callback }) => { + setTimeout(() => callback(unsortedMarkets), 0); + return jest.fn(); }); // Act @@ -602,50 +476,24 @@ describe('usePerpsMarkets', () => { expect(result.current.isLoading).toBe(false); }); - // Assert - expect(result.current.error).toBe('Provider not available'); - expect(result.current.markets).toEqual([]); + // Assert - should be sorted by volume descending + const sortedSymbols = result.current.markets.map((m) => m.symbol); + expect(sortedSymbols).toEqual(['B', 'D', 'A', 'C', 'F', 'E', 'G']); }); + }); - it('handles empty market data response', async () => { + describe('Cleanup', () => { + it('unsubscribes from market data on unmount', () => { // Arrange - mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + const unsubscribeFn = jest.fn(); + mockSubscribe.mockReturnValue(unsubscribeFn); // Act - const { result } = renderHook(() => usePerpsMarkets()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + const { unmount } = renderHook(() => usePerpsMarkets()); + unmount(); // Assert - expect(result.current.markets).toEqual([]); - expect(result.current.error).toBeNull(); - }); - - it('handles concurrent refresh calls', async () => { - // Arrange - const { result } = renderHook(() => usePerpsMarkets()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - mockProvider.getMarketDataWithPrices.mockClear(); - - // Act - call refresh multiple times quickly - act(() => { - result.current.refresh(); - result.current.refresh(); - result.current.refresh(); - }); - - await waitFor(() => { - expect(result.current.isRefreshing).toBe(false); - }); - - // Assert - should not call API more than the number of refresh calls - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(3); + expect(unsubscribeFn).toHaveBeenCalled(); }); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsMarkets.ts b/app/components/UI/Perps/hooks/usePerpsMarkets.ts index 59465950b4f8..7d7d7d1fe30a 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarkets.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarkets.ts @@ -1,8 +1,9 @@ import { useState, useEffect, useCallback } from 'react'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; -import Engine from '../../../../core/Engine'; import type { PerpsMarketData } from '../controllers/types'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; +import { usePerpsStream } from '../providers/PerpsStreamManager'; +import { parseCurrencyString } from '../utils/formatUtils'; export interface UsePerpsMarketsResult { /** @@ -47,8 +48,7 @@ export interface UsePerpsMarketsOptions { /** * Custom hook to fetch and manage Perps market data from the active provider - * Uses the PerpsController to get data from the currently active protocol - * (HyperLiquid, GMX, dYdX, etc.) + * Uses the StreamManager's marketData channel for caching and deduplication */ export const usePerpsMarkets = ( options: UsePerpsMarketsOptions = {}, @@ -59,116 +59,140 @@ export const usePerpsMarkets = ( skipInitialFetch = false, } = options; + const streamManager = usePerpsStream(); const [markets, setMarkets] = useState([]); const [isLoading, setIsLoading] = useState(!skipInitialFetch); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); - const fetchMarketData = useCallback( - async (isRefresh = false): Promise => { - if (isRefresh) { - setIsRefreshing(true); - } else { - setIsLoading(true); - } - setError(null); - - try { - DevLogger.log('Perps: Fetching market data from active provider...'); - - // Get the active provider via PerpsController - const controller = Engine.context.PerpsController; - const provider = controller.getActiveProvider(); - - // Get markets with price data directly from the provider - const marketDataWithPrices = await provider.getMarketDataWithPrices(); - - // Sort markets by 24h volume (highest first) - const sortedMarkets = [...marketDataWithPrices].sort((a, b) => { - // Helper function to parse volume string and convert to number - const getVolumeNumber = (volumeStr: string | undefined): number => { - if (!volumeStr) return -1; // Put undefined at the end - - // Handle special cases - if (volumeStr === PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY) return -1; // Put missing data at the end - if (volumeStr === '$<1') return 0.5; // Treat as very small but not zero - - // Remove $ and commas, handle different suffixes - const cleaned = volumeStr.replace(/[$,]/g, ''); - - // Handle billion (B), million (M), thousand (K) suffixes - if (cleaned.includes('B')) { - return parseFloat(cleaned.replace('B', '')) * 1e9; - } - if (cleaned.includes('M')) { - return parseFloat(cleaned.replace('M', '')) * 1e6; - } - if (cleaned.includes('K')) { - return parseFloat(cleaned.replace('K', '')) * 1e3; - } - - // Plain number without suffix (including 0) - const num = parseFloat(cleaned); - return isNaN(num) ? -1 : num; + // Helper function to sort markets by volume + const sortMarketsByVolume = useCallback( + (marketData: PerpsMarketData[]): PerpsMarketData[] => { + const parseVolume = (volumeStr: string | undefined): number => { + if (!volumeStr) return -1; // Put undefined at the end + + // Handle special cases + if (volumeStr === PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY) return -1; + if (volumeStr === '$<1') return 0.5; // Treat as very small but not zero + + // Handle suffixed values (e.g., "$1.5M", "$2.3B", "$500K") + const suffixMatch = volumeStr.match(/\$?([\d.,]+)([KMBT])?/); + if (suffixMatch) { + const [, numberPart, suffix] = suffixMatch; + const baseValue = parseFloat(numberPart.replace(/,/g, '')); + + if (isNaN(baseValue)) return -1; + + const multipliers: Record = { + K: 1e3, + M: 1e6, + B: 1e9, + T: 1e12, }; - const volumeA = getVolumeNumber(a.volume); - const volumeB = getVolumeNumber(b.volume); - - return volumeB - volumeA; // Descending order - }); + return suffix ? baseValue * multipliers[suffix] : baseValue; + } - setMarkets(sortedMarkets); + // Fallback to currency parser for regular values + return parseCurrencyString(volumeStr) || -1; + }; - DevLogger.log( - 'Perps: Successfully fetched and transformed market data', - { - marketCount: marketDataWithPrices.length, - }, - ); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : 'Unknown error occurred'; - setError(errorMessage); - DevLogger.log('Perps: Failed to fetch market data', err); - - // Keep existing data on error to prevent UI flash - setMarkets((currentMarkets) => { - if (currentMarkets.length === 0) { - return []; - } - return currentMarkets; - }); - } finally { - setIsLoading(false); - setIsRefreshing(false); - } + return [...marketData].sort((a, b) => { + const volumeA = parseVolume(a.volume); + const volumeB = parseVolume(b.volume); + return volumeB - volumeA; // Descending order + }); }, [], ); - const refresh = useCallback( - (): Promise => fetchMarketData(true), - [fetchMarketData], - ); + // Manual refresh function + const refresh = useCallback(async (): Promise => { + try { + setIsRefreshing(true); + setError(null); - // Initial data fetch + // Force refresh the market data + await streamManager.marketData.refresh(); + + DevLogger.log('Perps: Manual refresh completed'); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Unknown error occurred'; + setError(errorMessage); + DevLogger.log('Perps: Failed to refresh market data', err); + } finally { + setIsRefreshing(false); + } + }, [streamManager.marketData]); + + // Subscribe to market data updates useEffect(() => { - if (!skipInitialFetch) { - fetchMarketData(); + if (skipInitialFetch) { + setIsLoading(false); + return; } - }, [fetchMarketData, skipInitialFetch]); + + let isFirstUpdate = true; + const subscriptionStartTime = Date.now(); + + const unsubscribe = streamManager.marketData.subscribe({ + callback: (marketData) => { + const receiveTime = Date.now(); + const timeToData = receiveTime - subscriptionStartTime; + if (marketData && marketData.length > 0) { + const sortedMarkets = sortMarketsByVolume(marketData); + setMarkets(sortedMarkets); + setIsLoading(false); + setError(null); + + if (isFirstUpdate) { + DevLogger.log('Perps: Market data received (first load)', { + marketCount: marketData.length, + timeToDataMs: timeToData, + source: timeToData < 100 ? 'cache' : 'fresh_fetch', + cacheHit: timeToData < 100, + }); + isFirstUpdate = false; + } else { + DevLogger.log('Perps: Market data updated', { + marketCount: marketData.length, + }); + } + } else if (marketData) { + // Empty array + setMarkets([]); + setIsLoading(false); + if (isFirstUpdate) { + DevLogger.log('Perps: No market data available', { + timeToDataMs: timeToData, + }); + isFirstUpdate = false; + } + } + }, + throttleMs: 0, // No throttle for market data updates + }); + + return () => { + unsubscribe(); + }; + }, [streamManager.marketData, sortMarketsByVolume, skipInitialFetch]); // Polling effect useEffect(() => { if (!enablePolling) return; - const intervalId = setInterval(() => { - fetchMarketData(true); + const intervalId = setInterval(async () => { + try { + await streamManager.marketData.refresh(); + } catch (err) { + DevLogger.log('Perps: Polling refresh failed', err); + } }, pollingInterval); return () => clearInterval(intervalId); - }, [enablePolling, pollingInterval, fetchMarketData]); + }, [enablePolling, pollingInterval, streamManager.marketData]); return { markets, diff --git a/app/components/UI/Perps/integration/connectionLifecycle.test.tsx b/app/components/UI/Perps/integration/connectionLifecycle.test.tsx new file mode 100644 index 000000000000..ebf3f976c737 --- /dev/null +++ b/app/components/UI/Perps/integration/connectionLifecycle.test.tsx @@ -0,0 +1,423 @@ +import React from 'react'; +import { render, act, waitFor } from '@testing-library/react-native'; +import { Text, AppState } from 'react-native'; +import BackgroundTimer from 'react-native-background-timer'; +import Device from '../../../../util/device'; +import { + PerpsConnectionProvider, + usePerpsConnection, +} from '../providers/PerpsConnectionProvider'; +import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; +import PerpsTabViewWithProvider from '../Views/PerpsTabView'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig'; + +// Mock dependencies +jest.mock('react-native/Libraries/AppState/AppState', () => ({ + currentState: 'active', + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + removeEventListener: jest.fn(), +})); + +jest.mock('react-native-background-timer', () => ({ + setTimeout: jest.fn(), + clearTimeout: jest.fn(), + start: jest.fn(), + stop: jest.fn(), +})); + +jest.mock('../../../../util/device'); +jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ + DevLogger: { + log: jest.fn(), + }, +})); + +jest.mock('../services/PerpsConnectionManager'); +jest.mock('../hooks/usePerpsDepositStatus', () => ({ + usePerpsDepositStatus: jest.fn(), +})); + +// Mock child components to simplify testing +jest.mock('../Views/PerpsTabView/PerpsTabView', () => ({ + __esModule: true, + default: () => { + const { View } = jest.requireActual('react-native'); + return ; + }, +})); + +jest.mock('../providers/PerpsStreamManager', () => ({ + PerpsStreamProvider: ({ children }: { children: React.ReactNode }) => + children, +})); + +describe('Connection Lifecycle Integration Tests', () => { + let mockAppStateListener: ((state: string) => void) | null = null; + let mockGetConnectionState: jest.Mock; + let mockConnect: jest.Mock; + let mockDisconnect: jest.Mock; + + const mockIsIos = Device.isIos as jest.MockedFunction; + const mockIsAndroid = Device.isAndroid as jest.MockedFunction< + typeof Device.isAndroid + >; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + // Reset AppState + (AppState as { currentState: string }).currentState = 'active'; + + // Capture AppState listener + (AppState.addEventListener as jest.Mock).mockImplementation( + (event, handler) => { + if (event === 'change') { + mockAppStateListener = handler; + } + return { remove: jest.fn() }; + }, + ); + + // Default to iOS + mockIsIos.mockReturnValue(true); + mockIsAndroid.mockReturnValue(false); + + // Setup PerpsConnectionManager mocks + mockGetConnectionState = jest.fn().mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: true, + }); + mockConnect = jest.fn().mockResolvedValue(undefined); + mockDisconnect = jest.fn().mockResolvedValue(undefined); + + (PerpsConnectionManager.getConnectionState as jest.Mock) = + mockGetConnectionState; + (PerpsConnectionManager.connect as jest.Mock) = mockConnect; + (PerpsConnectionManager.disconnect as jest.Mock) = mockDisconnect; + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + mockAppStateListener = null; + }); + + describe('Tab Visibility Integration', () => { + it('should connect when tab becomes visible and disconnect when hidden', async () => { + let visibilityCallback: ((visible: boolean) => void) | null = null; + const mockOnVisibilityChange = jest.fn((callback) => { + visibilityCallback = callback; + }); + + const { unmount } = render( + , + ); + + // Wait for initial render + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + // Initially not visible - should not connect + expect(mockConnect).not.toHaveBeenCalled(); + + // Make tab visible + await act(async () => { + visibilityCallback?.(true); + }); + + // Note: Connection is now managed by usePerpsConnectionLifecycle hook + // which is mocked in these tests, so we just verify the callback was called + expect(visibilityCallback).toBeTruthy(); + + // Make tab hidden + act(() => { + visibilityCallback?.(false); + }); + + // Verify visibility callback was invoked for hide + expect(visibilityCallback).toBeTruthy(); + + unmount(); + }); + + it('should handle rapid tab switches gracefully', async () => { + let visibilityCallback: ((visible: boolean) => void) | null = null; + const mockOnVisibilityChange = jest.fn((callback) => { + visibilityCallback = callback; + }); + + render( + , + ); + + // Rapid visibility changes + let callCount = 0; + + // Show/hide 5 times + const toggleVisibility = (visible: boolean) => { + visibilityCallback?.(visible); + callCount++; + }; + + for (let i = 0; i < 5; i++) { + act(() => { + toggleVisibility(true); + }); + act(() => { + toggleVisibility(false); + }); + } + + // Should handle without errors - verify callbacks were invoked + expect(callCount).toBe(10); // 5 show + 5 hide + }); + }); + + describe('App Background/Foreground Integration', () => { + describe('iOS Background Timer', () => { + beforeEach(() => { + mockIsIos.mockReturnValue(true); + mockIsAndroid.mockReturnValue(false); + }); + + it('should disconnect after 20 seconds when app is backgrounded', async () => { + render(); + + // Wait for initial connection + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + expect(mockConnect).toHaveBeenCalledTimes(1); + + // Simulate app going to background + act(() => { + mockAppStateListener?.('background'); + }); + + expect(BackgroundTimer.start).toHaveBeenCalled(); + expect(mockDisconnect).not.toHaveBeenCalled(); + + // Advance time to trigger disconnection + act(() => { + jest.advanceTimersByTime(PERPS_CONSTANTS.BACKGROUND_DISCONNECT_DELAY); + }); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + expect(BackgroundTimer.stop).toHaveBeenCalled(); + }); + + it('should cancel disconnection when app returns quickly', async () => { + render(); + + // Wait for initial connection + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + // App goes to background + act(() => { + mockAppStateListener?.('background'); + }); + + expect(BackgroundTimer.start).toHaveBeenCalled(); + + // App returns before 20 seconds + act(() => { + jest.advanceTimersByTime(5000); + mockAppStateListener?.('active'); + }); + + expect(BackgroundTimer.stop).toHaveBeenCalled(); + expect(mockDisconnect).not.toHaveBeenCalled(); + + // Verify timer doesn't fire later + act(() => { + jest.advanceTimersByTime(PERPS_CONSTANTS.BACKGROUND_DISCONNECT_DELAY); + }); + + expect(mockDisconnect).not.toHaveBeenCalled(); + }); + }); + + describe('Android Background Timer', () => { + beforeEach(() => { + mockIsIos.mockReturnValue(false); + mockIsAndroid.mockReturnValue(true); + }); + + it('should use BackgroundTimer.setTimeout on Android', async () => { + const mockTimerId = 456; + (BackgroundTimer.setTimeout as jest.Mock).mockReturnValue(mockTimerId); + + render(); + + // Wait for initial connection + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + // App goes to background + act(() => { + mockAppStateListener?.('background'); + }); + + expect(BackgroundTimer.setTimeout).toHaveBeenCalledWith( + expect.any(Function), + PERPS_CONSTANTS.BACKGROUND_DISCONNECT_DELAY, + ); + + // App returns quickly + act(() => { + mockAppStateListener?.('active'); + }); + + expect(BackgroundTimer.clearTimeout).toHaveBeenCalledWith(mockTimerId); + }); + }); + }); + + describe('Combined Visibility and App State', () => { + it('should not reconnect when app foregrounds if tab is not visible', async () => { + let visibilityCallback: ((visible: boolean) => void) | null = null; + const mockOnVisibilityChange = jest.fn((callback) => { + visibilityCallback = callback; + }); + + render( + , + ); + + // Wait for initial connection + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + expect(mockConnect).toHaveBeenCalledTimes(1); + + // Hide tab + act(() => { + visibilityCallback?.(false); + }); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + mockConnect.mockClear(); + + // App goes to background and returns + act(() => { + mockAppStateListener?.('background'); + jest.advanceTimersByTime(1000); + mockAppStateListener?.('active'); + }); + + // Should not reconnect because tab is not visible + expect(mockConnect).not.toHaveBeenCalled(); + }); + + it('should cancel background timer when tab becomes hidden', async () => { + mockIsIos.mockReturnValue(true); + + let visibilityCallback: ((visible: boolean) => void) | null = null; + const mockOnVisibilityChange = jest.fn((callback) => { + visibilityCallback = callback; + }); + + render( + , + ); + + // Wait for initial connection + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + // App goes to background + act(() => { + mockAppStateListener?.('background'); + }); + + expect(BackgroundTimer.start).toHaveBeenCalled(); + + // Tab becomes hidden before timer expires + act(() => { + visibilityCallback?.(false); + }); + + expect(BackgroundTimer.stop).toHaveBeenCalled(); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + }); + + describe('Multiple Providers Sharing Connection', () => { + it('should maintain single connection with multiple providers', async () => { + // Simulate multiple providers (screen and modal) + const { unmount: unmount1 } = render( + + Screen Provider + , + ); + + const { unmount: unmount2 } = render( + + Modal Provider + , + ); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + // Connection manager should be called from both providers + expect(mockConnect).toHaveBeenCalledTimes(2); + + // Unmount one provider + unmount1(); + + // Should call disconnect once + expect(mockDisconnect).toHaveBeenCalledTimes(1); + + // Unmount second provider + unmount2(); + + // Should call disconnect again + expect(mockDisconnect).toHaveBeenCalledTimes(2); + }); + }); + + describe('Error Propagation', () => { + it('should propagate connection errors through the stack', async () => { + const connectionError = new Error('Connection failed'); + mockConnect.mockRejectedValueOnce(connectionError); + + const TestComponent = () => { + const { error } = usePerpsConnection(); + return {error || 'No error'}; + }; + + const { getByText } = render( + + + , + ); + + await waitFor(() => { + expect(getByText('Connection failed')).toBeDefined(); + }); + }); + }); +}); diff --git a/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx b/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx index 62d01ab8adab..9869d27fc2ef 100644 --- a/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx +++ b/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx @@ -27,6 +27,9 @@ jest.mock('../components/PerpsLoadingSkeleton', () => ({ jest.mock('../hooks/usePerpsDepositStatus', () => ({ usePerpsDepositStatus: jest.fn(() => ({ depositInProgress: false })), })); +jest.mock('../hooks/usePerpsConnectionLifecycle', () => ({ + usePerpsConnectionLifecycle: jest.fn(() => ({ hasConnected: false })), +})); // Test component that uses the hook interface ConnectionState { @@ -119,8 +122,21 @@ describe('PerpsConnectionProvider', () => { }); it('should connect on mount', async () => { + // Mock the lifecycle hook to trigger connection when visible + const mockLifecycleHook = jest.requireMock( + '../hooks/usePerpsConnectionLifecycle', + ).usePerpsConnectionLifecycle; + + mockLifecycleHook.mockImplementation( + ({ onConnect }: { onConnect: () => Promise }) => { + // Simulate immediate connection since isVisible defaults to true + setTimeout(() => onConnect(), 0); + return { hasConnected: false }; + }, + ); + render( - + Test , ); @@ -131,17 +147,32 @@ describe('PerpsConnectionProvider', () => { }); it('should disconnect on unmount', async () => { + // Mock the lifecycle hook to trigger disconnection on unmount + const mockLifecycleHook = jest.requireMock( + '../hooks/usePerpsConnectionLifecycle', + ).usePerpsConnectionLifecycle; + + let disconnectCallback: (() => Promise) | null = null; + mockLifecycleHook.mockImplementation( + ({ onDisconnect }: { onDisconnect: () => Promise }) => { + disconnectCallback = onDisconnect; + return { hasConnected: false }; + }, + ); + const { unmount } = render( Test , ); + // Simulate unmount triggering disconnect + if (disconnectCallback) { + await (disconnectCallback as () => Promise)(); + } unmount(); - await waitFor(() => { - expect(mockDisconnect).toHaveBeenCalledTimes(1); - }); + expect(mockDisconnect).toHaveBeenCalledTimes(1); }); it('should provide connection state through context', async () => { @@ -212,10 +243,34 @@ describe('PerpsConnectionProvider', () => { const error = new Error('Connection failed'); mockConnect.mockRejectedValue(error); + // Mock lifecycle hook to trigger error + const mockLifecycleHook = jest.requireMock( + '../hooks/usePerpsConnectionLifecycle', + ).usePerpsConnectionLifecycle; + + mockLifecycleHook.mockImplementation( + ({ + onConnect, + onError, + }: { + onConnect: () => Promise; + onError: (error: string) => void; + }) => { + setTimeout(async () => { + try { + await onConnect(); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } + }, 0); + return { hasConnected: false }; + }, + ); + const onRender = jest.fn(); render( - + , ); @@ -269,8 +324,8 @@ describe('PerpsConnectionProvider', () => { ); await waitFor(() => { - // Called once on mount and once from component - expect(mockConnect).toHaveBeenCalledTimes(2); + // Called once from component + expect(mockConnect).toHaveBeenCalledTimes(1); }); }); @@ -322,10 +377,37 @@ describe('PerpsConnectionProvider', () => { // Non-Error object thrown mockConnect.mockRejectedValue('String error'); + // Mock lifecycle hook to trigger error with non-Error object + const mockLifecycleHook = jest.requireMock( + '../hooks/usePerpsConnectionLifecycle', + ).usePerpsConnectionLifecycle; + + mockLifecycleHook.mockImplementation( + ({ + onConnect, + onError, + }: { + onConnect: () => Promise; + onError: (error: string) => void; + }) => { + setTimeout(async () => { + try { + await onConnect(); + } catch (err) { + // Simulate unknown error handling + onError( + err instanceof Error ? err.message : 'Unknown connection error', + ); + } + }, 0); + return { hasConnected: false }; + }, + ); + const onRender = jest.fn(); render( - + , ); @@ -387,6 +469,211 @@ describe('PerpsConnectionProvider', () => { clearIntervalSpy.mockRestore(); }); + describe('usePerpsConnectionLifecycle Hook Integration', () => { + let mockUsePerpsConnectionLifecycle: jest.Mock; + + beforeEach(() => { + mockUsePerpsConnectionLifecycle = jest.requireMock( + '../hooks/usePerpsConnectionLifecycle', + ).usePerpsConnectionLifecycle; + }); + + it('should call usePerpsConnectionLifecycle with correct parameters', () => { + const { rerender } = render( + + Test + , + ); + + expect(mockUsePerpsConnectionLifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + isVisible: true, + onConnect: expect.any(Function), + onDisconnect: expect.any(Function), + onError: expect.any(Function), + }), + ); + + // Test with different visibility + rerender( + + Test + , + ); + + expect(mockUsePerpsConnectionLifecycle).toHaveBeenLastCalledWith( + expect.objectContaining({ + isVisible: false, + }), + ); + }); + + it('should update connection state when hook onConnect is called', async () => { + let capturedOnConnect: (() => Promise) | null = null; + + mockUsePerpsConnectionLifecycle.mockImplementation( + ({ onConnect }: { onConnect: () => Promise }) => { + capturedOnConnect = onConnect; + return { hasConnected: false }; + }, + ); + + mockGetConnectionState.mockReturnValue({ + isConnected: false, // Start disconnected + isConnecting: false, + isInitialized: true, + }); + + const TestComponentConnect = () => { + const { isConnected } = usePerpsConnection(); + return {isConnected ? 'Connected' : 'Not Connected'}; + }; + + const { getByText } = render( + + + , + ); + + // Initially not connected + expect(getByText('Not Connected')).toBeDefined(); + + // Call the onConnect callback + await act(async () => { + if (capturedOnConnect) { + // Update mock to return connected state after connect + mockGetConnectionState.mockReturnValue({ + isConnected: true, + isConnecting: false, + isInitialized: true, + }); + await capturedOnConnect(); + } + }); + + await waitFor(() => { + expect(mockConnect).toHaveBeenCalled(); + expect(getByText('Connected')).toBeDefined(); + }); + }); + + it('should update connection state when hook onDisconnect is called', async () => { + let capturedOnDisconnect: (() => Promise) | null = null; + + mockUsePerpsConnectionLifecycle.mockImplementation( + ({ onDisconnect }: { onDisconnect: () => Promise }) => { + capturedOnDisconnect = onDisconnect; + return { hasConnected: true }; + }, + ); + + mockGetConnectionState + .mockReturnValueOnce({ + isConnected: true, + isConnecting: false, + isInitialized: true, + }) + .mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: false, + }); + + const TestComponentDisconnect = () => { + const { isConnected } = usePerpsConnection(); + return {isConnected ? 'Connected' : 'Not Connected'}; + }; + + const { getByText } = render( + + + , + ); + + // Initially connected + expect(getByText('Connected')).toBeDefined(); + + // Call the onDisconnect callback + await act(async () => { + if (capturedOnDisconnect) { + await capturedOnDisconnect(); + } + }); + + await waitFor(() => { + expect(mockDisconnect).toHaveBeenCalled(); + }); + }); + + it('should set error when hook onError is called', () => { + let capturedOnError: ((error: string) => void) | null = null; + + mockUsePerpsConnectionLifecycle.mockImplementation( + ({ onError }: { onError: (error: string) => void }) => { + capturedOnError = onError; + return { hasConnected: false }; + }, + ); + + const TestComponentError = () => { + const { error } = usePerpsConnection(); + return {error || 'No error'}; + }; + + const { getByText } = render( + + + , + ); + + expect(getByText('No error')).toBeDefined(); + + // Call the onError callback + act(() => { + if (capturedOnError) { + capturedOnError('Test error message'); + } + }); + + expect(getByText('Test error message')).toBeDefined(); + }); + + it('should handle visibility changes through the hook', () => { + const { rerender } = render( + + Test + , + ); + + // Initial call with visible + expect(mockUsePerpsConnectionLifecycle).toHaveBeenCalledWith( + expect.objectContaining({ isVisible: true }), + ); + + // Change to not visible + rerender( + + Test + , + ); + + expect(mockUsePerpsConnectionLifecycle).toHaveBeenLastCalledWith( + expect.objectContaining({ isVisible: false }), + ); + + // Change back to visible + rerender( + + Test + , + ); + + expect(mockUsePerpsConnectionLifecycle).toHaveBeenLastCalledWith( + expect.objectContaining({ isVisible: true }), + ); + }); + }); + it('should handle rapid state changes', async () => { const onRender = jest.fn(); diff --git a/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx b/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx index 285e057408ad..0637a5d44c7a 100644 --- a/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx +++ b/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx @@ -7,11 +7,11 @@ import React, { useState, useRef, } from 'react'; -import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import { strings } from '../../../../../locales/i18n'; import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; import PerpsLoadingSkeleton from '../components/PerpsLoadingSkeleton'; import { usePerpsDepositStatus } from '../hooks/usePerpsDepositStatus'; +import { usePerpsConnectionLifecycle } from '../hooks/usePerpsConnectionLifecycle'; interface PerpsConnectionContextValue { isConnected: boolean; @@ -28,6 +28,7 @@ const PerpsConnectionContext = interface PerpsConnectionProviderProps { children: React.ReactNode; + isVisible?: boolean; } /** @@ -38,7 +39,7 @@ interface PerpsConnectionProviderProps { */ export const PerpsConnectionProvider: React.FC< PerpsConnectionProviderProps -> = ({ children }) => { +> = ({ children, isVisible }) => { const [connectionState, setConnectionState] = useState(() => PerpsConnectionManager.getConnectionState(), ); @@ -128,46 +129,21 @@ export const PerpsConnectionProvider: React.FC< setError(null); }, []); - // Connect on mount, disconnect on unmount using singleton - useEffect(() => { - DevLogger.log('PerpsConnectionProvider: Component mounted', { - timestamp: new Date().toISOString(), - }); - - // Connect using the singleton manager - const initializeConnection = async () => { - try { - await PerpsConnectionManager.connect(); - const state = PerpsConnectionManager.getConnectionState(); - setConnectionState((prevState) => { - // Only update if state has actually changed - if ( - prevState.isConnected !== state.isConnected || - prevState.isConnecting !== state.isConnecting || - prevState.isInitialized !== state.isInitialized - ) { - return state; - } - return prevState; - }); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : 'Unknown connection error'; - setError(errorMessage); - } - }; - - initializeConnection(); - - // Disconnect when provider unmounts - return () => { - DevLogger.log('PerpsConnectionProvider: Component unmounting', { - timestamp: new Date().toISOString(), - }); - - PerpsConnectionManager.disconnect(); - }; - }, []); + // Use the connection lifecycle hook to manage visibility and app state + usePerpsConnectionLifecycle({ + isVisible, + onConnect: async () => { + await PerpsConnectionManager.connect(); + const state = PerpsConnectionManager.getConnectionState(); + setConnectionState(state); + }, + onDisconnect: async () => { + await PerpsConnectionManager.disconnect(); + const state = PerpsConnectionManager.getConnectionState(); + setConnectionState(state); + }, + onError: setError, + }); // Memoize context value to prevent unnecessary re-renders const contextValue = useMemo( diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index fa9683aa99ec..ae9217f91798 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -8,7 +8,7 @@ import { } from './PerpsStreamManager'; import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; -import type { PriceUpdate } from '../controllers/types'; +import type { PriceUpdate, PerpsMarketData } from '../controllers/types'; // Mock dependencies jest.mock('../../../../core/Engine'); @@ -971,4 +971,213 @@ describe('PerpsStreamManager', () => { expect(mockUnsubscribe).toHaveBeenCalled(); }); }); + + describe('MarketDataChannel', () => { + const mockGetMarketDataWithPrices = jest.fn(); + const mockMarketData: PerpsMarketData[] = [ + { + symbol: 'BTC', + name: 'Bitcoin', + maxLeverage: '40x', + price: '$50,000.00', + change24h: '+2.5%', + change24hPercent: '2.5', + volume: '$1.2B', + }, + { + symbol: 'ETH', + name: 'Ethereum', + maxLeverage: '25x', + price: '$3,000.00', + change24h: '-1.2%', + change24hPercent: '-1.2', + volume: '$900M', + }, + ]; + + const mockProvider = { + getMarketDataWithPrices: mockGetMarketDataWithPrices, + }; + + beforeEach(() => { + mockGetMarketDataWithPrices.mockResolvedValue(mockMarketData); + mockEngine.context.PerpsController.getActiveProvider = jest + .fn() + .mockReturnValue(mockProvider); + }); + + afterEach(() => { + mockGetMarketDataWithPrices.mockClear(); + }); + + it('should fetch market data on first subscription', async () => { + const callback = jest.fn(); + + const unsubscribe = testStreamManager.marketData.subscribe({ + callback, + throttleMs: 0, + }); + + // Should fetch data immediately + await waitFor(() => { + expect(mockGetMarketDataWithPrices).toHaveBeenCalledTimes(1); + }); + + // Should notify subscriber with data + await waitFor(() => { + expect(callback).toHaveBeenCalledWith(mockMarketData); + }); + + unsubscribe(); + }); + + it('should use cached data for subsequent subscriptions within cache duration', async () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + // First subscription + const unsubscribe1 = testStreamManager.marketData.subscribe({ + callback: callback1, + throttleMs: 0, + }); + + await waitFor(() => { + expect(mockGetMarketDataWithPrices).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith(mockMarketData); + }); + + // Second subscription (within cache duration) + const unsubscribe2 = testStreamManager.marketData.subscribe({ + callback: callback2, + throttleMs: 0, + }); + + // Should use cached data, not fetch again + await waitFor(() => { + expect(callback2).toHaveBeenCalledWith(mockMarketData); + }); + + expect(mockGetMarketDataWithPrices).toHaveBeenCalledTimes(1); // Still only 1 call + + unsubscribe1(); + unsubscribe2(); + }); + + it('should refresh market data when refresh() is called', async () => { + const callback = jest.fn(); + + const unsubscribe = testStreamManager.marketData.subscribe({ + callback, + throttleMs: 0, + }); + + await waitFor(() => { + expect(mockGetMarketDataWithPrices).toHaveBeenCalledTimes(1); + }); + + // Call refresh + await testStreamManager.marketData.refresh(); + + // Should fetch again + await waitFor(() => { + expect(mockGetMarketDataWithPrices).toHaveBeenCalledTimes(2); + }); + + // Should notify subscriber with new data + expect(callback).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); + + it('should clear cache when clearCache() is called', async () => { + const callback = jest.fn(); + + // First subscription to populate cache + const unsubscribe = testStreamManager.marketData.subscribe({ + callback, + throttleMs: 0, + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith(mockMarketData); + }); + + // Clear cache + testStreamManager.marketData.clearCache(); + + // Should notify with empty array + expect(callback).toHaveBeenLastCalledWith([]); + + unsubscribe(); + }); + + it('should handle fetch errors gracefully', async () => { + const callback = jest.fn(); + const error = new Error('Network error'); + + mockGetMarketDataWithPrices.mockRejectedValue(error); + + const unsubscribe = testStreamManager.marketData.subscribe({ + callback, + throttleMs: 0, + }); + + // Wait for fetch attempt + await waitFor(() => { + expect(mockGetMarketDataWithPrices).toHaveBeenCalled(); + }); + + // Should not crash, callback might receive empty array or cached data + // depending on implementation + + unsubscribe(); + }); + + it('should prewarm market data cache', async () => { + // Call prewarm + const cleanup = testStreamManager.marketData.prewarm(); + + // Should fetch data immediately + await waitFor(() => { + expect(mockGetMarketDataWithPrices).toHaveBeenCalledTimes(1); + }); + + // Cleanup function should be a no-op for REST data + expect(typeof cleanup).toBe('function'); + cleanup(); // Should not throw + }); + + it('should deduplicate concurrent fetch requests', async () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + // Clear cache to force fetch + testStreamManager.marketData.clearCache(); + + // Two subscriptions at the same time + const unsubscribe1 = testStreamManager.marketData.subscribe({ + callback: callback1, + throttleMs: 0, + }); + + const unsubscribe2 = testStreamManager.marketData.subscribe({ + callback: callback2, + throttleMs: 0, + }); + + // Should only fetch once despite two subscriptions + await waitFor(() => { + expect(mockGetMarketDataWithPrices).toHaveBeenCalledTimes(1); + }); + + // Both callbacks should receive data + await waitFor(() => { + expect(callback1).toHaveBeenCalledWith(mockMarketData); + expect(callback2).toHaveBeenCalledWith(mockMarketData); + }); + + unsubscribe1(); + unsubscribe2(); + }); + }); }); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index 738aba0fbbbb..64785c4924e1 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -1,12 +1,15 @@ import React, { createContext, useContext } from 'react'; import Engine from '../../../../core/Engine'; +import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import type { PriceUpdate, Position, Order, OrderFill, AccountState, + PerpsMarketData, } from '../controllers/types'; +import { PERFORMANCE_CONFIG } from '../constants/perpsConfig'; // Generic subscription parameters interface StreamSubscription { @@ -211,18 +214,15 @@ class PriceStreamChannel extends StreamChannel> { callback: (prices: Record) => void; throttleMs?: number; }): () => void { - // Track new symbols - const newSymbols: string[] = []; + // Track symbols for filtering params.symbols.forEach((s) => { - if (!this.symbols.has(s)) { - newSymbols.push(s); - } this.symbols.add(s); }); - // If we have new symbols and WebSocket is already connected, we need to reconnect - if (newSymbols.length > 0 && this.wsSubscription) { - this.disconnect(); + // Ensure connection is established (allMids provides all symbols) + // No need to reconnect when new symbols are added since allMids + // already provides prices for all markets + if (!this.wsSubscription) { this.connect(); } @@ -436,6 +436,141 @@ class AccountStreamChannel extends StreamChannel { } } +// Market data channel for caching market list data +class MarketDataChannel extends StreamChannel { + private lastFetchTime = 0; + private fetchPromise: Promise | null = null; + private readonly CACHE_DURATION = + PERFORMANCE_CONFIG.MARKET_DATA_CACHE_DURATION_MS; + + protected connect() { + // Fetch if cache is stale or empty + const now = Date.now(); + const cached = this.cache.get('markets'); + const cacheAge = now - this.lastFetchTime; + if (!cached || cacheAge > this.CACHE_DURATION) { + DevLogger.log('PerpsStreamManager: Cache miss or stale', { + hasCached: !!cached, + cacheAgeMs: cached ? cacheAge : null, + cacheExpired: cacheAge > this.CACHE_DURATION, + cacheDurationMs: this.CACHE_DURATION, + }); + // Don't await - just trigger the fetch and handle errors + this.fetchMarketData().catch((error) => { + console.error('PerpsStreamManager: Failed to fetch market data', error); + }); + } else { + DevLogger.log('PerpsStreamManager: Using cached market data', { + cacheAgeMs: cacheAge, + cacheAgeSeconds: Math.round(cacheAge / 1000), + marketCount: cached.length, + cacheValidForMs: this.CACHE_DURATION - cacheAge, + }); + // Notify subscribers with cached data immediately + this.notifySubscribers(cached); + } + } + + private async fetchMarketData(): Promise { + // Prevent concurrent fetches + if (this.fetchPromise) { + await this.fetchPromise; + return; + } + + this.fetchPromise = (async () => { + const fetchStartTime = Date.now(); + try { + DevLogger.log( + 'PerpsStreamManager: Fetching fresh market data from API', + ); + + const controller = Engine.context.PerpsController; + const provider = controller.getActiveProvider(); + const data = await provider.getMarketDataWithPrices(); + const fetchTime = Date.now() - fetchStartTime; + + // Update cache + this.cache.set('markets', data); + this.lastFetchTime = Date.now(); + + // Notify all subscribers + this.notifySubscribers(data); + + DevLogger.log('PerpsStreamManager: Market data fetched and cached', { + marketCount: data.length, + fetchTimeMs: fetchTime, + cacheValidUntil: new Date( + Date.now() + this.CACHE_DURATION, + ).toISOString(), + }); + } catch (error) { + const fetchTime = Date.now() - fetchStartTime; + DevLogger.log('PerpsStreamManager: Failed to fetch market data', { + error, + fetchTimeMs: fetchTime, + }); + // Keep existing cache if fetch fails + const existing = this.cache.get('markets'); + if (existing) { + DevLogger.log( + 'PerpsStreamManager: Using stale cache after fetch failure', + { + marketCount: existing.length, + }, + ); + this.notifySubscribers(existing); + } + } finally { + this.fetchPromise = null; + } + })(); + + await this.fetchPromise; + } + + /** + * Force refresh market data + */ + public async refresh(): Promise { + this.lastFetchTime = 0; // Force cache to be considered stale + await this.fetchMarketData(); + } + + protected getCachedData(): PerpsMarketData[] | null { + return this.cache.get('markets') || null; + } + + protected getClearedData(): PerpsMarketData[] { + return []; + } + + /** + * Prewarm market data cache + * @returns Cleanup function (no-op for REST data) + */ + public prewarm(): () => void { + // Fetch data immediately to populate cache + this.fetchMarketData().catch((error) => { + DevLogger.log('PerpsStreamManager: Failed to prewarm market data', error); + }); + + // No cleanup needed for REST data + return () => { + // No-op + }; + } + + /** + * Clear cache and reset fetch time + */ + public clearCache(): void { + super.clearCache(); + this.lastFetchTime = 0; + this.fetchPromise = null; + } +} + // Main manager class export class PerpsStreamManager { public readonly prices = new PriceStreamChannel(); @@ -443,6 +578,7 @@ export class PerpsStreamManager { public readonly positions = new PositionStreamChannel(); public readonly fills = new FillStreamChannel(); public readonly account = new AccountStreamChannel(); + public readonly marketData = new MarketDataChannel(); // Future channels can be added here: // public readonly funding = new FundingStreamChannel(); diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts index 768a9b6b80d6..c40f9e37bf27 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts @@ -1276,4 +1276,86 @@ describe('HyperLiquidSubscriptionService', () => { unsubscribe(); }); }); + + describe('Race condition prevention', () => { + it('should prevent duplicate allMids subscriptions when multiple subscribeToPrices calls happen simultaneously', async () => { + const callbacks = [jest.fn(), jest.fn(), jest.fn()]; + const unsubscribes: (() => void)[] = []; + + // Call subscribeToPrices multiple times simultaneously + const subscribePromises = callbacks.map((callback) => { + const unsubscribe = service.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + unsubscribes.push(unsubscribe); + return new Promise((resolve) => setTimeout(resolve, 10)); + }); + + // Wait for all subscriptions to complete + await Promise.all(subscribePromises); + + // Should only create one allMids subscription despite multiple simultaneous calls + expect(mockSubscriptionClient.allMids).toHaveBeenCalledTimes(1); + + // All callbacks should still work + await new Promise((resolve) => setTimeout(resolve, 50)); + callbacks.forEach((callback) => { + expect(callback).toHaveBeenCalled(); + }); + + // Cleanup + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }); + + it('should retry allMids subscription if initial attempt fails', async () => { + const callback = jest.fn(); + const mockUnsubscribeFn = jest.fn(); + const mockSubscriptionObj = { + unsubscribe: mockUnsubscribeFn, + }; + + // Make first attempt fail + mockSubscriptionClient.allMids.mockImplementationOnce(() => + Promise.reject(new Error('Connection failed')), + ); + + // Second attempt succeeds + mockSubscriptionClient.allMids.mockImplementationOnce((cb: any) => { + setTimeout(() => { + cb({ + mids: { + BTC: '50000', + }, + }); + }, 10); + return Promise.resolve(mockSubscriptionObj); + }); + + // First subscription attempt + const unsubscribe1 = service.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + + // Wait for first attempt to fail + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Second subscription attempt should retry + const unsubscribe2 = service.subscribeToPrices({ + symbols: ['ETH'], + callback, + }); + + // Wait for second attempt to succeed + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should have tried twice total + expect(mockSubscriptionClient.allMids).toHaveBeenCalledTimes(2); + + // Cleanup + unsubscribe1(); + unsubscribe2(); + }); + }); }); diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index 844c125e9381..cd82e5cd94db 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -64,6 +64,7 @@ export class HyperLiquidSubscriptionService { // Global singleton subscriptions private globalAllMidsSubscription?: Subscription; + private globalAllMidsPromise?: Promise; // Track in-progress subscription private globalActiveAssetSubscriptions = new Map(); private globalL2BookSubscriptions = new Map(); private symbolSubscriberCounts = new Map(); @@ -655,7 +656,8 @@ export class HyperLiquidSubscriptionService { * Ensure global allMids subscription is active (singleton pattern) */ private ensureGlobalAllMidsSubscription(): void { - if (this.globalAllMidsSubscription) { + // Check both the subscription AND the promise to prevent race conditions + if (this.globalAllMidsSubscription || this.globalAllMidsPromise) { return; } @@ -672,7 +674,8 @@ export class HyperLiquidSubscriptionService { startTime: Date.now(), }; - subscriptionClient + // Store the promise immediately to prevent duplicate calls + this.globalAllMidsPromise = subscriptionClient .allMids((data: WsAllMids) => { wsMetrics.messagesReceived++; wsMetrics.lastMessageTime = Date.now(); @@ -701,6 +704,9 @@ export class HyperLiquidSubscriptionService { }); }) .catch((error) => { + // Clear the promise on error so it can be retried + this.globalAllMidsPromise = undefined; + DevLogger.log(strings('perps.errors.failedToEstablishAllMids'), error); // Trace WebSocket error diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts index f78f67e6db8f..b42dfa17fe97 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts @@ -47,6 +47,7 @@ const mockStreamManagerInstance = { positions: { clearCache: jest.fn(), prewarm: jest.fn(() => jest.fn()) }, orders: { clearCache: jest.fn(), prewarm: jest.fn(() => jest.fn()) }, account: { clearCache: jest.fn(), prewarm: jest.fn(() => jest.fn()) }, + marketData: { clearCache: jest.fn(), prewarm: jest.fn(() => jest.fn()) }, }; jest.mock('../providers/PerpsStreamManager', () => ({ @@ -106,9 +107,11 @@ describe('PerpsConnectionManager', () => { mockStreamManagerInstance.positions.clearCache.mockClear(); mockStreamManagerInstance.orders.clearCache.mockClear(); mockStreamManagerInstance.account.clearCache.mockClear(); + mockStreamManagerInstance.marketData.clearCache.mockClear(); mockStreamManagerInstance.positions.prewarm.mockClear(); mockStreamManagerInstance.orders.prewarm.mockClear(); mockStreamManagerInstance.account.prewarm.mockClear(); + mockStreamManagerInstance.marketData.prewarm.mockClear(); // Reset the singleton instance state resetManager(PerpsConnectionManager); @@ -223,9 +226,7 @@ describe('PerpsConnectionManager', () => { await PerpsConnectionManager.connect(); - expect(mockDevLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Stale connection detected'), - ); + // Should have reconnected after detecting stale connection expect(mockPerpsController.initializeProviders).toHaveBeenCalledTimes(2); }); @@ -243,6 +244,46 @@ describe('PerpsConnectionManager', () => { // getAccountState called twice - once for initial connect, once for health check expect(mockPerpsController.getAccountState).toHaveBeenCalledTimes(2); }); + + it('should wait for disconnection to complete before connecting', async () => { + // Setup initial connection + mockPerpsController.initializeProviders.mockResolvedValue(); + mockPerpsController.getAccountState.mockResolvedValue({}); + + // Mock disconnect to be slow so we can test the waiting behavior + let resolveDisconnect: () => void = () => { + // Initial placeholder function + }; + const slowDisconnectPromise = new Promise((resolve) => { + resolveDisconnect = resolve; + }); + mockPerpsController.disconnect.mockReturnValue(slowDisconnectPromise); + + // Connect first + await PerpsConnectionManager.connect(); + + // Start a disconnection but don't await it + const disconnectPromise = PerpsConnectionManager.disconnect(); + + // Immediately try to connect while disconnection is in progress + // This simulates the isDisconnecting && disconnectPromise condition + const connectPromise = PerpsConnectionManager.connect(); + + // Verify the waiting log was called immediately + expect(mockDevLogger.log).toHaveBeenCalledWith( + 'PerpsConnectionManager: Waiting for disconnection to complete before connecting', + ); + + // Now complete the disconnection + resolveDisconnect(); + + // Wait for both operations to complete + await disconnectPromise; + await connectPromise; + + // Verify that connect waited and then proceeded + expect(mockPerpsController.initializeProviders).toHaveBeenCalledTimes(2); + }); }); describe('disconnect', () => { @@ -354,6 +395,7 @@ describe('PerpsConnectionManager', () => { isConnected: false, isConnecting: false, isInitialized: false, + isDisconnecting: false, }); }); @@ -559,6 +601,9 @@ describe('PerpsConnectionManager', () => { expect(mockStreamManagerInstance.positions.clearCache).toHaveBeenCalled(); expect(mockStreamManagerInstance.orders.clearCache).toHaveBeenCalled(); expect(mockStreamManagerInstance.account.clearCache).toHaveBeenCalled(); + expect( + mockStreamManagerInstance.marketData.clearCache, + ).toHaveBeenCalled(); }); it('should reinitialize controller with new context', async () => { diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index 56738f4c769b..6e9a6b97a830 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -15,8 +15,10 @@ class PerpsConnectionManagerClass { private isConnected = false; private isConnecting = false; private isInitialized = false; + private isDisconnecting = false; private connectionRefCount = 0; private initPromise: Promise | null = null; + private disconnectPromise: Promise | null = null; private hasPreloaded = false; private prewarmCleanups: (() => void)[] = []; private unsubscribeFromStore: (() => void) | null = null; @@ -111,6 +113,16 @@ class PerpsConnectionManagerClass { } async connect(): Promise { + // Wait if we're still disconnecting + if (this.isDisconnecting && this.disconnectPromise) { + DevLogger.log( + 'PerpsConnectionManager: Waiting for disconnection to complete before connecting', + ); + await this.disconnectPromise; + // Add small delay to ensure cleanup is complete + await new Promise((resolve) => setTimeout(resolve, 200)); + } + // Set up monitoring when first entering Perps (refCount 0 -> 1) if (this.connectionRefCount === 0) { this.setupStateMonitoring(); @@ -118,11 +130,14 @@ class PerpsConnectionManagerClass { this.connectionRefCount++; DevLogger.log( - `PerpsConnectionManager: Connection requested (refCount: ${this.connectionRefCount})`, + `PerpsConnectionManager: Connection requested (refCount: ${this.connectionRefCount}, isConnected: ${this.isConnected}, isInitialized: ${this.isInitialized})`, ); // If already connecting, return the existing promise if (this.initPromise) { + DevLogger.log( + 'PerpsConnectionManager: Already connecting, returning existing promise', + ); return this.initPromise; } @@ -132,11 +147,15 @@ class PerpsConnectionManagerClass { try { // Quick check to see if connection is actually alive await Engine.context.PerpsController.getAccountState(); + DevLogger.log( + 'PerpsConnectionManager: Connection is already active and healthy', + ); return Promise.resolve(); } catch (error) { // Connection is stale, reset state and reconnect DevLogger.log( - 'PerpsConnectionManager: Stale connection detected, reconnecting', + 'PerpsConnectionManager: Stale connection detected, will reconnect', + error, ); this.isConnected = false; this.isInitialized = false; @@ -194,6 +213,7 @@ class PerpsConnectionManagerClass { streamManager.positions.clearCache(); streamManager.orders.clearCache(); streamManager.account.clearCache(); + streamManager.marketData.clearCache(); // Reset state this.isConnected = false; @@ -242,27 +262,39 @@ class PerpsConnectionManagerClass { this.connectionRefCount = 0; // Ensure it doesn't go negative if (this.isConnected || this.isInitialized) { - try { - DevLogger.log( - 'PerpsConnectionManager: Disconnecting (no more references)', - ); - - // Clean up preloaded subscriptions - this.cleanupPreloadedSubscriptions(); - - // Clean up state monitoring when leaving Perps - this.cleanupStateMonitoring(); - - // Reset state before disconnecting to prevent race conditions - this.isConnected = false; - this.isInitialized = false; - this.isConnecting = false; - this.hasPreloaded = false; // Reset pre-load flag on disconnect - - await Engine.context.PerpsController.disconnect(); - } catch (error) { - DevLogger.log('PerpsConnectionManager: Disconnection error', error); - } + // Track that we're disconnecting + this.isDisconnecting = true; + + this.disconnectPromise = (async () => { + try { + DevLogger.log( + 'PerpsConnectionManager: Disconnecting (no more references)', + ); + + // Clean up preloaded subscriptions + this.cleanupPreloadedSubscriptions(); + + // Clean up state monitoring when leaving Perps + this.cleanupStateMonitoring(); + + // Reset state before disconnecting to prevent race conditions + this.isConnected = false; + this.isInitialized = false; + this.isConnecting = false; + this.hasPreloaded = false; // Reset pre-load flag on disconnect + + await Engine.context.PerpsController.disconnect(); + + DevLogger.log('PerpsConnectionManager: Disconnection complete'); + } catch (error) { + DevLogger.log('PerpsConnectionManager: Disconnection error', error); + } finally { + this.isDisconnecting = false; + this.disconnectPromise = null; + } + })(); + + await this.disconnectPromise; } else { // Even if not connected, clean up monitoring when leaving Perps this.cleanupStateMonitoring(); @@ -291,14 +323,20 @@ class PerpsConnectionManagerClass { // Get the singleton StreamManager instance const streamManager = getStreamManagerInstance(); - // Pre-warm the positions, orders, and account channels + // Pre-warm the positions, orders, account, and market data channels // This creates persistent subscriptions that keep connections alive // Store cleanup functions to call when leaving Perps const positionCleanup = streamManager.positions.prewarm(); const orderCleanup = streamManager.orders.prewarm(); const accountCleanup = streamManager.account.prewarm(); + const marketDataCleanup = streamManager.marketData.prewarm(); - this.prewarmCleanups.push(positionCleanup, orderCleanup, accountCleanup); + this.prewarmCleanups.push( + positionCleanup, + orderCleanup, + accountCleanup, + marketDataCleanup, + ); // Give subscriptions a moment to receive initial data await new Promise((resolve) => setTimeout(resolve, 100)); @@ -353,8 +391,22 @@ class PerpsConnectionManagerClass { isConnected: this.isConnected, isConnecting: this.isConnecting, isInitialized: this.isInitialized, + isDisconnecting: this.isDisconnecting, }; } + + /** + * Check if the manager is fully disconnected and ready to connect + */ + isFullyDisconnected(): boolean { + return ( + !this.isConnected && + !this.isInitialized && + !this.isConnecting && + !this.isDisconnecting && + this.connectionRefCount === 0 + ); + } } export const PerpsConnectionManager = PerpsConnectionManagerClass.getInstance(); diff --git a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx index 6c766b6a6f70..baf1d830e050 100644 --- a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx +++ b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx @@ -121,4 +121,19 @@ describe('MultichainAccountActions', () => { }, ); }); + + it('navigates to address list when addresses button is pressed', () => { + const { getByTestId } = renderWithProvider(); + + const addressesButton = getByTestId(MULTICHAIN_ACCOUNT_ACTIONS_ADDRESSES); + addressesButton.props.onPress(); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MULTICHAIN_ACCOUNTS.ADDRESS_LIST, + { + groupId: mockAccountGroup.id, + title: `Addresses / ${mockAccountGroup.metadata.name}`, + }, + ); + }); }); diff --git a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx index 93a05d788477..4f87dbb34f60 100644 --- a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx +++ b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx @@ -23,6 +23,7 @@ import { // MULTICHAIN_ACCOUNT_ACTIONS_EDIT_NAME, MULTICHAIN_ACCOUNT_ACTIONS_ADDRESSES, } from './MultichainAccountActions.testIds'; +import { createAddressListNavigationDetails } from '../../AddressList/AddressList'; interface MultichainAccountActionsParams { accountGroup: AccountGroupObject; @@ -45,7 +46,16 @@ const MultichainAccountActions = () => { // const goToEditAccountName = useCallback(() => null, []); // TODO: To be implemented - const goToAddresses = useCallback(() => null, []); // TODO: To be implemented + const goToAddresses = useCallback(() => { + navigate( + ...createAddressListNavigationDetails({ + groupId: accountGroup.id, + title: `${strings('multichain_accounts.address_list.addresses')} / ${ + accountGroup.metadata.name + }`, + }), + ); + }, [accountGroup.id, accountGroup.metadata.name, navigate]); return ( diff --git a/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap b/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap index 9f5951bf2b2c..6e8c4ba8178c 100644 --- a/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap @@ -970,7 +970,7 @@ exports[`Wallet Conditional Rendering should render banner when basic functional } > View: Wallet -TypeError: Cannot read properties of undefined (reading 'dismissedBanners') +TypeError: Cannot read properties of undefined (reading 'key') @@ -2083,7 +2083,7 @@ exports[`Wallet Conditional Rendering should render loader when no selected acco } > View: Wallet -TypeError: Cannot read properties of undefined (reading 'dismissedBanners') +TypeError: Cannot read properties of undefined (reading 'key') @@ -3196,7 +3196,7 @@ exports[`Wallet should render correctly 1`] = ` } > View: Wallet -TypeError: Cannot read properties of undefined (reading 'dismissedBanners') +TypeError: Cannot read properties of undefined (reading 'key') @@ -4309,7 +4309,7 @@ exports[`Wallet should render correctly when Solana support is enabled 1`] = ` } > View: Wallet -TypeError: Cannot read properties of undefined (reading 'dismissedBanners') +TypeError: Cannot read properties of undefined (reading 'key') @@ -5422,7 +5422,7 @@ exports[`Wallet should render correctly when there are no detected tokens 1`] = } > View: Wallet -TypeError: Cannot read properties of undefined (reading 'dismissedBanners') +TypeError: Cannot read properties of undefined (reading 'key') diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index d4afa1035487..a8682facea0f 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { Json } from '@metamask/utils'; // Local mocks specific to this test file to avoid affecting other tests jest.mock('react-native-device-info', () => ({ @@ -21,11 +22,33 @@ jest.mock('../AssetDetails/AssetDetailsActions', () => jest.fn((_props) => null), ); +// Mock PerpsTabView +jest.mock('../../UI/Perps/Views/PerpsTabView', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +// Mock remoteFeatureFlag util to ensure version check passes +jest.mock('../../../util/remoteFeatureFlag', () => ({ + hasMinimumRequiredVersion: jest.fn(() => true), +})); + +// Mock the Perps feature flag selector - will be controlled per test +let mockPerpsEnabled = true; +jest.mock('../../UI/Perps/selectors/featureFlags', () => ({ + selectPerpsEnabledFlag: jest.fn(() => mockPerpsEnabled), + selectPerpsServiceInterruptionBannerEnabledFlag: jest.fn(() => false), +})); + // Create shared mock reference let mockScrollableTabViewComponent: jest.Mock; jest.mock('react-native-scrollable-tab-view', () => { - const mockComponent = jest.fn((_props) => null); + const ReactMock = jest.requireActual('react'); + const mockComponent = jest.fn((props) => + // Render children so we can test them + ReactMock.createElement('View', null, props.children), + ); // Store reference for tests mockScrollableTabViewComponent = mockComponent; @@ -56,6 +79,7 @@ import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletV import Engine from '../../../core/Engine'; import { useSelector } from 'react-redux'; import { isUnifiedSwapsEnvVarEnabled } from '../../../core/redux/slices/bridge/utils/isUnifiedSwapsEnvVarEnabled'; +import { mockedPerpsFeatureFlagsEnabledState } from '../../UI/Perps/mocks/remoteFeatureFlagMocks'; import { initialState as cardInitialState } from '../../../core/redux/slices/card'; import { NavigationProp, ParamListBase } from '@react-navigation/native'; import { @@ -92,6 +116,18 @@ jest.mock('../../../core/Engine', () => { }), context: { NftController: { + state: { + allNfts: { + [MOCK_ADDRESS]: { + [MOCK_ADDRESS]: [], + }, + }, + allNftContracts: { + [MOCK_ADDRESS]: { + [MOCK_ADDRESS]: [], + }, + }, + }, allNfts: { [MOCK_ADDRESS]: { [MOCK_ADDRESS]: [], @@ -949,4 +985,174 @@ describe('Wallet', () => { }); }); }); + + describe('Perps Tab Visibility', () => { + let mockPerpsTabView: jest.Mock; + let mockNavigation: NavigationProp; + + beforeEach(() => { + // Get the actual mock that was created at the top + mockPerpsTabView = jest.requireMock( + '../../UI/Perps/Views/PerpsTabView', + ).default; + mockPerpsTabView.mockClear(); + + // Setup navigation mock + mockNavigation = { + navigate: mockNavigate, + setOptions: mockSetOptions, + } as unknown as NavigationProp; + + // Default to enabled + mockPerpsEnabled = true; + }); + + afterEach(() => { + jest.clearAllMocks(); + mockPerpsEnabled = true; // Reset to default + }); + + it('should register visibility callback when Perps is enabled', () => { + const state = { + ...mockInitialState, + engine: { + backgroundState: { + ...backgroundState, + RemoteFeatureFlagController: { + ...backgroundState.RemoteFeatureFlagController, + remoteFeatureFlags: { + ...backgroundState.RemoteFeatureFlagController + .remoteFeatureFlags, + ...(mockedPerpsFeatureFlagsEnabledState as unknown as Record< + string, + Json + >), + }, + }, + }, + }, + }; + + renderWithProvider( + , + { state }, + ); + + // Debug: Check if ScrollableTabView was rendered + expect(mockScrollableTabViewComponent).toHaveBeenCalled(); + + // Check that PerpsTabView was rendered + expect(mockPerpsTabView).toHaveBeenCalled(); + + // Check the props it was called with + const perpsTabViewProps = mockPerpsTabView.mock.calls[0][0]; + expect(perpsTabViewProps.onVisibilityChange).toBeDefined(); + expect(typeof perpsTabViewProps.onVisibilityChange).toBe('function'); + expect(perpsTabViewProps.isVisible).toBe(false); // Initially not visible (tab 0 is selected) + }); + + it('should calculate correct perpsTabIndex when Perps is enabled', () => { + const state = { + ...mockInitialState, + engine: { + backgroundState: { + ...backgroundState, + RemoteFeatureFlagController: { + ...backgroundState.RemoteFeatureFlagController, + remoteFeatureFlags: { + ...backgroundState.RemoteFeatureFlagController + .remoteFeatureFlags, + ...(mockedPerpsFeatureFlagsEnabledState as unknown as Record< + string, + Json + >), + }, + }, + }, + }, + }; + + renderWithProvider( + , + { state }, + ); + + // Perps should be at index 1 when enabled (after Tokens at index 0) + const perpsTabViewProps = mockPerpsTabView.mock.calls[0][0]; + expect(perpsTabViewProps.isVisible).toBe(false); // Initially not visible (tab 0 is selected) + }); + + it('should not render PerpsTabView when Perps is disabled', () => { + // Set the flag to disabled for this test + mockPerpsEnabled = false; + + const state = { + ...mockInitialState, + engine: { + backgroundState: { + ...backgroundState, + RemoteFeatureFlagController: { + ...backgroundState.RemoteFeatureFlagController, + remoteFeatureFlags: { + ...backgroundState.RemoteFeatureFlagController + .remoteFeatureFlags, + perpsPerpTradingEnabled: { + enabled: false, + minimumVersion: '1.0.0', + }, + }, + }, + }, + }, + }; + + renderWithProvider( + , + { state }, + ); + + // PerpsTabView should not be rendered + expect(mockPerpsTabView).not.toHaveBeenCalled(); + }); + + it('should not call visibility callback when Perps is disabled', () => { + // Set the flag to disabled for this test + mockPerpsEnabled = false; + + const state = { + ...mockInitialState, + engine: { + backgroundState: { + ...backgroundState, + RemoteFeatureFlagController: { + ...backgroundState.RemoteFeatureFlagController, + remoteFeatureFlags: { + ...backgroundState.RemoteFeatureFlagController + .remoteFeatureFlags, + perpsPerpTradingEnabled: { + enabled: false, + minimumVersion: '1.0.0', + }, + }, + }, + }, + }, + }; + + renderWithProvider( + , + { state }, + ); + + // Simulate tab change + const scrollableTabView = mockScrollableTabViewComponent.mock.calls[0][0]; + scrollableTabView.onChangeTab({ + i: 1, + ref: { props: { tabLabel: 'Perps' } }, + }); + + // Perps visibility callback should not be called since Perps is disabled + expect(mockPerpsTabView).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index eea5a54ecf52..c24de7934282 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -116,6 +116,7 @@ import { Carousel } from '../../UI/Carousel'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { useNftDetectionChainIds } from '../../hooks/useNftDetectionChainIds'; import Logger from '../../../util/Logger'; +import { DevLogger } from '../../../core/SDKConnect/utils/DevLogger'; import { cloneDeep } from 'lodash'; import { prepareNftDetectionEvents } from '../../../util/assets'; import DeFiPositionsList from '../../UI/DeFiPositions/DeFiPositionsList'; @@ -220,6 +221,7 @@ const WalletTokensTabView = React.memo( }) => { const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); const { navigation, onChangeTab, defiEnabled, collectiblesEnabled } = props; + const [currentTabIndex, setCurrentTabIndex] = React.useState(0); const theme = useTheme(); const styles = useMemo(() => createStyles(theme), [theme]); @@ -274,15 +276,60 @@ const WalletTokensTabView = React.memo( [navigation], ); + const handleTabChange = useCallback( + (changeTabProperties: ChangeTabProperties) => { + const newIndex = changeTabProperties.i; + const tabLabel = changeTabProperties.ref?.props?.tabLabel; + DevLogger.log('WalletTabView: Tab changed', { + newIndex, + tabLabel, + isPerpsTab: tabLabel === strings('wallet.perps'), + previousIndex: currentTabIndex, + }); + setCurrentTabIndex(newIndex); + onChangeTab(changeTabProperties); + }, + [onChangeTab, currentTabIndex], + ); + + // Calculate Perps tab index dynamically based on what tabs are enabled + // Tokens is always index 0, Perps is index 1 if enabled + const perpsTabIndex = isPerpsEnabled ? 1 : -1; + const isPerpsTabVisible = currentTabIndex === perpsTabIndex; + + // Store the visibility update callback from PerpsTabView + const perpsVisibilityCallback = useRef<((visible: boolean) => void) | null>( + null, + ); + + // Update Perps visibility when tab changes + useEffect(() => { + if (isPerpsEnabled && perpsVisibilityCallback.current) { + DevLogger.log('WalletTabView: Updating Perps visibility', { + currentTabIndex, + perpsTabIndex, + isPerpsTabVisible, + }); + perpsVisibilityCallback.current(isPerpsTabVisible); + } + }, [currentTabIndex, perpsTabIndex, isPerpsTabVisible, isPerpsEnabled]); + return ( {isPerpsEnabled && ( - + { + perpsVisibilityCallback.current = callback; + }} + /> )} {defiEnabled && (