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