feat(MM-65145): low connectivity manager (#9283)

* feat(MM-65145): low connectivity manager
* forgot localization
* connection banner showing limited network connectivity
* network_performance_manager test
* adding some test related to network performance monitoring
* add comment about count-based sliding window
* component and hook tests
* clean up some code based on review.
* changes based on CP review
This commit is contained in:
Rahim Rahman 2025-12-04 10:44:33 -07:00 committed by GitHub
parent 51393f13f3
commit abe7a24be5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2084 additions and 91 deletions

View file

@ -6,6 +6,7 @@ import {DeviceEventEmitter} from 'react-native';
import LocalConfig from '@assets/config.json';
import {Events} from '@constants';
import NetworkPerformanceManager from '@managers/network_performance_manager';
import test_helper from '@test/test_helper';
import * as ClientConstants from './constants';
@ -62,7 +63,17 @@ jest.mock('@managers/performance_metrics_manager', () => ({
collectNetworkRequestData: jest.fn(),
}));
jest.mock('@managers/network_performance_manager', () => ({
__esModule: true,
default: {
startRequestTracking: jest.fn(() => 'mock-request-id-123'),
completeRequestTracking: jest.fn(),
cancelRequestTracking: jest.fn(),
},
}));
describe('ClientTracking', () => {
const mockedNPM = jest.mocked(NetworkPerformanceManager);
const apiClientMock = {
baseUrl: 'https://example.com',
get: jest.fn(),
@ -877,5 +888,73 @@ describe('ClientTracking', () => {
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined();
});
});
describe('Network Performance Tracking', () => {
const createMockMetrics = (overrides = {}) => ({
latency: 500,
size: 1000,
compressedSize: 500,
startTime: Date.now(),
endTime: Date.now() + 500,
speedInMbps: 1,
networkType: 'Wi-Fi',
tlsCipherSuite: 'none',
tlsVersion: 'none',
isCached: false,
httpVersion: 'h2',
connectionTime: 0,
...overrides,
});
const mockSuccessResponse = (metrics = createMockMetrics()) => ({
ok: true,
data: {success: true},
headers: {},
metrics,
});
const requestOptions = {
method: 'GET',
groupLabel: 'Cold Start' as RequestGroupLabel,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should call full tracking lifecycle', async () => {
const mockMetrics = createMockMetrics();
apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics));
await client.doFetchWithTracking('https://example.com/api', requestOptions);
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api');
expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123', mockMetrics);
});
it('should pass correct server URL to NetworkPerformanceManager', async () => {
const customBaseUrl = 'https://custom-server.com';
const customApiClient = {...apiClientMock, baseUrl: customBaseUrl};
const customClient = new ClientTracking(customApiClient as unknown as APIClientInterface);
const mockMetrics = createMockMetrics({latency: 300, size: 2000, compressedSize: 1000, speedInMbps: 2});
customApiClient.get.mockResolvedValue(mockSuccessResponse(mockMetrics));
await customClient.doFetchWithTracking('https://custom-server.com/api', requestOptions);
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith(customBaseUrl, 'https://custom-server.com/api');
expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith(customBaseUrl, 'mock-request-id-123', mockMetrics);
});
it('should call cancelRequestTracking when request fails', async () => {
apiClientMock.get.mockRejectedValue(new Error('Request failed'));
await expect(client.doFetchWithTracking('https://example.com/api', requestOptions)).rejects.toThrow('Received invalid response from the server.');
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api');
expect(mockedNPM.cancelRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123');
expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled();
});
});
});
/* eslint-enable max-lines */

View file

