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:
parent
51393f13f3
commit
abe7a24be5
11 changed files with 2084 additions and 91 deletions
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
);
|
||||
|
|
|
|||
126
app/components/connection_banner/connection_banner.test.tsx
Normal file
126
app/components/connection_banner/connection_banner.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
678
app/components/connection_banner/use_connection_banner.test.ts
Normal file
678
app/components/connection_banner/use_connection_banner.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
192
app/components/connection_banner/use_connection_banner.ts
Normal file
192
app/components/connection_banner/use_connection_banner.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
651
app/managers/network_performance_manager.test.ts
Normal file
651
app/managers/network_performance_manager.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
330
app/managers/network_performance_manager.ts
Normal file
330
app/managers/network_performance_manager.ts
Normal 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;
|
||||
|
|
@ -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...",
|
||||
|
|
|
|||
Loading…
Reference in a new issue