Skip to content

Commit e51d6d1

Browse files
abretonc7smichalconsensysclaude
authored
chore(runway): cherry-pick feat(perps): sdk reconnect on native socket event (#25022) (#25573)
## Summary Cherry-pick of #25022 (`91ad46f5f8`) for release 7.63.1. **Original PR**: #25022 ### Changes This PR implements event-based WebSocket connection health monitoring for Perps, replacing the previous polling-based approach: - **Removes polling**: Eliminates 5-second interval health checks - **Uses SDK events**: Leverages SDK's `terminate` event for instant disconnection detection - **Adds toast notifications**: Visual feedback for connection states (connected/connecting/disconnected) - **Manual retry**: Users can tap "Retry" button when disconnected - **Auto-retry**: Automatic reconnection attempt after 10 seconds ### Conflict Resolution 5 files had conflicts due to the architectural change from polling to event-based monitoring: - `HyperLiquidProvider.ts` / `.test.ts` - `HyperLiquidClientService.ts` / `.test.ts` - `HyperLiquidSubscriptionService.test.ts` Resolved by accepting the PR version (event-based approach), which is the intended behavior. ## Test Plan - [x] TypeScript compilation passes - [x] All Perps tests pass (243 suites, 5869 tests) - [x] ESLint passes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces new WebSocket connection-state plumbing from the Perps SDK/provider up through `PerpsController` to an app-level toast with auto-retry, which could impact connection/reconnection behavior and user UX if state transitions are mishandled. > > **Overview** > Adds **event-driven Perps WebSocket health UX** by introducing `PerpsWebSocketHealthToast` (with a new `WebSocketHealthToastProvider` context) and wiring it into `App.tsx` so connection status can be surfaced above all screens. > > Extends the Perps stack to expose and consume WebSocket connection state: `PerpsController` now provides `getWebSocketConnectionState`, `subscribeToConnectionState`, and `reconnect`, `PerpsStreamBridge` enables the new `useWebSocketHealthToast` hook, and the hook triggers disconnected/connecting/connected toasts plus manual retry and a 10s auto-retry. > > Updates Perps config/constants and tests/mocks to support reconnection delays, new selector test IDs, and provider/controller coverage for the new connection-state APIs (plus a few related test expectation adjustments). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 85f917e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Michal Szorad <michal.szorad@consensys.net> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6a9d90f commit e51d6d1

30 files changed

Lines changed: 3480 additions & 1425 deletions

app/components/Nav/App/App.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ import ModalConfirmation from '../../../component-library/components/Modals/Moda
3636
import Toast, {
3737
ToastContext,
3838
} from '../../../component-library/components/Toast';
39+
import PerpsWebSocketHealthToast, {
40+
WebSocketHealthToastProvider,
41+
} from '../../UI/Perps/components/PerpsWebSocketHealthToast';
3942
import AccountSelector from '../../../components/Views/AccountSelector';
4043
import AddressSelector from '../../../components/Views/AddressSelector';
4144
import { TokenSortBottomSheet } from '../../UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet';
@@ -1276,11 +1279,12 @@ const App: React.FC = () => {
12761279
}, []);
12771280

12781281
return (
1279-
<>
1282+
<WebSocketHealthToastProvider>
12801283
<AppFlow />
12811284
<Toast ref={toastRef} />
1285+
<PerpsWebSocketHealthToast />
12821286
<ProfilerManager />
1283-
</>
1287+
</WebSocketHealthToastProvider>
12841288
);
12851289
};
12861290

app/components/UI/Perps/Perps.testIds.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,3 +675,12 @@ export const PerpsOrderBookTableSelectorsIDs = {
675675
export const PerpsOrderBookDepthChartSelectorsIDs = {
676676
CONTAINER: 'perps-order-book-depth-chart',
677677
} as const;
678+
679+
// ========================================
680+
// PERPS WEBSOCKET HEALTH TOAST SELECTORS
681+
// ========================================
682+
683+
export const PerpsWebSocketHealthToastSelectorsIDs = {
684+
TOAST: 'perps-websocket-health-toast',
685+
RETRY_BUTTON: 'perps-websocket-health-toast-retry-button',
686+
} as const;

app/components/UI/Perps/__mocks__/providerMocks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export const createMockHyperLiquidProvider =
5252
subscribeToOrders: jest.fn(),
5353
subscribeToAccount: jest.fn(),
5454
setUserFeeDiscount: jest.fn(),
55+
// WebSocket connection state methods
56+
getWebSocketConnectionState: jest.fn(),
57+
subscribeToConnectionState: jest.fn().mockReturnValue(() => undefined),
58+
reconnect: jest.fn().mockResolvedValue(undefined),
5559
}) as unknown as jest.Mocked<HyperLiquidProvider>;
5660

