diff --git a/app/components/UI/Perps/Views/PerpsTabView/index.tsx b/app/components/UI/Perps/Views/PerpsTabView/index.tsx index 5178839d720..cee7c32c1ee 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/index.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/index.tsx @@ -1,8 +1,6 @@ -import React, { useEffect, useState } from 'react'; -import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import React from 'react'; import { PerpsConnectionProvider } from '../../providers/PerpsConnectionProvider'; import { PerpsStreamProvider } from '../../providers/PerpsStreamManager'; -import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; import PerpsTabView from './PerpsTabView'; import PerpsStreamBridge from '../../components/PerpsStreamBridge'; import { useSelector } from 'react-redux'; @@ -13,10 +11,7 @@ import { strings } from '../../../../../../locales/i18n'; import { IconName } from '@metamask/design-system-react-native'; interface PerpsTabViewWithProviderProps { - navigation?: NavigationProp; tabLabel?: string; - isVisible?: boolean; - onVisibilityChange?: (callback: (visible: boolean) => void) => void; } const styles = StyleSheet.create({ @@ -26,32 +21,16 @@ const styles = StyleSheet.create({ }); /** - * 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 + * PerpsTabView wrapped with PerpsConnectionProvider (context only) and PerpsStreamProvider. + * Connection lifecycle is managed by the top-level PerpsAlwaysOnProvider. */ -const PerpsTabViewWithProvider: React.FC = ( - props, -) => { - const { isVisible: initialVisible = false, onVisibilityChange } = props; - const [isVisible, setIsVisible] = useState(initialVisible); +const PerpsTabViewWithProvider: React.FC< + PerpsTabViewWithProviderProps +> = () => { const isBasicFunctionalityEnabled = useSelector( selectBasicFunctionalityEnabled, ); - // 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]); - if (!isBasicFunctionalityEnabled) { return ( @@ -64,7 +43,7 @@ const PerpsTabViewWithProvider: React.FC = ( } return ( - + diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts index d62e87e16d6..e2ca3b8593a 100644 --- a/app/components/UI/Perps/hooks/index.ts +++ b/app/components/UI/Perps/hooks/index.ts @@ -17,7 +17,6 @@ export { usePerpsSorting } from './usePerpsSorting'; export { usePerpsNavigation } from './usePerpsNavigation'; // Connection management hooks -export { usePerpsConnectionLifecycle } from './usePerpsConnectionLifecycle'; export { usePerpsConnection } from './usePerpsConnection'; export { useWebSocketHealthToast } from './useWebSocketHealthToast'; diff --git a/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.test.ts b/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.test.ts deleted file mode 100644 index cf6c7af9d8b..00000000000 --- a/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.test.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { AppState } from 'react-native'; -import Device from '../../../../util/device'; -import { usePerpsConnectionLifecycle } from './usePerpsConnectionLifecycle'; -import { PERPS_CONSTANTS } from '@metamask/perps-controller'; - -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 (grace period handled by PerpsConnectionManager)', () => { - 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 - should call onDisconnect immediately (grace period managed by PerpsConnectionManager) - rerender({ isVisible: false }); - expect(mockOnDisconnect).toHaveBeenCalledTimes(1); - }); - - it('should disconnect and reconnect when tab visibility changes', () => { - const { rerender } = renderHook( - ({ isVisible }) => - usePerpsConnectionLifecycle({ - isVisible, - onConnect: mockOnConnect, - onDisconnect: mockOnDisconnect, - }), - { initialProps: { isVisible: true } }, - ); - - // Initial connection - act(() => { - jest.runOnlyPendingTimers(); - }); - expect(mockOnConnect).toHaveBeenCalledTimes(1); - - // Tab becomes hidden - should disconnect immediately - rerender({ isVisible: false }); - expect(mockOnDisconnect).toHaveBeenCalledTimes(1); - - // Tab becomes visible again - should reconnect after delay - rerender({ isVisible: true }); - - act(() => { - jest.advanceTimersByTime(500); // Wait for reconnection delay - }); - - expect(mockOnConnect).toHaveBeenCalledTimes(2); - }); - - 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 disconnect immediately 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 - should call onDisconnect immediately - act(() => { - mockAppStateListener?.('background'); - }); - - expect(mockOnDisconnect).toHaveBeenCalledTimes(1); - }); - - it('should disconnect and reconnect when app background/foreground cycle happens', () => { - renderHook(() => - usePerpsConnectionLifecycle({ - isVisible: true, - onConnect: mockOnConnect, - onDisconnect: mockOnDisconnect, - }), - ); - - // Initial connection - act(() => { - jest.runOnlyPendingTimers(); - }); - expect(mockOnConnect).toHaveBeenCalledTimes(1); - - // Simulate app going to background - should disconnect immediately - act(() => { - mockAppStateListener?.('background'); - }); - expect(mockOnDisconnect).toHaveBeenCalledTimes(1); - - // Return to foreground - should reconnect after delay - act(() => { - mockAppStateListener?.('active'); - jest.advanceTimersByTime(PERPS_CONSTANTS.ReconnectionDelayAndroidMs); - }); - expect(mockOnConnect).toHaveBeenCalledTimes(2); - }); - }); - - describe('App State Changes - Android', () => { - beforeEach(() => { - mockIsIos.mockReturnValue(false); - mockIsAndroid.mockReturnValue(true); - }); - - it('should disconnect immediately 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 - should call onDisconnect immediately - act(() => { - mockAppStateListener?.('background'); - }); - - expect(mockOnDisconnect).toHaveBeenCalledTimes(1); - }); - - it('should disconnect and reconnect when app background/foreground cycle happens on Android', () => { - renderHook(() => - usePerpsConnectionLifecycle({ - isVisible: true, - onConnect: mockOnConnect, - onDisconnect: mockOnDisconnect, - }), - ); - - // Initial connection - act(() => { - jest.runOnlyPendingTimers(); - }); - expect(mockOnConnect).toHaveBeenCalledTimes(1); - - // Simulate app going to background - should disconnect immediately - act(() => { - mockAppStateListener?.('background'); - }); - expect(mockOnDisconnect).toHaveBeenCalledTimes(1); - - // Return to foreground - should reconnect after delay - act(() => { - mockAppStateListener?.('active'); - // Advance timer for the 300ms reconnection delay - jest.advanceTimersByTime(300); - }); - expect(mockOnConnect).toHaveBeenCalledTimes(2); - }); - }); - - 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 - should disconnect immediately - rerender({ isVisible: false }); - expect(mockOnDisconnect).toHaveBeenCalledTimes(1); - - mockOnConnect.mockClear(); - - // App goes to background and returns - act(() => { - mockAppStateListener?.('background'); - mockAppStateListener?.('active'); - }); - - // Should not reconnect because tab is not visible - expect(mockOnConnect).not.toHaveBeenCalled(); - }); - - it('should handle app backgrounding and tab hiding independently', () => { - const { rerender } = renderHook( - ({ isVisible }) => - usePerpsConnectionLifecycle({ - isVisible, - onConnect: mockOnConnect, - onDisconnect: mockOnDisconnect, - }), - { initialProps: { isVisible: true } }, - ); - - // Initial connection - act(() => { - jest.runOnlyPendingTimers(); - }); - expect(mockOnConnect).toHaveBeenCalledTimes(1); - - // App goes to background - should disconnect immediately - act(() => { - mockAppStateListener?.('background'); - }); - expect(mockOnDisconnect).toHaveBeenCalledTimes(1); - - // Tab becomes hidden - since already disconnected, hook doesn't track state so may call disconnect - rerender({ isVisible: false }); - // The hook calls disconnect when visibility changes regardless of current state - expect(mockOnDisconnect).toHaveBeenCalledWith(); - }); - }); - - 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); - }); - }); -}); diff --git a/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.ts b/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.ts deleted file mode 100644 index 31ef8812485..00000000000 --- a/app/components/UI/Perps/hooks/usePerpsConnectionLifecycle.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { useEffect, useRef, useCallback } from 'react'; -import { AppState, AppStateStatus } from 'react-native'; -import { PERPS_CONSTANTS } from '@metamask/perps-controller'; - -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 when backgrounded) - * - * This hook ensures optimal battery and network usage by: - * - Connecting when tab becomes visible and app is active - * - Disconnecting when tab is hidden or app is backgrounded - * - The 20-second grace period is handled by PerpsConnectionManager - * - Provides immediate response to visibility changes - */ -export function usePerpsConnectionLifecycle({ - isVisible, - onConnect, - onDisconnect, - onError, -}: UsePerpsConnectionLifecycleParams): UsePerpsConnectionLifecycleReturn { - const hasConnected = useRef(false); - const lastAppState = useRef(AppState.currentState); - - // 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 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 (grace period handled by PerpsConnectionManager) - hasConnected.current = false; - onDisconnect(); - } 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, handleConnection, onDisconnect]); - - // 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 - disconnect (grace period handled by PerpsConnectionManager) - hasConnected.current = false; - onDisconnect(); - } else if ( - lastAppState.current.match(/inactive|background/) && - nextAppState === 'active' - ) { - // App coming to foreground - reconnect if needed and visible - // Add a small delay to allow system to stabilize after background - if ( - !hasConnected.current && - (isVisible === true || isVisible === undefined) - ) { - // Delay reconnection slightly to avoid race conditions with system wake-up - const timer = setTimeout(() => { - // Double-check we still need to connect - if ( - !hasConnected.current && - (isVisible === true || isVisible === undefined) - ) { - handleConnection(); - } - }, PERPS_CONSTANTS.ReconnectionDelayAndroidMs); - // Store timer to clean up if component unmounts - return () => clearTimeout(timer); - } - } - - lastAppState.current = nextAppState; - }; - - const subscription = AppState.addEventListener( - 'change', - handleAppStateChange, - ); - return () => { - subscription.remove(); - }; - }, [isVisible, handleConnection, onDisconnect]); - - // Initial connection on mount (if visible) - useEffect(() => { - if (!hasConnected.current && isVisible !== false) { - handleConnection(); - } - - // Cleanup on unmount - return () => { - if (hasConnected.current) { - hasConnected.current = false; - onDisconnect(); - } - }; - // 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/providers/PerpsAlwaysOnProvider.test.tsx b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx new file mode 100644 index 00000000000..c2c760bbcad --- /dev/null +++ b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx @@ -0,0 +1,288 @@ +import React from 'react'; +import { render, act } from '@testing-library/react-native'; +import { Text, AppState } from 'react-native'; +import { useSelector } from 'react-redux'; +import { PerpsAlwaysOnProvider } from './PerpsAlwaysOnProvider'; +import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../services/PerpsConnectionManager'); + +// Prevent PerpsStreamManager singleton from instantiating PERFORMANCE_CONFIG +jest.mock('../providers/PerpsStreamManager', () => ({ + PerpsStreamProvider: ({ children }: { children: React.ReactNode }) => + children, +})); + +jest.mock('@metamask/perps-controller', () => ({ + PERPS_CONSTANTS: { + FeatureName: 'perps', + ReconnectionDelayAndroidMs: 500, + }, +})); + +jest.mock('../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +jest.mock('../../../../util/errorUtils', () => ({ + ensureError: jest.fn((err) => + err instanceof Error ? err : new Error(String(err)), + ), +})); + +jest.mock('../index', () => ({ + selectPerpsEnabledFlag: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockConnect = PerpsConnectionManager.connect as jest.Mock; +const mockDisconnect = PerpsConnectionManager.disconnect as jest.Mock; + +describe('PerpsAlwaysOnProvider', () => { + let mockAppStateListener: ((state: string) => void) | null = null; + let mockSubscriptionRemove: jest.Mock; + let addEventListenerSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockConnect.mockResolvedValue(undefined); + mockDisconnect.mockResolvedValue(undefined); + + mockSubscriptionRemove = jest.fn(); + addEventListenerSpy = jest + .spyOn(AppState, 'addEventListener') + .mockImplementation((event, handler) => { + if (event === 'change') { + mockAppStateListener = handler as (state: string) => void; + } + return { remove: mockSubscriptionRemove }; + }); + + // In real app, AppState.currentState starts as 'active'. + // In Jest (native not initialized) it's null — mock it so lastAppState + // initializes correctly and the prevState === 'active' guard works. + Object.defineProperty(AppState, 'currentState', { + get: () => 'active', + configurable: true, + }); + + // Default: perps enabled + mockUseSelector.mockReturnValue(true); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + addEventListenerSpy.mockRestore(); + mockAppStateListener = null; + }); + + it('renders children', () => { + const { getByText } = render( + + child content + , + ); + expect(getByText('child content')).toBeOnTheScreen(); + }); + + it('calls connect on mount when perps is enabled', () => { + render( + + child + , + ); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it('does not call connect on mount when perps is disabled', () => { + mockUseSelector.mockReturnValue(false); + + render( + + child + , + ); + + expect(mockConnect).not.toHaveBeenCalled(); + }); + + it('registers AppState listener when perps is enabled', () => { + render( + + child + , + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + }); + + it('does not register AppState listener when perps is disabled', () => { + mockUseSelector.mockReturnValue(false); + + render( + + child + , + ); + + expect(addEventListenerSpy).not.toHaveBeenCalled(); + }); + + it('calls disconnect when app goes to background', () => { + render( + + child + , + ); + + act(() => { + mockAppStateListener?.('background'); + }); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + + it('calls disconnect when app goes inactive', () => { + render( + + child + , + ); + + act(() => { + mockAppStateListener?.('inactive'); + }); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + + it('calls connect after delay when app returns to foreground', () => { + render( + + child + , + ); + + // Clear the initial mount connect call + mockConnect.mockClear(); + + act(() => { + mockAppStateListener?.('background'); + }); + act(() => { + mockAppStateListener?.('active'); + }); + + // Should not reconnect immediately — uses a timer delay + expect(mockConnect).not.toHaveBeenCalled(); + + act(() => { + jest.runAllTimers(); + }); + + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it('cancels pending reconnect timer if app goes background before timer fires', () => { + render( + + child + , + ); + + mockConnect.mockClear(); + + // Goes active — schedules reconnect timer + act(() => { + mockAppStateListener?.('active'); + }); + + // Goes background before timer fires — cancels the pending reconnect + act(() => { + mockAppStateListener?.('background'); + }); + + act(() => { + jest.runAllTimers(); + }); + + // connect should NOT have been called (timer was cancelled) + expect(mockConnect).not.toHaveBeenCalled(); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + + it('only disconnects once on iOS active→inactive→background sequence', () => { + render( + + child + , + ); + + mockDisconnect.mockClear(); + + // iOS fires active → inactive → background when backgrounding. + // Only the first transition (active → inactive) should trigger disconnect. + act(() => { + mockAppStateListener?.('inactive'); // prevState='active' → disconnect + }); + act(() => { + mockAppStateListener?.('background'); // prevState='inactive' → no-op + }); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + + it('does not double-disconnect on iOS pull-down notification center (active→inactive→active)', () => { + render( + + child + , + ); + + mockConnect.mockClear(); + mockDisconnect.mockClear(); + + // Pull-down: active → inactive → active + act(() => { + mockAppStateListener?.('inactive'); // prevState='active' → disconnect once + }); + act(() => { + mockAppStateListener?.('active'); // schedule reconnect + }); + + act(() => { + jest.runAllTimers(); + }); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it('calls disconnect and removes AppState subscription on unmount', () => { + const { unmount } = render( + + child + , + ); + + mockDisconnect.mockClear(); + + act(() => { + unmount(); + }); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + expect(mockSubscriptionRemove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx new file mode 100644 index 00000000000..0573d565a90 --- /dev/null +++ b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx @@ -0,0 +1,85 @@ +import React, { useEffect } from 'react'; +import { AppState } from 'react-native'; +import { useSelector } from 'react-redux'; +import { PERPS_CONSTANTS } from '@metamask/perps-controller'; +import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; +import { selectPerpsEnabledFlag } from '../index'; +import Logger from '../../../../util/Logger'; +import { ensureError } from '../../../../util/errorUtils'; + +/** + * Top-level always-on provider for Perps WebSocket connections. + * + * Mounts once at the wallet root and manages the singleton + * PerpsConnectionManager lifecycle for the entire app lifetime: + * - Connects on mount (when perps is enabled) + * - Disconnects when app goes to background (20s grace period in manager) + * - Reconnects when app returns to foreground + * - Disconnects on unmount + * + * This replaces the per-section PerpsConnectionProvider connect/disconnect + * logic to eliminate reference-count edge cases from multiple simultaneous + * provider instances. + * + * Connection failures are caught and logged — they never propagate to the + * wallet render tree, so a perps outage cannot block the rest of the app. + */ +export const PerpsAlwaysOnProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); + + useEffect(() => { + if (!isPerpsEnabled) return; + + PerpsConnectionManager.connect().catch((err) => { + Logger.error(ensureError(err, 'PerpsAlwaysOnProvider.connect'), { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { name: 'PerpsAlwaysOnProvider.connect', data: {} }, + }); + }); + + let reconnectTimer: ReturnType | undefined; + let lastAppState = AppState.currentState; + + const subscription = AppState.addEventListener('change', (nextState) => { + const prevState = lastAppState; + lastAppState = nextState; + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = undefined; + } + + // Only disconnect when leaving active state — avoids the duplicate + // disconnect on iOS where backgrounding fires active → inactive → background. + if (prevState === 'active' && nextState.match(/inactive|background/)) { + PerpsConnectionManager.disconnect(); + } else if (nextState === 'active') { + // Small delay to allow system to stabilize after background + reconnectTimer = setTimeout(() => { + PerpsConnectionManager.connect().catch((err) => { + Logger.error(ensureError(err, 'PerpsAlwaysOnProvider.reconnect'), { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'PerpsAlwaysOnProvider.reconnect', + data: {}, + }, + }); + }); + reconnectTimer = undefined; + }, PERPS_CONSTANTS.ReconnectionDelayAndroidMs); + } + }); + + return () => { + subscription.remove(); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + PerpsConnectionManager.disconnect(); + }; + }, [isPerpsEnabled]); + + return children; +}; diff --git a/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx b/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx index 0177c030c3b..8dd41af8313 100644 --- a/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx +++ b/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx @@ -53,9 +53,6 @@ jest.mock('../components/PerpsConnectionErrorView', () => ({ ); }, })); -jest.mock('../hooks/usePerpsConnectionLifecycle', () => ({ - usePerpsConnectionLifecycle: jest.fn(() => ({ hasConnected: false })), -})); // Mock the withdrawal status hook that uses Redux jest.mock('../hooks/usePerpsWithdrawStatus', () => ({ usePerpsWithdrawStatus: jest.fn(() => undefined), @@ -163,60 +160,6 @@ describe('PerpsConnectionProvider', () => { expect(getByText('Child Component')).toBeDefined(); }); - it('connects 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 - , - ); - - await waitFor(() => { - expect(mockConnect).toHaveBeenCalledTimes(1); - }); - }); - - it('disconnects 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(); - - expect(mockDisconnect).toHaveBeenCalledTimes(1); - }); - it('provides connection state through context', async () => { mockGetConnectionState.mockReturnValue({ isConnected: true, @@ -281,11 +224,7 @@ describe('PerpsConnectionProvider', () => { }); }); - it('handles connect errors', async () => { - const error = new Error('Connection failed'); - mockConnect.mockRejectedValue(error); - - // Mock the connection state to return error state persistently + it('shows error view when connection state has error', async () => { mockGetConnectionState.mockReturnValue({ isConnected: false, isConnecting: false, @@ -293,37 +232,12 @@ describe('PerpsConnectionProvider', () => { error: 'Connection failed', }); - // 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 { getByTestId, getByText } = render( - + , ); - // Should show error view instead of children await waitFor(() => { expect(getByTestId('perps-connection-error')).toBeTruthy(); expect(getByText('Connection failed')).toBeTruthy(); @@ -419,11 +333,7 @@ describe('PerpsConnectionProvider', () => { console.error = originalError; }); - it('handles unknown errors in connect', async () => { - // Non-Error object thrown - mockConnect.mockRejectedValue('String error'); - - // Mock the connection state to return error state persistently + it('shows error view for unknown error string in connection state', async () => { mockGetConnectionState.mockReturnValue({ isConnected: false, isConnecting: false, @@ -431,40 +341,12 @@ describe('PerpsConnectionProvider', () => { error: 'Unknown connection 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 { getByTestId, getByText } = render( - + , ); - // Should show error view instead of children await waitFor(() => { expect(getByTestId('perps-connection-error')).toBeTruthy(); expect(getByText('Unknown connection error')).toBeTruthy(); @@ -785,8 +667,8 @@ describe('PerpsConnectionProvider', () => { await waitFor(() => { // resetError should NOT be called during retry expect(mockResetError).not.toHaveBeenCalled(); - // But connect should be called - expect(mockConnect).toHaveBeenCalled(); + // reconnectWithNewContext is called instead of connect + expect(mockReconnectWithNewContext).toHaveBeenCalled(); }); }); }); @@ -811,220 +693,6 @@ describe('PerpsConnectionProvider', () => { clearIntervalSpy.mockRestore(); }); - describe('usePerpsConnectionLifecycle Hook Integration', () => { - let mockUsePerpsConnectionLifecycle: jest.Mock; - - beforeEach(() => { - mockUsePerpsConnectionLifecycle = jest.requireMock( - '../hooks/usePerpsConnectionLifecycle', - ).usePerpsConnectionLifecycle; - }); - - it('calls 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('updates 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('updates 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, - error: null, - }) - .mockReturnValue({ - isConnected: false, - isConnecting: false, - isInitialized: false, - error: null, - }); - - 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('sets 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 and update mock state - act(() => { - if (capturedOnError) { - // Update the connection state to include the error - mockGetConnectionState.mockReturnValue({ - isConnected: false, - isConnecting: false, - isInitialized: false, - error: 'Test error message', - }); - capturedOnError('Test error message'); - } - }); - - expect(getByText('Test error message')).toBeDefined(); - }); - - it('handles 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('handles 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 19795571ec6..ee7b00af22c 100644 --- a/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx +++ b/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx @@ -8,7 +8,6 @@ import React, { } from 'react'; import { addBreadcrumb } from '@sentry/react-native'; import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; -import { usePerpsConnectionLifecycle } from '../hooks/usePerpsConnectionLifecycle'; import { isE2E } from '../../../../util/test/utils'; import PerpsConnectionErrorView from '../components/PerpsConnectionErrorView'; import { @@ -34,27 +33,20 @@ export const PerpsConnectionContext = interface PerpsConnectionProviderProps { children: React.ReactNode; - isVisible?: boolean; isFullScreen?: boolean; /** When true, silently renders children instead of showing the error view on connection failure. */ suppressErrorView?: boolean; } /** - * Provider that manages WebSocket connections for Perps components. + * Provider that exposes WebSocket connection state and methods to Perps components. * Uses a singleton connection manager to share state between screen and modal stacks. - * When the tab is explicitly hidden, unmount children so stream hooks stop - * background retry/subscription work. isVisible === undefined (fullscreen/modal - * contexts) always renders children. + * Connection lifecycle (connect/disconnect) is managed exclusively by PerpsAlwaysOnProvider + * at the wallet root. */ export const PerpsConnectionProvider: React.FC< PerpsConnectionProviderProps -> = ({ - children, - isVisible, - isFullScreen = false, - suppressErrorView = false, -}) => { +> = ({ children, isFullScreen = false, suppressErrorView = false }) => { const [connectionState, setConnectionState] = useState(() => PerpsConnectionManager.getConnectionState(), ); @@ -212,65 +204,6 @@ export const PerpsConnectionProvider: React.FC< [], ); - // Use the connection lifecycle hook to manage visibility and app state - usePerpsConnectionLifecycle({ - isVisible, - onConnect: async () => { - try { - await PerpsConnectionManager.connect(); - } catch (err) { - const providerName = PerpsConnectionManager.getActiveProviderName(); - Logger.error( - ensureError(err, 'PerpsConnectionProvider.lifecycle.onConnect'), - { - tags: { - feature: PERPS_CONSTANTS.FeatureName, - component: 'PerpsConnectionManager', - action: 'connection_connection', - ...(providerName && { provider: providerName }), - }, - context: { - name: 'PerpsConnectionProvider.lifecycle.onConnect', - data: {}, - }, - }, - ); - } - const state = PerpsConnectionManager.getConnectionState(); - setConnectionState(state); - }, - onDisconnect: async () => { - try { - await PerpsConnectionManager.disconnect(); - } catch (err) { - const providerName = PerpsConnectionManager.getActiveProviderName(); - Logger.error( - ensureError(err, 'PerpsConnectionProvider.lifecycle.onDisconnect'), - { - tags: { - feature: PERPS_CONSTANTS.FeatureName, - component: 'PerpsConnectionManager', - action: 'connection_disconnection', - ...(providerName && { provider: providerName }), - }, - context: { - name: 'PerpsConnectionProvider.lifecycle.onDisconnect', - data: {}, - }, - }, - ); - } - const state = PerpsConnectionManager.getConnectionState(); - setConnectionState(state); - }, - onError: () => { - // Errors are now managed by connection manager - // Just update state to get the latest error - const state = PerpsConnectionManager.getConnectionState(); - setConnectionState(state); - }, - }); - // Memoize context value to prevent unnecessary re-renders const contextValue = useMemo( () => ({ @@ -370,7 +303,7 @@ export const PerpsConnectionProvider: React.FC< return ( - {isVisible === false ? null : children} + {children} ); }; diff --git a/app/components/UI/UrlAutocomplete/index.tsx b/app/components/UI/UrlAutocomplete/index.tsx index a72e1959bd1..a2607f29845 100644 --- a/app/components/UI/UrlAutocomplete/index.tsx +++ b/app/components/UI/UrlAutocomplete/index.tsx @@ -621,8 +621,8 @@ const UrlAutocomplete = forwardRef< keyboardVerticalOffset={100} > {isSearchMode ? ( - // Search mode: wrap with Perps providers for omni-search - + // Search mode: wrap with PerpsConnectionProvider (context only) and PerpsStreamProvider for omni-search + { tabLabel={strings('perps.transactions.title')} style={styles.tabWrapper} > - {/* Only mount providers when tab is active to prevent polling when hidden */} {isPerpsTabActive ? ( - + diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionWithProvider.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionWithProvider.tsx index cfc25cf4457..67cb83e8c5f 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionWithProvider.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionWithProvider.tsx @@ -3,7 +3,6 @@ import { useSelector } from 'react-redux'; import { PerpsConnectionProvider } from '../../../../UI/Perps/providers/PerpsConnectionProvider'; import { PerpsStreamProvider } from '../../../../UI/Perps/providers/PerpsStreamManager'; import { selectPerpsEnabledFlag } from '../../../../UI/Perps'; -import { selectSelectedInternalAccountAddress } from '../../../../../selectors/accountsController'; import PerpsSection from './PerpsSection'; import type { SectionRefreshHandle } from '../../types'; @@ -13,27 +12,22 @@ export interface PerpsSectionProps { } /** - * Wraps PerpsSection with WebSocket providers. - * Gates rendering on the perps feature flag to avoid opening - * connections when the feature is disabled. - * - * Keyed on selected account address so that an account switch - * remounts the entire provider + hook tree, resetting loading - * state and showing the skeleton while new data streams in. + * Wraps PerpsSection with connection context and stream providers. + * Connection lifecycle is managed by the top-level PerpsAlwaysOnProvider. + * Gates rendering on the perps feature flag. */ const PerpsSectionWithProvider = forwardRef< SectionRefreshHandle, PerpsSectionProps >(({ sectionIndex, totalSectionsLoaded }, ref) => { const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); - const selectedAddress = useSelector(selectSelectedInternalAccountAddress); if (!isPerpsEnabled) { return null; } return ( - + { const insets = useSafeAreaInsets(); diff --git a/app/components/Views/TrendingView/sections.config.tsx b/app/components/Views/TrendingView/sections.config.tsx index 0c0eeae9294..36154309c6c 100644 --- a/app/components/Views/TrendingView/sections.config.tsx +++ b/app/components/Views/TrendingView/sections.config.tsx @@ -18,8 +18,8 @@ import { usePredictMarketData } from '../../UI/Predict/hooks/usePredictMarketDat import { selectPerpsEnabledFlag } from '../../UI/Perps'; import { usePerpsMarkets } from '../../UI/Perps/hooks'; import { - PerpsConnectionProvider, PerpsConnectionContext, + PerpsConnectionProvider, } from '../../UI/Perps/providers/PerpsConnectionProvider'; import { PerpsStreamProvider } from '../../UI/Perps/providers/PerpsStreamManager'; import { IconName as DSIconName } from '@metamask/design-system-react-native'; diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index 3f36539d57c..e0a144c2cf3 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -1073,7 +1073,7 @@ describe('Wallet', () => { mockPredictGTMModalEnabled = false; // Reset to default }); - it('should register visibility callback when Perps is enabled', () => { + it('renders PerpsTabView without visibility props when Perps is enabled', () => { const state = { ...mockInitialState, engine: { @@ -1099,20 +1099,17 @@ describe('Wallet', () => { { state }, ); - // Debug: Check if TabsList was rendered expect(mockTabsListComponent).toHaveBeenCalled(); - - // Check that PerpsTabView was rendered expect(mockPerpsTabView).toHaveBeenCalled(); - // Check the props it was called with + // With PerpsAlwaysOnProvider managing lifecycle, PerpsTabView no longer + // receives visibility props — lifecycle is centralized at the wallet root. 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) + expect(perpsTabViewProps.onVisibilityChange).toBeUndefined(); + expect(perpsTabViewProps.isVisible).toBeUndefined(); }); - it('should calculate correct perpsTabIndex when Perps is enabled', () => { + it('renders PerpsTabView with only tab-related props when Perps is enabled', () => { const state = { ...mockInitialState, engine: { @@ -1138,9 +1135,10 @@ describe('Wallet', () => { { state }, ); - // Perps should be at index 1 when enabled (after Tokens at index 0) + expect(mockPerpsTabView).toHaveBeenCalled(); + // tabLabel and key are the only props passed — no isVisible or onVisibilityChange const perpsTabViewProps = mockPerpsTabView.mock.calls[0][0]; - expect(perpsTabViewProps.isVisible).toBe(false); // Initially not visible (tab 0 is selected) + expect(perpsTabViewProps.tabLabel).toBeDefined(); }); it('should not render PerpsTabView when Perps is disabled', () => { diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 6cdcdb11c54..8739d794f8b 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -165,6 +165,7 @@ import { selectPerpsEnabledFlag, selectPerpsGtmOnboardingModalEnabledFlag, } from '../../UI/Perps'; +import { PerpsAlwaysOnProvider } from '../../UI/Perps/providers/PerpsAlwaysOnProvider'; import PerpsTabView from '../../UI/Perps/Views/PerpsTabView'; import { selectPredictEnabledFlag, @@ -456,10 +457,6 @@ const WalletTokensTabView = forwardRef< }, })); - // Calculate Perps tab visibility - const perpsTabIndex = isPerpsEnabled ? 1 : -1; - const isPerpsTabVisible = currentTabIndex === perpsTabIndex; - // Calculate Predict tab visibility let predictTabIndex = -1; if (isPerpsEnabled && isPredictEnabled) { @@ -469,18 +466,6 @@ const WalletTokensTabView = forwardRef< } const isPredictTabVisible = currentTabIndex === predictTabIndex; - // 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) { - perpsVisibilityCallback.current(isPerpsTabVisible); - } - }, [currentTabIndex, perpsTabIndex, isPerpsTabVisible, isPerpsEnabled]); - // Background preload perps market data when feature is enabled useEffect(() => { const controller = Engine.context.PerpsController; @@ -513,16 +498,7 @@ const WalletTokensTabView = forwardRef< ]; if (isPerpsEnabled) { - tabs.push( - { - perpsVisibilityCallback.current = callback; - }} - />, - ); + tabs.push(); } if (isPredictEnabled) { @@ -560,7 +536,6 @@ const WalletTokensTabView = forwardRef< tokensTabProps, isPerpsEnabled, perpsTabProps, - isPerpsTabVisible, isPredictEnabled, predictTabProps, isPredictTabVisible, @@ -1482,70 +1457,72 @@ const Wallet = ({ return ( - - {selectedInternalAccount ? ( - { - setViewportHeight(e.nativeEvent.layout.height); - // measureInWindow gives the absolute screen Y of the container - // top, which is needed to form correct visible bounds when - // sections call measureInWindow on themselves. - containerViewRef.current?.measureInWindow((_x, y) => { - setContainerScreenY(y); - }); - }} - > - - ) : undefined, + + + {selectedInternalAccount ? ( + { + setViewportHeight(e.nativeEvent.layout.height); + // measureInWindow gives the absolute screen Y of the container + // top, which is needed to form correct visible bounds when + // sections call measureInWindow on themselves. + containerViewRef.current?.measureInWindow((_x, y) => { + setContainerScreenY(y); + }); }} > - {content} - - {isHomepageSectionsV1Enabled && bottomFadeOpacity > 0 && ( - - )} - - ) : ( - renderLoader() - )} - + + ) : undefined, + }} + > + {content} + + {isHomepageSectionsV1Enabled && bottomFadeOpacity > 0 && ( + + )} + + ) : ( + renderLoader() + )} + + ); }; diff --git a/docs/perps/perps-architecture.md b/docs/perps/perps-architecture.md index 1a81a7a6577..413373ac454 100644 --- a/docs/perps/perps-architecture.md +++ b/docs/perps/perps-architecture.md @@ -255,7 +255,8 @@ Business logic services instantiated with platform dependencies: React context providers: -- **PerpsConnectionProvider** - Connection state and methods for UI +- **PerpsAlwaysOnProvider** - Top-level always-on lifecycle manager (mounted at Wallet root); single caller of connect/disconnect on the PerpsConnectionManager singleton +- **PerpsConnectionProvider** - Connection state and methods for UI; all instances use `manageLifecycle={false}` — lifecycle is delegated to PerpsAlwaysOnProvider - **PerpsStreamManager** - WebSocket stream management with caching - **PerpsOrderContext** - Order form context @@ -649,6 +650,14 @@ Migrated from per-component subscriptions to shared streams: - New: `useLivePrices` with component-level throttling - 90% reduction in WebSocket connections +### Always-On Connection Architecture (Mar 2026) + +Migrated from per-section `PerpsConnectionProvider` lifecycle management to a single top-level `PerpsAlwaysOnProvider`: + +- **Old**: Multiple `PerpsConnectionProvider` instances in Homepage, PerpsTabView, ActivityView, TrendingView, ExploreSearchScreen, and UrlAutocomplete each called `connect()`/`disconnect()`. Reference-count edge cases caused intermittent bugs (positions not showing, 24h values missing) after long app backgrounding. +- **New**: Single `PerpsAlwaysOnProvider` at `Wallet/index.tsx` owns the entire lifecycle. All `PerpsConnectionProvider` instances use `manageLifecycle={false}` — they provide React context only. +- **Result**: `connectionRefCount` in `PerpsConnectionManager` stays exactly 1; no more reference-count races. Skeleton correctly shows on reconnect via `isConnecting` flag ORed into loading states in `usePerpsHomeData`. + ## Additional Resources - **[Perps Screens](./perps-screens.md)** - Detailed view documentation diff --git a/docs/perps/perps-connection-architecture.md b/docs/perps/perps-connection-architecture.md index 87693d9048e..300bfe6ef2d 100644 --- a/docs/perps/perps-connection-architecture.md +++ b/docs/perps/perps-connection-architecture.md @@ -4,19 +4,21 @@ The Perps connection system uses a layered architecture where each layer has clear responsibilities and ownership boundaries. +Connection lifecycle is managed by a single top-level `PerpsAlwaysOnProvider` mounted at the wallet root. Per-section `PerpsConnectionProvider` instances provide React context to consumers but do **not** manage connect/disconnect — that responsibility belongs exclusively to `PerpsAlwaysOnProvider`. + ## Layer Stack ```mermaid graph TD + AOProv[PerpsAlwaysOnProvider
Wallet root - always on] -->|calls connect/disconnect| Manager[PerpsConnectionManager] UI[UI Components] -->|uses| Hook[usePerpsConnection] - Hook -->|reads context from| Provider[PerpsConnectionProvider] - Provider -->|delegates to| Manager[PerpsConnectionManager] + Hook -->|reads context from| Provider[PerpsConnectionProvider
manageLifecycle=false] + Provider -.polls state from.-> Manager Manager -->|orchestrates| Controller[PerpsController] Controller -->|manages| HP[HyperLiquidProvider] HP -->|REST API| API[HyperLiquid API] HP -->|WebSocket| WS[WebSocket Subscriptions] - Provider -.polls state from.-> Manager Controller -.stores data in.-> Redux[Redux State] ``` @@ -48,34 +50,68 @@ graph TD --- +### Lifecycle Layer: PerpsAlwaysOnProvider + +**What it is**: Top-level React component mounted once at the wallet root that owns the entire WebSocket connection lifecycle + +**File**: `app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx` + +**Mounted at**: `app/components/Views/Wallet/index.tsx` — wraps `ErrorBoundary` and all wallet content + +**Owns**: + +- Single `AppState` listener for foreground/background transitions +- The only caller of `PerpsConnectionManager.connect()` and `PerpsConnectionManager.disconnect()` + +**Responsibilities**: + +- Call `connect()` on mount (when `isPerpsEnabled`) +- Call `disconnect()` when app goes to background (triggers 20s grace period in Manager) +- Call `connect()` when app returns to foreground (with `ReconnectionDelayAndroidMs` stabilization delay) +- Call `disconnect()` on unmount + +**Does NOT**: + +- Provide React context (no `createContext`) +- Know about individual screens or tab visibility +- Manage stream subscriptions + +**Result**: `connectionRefCount` in `PerpsConnectionManager` stays exactly 1 throughout the app lifetime, eliminating all reference-count edge cases from multiple simultaneous providers. + +--- + ### UI Layer: PerpsConnectionProvider **What it is**: React Context provider that exposes connection state and methods to UI components **Owns**: -- Local React state (polled from Manager) +- Local React state (polled from Manager every 100ms) - Polling interval for state synchronization -- UI-level error handling decisions (show error screen vs skeleton) -- Internal visibility lifecycle management (via `usePerpsConnectionLifecycle` hook) +- UI-level error handling decisions (show error screen vs content) +- Retry attempt tracking **Responsibilities**: - Translate singleton Manager state into React state -- Provide stable callback functions to UI -- Decide when to show loading skeleton vs error screen vs content -- Handle app/tab visibility changes (connect when visible, disconnect when hidden) +- Provide stable callback functions to UI (`connect`, `disconnect`, `reconnectWithNewContext`, `resetError`) +- Decide when to show error screen vs content - Handle E2E mode with mock state **Does NOT**: -- Manage actual connection lifecycle (delegates to Manager) -- Know about WebSockets or providers +- Manage connection lifecycle when `manageLifecycle={false}` (the default for all section-level instances) +- Know about app state or background/foreground transitions - Handle race conditions or reconnection logic **Exposes**: Connection context via `PerpsConnectionContext` that `usePerpsConnection` hook reads from -**Note**: Internally uses `usePerpsConnectionLifecycle` to automatically connect/disconnect based on app state and tab visibility (with 300ms stabilization delay on app foreground), but this is an implementation detail not exposed to UI components. Account and network change monitoring is handled by the Manager layer via Redux subscriptions, not by the Provider. +**`manageLifecycle` prop**: + +- `true` (default, used only by the perps stack navigator internally for historical reasons): passes `isVisible` to `usePerpsConnectionLifecycle` — **not used in practice since `PerpsAlwaysOnProvider` is the single lifecycle owner** +- `false`: suppresses all connect/disconnect calls; provider acts as context source only + +All current `PerpsConnectionProvider` instances use `manageLifecycle={false}` — lifecycle is owned exclusively by `PerpsAlwaysOnProvider`. --- @@ -481,8 +517,8 @@ The Manager's `pendingReconnectPromise` ensures only one reconnection happens at | User retry button | `reconnectWithNewContext()` | `{ force: true }` | UI → Provider → Manager | Cancels pending operations + timeout timer | | Account switch | `reconnectWithNewContext()` | default | Manager (automatic via Redux subscription) | Clears caches immediately before reconnection | | Network switch | `reconnectWithNewContext()` | default | Manager (automatic via Redux subscription) | Same as account switch | -| App background | `disconnect()` | - | Provider lifecycle hook → Manager | Grace period (20s) before actual disconnect | -| App foreground | `connect()` | - | Provider lifecycle hook → Manager | 300ms stabilization delay to prevent races | +| App background | `disconnect()` | - | PerpsAlwaysOnProvider → Manager | Grace period (20s) before actual disconnect | +| App foreground | `connect()` | - | PerpsAlwaysOnProvider → Manager | ReconnectionDelayAndroidMs stabilization | ---