From 962c5ae7f3b419e08f52cc9462424edc098071e9 Mon Sep 17 00:00:00 2001 From: Ganesh Suresh Patra Date: Mon, 25 Aug 2025 20:39:23 +0530 Subject: [PATCH 1/4] fix: text style update (#18722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Should BOLD the text in Forget password ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/MetaMask/metamask-mobile/issues/18720 ### **After** Screenshot 2025-08-25 at 6 10
02 PM Screenshot 2025-08-25 at 6 10
11 PM Screenshot 2025-08-25 at 6 10
43 PM Screenshot 2025-08-25 at 6 10
53 PM ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../DeleteWalletModal/__snapshots__/index.test.tsx.snap | 9 ++++++--- app/components/UI/DeleteWalletModal/index.tsx | 2 ++ app/components/UI/DeleteWalletModal/styles.ts | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) 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', From 19fbfec52de23b24928f18a2de84c87be2447ac3 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Mon, 25 Aug 2025 23:15:10 +0800 Subject: [PATCH 2/4] fix(perps): perps WebSocket lifecycle - tab visibility & app state management (#18703) ## **Description** Improves Perps market loading performance and WebSocket efficiency while fixing connection lifecycle management issues. Introduces intelligent caching and fixes WebSocket duplication through race condition prevention. ### Performance Improvements 1. **Market Data Caching** - New 5-minute cache for market list data - Introduces MarketDataChannel in StreamManager for REST API caching - Cache persists across component remounts and tab switches - Automatic refresh on account/network changes - Centralized configuration in `perpsConfig.ts` - Reduces API calls significantly compared to fetching on every mount 2. **WebSocket Deduplication** - Fixed race condition causing duplicate subscriptions - **Problem:** Multiple duplicate "allMids" subscriptions when market list loads (17+ connections observed) - **Solution:** Promise-based tracking prevents concurrent connection attempts - Maintains efficient leaf-level subscription pattern for virtualized lists - Single WebSocket connection now handles all price updates 3. **Streamlined Data Flow** - Centralized caching via StreamManager - Replaced module-level state variables with StreamManager pattern - Promise-based deduplication prevents concurrent fetches - Consistent pattern across all data channels (positions, orders, markets) - Simplified usePerpsMarkets hook implementation 4. **Performance Tracking & Monitoring** - Enhanced visibility into cache effectiveness - Added comprehensive cache hit/miss logging with timing metrics - Performance measurements track actual data display (not skeleton screens) - Real-time cache age and validity tracking in DevLogger - Sentry integration for production performance monitoring ### Connection Lifecycle Fix **What broke:** After migrating to provider-based connection management, WebSocket connections stopped disconnecting on tab switches because `react-native-scrollable-tab-view` keeps all tabs mounted. **Solution:** 1. **Visibility Tracking** - Callback pattern allows parent to notify of tab changes 2. **App State Handling** - 20-second delay when backgrounded for quick returns 3. **Platform-Specific Timers** - Uses `react-native-background-timer` for reliability ### Impact - **Reduced API calls** - New 5-minute cache prevents repeated market data fetches - **Fewer WebSocket connections** - Fixed race condition eliminates duplicate connections - **Faster market list loading** - 85% faster load times (161ms with cache vs 1150ms without on Pixel 6 Pro) - **Battery/Network savings** - Properly disconnects when not viewing Perps - **Improved reliability** - Race conditions eliminated, proper cleanup on unmount ## **Changelog** CHANGELOG entry: Added market data caching for improved Perps performance, fixed WebSocket duplication issue, and restored proper connection lifecycle management on tab switches ## **Related issues** ## **Manual testing steps** ```gherkin Feature: Perps Performance and Connection Management Scenario: Market list loads from cache Given user has viewed the Perps market list When user navigates away and returns within 5 minutes Then market list should display instantly from cache And no new API call should be made And DevLogger should show cached data being used Scenario: No duplicate WebSocket subscriptions Given user opens the Perps market list When the list renders multiple market items Then DevLogger should show only ONE "Global allMids subscription established" And Network inspector should show single WebSocket connection Scenario: WebSocket disconnects on tab switch Given user is on the Perps tab with active WebSocket When user taps on Tokens tab Then WebSocket connection should disconnect within 1 second And DevLogger should show "Visibility changed" with isVisible: false Scenario: App background handling Given user is viewing the Perps tab When user backgrounds the app Then WebSocket should schedule disconnection in 20 seconds And quick return should cancel the scheduled disconnection Scenario: Cache refresh on context change Given user has cached market data When user switches account or network Then cache should be cleared and fresh data fetched ``` ## **Screenshots/Recordings** ### **Before** - Multiple WebSocket connections on market list load (17+ observed) - API calls on every component mount - Connection remains active when switching tabs ### **After** - Single WebSocket connection for all price updates - Market data cached for 5 minutes (new feature) - Connection properly disconnects on tab switch - **Real device performance (Pixel 6 Pro)**: - First load: 1150ms (fresh API fetch) - Subsequent loads: 161ms (from cache) - 85% improvement in load time ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable (comprehensive test coverage added) - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../PerpsMarketListView.tsx | 20 +- .../Views/PerpsTabView/PerpsTabView.test.tsx | 112 ++++ .../UI/Perps/Views/PerpsTabView/index.tsx | 38 +- .../UI/Perps/constants/perpsConfig.ts | 5 + app/components/UI/Perps/hooks/index.ts | 1 + .../hooks/usePerpsConnectionLifecycle.test.ts | 427 ++++++++++++ .../hooks/usePerpsConnectionLifecycle.ts | 195 ++++++ .../UI/Perps/hooks/usePerpsMarkets.test.ts | 630 +++++++----------- .../UI/Perps/hooks/usePerpsMarkets.ts | 202 +++--- .../integration/connectionLifecycle.test.tsx | 423 ++++++++++++ .../PerpsConnectionProvider.test.tsx | 303 ++++++++- .../providers/PerpsConnectionProvider.tsx | 60 +- .../providers/PerpsStreamManager.test.tsx | 211 +++++- .../UI/Perps/providers/PerpsStreamManager.tsx | 152 ++++- .../HyperLiquidSubscriptionService.test.ts | 82 +++ .../HyperLiquidSubscriptionService.ts | 10 +- .../services/PerpsConnectionManager.test.ts | 51 +- .../Perps/services/PerpsConnectionManager.ts | 102 ++- .../Wallet/__snapshots__/index.test.tsx.snap | 10 +- app/components/Views/Wallet/index.test.tsx | 208 +++++- app/components/Views/Wallet/index.tsx | 51 +- 21 files changed, 2701 insertions(+), 592 deletions(-) create mode 100644 app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.test.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.ts create mode 100644 app/components/UI/Perps/integration/connectionLifecycle.test.tsx 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/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 && ( Date: Mon, 25 Aug 2025 17:52:13 +0200 Subject: [PATCH 3/4] feat: implement navigation to address list in MultichainAccountActions (#18716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds navigation to addresses list from the new multichain account item menu ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/ac0d52e6-b149-422d-9a92-50601f99d0f5 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../MultichainAccountActions.test.tsx | 15 +++++++++++++++ .../MultichainAccountActions.tsx | 12 +++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) 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 ( From 40fe46a067d32c6e855f681355146aae5b58bd8d Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Mon, 25 Aug 2025 19:10:53 +0200 Subject: [PATCH 4/4] fix: cp-7.54 close solana websockets on inactive (#18718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes a performance issue by closing all Solana WebSocket connection whenever the client becomes inactive. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index f826764f58e7..4a41b8fbaf28 100644 --- a/package.json +++ b/package.json @@ -279,7 +279,7 @@ "@metamask/snaps-rpc-methods": "^13.5.0", "@metamask/snaps-sdk": "^9.3.0", "@metamask/snaps-utils": "^11.5.0", - "@metamask/solana-wallet-snap": "^2.3.0", + "@metamask/solana-wallet-snap": "^2.3.1", "@metamask/solana-wallet-standard": "^0.5.1", "@metamask/stake-sdk": "^3.2.0", "@metamask/swappable-obj-proxy": "^2.1.0", diff --git a/yarn.lock b/yarn.lock index 360510c1b0cb..a160069e97a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6221,10 +6221,10 @@ ses "^1.12.0" validate-npm-package-name "^5.0.0" -"@metamask/solana-wallet-snap@^2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@metamask/solana-wallet-snap/-/solana-wallet-snap-2.3.0.tgz#cf5438384033ee0f313d6c7b86ed6ed2d01620d3" - integrity sha512-zdqoC/obe407p3vZMXvIDsf51YviwzkcfOJbeqA3g4+RO5rb6eY5oUARsXpvSaaEO9kL/Xt9E8e3bApvEaNKwA== +"@metamask/solana-wallet-snap@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@metamask/solana-wallet-snap/-/solana-wallet-snap-2.3.1.tgz#e65de7edec3edc1a9828d32f28d45cc1086d24a5" + integrity sha512-fG63PD6g6ja2+VI1nxtIMrNlzM7Jv3UPXLyv24EtBk/36wHVBV1emKQNAaJz4fNtMbSfmr6t0pZ+VVWGJ8xRZA== "@metamask/solana-wallet-standard@^0.5.1": version "0.5.1"