Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,49 @@ describe('PerpsWebSocketHealthToast.context', () => {
expect(result.current.state.reconnectionAttempt).toBe(2);
expect(result.current.state.isVisible).toBe(false);
});

it('when hide({ userDismissed: true }), subsequent Disconnected is suppressed but Connecting and Connected show again', () => {
const { result } = renderHook(() => useWebSocketHealthToastContext(), {
wrapper,
});

act(() => {
result.current.show(WebSocketConnectionState.Disconnected, 1);
});
expect(result.current.state.isVisible).toBe(true);

act(() => {
result.current.hide({ userDismissed: true });
});
expect(result.current.state.isVisible).toBe(false);

// Showing Disconnected again should not show (user dismissed offline)
act(() => {
result.current.show(WebSocketConnectionState.Disconnected, 2);
});
expect(result.current.state.isVisible).toBe(false);

// Showing Connecting should show (user sees reconnection progress)
act(() => {
result.current.show(WebSocketConnectionState.Connecting, 3);
});
expect(result.current.state.isVisible).toBe(true);

// Showing Connected shows "online" toast and clears userDismissed
act(() => {
result.current.show(WebSocketConnectionState.Connected, 0);
});
expect(result.current.state.isVisible).toBe(true);

act(() => {
result.current.hide();
});
// Next Disconnected will show again (userDismissed was cleared)
act(() => {
result.current.show(WebSocketConnectionState.Disconnected, 4);
});
expect(result.current.state.isVisible).toBe(true);
});
});

describe('setOnRetry()', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
} from 'react';
import { WebSocketConnectionState } from '../../controllers/types';

/** No-op function for context defaults */
Expand All @@ -13,6 +19,12 @@ export interface WebSocketHealthToastState {
reconnectionAttempt: number;
}

/** Options for hiding the toast (e.g. user swipe dismiss) */
export interface WebSocketHealthToastHideOptions {
/** When true, toast will not be shown again until connection is restored (Connected state) */
userDismissed?: boolean;
}

/**
* Context params for controlling the WebSocket health toast.
*/
Expand All @@ -22,7 +34,7 @@ export interface WebSocketHealthToastContextParams {
connectionState: WebSocketConnectionState,
reconnectionAttempt?: number,
) => void;
hide: () => void;
hide: (options?: WebSocketHealthToastHideOptions) => void;
onRetry?: () => void;
setOnRetry: (callback: () => void) => void;
}
Expand Down Expand Up @@ -50,33 +62,73 @@ export const WebSocketHealthToastProvider: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const [state, setState] = useState<WebSocketHealthToastState>(defaultState);
const [onRetry, setOnRetryCallback] = useState<(() => void) | undefined>(
undefined,
);
const [userDismissed, setUserDismissed] = useState(false);
const [onRetryCallback, setOnRetryCallback] = useState<
(() => void) | undefined
>(undefined);

const show = useCallback(
(connectionState: WebSocketConnectionState, reconnectionAttempt = 0) => {
const isConnected =
connectionState === WebSocketConnectionState.Connected;
const isConnecting =
connectionState === WebSocketConnectionState.Connecting;

// When connection is restored, always show "online" toast and clear dismiss state
// (handled first so we never skip due to stale userDismissed closure)
if (isConnected) {
setUserDismissed(false);
setState({
isVisible: true,
connectionState,
reconnectionAttempt,
});
return;
}

// When reconnecting, clear userDismissed so "connecting" and later "online" toasts can show
if (isConnecting) {
setUserDismissed(false);
}

// Don't show Disconnected if user previously dismissed (until connection is restoring/restored).
// Connecting is always shown so user sees progress after having dismissed "offline".
if (userDismissed && !isConnecting) {
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dismissal resets too early on reconnect

Medium Severity

show() clears userDismissed on WebSocketConnectionState.Connecting, which can allow a previously dismissed offline banner to reappear if the connection flaps Disconnected → Connecting → Disconnected without ever reaching Connected. This contradicts the intended “stay dismissed until restored” behavior.

Fix in Cursor Fix in Web

setState({
isVisible: true,
connectionState,
reconnectionAttempt,
});
},
[],
[userDismissed],
);

const hide = useCallback(() => {
const hide = useCallback((options?: WebSocketHealthToastHideOptions) => {
if (options?.userDismissed) {
setUserDismissed(true);
}
setState((prev) => ({ ...prev, isVisible: false }));
}, []);

const setOnRetry = useCallback((callback: () => void) => {
setOnRetryCallback(() => callback);
}, []);

const contextValue = useMemo(
() => ({
state,
show,
hide,
onRetry: onRetryCallback,
setOnRetry,
}),
[state, show, hide, onRetryCallback, setOnRetry],
);

return (
<WebSocketHealthToastContext.Provider
value={{ state, show, hide, onRetry, setOnRetry }}
>
<WebSocketHealthToastContext.Provider value={contextValue}>
{children}
</WebSocketHealthToastContext.Provider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,12 @@ const styleSheet = (params: { theme: Theme }) => {
right: 12,
zIndex: 9999,
},
// Inner toast content
toast: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 12,
paddingHorizontal: 16,
// Wrapper with default background (close wrap: same edges, radius)
toastWrapper: {
borderRadius: 12,
backgroundColor: colors.background.default,
padding: 2,
overflow: 'hidden',
// Shadow for elevation
shadowColor: colors.shadow.default,
shadowOffset: {
Expand All @@ -32,6 +29,16 @@ const styleSheet = (params: { theme: Theme }) => {
shadowRadius: 8,
elevation: 8,
},
// Inner toast content (muted background)
toast: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 10,
backgroundColor: colors.background.muted,
},
// Icon container
iconContainer: {
width: 32,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { render, fireEvent, waitFor, act } from '@testing-library/react-native';
import PerpsWebSocketHealthToast from './PerpsWebSocketHealthToast';
import { WebSocketConnectionState } from '../../controllers/types';
import { PerpsWebSocketHealthToastSelectorsIDs } from '../../Perps.testIds';
Expand Down Expand Up @@ -248,8 +248,10 @@ describe('PerpsWebSocketHealthToast', () => {

render(<PerpsWebSocketHealthToast />);

// Fast-forward time
jest.advanceTimersByTime(3000);
// Fast-forward time (wrap in act so Animated callbacks flush)
await act(async () => {
jest.advanceTimersByTime(3000);
});

expect(mockHide).toHaveBeenCalled();
});
Expand Down
Loading
Loading