@ -7,6 +7,7 @@ import {DeviceEventEmitter, Platform} from 'react-native';
import {CollectNetworkMetrics} from '@assets/config.json';
import {Events} from '@constants';
import {setServerCredentials} from '@init/credentials';
import NetworkPerformanceManager from '@managers/network_performance_manager';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {NetworkRequestMetrics} from '@managers/performance_metrics_manager/constant';
import {isErrorWithStatusCode} from '@utils/errors';
@ -383,10 +384,13 @@ export default class ClientTracking {
this.incrementRequestCount(groupLabel);
}
const performanceRequestId = NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url);
let response: ClientResponse;
try {
response = await request!(url, this.buildRequestOptions(options));
} catch (error) {
NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, performanceRequestId);
const response_error = error as ClientError;
const status_code = isErrorWithStatusCode(error) ? error.status_code : undefined;
throw new ClientError(this.apiClient.baseUrl, {
@ -409,6 +413,7 @@ export default class ClientTracking {
if (groupLabel && CollectNetworkMetrics) {
this.trackRequest(groupLabel, url, response.metrics);
}
NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, performanceRequestId, response.metrics);
const serverVersion = semverFromServerVersion(
headers[ClientConstants.HEADER_X_VERSION_ID] || headers[ClientConstants.HEADER_X_VERSION_ID.toLowerCase()],
);

View file

@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithIntl, screen} from '@test/intl-test-helper';
import ConnectionBanner from './connection_banner';
import {useConnectionBanner} from './use_connection_banner';
jest.mock('./use_connection_banner');
jest.mock('@context/theme', () => ({
useTheme: () => ({
centerChannelBg: '#ffffff',
centerChannelColor: '#3d3c40',
onlineIndicator: '#06d6a0',
sidebarBg: '#2f3e4e',
}),
}));
jest.mock('@hooks/device', () => ({
useAppState: () => 'active',
}));
jest.mock('@react-native-community/netinfo', () => ({
useNetInfo: () => ({
type: 'wifi',
isConnected: true,
isInternetReachable: true,
}),
}));
describe('ConnectionBanner', () => {
const mockUseConnectionBanner = useConnectionBanner as jest.MockedFunction<typeof useConnectionBanner>;
beforeEach(() => {
jest.clearAllMocks();
});
it('should not render banner content when not visible', () => {
mockUseConnectionBanner.mockReturnValue({
visible: false,
bannerText: 'Test message',
isShowingConnectedBanner: false,
});
renderWithIntl(
<ConnectionBanner
websocketState='connected'
networkPerformanceState='normal'
/>,
);
expect(screen.queryByText('Test message')).toBeNull();
});
it('should render disconnection banner when visible', () => {
mockUseConnectionBanner.mockReturnValue({
visible: true,
bannerText: 'Unable to connect to network',
isShowingConnectedBanner: false,
});
renderWithIntl(
<ConnectionBanner
websocketState='not_connected'
networkPerformanceState='normal'
/>,
);
expect(screen.getByText('Unable to connect to network')).toBeTruthy();
});
it('should render connection restored banner when showing connected state', () => {
mockUseConnectionBanner.mockReturnValue({
visible: true,
bannerText: 'Connection restored',
isShowingConnectedBanner: true,
});
renderWithIntl(
<ConnectionBanner
websocketState='connected'
networkPerformanceState='normal'
/>,
);
expect(screen.getByText('Connection restored')).toBeTruthy();
});
it('should render slow network banner', () => {
mockUseConnectionBanner.mockReturnValue({
visible: true,
bannerText: 'Limited network connection',
isShowingConnectedBanner: false,
});
renderWithIntl(
<ConnectionBanner
websocketState='connected'
networkPerformanceState='slow'
/>,
);
expect(screen.getByText('Limited network connection')).toBeTruthy();
});
it('should render server not reachable banner', () => {
mockUseConnectionBanner.mockReturnValue({
visible: true,
bannerText: 'The server is not reachable',
isShowingConnectedBanner: false,
});
renderWithIntl(
<ConnectionBanner
websocketState='not_connected'
networkPerformanceState='normal'
/>,
);
expect(screen.getByText('The server is not reachable')).toBeTruthy();
});
});

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {useNetInfo} from '@react-native-community/netinfo';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import React, {useEffect} from 'react';
import {useIntl} from 'react-intl';
import {
Text,
@ -14,13 +14,16 @@ import CompassIcon from '@components/compass_icon';
import {ANNOUNCEMENT_BAR_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {useAppState} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {toMilliseconds} from '@utils/datetime';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {useConnectionBanner} from './use_connection_banner';
import type {NetworkPerformanceState} from '@managers/network_performance_manager';
type Props = {
websocketState: WebsocketConnectedState;
networkPerformanceState: NetworkPerformanceState;
}
const getStyle = makeStyleSheetFromTheme((theme: Theme) => {
@ -64,87 +67,24 @@ const getStyle = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const clearTimeoutRef = (ref: React.MutableRefObject<NodeJS.Timeout | null | undefined>) => {
if (ref.current) {
clearTimeout(ref.current);
ref.current = null;
}
};
const TIME_TO_OPEN = toMilliseconds({seconds: 3});
const TIME_TO_CLOSE = toMilliseconds({seconds: 1});
const ConnectionBanner = ({
websocketState,
networkPerformanceState,
}: Props) => {
const intl = useIntl();
const closeTimeout = useRef<NodeJS.Timeout | null>();
const openTimeout = useRef<NodeJS.Timeout | null>();
const height = useSharedValue(0);
const theme = useTheme();
const [visible, setVisible] = useState(false);
const style = getStyle(theme);
const appState = useAppState();
const netInfo = useNetInfo();
const height = useSharedValue(0);
const isConnected = websocketState === 'connected';
const openCallback = useCallback(() => {
setVisible(true);
clearTimeoutRef(openTimeout);
}, []);
const closeCallback = useCallback(() => {
setVisible(false);
clearTimeoutRef(closeTimeout);
}, []);
useEffect(() => {
if (websocketState === 'connecting') {
openCallback();
} else if (!isConnected) {
openTimeout.current = setTimeout(openCallback, TIME_TO_OPEN);
}
return () => {
clearTimeoutRef(openTimeout);
clearTimeoutRef(closeTimeout);
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- only run on mount
useDidUpdate(() => {
if (isConnected) {
if (visible) {
if (!closeTimeout.current) {
closeTimeout.current = setTimeout(closeCallback, TIME_TO_CLOSE);
}
} else {
clearTimeoutRef(openTimeout);
}
} else if (visible) {
clearTimeoutRef(closeTimeout);
} else if (appState === 'active') {
setVisible(true);
}
}, [isConnected]);
useDidUpdate(() => {
if (appState === 'active') {
if (!isConnected && !visible) {
if (!openTimeout.current) {
openTimeout.current = setTimeout(openCallback, TIME_TO_OPEN);
}
}
if (isConnected && visible) {
if (!closeTimeout.current) {
closeTimeout.current = setTimeout(closeCallback, TIME_TO_CLOSE);
}
}
} else {
setVisible(false);
clearTimeoutRef(openTimeout);
clearTimeoutRef(closeTimeout);
}
}, [appState === 'active']);
const {visible, bannerText, isShowingConnectedBanner} = useConnectionBanner({
websocketState,
networkPerformanceState,
netInfo,
appState,
intl,
});
useEffect(() => {
height.value = withTiming(visible ? ANNOUNCEMENT_BAR_HEIGHT : 0, {
@ -156,23 +96,12 @@ const ConnectionBanner = ({
height: height.value,
}));
let text;
if (isConnected) {
text = intl.formatMessage({id: 'connection_banner.connected', defaultMessage: 'Connection restored'});
} else if (websocketState === 'connecting') {
text = intl.formatMessage({id: 'connection_banner.connecting', defaultMessage: 'Connecting...'});
} else if (netInfo.isInternetReachable) {
text = intl.formatMessage({id: 'connection_banner.not_reachable', defaultMessage: 'The server is not reachable'});
} else {
text = intl.formatMessage({id: 'connection_banner.not_connected', defaultMessage: 'Unable to connect to network'});
}
return (
<Animated.View
style={[style.background, bannerStyle]}
>
<View
style={isConnected ? style.bannerContainerConnected : style.bannerContainerNotConnected}
style={isShowingConnectedBanner ? style.bannerContainerConnected : style.bannerContainerNotConnected}
>
{visible &&
<View
@ -185,12 +114,12 @@ const ConnectionBanner = ({
>
<CompassIcon
color={theme.centerChannelBg}
name={isConnected ? 'check' : 'information-outline'}
name={isShowingConnectedBanner ? 'check' : 'information-outline'}
size={18}
/>
{' '}
<Text style={style.bannerText}>
{text}
{bannerText}
</Text>
</Text>
</View>

View file

@ -4,12 +4,14 @@
import {withObservables} from '@nozbe/watermelondb/react';
import {withServerUrl} from '@context/server';
import NetworkPerformanceManager from '@managers/network_performance_manager';
import WebsocketManager from '@managers/websocket_manager';
import ConnectionBanner from './connection_banner';
const enhanced = withObservables(['serverUrl'], ({serverUrl}: {serverUrl: string}) => ({
websocketState: WebsocketManager.observeWebsocketState(serverUrl),
networkPerformanceState: NetworkPerformanceManager.observePerformanceState(serverUrl),
}));
export default withServerUrl(enhanced(ConnectionBanner));

View file

@ -0,0 +1,678 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook, act, waitFor} from '@testing-library/react-native';
import {useConnectionBanner} from './use_connection_banner';
import type {NetworkPerformanceState} from '@managers/network_performance_manager';
import type {NetInfoState} from '@react-native-community/netinfo';
import type {IntlShape} from 'react-intl';
const createMockIntl = (): IntlShape => ({
formatMessage: jest.fn(({defaultMessage}) => defaultMessage || ''),
formatDate: jest.fn(),
formatTime: jest.fn(),
formatNumber: jest.fn(),
formatPlural: jest.fn(),
formatList: jest.fn(),
formatDisplayName: jest.fn(),
} as unknown as IntlShape);
const createMockNetInfo = (isInternetReachable: boolean | null = true): NetInfoState => ({
type: 'wifi',
isConnected: true,
isInternetReachable,
details: {
ssid: 'test-network',
bssid: null,
strength: 100,
ipAddress: '192.168.1.1',
subnet: '255.255.255.0',
frequency: 2400,
linkSpeed: 100,
rxLinkSpeed: null,
txLinkSpeed: null,
isConnectionExpensive: false,
},
} as NetInfoState);
describe('useConnectionBanner', () => {
let mockIntl: IntlShape;
beforeEach(() => {
mockIntl = createMockIntl();
});
describe('initial session behavior', () => {
it('should not show disconnection banner during initial session', async () => {
const {result} = renderHook(() => useConnectionBanner({
websocketState: 'not_connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
}));
await waitFor(() => {
expect(result.current.visible).toBe(false);
expect(result.current.bannerText).toBe('');
});
});
it('should not show connecting banner during initial session', async () => {
const {result} = renderHook(() => useConnectionBanner({
websocketState: 'connecting' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
}));
await waitFor(() => {
expect(result.current.visible).toBe(false);
});
});
it('should show internet unreachable banner even during initial session', async () => {
const {result} = renderHook(() => useConnectionBanner({
websocketState: 'not_connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(false),
appState: 'active',
intl: mockIntl,
}));
await waitFor(() => {
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('The server is not reachable');
});
});
});
describe('after initial session (post-first-connection)', () => {
it('should show disconnection banner after initial connection is established', async () => {
const {result, rerender} = renderHook(
({websocketState, ...rest}) => useConnectionBanner({
websocketState,
...rest,
}),
{
initialProps: {
websocketState: 'connecting' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
},
},
);
// First, establish connection (ends initial session)
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
// Should not show "Connection restored" on initial connection
await waitFor(() => {
expect(result.current.visible).toBe(false);
});
// Now disconnect - should show banner
act(() => {
rerender({
websocketState: 'not_connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
await waitFor(() => {
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('Unable to connect to network');
});
});
it('should show connecting banner after initial session', async () => {
const {result, rerender} = renderHook(
(props) => useConnectionBanner(props),
{
initialProps: {
websocketState: 'not_connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
},
},
);
// First connect to end initial session
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
// Disconnect
act(() => {
rerender({
websocketState: 'not_connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
// Now go to connecting state - should show banner
act(() => {
rerender({
websocketState: 'connecting' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
await waitFor(() => {
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('Connecting...');
});
});
it('should show connection restored banner on reconnection', async () => {
const {result, rerender} = renderHook(
(props) => useConnectionBanner(props),
{
initialProps: {
websocketState: 'not_connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
},
},
);
// First connect to end initial session
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
await waitFor(() => {
expect(result.current.visible).toBe(false);
});
// Disconnect
act(() => {
rerender({
websocketState: 'not_connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
await waitFor(() => {
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('Unable to connect to network');
});
// Reconnect - should show "Connection restored"
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
await waitFor(() => {
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('Connection restored');
expect(result.current.isShowingConnectedBanner).toBe(true);
});
});
});
describe('slow network state', () => {
it('should show slow network banner when network is slow', async () => {
const {result} = renderHook(() => useConnectionBanner({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'slow' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
}));
await waitFor(() => {
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('Limited network connection');
});
});
it('should only show slow network banner once', () => {
jest.useFakeTimers();
const {result, rerender} = renderHook(
({networkPerformanceState, ...rest}) => useConnectionBanner({
networkPerformanceState,
...rest,
}),
{
initialProps: {
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'slow' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
},
},
);
expect(result.current.visible).toBe(true);
// Wait for auto-close
act(() => {
jest.advanceTimersByTime(2100);
});
expect(result.current.visible).toBe(false);
// Go to normal then back to slow
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'slow' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
// Should not show again
expect(result.current.visible).toBe(false);
jest.useRealTimers();
});
});
describe('banner priorities', () => {
it('should prioritize internet unreachable over disconnected', async () => {
const {result} = renderHook(() => useConnectionBanner({
websocketState: 'not_connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(false),
appState: 'active',
intl: mockIntl,
}));
await waitFor(() => {
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('The server is not reachable');
});
});
it('should not show other banners when one is already visible with timeout', async () => {
const {result, rerender} = renderHook(
(props) => useConnectionBanner(props),
{
initialProps: {
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(false),
appState: 'active',
intl: mockIntl,
},
},
);
await waitFor(() => {
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('The server is not reachable');
});
// Try to trigger slow network while banner is visible
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'slow' as NetworkPerformanceState,
netInfo: createMockNetInfo(false),
appState: 'active',
intl: mockIntl,
});
});
// Should still show internet unreachable
await waitFor(() => {
expect(result.current.bannerText).toBe('The server is not reachable');
});
});
});
describe('app state changes', () => {
it('should hide banner when app goes to background', async () => {
const {result, rerender} = renderHook(
({appState, ...rest}) => useConnectionBanner({
appState,
...rest,
}),
{
initialProps: {
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(false),
appState: 'active',
intl: mockIntl,
},
},
);
await waitFor(() => {
expect(result.current.visible).toBe(true);
});
// Go to background
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(false),
appState: 'background',
intl: mockIntl,
});
});
await waitFor(() => {
expect(result.current.visible).toBe(false);
expect(result.current.bannerText).toBe('');
});
});
it('should reset slow banner flag when app goes to background', async () => {
const {result, rerender} = renderHook(
({appState, networkPerformanceState, ...rest}) => useConnectionBanner({
appState,
networkPerformanceState,
...rest,
}),
{
initialProps: {
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'slow' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
},
},
);
await waitFor(() => {
expect(result.current.visible).toBe(true);
});
// Go to background
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'slow' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'background',
intl: mockIntl,
});
});
// Come back to active
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'slow' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
// Should show slow banner again (flag was reset)
await waitFor(() => {
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('Limited network connection');
});
});
it('should not show connection restored banner when returning from background if websocket stayed connected', async () => {
const {result, rerender} = renderHook(
({appState, websocketState, ...rest}) => useConnectionBanner({
appState,
websocketState,
...rest,
}),
{
initialProps: {
websocketState: 'not_connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
},
},
);
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
await waitFor(() => {
expect(result.current.visible).toBe(false);
});
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'background',
intl: mockIntl,
});
});
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
await waitFor(() => {
expect(result.current.visible).toBe(false);
expect(result.current.bannerText).toBe('');
expect(result.current.isShowingConnectedBanner).toBe(false);
});
});
});
describe('auto-close behavior', () => {
it('should auto-close internet unreachable banner after 2 seconds', () => {
jest.useFakeTimers();
const {result, rerender} = renderHook(
(props) => useConnectionBanner(props),
{
initialProps: {
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(false),
appState: 'active',
intl: mockIntl,
},
},
);
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('The server is not reachable');
// Internet becomes reachable again, then wait for timeout
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(true),
appState: 'active',
intl: mockIntl,
});
jest.advanceTimersByTime(2100);
});
expect(result.current.visible).toBe(false);
jest.useRealTimers();
});
it('should auto-close slow network banner after 2 seconds', () => {
jest.useFakeTimers();
const {result, rerender} = renderHook(
(props) => useConnectionBanner(props),
{
initialProps: {
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'slow' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
},
},
);
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('Limited network connection');
// Network becomes normal, then wait for timeout
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
jest.advanceTimersByTime(2100);
});
expect(result.current.visible).toBe(false);
jest.useRealTimers();
});
it('should auto-close connection restored banner after 2 seconds', () => {
jest.useFakeTimers();
const {result, rerender} = renderHook(
(props) => useConnectionBanner(props),
{
initialProps: {
websocketState: 'not_connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
},
},
);
// First connect to end initial session
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
expect(result.current.visible).toBe(false);
// Disconnect
act(() => {
rerender({
websocketState: 'not_connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('Unable to connect to network');
// Reconnect - should show "Connection restored"
act(() => {
rerender({
websocketState: 'connected' as WebsocketConnectedState,
networkPerformanceState: 'normal' as NetworkPerformanceState,
netInfo: createMockNetInfo(),
appState: 'active',
intl: mockIntl,
});
});
expect(result.current.visible).toBe(true);
expect(result.current.bannerText).toBe('Connection restored');
expect(result.current.isShowingConnectedBanner).toBe(true);
// Wait for auto-close
act(() => {
jest.advanceTimersByTime(2100);
});
expect(result.current.visible).toBe(false);
expect(result.current.isShowingConnectedBanner).toBe(false);
jest.useRealTimers();
});
});
});

View file

@ -0,0 +1,192 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useEffect, useRef, useState} from 'react';
import useDidUpdate from '@hooks/did_update';
import type {NetworkPerformanceState} from '@managers/network_performance_manager';
import type {NetInfoState} from '@react-native-community/netinfo';
import type {IntlShape} from 'react-intl';
const CLOSE_TIMEOUT_DURATION_MS = 2000;
const clearTimeoutRef = (ref: React.MutableRefObject<NodeJS.Timeout | null | undefined>) => {
if (ref.current) {
clearTimeout(ref.current);
ref.current = null;
}
};
type UseConnectionBannerParams = {
websocketState: WebsocketConnectedState;
networkPerformanceState: NetworkPerformanceState;
netInfo: NetInfoState;
appState: string;
intl: IntlShape;
};
type UseConnectionBannerReturn = {
visible: boolean;
bannerText: string;
isShowingConnectedBanner: boolean;
};
export const useConnectionBanner = ({
websocketState,
networkPerformanceState,
netInfo,
appState,
intl,
}: UseConnectionBannerParams): UseConnectionBannerReturn => {
const closeTimeout = useRef<NodeJS.Timeout | null>();
const openTimeout = useRef<NodeJS.Timeout | null>();
const initialAppSession = useRef(true);
const previousWebsocketState = useRef<WebsocketConnectedState>(websocketState);
const hasShownSlowBanner = useRef(false);
const [visible, setVisible] = useState(false);
const [bannerText, setBannerText] = useState('');
const [isShowingConnectedBanner, setIsShowingConnectedBanner] = useState(false);
const closeCallback = useCallback(() => {
setVisible(false);
clearTimeoutRef(closeTimeout);
}, []);
const openCallback = useCallback(() => {
clearTimeoutRef(closeTimeout);
clearTimeoutRef(openTimeout);
setVisible(true);
}, []);
const handleDisconnectedState = useCallback((): boolean => {
if (websocketState === 'not_connected') {
previousWebsocketState.current = 'not_connected';
if (!initialAppSession.current) {
setBannerText(intl.formatMessage({id: 'connection_banner.not_connected', defaultMessage: 'Unable to connect to network'}));
openCallback();
return true;
}
}
return false;
}, [websocketState, openCallback, intl]);
const handleInternetUnreachableState = useCallback((): boolean => {
if (netInfo.isInternetReachable === false) {
setBannerText(intl.formatMessage({id: 'connection_banner.not_reachable', defaultMessage: 'The server is not reachable'}));
openCallback();
closeTimeout.current = setTimeout(closeCallback, CLOSE_TIMEOUT_DURATION_MS);
return true;
}
return false;
}, [netInfo.isInternetReachable, intl, openCallback, closeCallback]);
const handleSlowNetworkState = useCallback((): boolean => {
if (networkPerformanceState === 'slow' && !hasShownSlowBanner.current) {
hasShownSlowBanner.current = true;
setBannerText(intl.formatMessage({id: 'connection_banner.slow', defaultMessage: 'Limited network connection'}));
openCallback();
closeTimeout.current = setTimeout(() => {
closeCallback();
}, CLOSE_TIMEOUT_DURATION_MS);
return true;
}
return false;
}, [networkPerformanceState, intl, openCallback, closeCallback]);
const handleConnectedState = useCallback((): boolean => {
if (websocketState === 'connected' && previousWebsocketState.current !== 'connected') {
previousWebsocketState.current = 'connected';
if (!initialAppSession.current && !isShowingConnectedBanner) {
setIsShowingConnectedBanner(true);
setBannerText(intl.formatMessage({id: 'connection_banner.connected', defaultMessage: 'Connection restored'}));
openCallback();
closeTimeout.current = setTimeout(() => {
closeCallback();
setIsShowingConnectedBanner(false);
}, CLOSE_TIMEOUT_DURATION_MS);
return true;
}
initialAppSession.current = false;
return true;
}
return false;
}, [websocketState, intl, openCallback, closeCallback, isShowingConnectedBanner]);
const handleConnectingState = useCallback((): boolean => {
if (websocketState === 'connecting') {
if (!initialAppSession.current) {
setBannerText(intl.formatMessage({id: 'connection_banner.connecting', defaultMessage: 'Connecting...'}));
openCallback();
return true;
}
previousWebsocketState.current = 'connecting';
}
return false;
}, [websocketState, intl, openCallback]);
useEffect(() => {
return () => {
clearTimeoutRef(closeTimeout);
clearTimeoutRef(openTimeout);
};
}, []);
useEffect(() => {
if (appState !== 'active') {
return;
}
if (visible && closeTimeout.current) {
return;
}
const priorities = () => {
if (handleInternetUnreachableState()) {
return;
}
if (handleDisconnectedState()) {
return;
}
if (handleSlowNetworkState()) {
return;
}
if (handleConnectedState()) {
return;
}
handleConnectingState();
};
priorities();
}, [
handleInternetUnreachableState,
handleDisconnectedState,
handleSlowNetworkState,
handleConnectedState,
handleConnectingState,
visible,
appState,
]);
useDidUpdate(() => {
if (appState !== 'active') {
setVisible(false);
setBannerText('');
clearTimeoutRef(openTimeout);
clearTimeoutRef(closeTimeout);
hasShownSlowBanner.current = false;
setIsShowingConnectedBanner(false);
}
}, [appState]);
return {
visible,
bannerText,
isShowingConnectedBanner,
};
};

View file

@ -62,7 +62,7 @@ class NetworkManagerSingleton {
waitsForConnectivity: false,
httpMaximumConnectionsPerHost: 100,
cancelRequestsOnUnauthorized: true,
collectMetrics: false,
collectMetrics: true,
},
retryPolicyConfiguration: {
type: RetryTypes.EXPONENTIAL_RETRY,
@ -139,7 +139,7 @@ class NetworkManagerSingleton {
timeoutIntervalForRequest: managedConfig?.timeout ? parseInt(managedConfig.timeout, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForRequest,
timeoutIntervalForResource: managedConfig?.timeoutVPN ? parseInt(managedConfig.timeoutVPN, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForResource,
waitsForConnectivity: managedConfig?.useVPN === 'true',
collectMetrics: LocalConfig.CollectNetworkMetrics,
collectMetrics: true,
},
headers,
};

View file

@ -0,0 +1,651 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {testExports} from './network_performance_manager';
import type {ClientResponseMetrics} from '@mattermost/react-native-network-client';
import type {AppStateStatus} from 'react-native';
jest.mock('@utils/log', () => ({
logDebug: jest.fn(),
}));
jest.mock('react-native', () => ({
AppState: {
addEventListener: jest.fn(() => ({
remove: jest.fn(),
})),
},
}));
const {
NetworkPerformanceManagerSingleton,
REQUEST_OUTCOME_WINDOW_SIZE,
MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
calculatePerformanceStateFromOutcomes,
} = testExports;
const createMockMetrics = (latency: number, size: number, compressedSize: number): ClientResponseMetrics => ({
latency,
size,
compressedSize,
startTime: Date.now(),
endTime: Date.now() + latency,
networkType: 'wifi',
tlsCipherSuite: 'TLS_AES_256_GCM_SHA384',
tlsVersion: 'TLSv1.3',
httpVersion: 'HTTP/2',
isCached: false,
connectionTime: 0,
speedInMbps: 0,
});
describe('Pure Functions', () => {
describe('calculatePerformanceStateFromOutcomes', () => {
describe('initial detection', () => {
it('should return normal when not enough requests for initial detection', () => {
const outcomes = [
{timestamp: Date.now(), isSlow: true, wasEarlyDetection: false},
{timestamp: Date.now(), isSlow: true, wasEarlyDetection: false},
];
const state = calculatePerformanceStateFromOutcomes(outcomes, true);
expect(state).toBe('normal');
});
it('should return slow when initial detection threshold is met', () => {
const outcomes = Array.from({length: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION}, (_, i) => ({
timestamp: Date.now(),
isSlow: i < 3, // 3 out of 4 = 75%
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, true);
expect(state).toBe('slow');
});
it('should return normal when initial detection threshold is met but percentage is low', () => {
const outcomes = Array.from({length: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION}, (_, i) => ({
timestamp: Date.now(),
isSlow: i < 2, // 2 out of 4 = 50%
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, true);
expect(state).toBe('normal');
});
});
describe('subsequent detection', () => {
it('should return slow even with fewer requests than subsequent threshold', () => {
const outcomes = Array.from({length: 6}, (_, i) => ({
timestamp: Date.now(),
isSlow: i < 5, // 5 out of 6 = 83%
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
expect(state).toBe('slow');
});
it('should return normal when slow percentage is below threshold', () => {
const outcomes = Array.from({length: 10}, (_, i) => ({
timestamp: Date.now(),
isSlow: i < 3, // 3 out of 10 = 30%
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
expect(state).toBe('normal');
});
it('should return slow when slow percentage meets threshold', () => {
const outcomes = Array.from({length: 10}, (_, i) => ({
timestamp: Date.now(),
isSlow: i < 7, // 7 out of 10 = 70%
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
expect(state).toBe('slow');
});
it('should return slow when slow percentage exceeds threshold', () => {
const outcomes = Array.from({length: 10}, (_, i) => ({
timestamp: Date.now(),
isSlow: i < 8, // 8 out of 10 = 80%
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
expect(state).toBe('slow');
});
});
});
});
describe('NetworkPerformanceManager', () => {
let performanceManager: InstanceType<typeof NetworkPerformanceManagerSingleton>;
const serverUrl = 'https://test-server.com';
beforeEach(() => {
performanceManager = new NetworkPerformanceManagerSingleton();
});
describe('request tracking lifecycle', () => {
it('should start and complete request tracking', () => {
performanceManager.observePerformanceState(serverUrl);
const url = '/api/v4/users/me';
const metrics = createMockMetrics(500, 1000, 500);
const requestId = performanceManager.startRequestTracking(serverUrl, url);
expect(typeof requestId).toBe('string');
expect(requestId).toContain('-');
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(1);
expect(stats.slowRequests).toBe(0);
});
it('should track slow requests correctly', () => {
performanceManager.observePerformanceState(serverUrl);
const url = '/api/v4/posts';
const slowMetrics = createMockMetrics(3000, 1000, 500);
const requestId = performanceManager.startRequestTracking(serverUrl, url);
performanceManager.completeRequestTracking(serverUrl, requestId, slowMetrics);
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(1);
expect(stats.slowRequests).toBe(1);
expect(stats.slowPercentage).toBe(1.0);
});
it('should cancel request tracking on failure', () => {
const url = '/api/v4/teams';
const requestId = performanceManager.startRequestTracking(serverUrl, url);
performanceManager.cancelRequestTracking(serverUrl, requestId);
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(0);
});
});
describe('performance state calculation', () => {
it('should return normal when slow percentage is below threshold', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
states.push(state);
});
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
subscription.unsubscribe();
expect(states).toContain('normal');
});
it('should return slow when slow percentage meets threshold', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
states.push(state);
});
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 7 ? 3000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
subscription.unsubscribe();
expect(states).toContain('slow');
});
it('should use initial detection threshold for first slow detection', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
states.push(state);
});
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500); // 3 out of 4 = 75%
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
subscription.unsubscribe();
expect(states).toEqual(['normal', 'slow']);
});
it('should not return to normal when switching from initial to subsequent detection', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
states.push(state);
});
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
for (let i = 0; i < 4; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-extra-${i}`);
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500); // 3 more slow out of 4
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
subscription.unsubscribe();
expect(states.filter((s) => s === 'normal')).toHaveLength(1);
expect(states).toEqual(['normal', 'slow']);
});
});
describe('outcome statistics', () => {
it('should provide accurate request outcome statistics', () => {
performanceManager.observePerformanceState(serverUrl);
for (let i = 0; i < 20; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 15 ? 3000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(20);
expect(stats.slowRequests).toBe(15);
expect(stats.slowPercentage).toBe(0.75);
expect(stats.earlyDetectionCount).toBe(0);
});
it('should limit outcome window size', () => {
performanceManager.observePerformanceState(serverUrl);
for (let i = 0; i < REQUEST_OUTCOME_WINDOW_SIZE + 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(REQUEST_OUTCOME_WINDOW_SIZE);
});
});
describe('server management', () => {
it('should handle multiple servers independently', () => {
const serverUrl2 = 'https://test-server-2.com';
performanceManager.observePerformanceState(serverUrl);
performanceManager.observePerformanceState(serverUrl2);
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl2, `/api/request-${i}`);
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl2, requestId, metrics);
}
const state1 = performanceManager.getCurrentPerformanceState(serverUrl);
const state2 = performanceManager.getCurrentPerformanceState(serverUrl2);
expect(state1).toBe('normal');
expect(state2).toBe('slow');
performanceManager.removeServer(serverUrl2);
});
it('should clean up when server is removed', () => {
const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test');
const metrics = createMockMetrics(500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
performanceManager.removeServer(serverUrl);
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(0);
});
it('should not recreate storage when request completes after server removal', () => {
performanceManager.observePerformanceState(serverUrl);
const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test');
performanceManager.removeServer(serverUrl);
const metrics = createMockMetrics(3000, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(0);
const state = performanceManager.getCurrentPerformanceState(serverUrl);
expect(state).toBe('normal');
});
it('should not recreate storage when early detection timer fires after server removal', () => {
jest.useFakeTimers();
performanceManager.observePerformanceState(serverUrl);
performanceManager.startRequestTracking(serverUrl, '/api/test');
performanceManager.removeServer(serverUrl);
jest.advanceTimersByTime(2000);
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(0);
const state = performanceManager.getCurrentPerformanceState(serverUrl);
expect(state).toBe('normal');
jest.useRealTimers();
});
});
describe('observable behavior', () => {
it('should emit state changes when performance changes', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
states.push(state);
});
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
subscription.unsubscribe();
expect(states).toEqual(['normal', 'slow']);
});
});
describe('debug logging', () => {
const {logDebug} = require('@utils/log');
beforeEach(() => {
logDebug.mockClear();
});
it('should log when performance state degrades from normal to slow', () => {
performanceManager.observePerformanceState(serverUrl);
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
expect(logDebug).toHaveBeenCalledWith(
`Network performance degraded for ${serverUrl}: normal -> slow`,
expect.objectContaining({
totalRequests: expect.any(Number),
slowRequests: expect.any(Number),
slowPercentage: expect.any(String),
earlyDetectionCount: expect.any(Number),
lastOutcome: expect.objectContaining({
isSlow: expect.any(Boolean),
wasEarlyDetection: expect.any(Boolean),
}),
}),
);
});
it('should not log when performance state remains the same', () => {
for (let i = 0; i < 3; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
expect(logDebug).not.toHaveBeenCalled();
});
it('should log with correct details when performance degrades', () => {
performanceManager.observePerformanceState(serverUrl);
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(3000, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
expect(logDebug).toHaveBeenCalledWith(
`Network performance degraded for ${serverUrl}: normal -> slow`,
expect.objectContaining({
totalRequests: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
slowRequests: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
slowPercentage: '100.0%',
earlyDetectionCount: 0,
currentTimestamp: expect.any(Number),
detectionDelayMs: expect.any(Number),
detectionDelaySeconds: expect.any(String),
lastOutcome: {
isSlow: true,
wasEarlyDetection: false,
},
}),
);
});
it('should not log when performance recovers from slow to normal', () => {
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(3000, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
logDebug.mockClear();
for (let i = 0; i < 20; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/fast-${i}`);
const metrics = createMockMetrics(500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
expect(logDebug).not.toHaveBeenCalled();
});
});
describe('early detection', () => {
it('should detect slow request early when timer fires before completion', () => {
jest.useFakeTimers();
performanceManager.observePerformanceState(serverUrl);
// Start requests that will be slow
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
performanceManager.startRequestTracking(serverUrl, `/api/slow-${i}`);
}
// Advance time past the slow request threshold
jest.advanceTimersByTime(2000);
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(MINIMUM_REQUESTS_FOR_INITIAL_DETECTION);
expect(stats.slowRequests).toBe(MINIMUM_REQUESTS_FOR_INITIAL_DETECTION);
expect(stats.earlyDetectionCount).toBe(MINIMUM_REQUESTS_FOR_INITIAL_DETECTION);
expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(0);
jest.useRealTimers();
});
it('should not record outcome when completing request that was already early detected', () => {
jest.useFakeTimers();
performanceManager.observePerformanceState(serverUrl);
const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test');
// Advance time to trigger early detection
jest.advanceTimersByTime(2000);
// Now complete the request (simulate it finally finishing)
const metrics = createMockMetrics(2500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
// Should still only have 1 outcome (from early detection, not from completion)
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(1);
expect(stats.earlyDetectionCount).toBe(1);
jest.useRealTimers();
});
it('should handle early detection in performance state calculation', () => {
jest.useFakeTimers();
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
states.push(state);
});
// Start enough slow requests to trigger initial detection
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
performanceManager.startRequestTracking(serverUrl, `/api/early-${i}`);
}
// Trigger early detection
jest.advanceTimersByTime(2000);
expect(states).toContain('slow');
subscription.unsubscribe();
jest.useRealTimers();
});
});
describe('metrics handling', () => {
it('should not record outcome when metrics is undefined', () => {
performanceManager.observePerformanceState(serverUrl);
const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test');
performanceManager.completeRequestTracking(serverUrl, requestId, undefined);
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(0);
});
it('should not record outcome when metrics.latency is undefined', () => {
performanceManager.observePerformanceState(serverUrl);
const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test');
const metricsWithoutLatency = {
...createMockMetrics(500, 1000, 500),
latency: undefined as unknown as number,
};
performanceManager.completeRequestTracking(serverUrl, requestId, metricsWithoutLatency);
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(0);
});
});
describe('destroy method', () => {
it('should clean up multiple servers with active requests', () => {
const serverUrl2 = 'https://test-server-2.com';
performanceManager.observePerformanceState(serverUrl);
performanceManager.observePerformanceState(serverUrl2);
performanceManager.startRequestTracking(serverUrl, '/api/test1');
performanceManager.startRequestTracking(serverUrl2, '/api/test2');
expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1);
expect(performanceManager.getActiveRequestCount(serverUrl2)).toBe(1);
performanceManager.destroy();
expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(0);
expect(performanceManager.getActiveRequestCount(serverUrl2)).toBe(0);
});
});
describe('AppState monitoring', () => {
const {AppState} = require('react-native');
let appStateHandler: (nextAppState: AppStateStatus) => void;
beforeEach(() => {
AppState.addEventListener.mockImplementation((event: string, handler: (nextAppState: AppStateStatus) => void) => {
if (event === 'change') {
appStateHandler = handler;
}
return {remove: jest.fn()};
});
});
it('should setup AppState monitoring on construction', () => {
const manager = new NetworkPerformanceManagerSingleton();
expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
manager.destroy();
});
it('should clean up active requests when app goes to background', () => {
performanceManager.startRequestTracking(serverUrl, '/api/test');
expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1);
appStateHandler('background');
expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(0);
});
it('should reset performance state to normal when app becomes inactive', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
states.push(state);
});
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
expect(performanceManager.getCurrentPerformanceState(serverUrl)).toBe('slow');
appStateHandler('inactive');
expect(performanceManager.getCurrentPerformanceState(serverUrl)).toBe('normal');
subscription.unsubscribe();
});
it('should not clean up when app state is active', () => {
const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test');
expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1);
appStateHandler('active');
expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1);
performanceManager.cancelRequestTracking(serverUrl, requestId);
});
it('should clean up AppState subscription on destroy', () => {
const mockRemove = jest.fn();
AppState.addEventListener.mockReturnValue({remove: mockRemove});
const manager = new NetworkPerformanceManagerSingleton();
manager.destroy();
expect(mockRemove).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,330 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppState, type AppStateStatus, type NativeEventSubscription} from 'react-native';
import {BehaviorSubject} from 'rxjs';
import {distinctUntilChanged} from 'rxjs/operators';
import {logDebug} from '@utils/log';
import type {ClientResponseMetrics} from '@mattermost/react-native-network-client';
export type NetworkPerformanceState = 'normal' | 'slow';
interface ActiveRequest {
id: string;
url: string;
startTime: number;
checkTimer?: NodeJS.Timeout;
}
interface RequestOutcome {
timestamp: number;
isSlow: boolean;
wasEarlyDetection: boolean;
}
const SLOW_REQUEST_THRESHOLD = 2000;
const SLOW_REQUEST_PERCENTAGE_THRESHOLD = 0.7;
// We use a count-based sliding window instead of time-based filtering to prevent
// flip-flopping between states. With the 70% threshold and 10 minimum requests,
// the network needs 7/10 new requests to be fast/slow to change state, regardless
// of how old previous requests are. This provides stable state transitions.
const REQUEST_OUTCOME_WINDOW_SIZE = 20;
const MINIMUM_REQUESTS_FOR_INITIAL_DETECTION = 4;
const MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION = 10;
const calculatePerformanceStateFromOutcomes = (outcomes: RequestOutcome[], isInitialDetection: boolean): NetworkPerformanceState => {
const minimumRequests = isInitialDetection ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION;
if (isInitialDetection && outcomes.length < minimumRequests) {
return 'normal';
}
const slowRequestCount = outcomes.filter((outcome) => outcome.isSlow).length;
const slowPercentage = slowRequestCount / outcomes.length;
return slowPercentage >= SLOW_REQUEST_PERCENTAGE_THRESHOLD ? 'slow' : 'normal';
};
class NetworkPerformanceManagerSingleton {
private performanceSubjects: Record<string, BehaviorSubject<NetworkPerformanceState>> = {};
private activeRequests: Record<string, Record<string, ActiveRequest>> = {};
private requestOutcomes: Record<string, RequestOutcome[]> = {};
private initialRequestTimestamp: Record<string, number> = {};
private isInitialDetection: Record<string, boolean> = {};
private appStateSubscription: NativeEventSubscription | null = null;
constructor() {
this.setupAppStateMonitoring();
}
/**
* Starts tracking a request for early performance detection.
* Returns a unique request ID that should be used when the request completes.
*/
public startRequestTracking = (serverUrl: string, url: string): string => {
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
if (!this.activeRequests[serverUrl]) {
this.activeRequests[serverUrl] = {};
}
const request: ActiveRequest = {
id: requestId,
url,
startTime: Date.now(),
};
request.checkTimer = setTimeout(() => {
this.checkRequestLatency(serverUrl, requestId);
}, SLOW_REQUEST_THRESHOLD);
this.activeRequests[serverUrl][requestId] = request;
return requestId;
};
/**
* Completes request tracking and adds metrics for performance reporting.
* Should be called when the request finishes with the ID from startRequestTracking.
*/
public completeRequestTracking = (serverUrl: string, requestId: string, metrics: ClientResponseMetrics | undefined) => {
const activeRequest = this.activeRequests[serverUrl]?.[requestId];
const wasEarlyDetected = !activeRequest; // If not found, it was already early detected and removed
this.clearActiveRequest(serverUrl, requestId);
// Only record the outcome if it wasn't already recorded by early detection
if (!wasEarlyDetected && metrics?.latency) {
this.recordRequestOutcome(serverUrl, {
timestamp: Date.now(),
isSlow: metrics.latency >= SLOW_REQUEST_THRESHOLD,
wasEarlyDetection: false,
});
}
};
/**
* Cancels request tracking when a request fails.
* Should be called when the request fails with the ID from startRequestTracking.
*/
public cancelRequestTracking = (serverUrl: string, requestId: string) => {
this.clearActiveRequest(serverUrl, requestId);
};
/**
* Returns an observable that emits network performance state changes.
* Emits 'normal' or 'slow' based on current performance metrics.
*/
public observePerformanceState = (serverUrl: string) => {
return this.getPerformanceSubject(serverUrl).asObservable().pipe(
distinctUntilChanged(),
);
};
/**
* Gets the current network performance state for a server.
*/
public getCurrentPerformanceState = (serverUrl: string): NetworkPerformanceState => {
return this.getPerformanceSubject(serverUrl).getValue();
};
/**
* Gets the current request outcome statistics for a server.
*/
public getRequestOutcomeStats = (serverUrl: string) => {
const outcomes = this.requestOutcomes[serverUrl] || [];
if (!outcomes.length) {
return {
totalRequests: 0,
slowRequests: 0,
slowPercentage: 0,
earlyDetectionCount: 0,
};
}
const slowRequests = outcomes.filter((outcome) => outcome.isSlow).length;
const earlyDetectionCount = outcomes.filter((outcome) => outcome.wasEarlyDetection).length;
return {
totalRequests: outcomes.length,
slowRequests,
slowPercentage: slowRequests / outcomes.length,
earlyDetectionCount,
};
};
/**
* Gets the count of active requests for a server (for testing purposes).
*/
public getActiveRequestCount = (serverUrl: string): number => {
return Object.keys(this.activeRequests[serverUrl] || {}).length;
};
/**
* Removes all data and subscriptions for a server.
*/
public removeServer = (serverUrl: string) => {
this.clearAllActiveRequests(serverUrl);
if (this.performanceSubjects[serverUrl]) {
this.performanceSubjects[serverUrl].complete();
delete this.performanceSubjects[serverUrl];
}
delete this.requestOutcomes[serverUrl];
delete this.initialRequestTimestamp[serverUrl];
delete this.isInitialDetection[serverUrl];
};
/**
* Cleans up all resources when the manager is being destroyed.
*/
public destroy = () => {
if (this.appStateSubscription) {
this.appStateSubscription.remove();
this.appStateSubscription = null;
}
Object.keys(this.activeRequests).forEach((serverUrl) => {
this.clearAllActiveRequests(serverUrl);
});
Object.keys(this.performanceSubjects).forEach((serverUrl) => {
this.performanceSubjects[serverUrl].complete();
});
this.performanceSubjects = {};
this.requestOutcomes = {};
this.initialRequestTimestamp = {};
this.isInitialDetection = {};
};
private checkRequestLatency = (serverUrl: string, requestId: string) => {
const activeRequest = this.activeRequests[serverUrl]?.[requestId];
if (!activeRequest) {
return;
}
const currentTime = Date.now();
const elapsedTime = currentTime - activeRequest.startTime;
if (elapsedTime >= SLOW_REQUEST_THRESHOLD) {
this.recordRequestOutcome(serverUrl, {
timestamp: currentTime,
isSlow: true,
wasEarlyDetection: true,
});
this.clearActiveRequest(serverUrl, requestId);
}
};
private clearActiveRequest = (serverUrl: string, requestId: string) => {
const activeRequest = this.activeRequests[serverUrl]?.[requestId];
if (activeRequest?.checkTimer) {
clearTimeout(activeRequest.checkTimer);
}
if (this.activeRequests[serverUrl]) {
delete this.activeRequests[serverUrl][requestId];
}
};
private clearAllActiveRequests = (serverUrl: string) => {
const requests = this.activeRequests[serverUrl];
if (requests) {
Object.values(requests).forEach((request) => {
if (request.checkTimer) {
clearTimeout(request.checkTimer);
}
});
delete this.activeRequests[serverUrl];
}
};
private recordRequestOutcome = (serverUrl: string, outcome: RequestOutcome) => {
if (!this.performanceSubjects[serverUrl]) {
return;
}
if (!this.requestOutcomes[serverUrl]) {
this.requestOutcomes[serverUrl] = [];
}
if (!this.initialRequestTimestamp[serverUrl] && outcome.isSlow) {
this.initialRequestTimestamp[serverUrl] = outcome.timestamp;
}
this.requestOutcomes[serverUrl].push(outcome);
if (this.requestOutcomes[serverUrl].length > REQUEST_OUTCOME_WINDOW_SIZE) {
this.requestOutcomes[serverUrl] = this.requestOutcomes[serverUrl].slice(-REQUEST_OUTCOME_WINDOW_SIZE);
}
const outcomes = this.requestOutcomes[serverUrl];
const currentPerformanceState = this.getCurrentPerformanceState(serverUrl);
const isInitialDetection = !this.isInitialDetection[serverUrl];
const newPerformanceState = calculatePerformanceStateFromOutcomes(outcomes, isInitialDetection);
if (currentPerformanceState !== newPerformanceState && newPerformanceState === 'slow') {
const stats = this.getRequestOutcomeStats(serverUrl);
const detectionDelayMs = outcome.timestamp - this.initialRequestTimestamp[serverUrl];
logDebug(`Network performance degraded for ${serverUrl}: ${currentPerformanceState} -> ${newPerformanceState}`, {
totalRequests: stats.totalRequests,
slowRequests: stats.slowRequests,
slowPercentage: `${(stats.slowPercentage * 100).toFixed(1)}%`,
earlyDetectionCount: stats.earlyDetectionCount,
currentTimestamp: Date.now(),
detectionDelayMs,
detectionDelaySeconds: `${(detectionDelayMs / 1000).toFixed(1)}s`,
lastOutcome: {
isSlow: outcome.isSlow,
wasEarlyDetection: outcome.wasEarlyDetection,
},
});
this.isInitialDetection[serverUrl] = true;
delete this.initialRequestTimestamp[serverUrl];
}
this.getPerformanceSubject(serverUrl).next(newPerformanceState);
};
private getPerformanceSubject = (serverUrl: string) => {
if (!this.performanceSubjects[serverUrl]) {
this.performanceSubjects[serverUrl] = new BehaviorSubject<NetworkPerformanceState>('normal');
}
return this.performanceSubjects[serverUrl];
};
private setupAppStateMonitoring = () => {
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange);
};
private handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState !== 'active') {
this.cleanupOnAppStateChange();
}
};
private cleanupOnAppStateChange = () => {
Object.keys(this.activeRequests).forEach((serverUrl) => {
this.clearAllActiveRequests(serverUrl);
});
Object.keys(this.performanceSubjects).forEach((serverUrl) => {
this.performanceSubjects[serverUrl].next('normal');
});
};
}
export const testExports = {
NetworkPerformanceManagerSingleton,
SLOW_REQUEST_THRESHOLD,
REQUEST_OUTCOME_WINDOW_SIZE,
MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION,
calculatePerformanceStateFromOutcomes,
};
const NetworkPerformanceManager = new NetworkPerformanceManagerSingleton();
export default NetworkPerformanceManager;

View file

@ -274,6 +274,7 @@
"connection_banner.connecting": "Connecting...",
"connection_banner.not_connected": "Unable to connect to network",
"connection_banner.not_reachable": "The server is not reachable",
"connection_banner.slow": "Limited network connection",
"create_direct_message.title": "Create Direct Message",
"create_post.deactivated": "You are viewing an archived channel with a deactivated user.",
"create_post.thread_reply": "Reply to this thread...",