5761
export const createMockOrderResult = () => ({

app/components/UI/Perps/components/PerpsStreamBridge.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import React from 'react';
22
import { usePerpsWithdrawStatus } from '../hooks/usePerpsWithdrawStatus';
33
import { usePerpsDepositStatus } from '../hooks/usePerpsDepositStatus';
4+
import { useWebSocketHealthToast } from '../hooks/useWebSocketHealthToast';
45

56
/**
67
* PerpsStreamBridge - Bridges stream context to global hooks.
78
*
89
* This component acts as a bridge, allowing hooks to access the PerpsStream context
910
* by being rendered inside both PerpsConnectionProvider and PerpsStreamProvider.
11+
*
12+
* The WebSocket health toast is rendered at the App level via WebSocketHealthToastProvider
13+
* and PerpsWebSocketHealthToast to ensure it appears on top of all other content.
1014
*/
1115
const PerpsStreamBridge: React.FC = () => {
1216
// Enable withdrawal status monitoring and toasts
@@ -15,7 +19,9 @@ const PerpsStreamBridge: React.FC = () => {
1519
// Enable deposit status monitoring and toasts
1620
usePerpsDepositStatus();
1721

18-
// This component doesn't render anything
22+
// Enable WebSocket health monitoring (toast is rendered at App level)
23+
useWebSocketHealthToast();
24+
1925
return null;
2026
};
2127

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
/**
2+
* Tests for PerpsWebSocketHealthToast.context
3+
*/
4+
5+
import React from 'react';
6+
import { Text } from 'react-native';
7+
import { renderHook, act } from '@testing-library/react-hooks';
8+
import { render } from '@testing-library/react-native';
9+
import {
10+
WebSocketHealthToastProvider,
11+
useWebSocketHealthToastContext,
12+
WebSocketHealthToastContext,
13+
} from './PerpsWebSocketHealthToast.context';
14+
import { WebSocketConnectionState } from '../../controllers/types';
15+
16+
describe('PerpsWebSocketHealthToast.context', () => {
17+
describe('WebSocketHealthToastProvider', () => {
18+
it('renders children correctly', () => {
19+
const { getByText } = render(
20+
<WebSocketHealthToastProvider>
21+
<Text>Test Child</Text>
22+
</WebSocketHealthToastProvider>,
23+
);
24+
25+
expect(getByText('Test Child')).toBeTruthy();
26+
});
27+
});
28+
29+
describe('useWebSocketHealthToastContext', () => {
30+
const wrapper = ({ children }: { children: React.ReactNode }) => (
31+
<WebSocketHealthToastProvider>{children}</WebSocketHealthToastProvider>
32+
);
33+
34+
it('has correct initial state', () => {
35+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
36+
wrapper,
37+
});
38+
39+
expect(result.current.state).toEqual({
40+
isVisible: false,
41+
connectionState: WebSocketConnectionState.DISCONNECTED,
42+
reconnectionAttempt: 0,
43+
});
44+
});
45+
46+
describe('show()', () => {
47+
it('updates visibility and connection state', () => {
48+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
49+
wrapper,
50+
});
51+
52+
act(() => {
53+
result.current.show(WebSocketConnectionState.CONNECTING, 1);
54+
});
55+
56+
expect(result.current.state).toEqual({
57+
isVisible: true,
58+
connectionState: WebSocketConnectionState.CONNECTING,
59+
reconnectionAttempt: 1,
60+
});
61+
});
62+
63+
it('updates reconnection attempt number', () => {
64+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
65+
wrapper,
66+
});
67+
68+
act(() => {
69+
result.current.show(WebSocketConnectionState.CONNECTING, 5);
70+
});
71+
72+
expect(result.current.state.reconnectionAttempt).toBe(5);
73+
});
74+
75+
it('defaults reconnectionAttempt to 0 when not provided', () => {
76+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
77+
wrapper,
78+
});
79+
80+
act(() => {
81+
result.current.show(WebSocketConnectionState.CONNECTED);
82+
});
83+
84+
expect(result.current.state.reconnectionAttempt).toBe(0);
85+
});
86+
87+
it('handles DISCONNECTED state', () => {
88+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
89+
wrapper,
90+
});
91+
92+
act(() => {
93+
result.current.show(WebSocketConnectionState.DISCONNECTED, 3);
94+
});
95+
96+
expect(result.current.state).toEqual({
97+
isVisible: true,
98+
connectionState: WebSocketConnectionState.DISCONNECTED,
99+
reconnectionAttempt: 3,
100+
});
101+
});
102+
103+
it('handles CONNECTED state', () => {
104+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
105+
wrapper,
106+
});
107+
108+
act(() => {
109+
result.current.show(WebSocketConnectionState.CONNECTED, 0);
110+
});
111+
112+
expect(result.current.state).toEqual({
113+
isVisible: true,
114+
connectionState: WebSocketConnectionState.CONNECTED,
115+
reconnectionAttempt: 0,
116+
});
117+
});
118+
});
119+
120+
describe('hide()', () => {
121+
it('sets visibility to false', () => {
122+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
123+
wrapper,
124+
});
125+
126+
// First show the toast
127+
act(() => {
128+
result.current.show(WebSocketConnectionState.DISCONNECTED, 1);
129+
});
130+
131+
expect(result.current.state.isVisible).toBe(true);
132+
133+
// Then hide it
134+
act(() => {
135+
result.current.hide();
136+
});
137+
138+
expect(result.current.state.isVisible).toBe(false);
139+
});
140+
141+
it('preserves other state when hiding', () => {
142+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
143+
wrapper,
144+
});
145+
146+
// Show with specific state
147+
act(() => {
148+
result.current.show(WebSocketConnectionState.CONNECTING, 2);
149+
});
150+
151+
// Hide
152+
act(() => {
153+
result.current.hide();
154+
});
155+
156+
// Other state properties should be preserved
157+
expect(result.current.state.connectionState).toBe(
158+
WebSocketConnectionState.CONNECTING,
159+
);
160+
expect(result.current.state.reconnectionAttempt).toBe(2);
161+
expect(result.current.state.isVisible).toBe(false);
162+
});
163+
});
164+
165+
describe('setOnRetry()', () => {
166+
it('registers retry callback', () => {
167+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
168+
wrapper,
169+
});
170+
171+
const mockCallback = jest.fn();
172+
173+
act(() => {
174+
result.current.setOnRetry(mockCallback);
175+
});
176+
177+
expect(result.current.onRetry).toBe(mockCallback);
178+
});
179+
180+
it('allows onRetry callback to be called', () => {
181+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
182+
wrapper,
183+
});
184+
185+
const mockCallback = jest.fn();
186+
187+
act(() => {
188+
result.current.setOnRetry(mockCallback);
189+
});
190+
191+
// Call the registered callback
192+
act(() => {
193+
result.current.onRetry?.();
194+
});
195+
196+
expect(mockCallback).toHaveBeenCalled();
197+
});
198+
199+
it('allows updating the retry callback', () => {
200+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
201+
wrapper,
202+
});
203+
204+
const firstCallback = jest.fn();
205+
const secondCallback = jest.fn();
206+
207+
act(() => {
208+
result.current.setOnRetry(firstCallback);
209+
});
210+
211+
act(() => {
212+
result.current.setOnRetry(secondCallback);
213+
});
214+
215+
expect(result.current.onRetry).toBe(secondCallback);
216+
});
217+
});
218+
219+
describe('onRetry', () => {
220+
it('is undefined initially', () => {
221+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
222+
wrapper,
223+
});
224+
225+
expect(result.current.onRetry).toBeUndefined();
226+
});
227+
228+
it('is accessible from context after setOnRetry', () => {
229+
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
230+
wrapper,
231+
});
232+
233+
const mockCallback = jest.fn();
234+
235+
act(() => {
236+
result.current.setOnRetry(mockCallback);
237+
});
238+
239+
expect(result.current.onRetry).toBeDefined();
240+
expect(typeof result.current.onRetry).toBe('function');
241+
});
242+
});
243+
});
244+
245+
describe('Default context values', () => {
246+
it('has default noop functions in context', () => {
247+
// Test using context directly without provider
248+
const { result } = renderHook(() =>
249+
React.useContext(WebSocketHealthToastContext),
250+
);
251+
252+
// Default values should exist and not throw
253+
expect(result.current.state).toEqual({
254+
isVisible: false,
255+
connectionState: WebSocketConnectionState.DISCONNECTED,
256+
reconnectionAttempt: 0,
257+
});
258+
259+
// Default functions should be no-ops
260+
expect(() => {
261+
result.current.show(WebSocketConnectionState.CONNECTED);
262+
result.current.hide();
263+
result.current.setOnRetry(() => undefined);
264+
}).not.toThrow();
265+
266+
// onRetry should be undefined by default
267+
expect(result.current.onRetry).toBeUndefined();
268+
});
269+
});
270+
});

0 commit comments

Comments
 (0)