Skip to content

Commit 9358e41

Browse files
mofojedmattrunyon
andauthored
fix: DH-14657 Better disconnect handling (#1261)
- In some cases, the API disconnects and the reconnects almost immediately afterwards, causing the "Reconnecting..." modal to appear and be distracting - Rather than have the API lie about whether it's connected or not, UI debounces when it displays the modal, just blocking interaction with a transparent modal for the duration of the disconnection if it reconnects immediately - Also added logging so we can see when connected/disconnected, in case this is further observed - Tested using the steps in #1149 , and also manually setting the `debounceMs` to `5000` to be able to react to it --------- Co-authored-by: Matthew Runyon <mattrunyonstuff@gmail.com>
1 parent f440eb9 commit 9358e41

8 files changed

Lines changed: 272 additions & 11 deletions

File tree

packages/code-studio/src/main/AppMainContainer.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
InfoModal,
2323
LoadingSpinner,
2424
BasicModal,
25+
DebouncedModal,
2526
} from '@deephaven/components';
2627
import {
2728
IrisGridModel,
@@ -549,14 +550,17 @@ export class AppMainContainer extends Component<
549550
}
550551

551552
handleDisconnect() {
553+
log.info('Disconnected from server');
552554
this.setState({ isDisconnected: true });
553555
}
554556

555557
handleReconnect() {
558+
log.info('Reconnected to server');
556559
this.setState({ isDisconnected: false });
557560
}
558561

559562
handleReconnectAuthFailed() {
563+
log.warn('Reconnect authentication failed');
560564
this.setState({ isAuthFailed: true });
561565
}
562566

@@ -948,16 +952,20 @@ export class AppMainContainer extends Component<
948952
style={{ display: 'none' }}
949953
onChange={this.handleImportLayoutFiles}
950954
/>
951-
<InfoModal
955+
<DebouncedModal
952956
isOpen={isDisconnected && !isAuthFailed}
953-
icon={vsDebugDisconnect}
954-
title={
955-
<>
956-
<LoadingSpinner /> Attempting to reconnect...
957-
</>
958-
}
959-
subtitle="Please check your network connection."
960-
/>
957+
debounceMs={250}
958+
>
959+
<InfoModal
960+
icon={vsDebugDisconnect}
961+
title={
962+
<>
963+
<LoadingSpinner /> Attempting to reconnect...
964+
</>
965+
}
966+
subtitle="Please check your network connection."
967+
/>
968+
</DebouncedModal>
961969
<BasicModal
962970
confirmButtonText="Refresh"
963971
onConfirm={AppMainContainer.handleRefresh}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React from 'react';
2+
import { act, render, screen } from '@testing-library/react';
3+
import DebouncedModal from './DebouncedModal';
4+
import Modal from './Modal';
5+
6+
const mockChildText = 'Mock Child';
7+
const children = (
8+
<Modal>
9+
<div>{mockChildText}</div>
10+
</Modal>
11+
);
12+
const DEFAULT_DEBOUNCE_MS = 250;
13+
14+
beforeAll(() => {
15+
jest.useFakeTimers();
16+
});
17+
18+
afterAll(() => {
19+
jest.useRealTimers();
20+
});
21+
22+
describe('display modal after debounce', () => {
23+
it('should render the modal after the debounce time has passed', () => {
24+
const { rerender } = render(
25+
<DebouncedModal isOpen={false} debounceMs={DEFAULT_DEBOUNCE_MS}>
26+
{children}
27+
</DebouncedModal>
28+
);
29+
expect(
30+
screen.queryByTestId('debounced-modal-backdrop')
31+
).not.toBeInTheDocument();
32+
expect(screen.queryByText(mockChildText)).not.toBeInTheDocument();
33+
34+
act(() => {
35+
rerender(
36+
<DebouncedModal isOpen debounceMs={DEFAULT_DEBOUNCE_MS}>
37+
{children}
38+
</DebouncedModal>
39+
);
40+
});
41+
expect(
42+
screen.queryByTestId('debounced-modal-backdrop')
43+
).toBeInTheDocument();
44+
expect(screen.queryByText(mockChildText)).not.toBeInTheDocument();
45+
46+
act(() => {
47+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
48+
});
49+
expect(
50+
screen.queryByTestId('debounced-modal-backdrop')
51+
).toBeInTheDocument();
52+
expect(screen.queryByText(mockChildText)).toBeInTheDocument();
53+
});
54+
55+
it('should not block interaction if set to false', () => {
56+
const { rerender } = render(
57+
<DebouncedModal
58+
isOpen={false}
59+
blockInteraction={false}
60+
debounceMs={DEFAULT_DEBOUNCE_MS}
61+
>
62+
{children}
63+
</DebouncedModal>
64+
);
65+
expect(
66+
screen.queryByTestId('debounced-modal-backdrop')
67+
).not.toBeInTheDocument();
68+
expect(screen.queryByText(mockChildText)).not.toBeInTheDocument();
69+
70+
act(() => {
71+
rerender(
72+
<DebouncedModal
73+
isOpen
74+
blockInteraction={false}
75+
debounceMs={DEFAULT_DEBOUNCE_MS}
76+
>
77+
{children}
78+
</DebouncedModal>
79+
);
80+
});
81+
expect(
82+
screen.queryByTestId('debounced-modal-backdrop')
83+
).not.toBeInTheDocument();
84+
expect(screen.queryByText(mockChildText)).not.toBeInTheDocument();
85+
86+
act(() => {
87+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS + 5);
88+
});
89+
expect(
90+
screen.queryByTestId('debounced-modal-backdrop')
91+
).not.toBeInTheDocument();
92+
expect(screen.queryByText(mockChildText)).toBeInTheDocument();
93+
});
94+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import React from 'react';
2+
import { useDebouncedValue } from '@deephaven/react-hooks';
3+
4+
export type DebouncedModalProps = {
5+
/** Whether to block interaction immediately */
6+
blockInteraction?: boolean;
7+
8+
/** Children to render after the alloted debounce time */
9+
children: React.ReactElement;
10+
11+
/** Time to debounce */
12+
debounceMs: number;
13+
14+
/**
15+
* Will render the `children` `debounceMs` after `isOpen` is set to `true.
16+
* Will stop rendering immediately after `isOpen` is set to `false`.
17+
*/
18+
isOpen?: boolean;
19+
};
20+
21+
/**
22+
* Display a modal after a debounce time. Blocks the screen from interaction immediately,
23+
* but then waits the set debounce time before rendering the modal.
24+
*/
25+
function DebouncedModal({
26+
blockInteraction = true,
27+
children,
28+
debounceMs,
29+
isOpen = false,
30+
}: DebouncedModalProps) {
31+
const debouncedIsOpen = useDebouncedValue(isOpen, debounceMs);
32+
33+
return (
34+
<>
35+
{blockInteraction && isOpen && (
36+
<div
37+
className="modal-backdrop"
38+
style={{ backgroundColor: 'transparent' }}
39+
data-testid="debounced-modal-backdrop"
40+
/>
41+
)}
42+
{React.cloneElement(children, { isOpen: isOpen && debouncedIsOpen })}
43+
</>
44+
);
45+
}
46+
47+
export default DebouncedModal;

packages/components/src/modal/InfoModal.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,30 @@ import ModalBody from './ModalBody';
66
import './InfoModal.scss';
77

88
type InfoModalProps = {
9+
/** Class name to give the info modal */
910
className?: string;
11+
12+
/** Icon to display in the modal */
1013
icon?: IconProp;
14+
15+
/** Title to display in the modal */
1116
title: React.ReactNode;
17+
18+
/** Subtitle/detail to display in the modal */
1219
subtitle?: React.ReactNode;
13-
isOpen: boolean;
20+
21+
/** Whether the modal is open/visible or not. */
22+
isOpen?: boolean;
1423
};
1524

25+
/**
26+
* A modal that displays a message with an icon. Can be used for informational messages, warnings, or errors.
27+
* Does not have any buttons and cannot be dismissed.
28+
*/
1629
function InfoModal({
1730
className,
1831
icon,
19-
isOpen,
32+
isOpen = false,
2033
subtitle,
2134
title,
2235
}: InfoModalProps): JSX.Element {

packages/components/src/modal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { default as DebouncedModal } from './DebouncedModal';
12
export { default as InfoModal } from './InfoModal';
23
export { default as Modal } from './Modal';
34
export { default as ModalBody } from './ModalBody';

packages/react-hooks/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export type {
77
UsePromiseFactoryOptions,
88
UsePromiseFactoryResult,
99
} from './usePromiseFactory';
10+
export * from './useDebouncedValue';
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { act, renderHook } from '@testing-library/react-hooks';
2+
import useDebouncedValue from './useDebouncedValue';
3+
4+
const DEFAULT_DEBOUNCE_MS = 100;
5+
beforeEach(() => {
6+
jest.useFakeTimers();
7+
});
8+
9+
afterAll(() => {
10+
jest.useRealTimers();
11+
});
12+
13+
it('should return the initial value', () => {
14+
const value = 'mock value';
15+
const { result } = renderHook(() =>
16+
useDebouncedValue(value, DEFAULT_DEBOUNCE_MS)
17+
);
18+
expect(result.current).toBe(value);
19+
});
20+
21+
it('should return the initial value after the debounce time has elapsed', () => {
22+
const value = 'mock value';
23+
const { result, rerender } = renderHook(() =>
24+
useDebouncedValue(value, DEFAULT_DEBOUNCE_MS)
25+
);
26+
expect(result.current).toBe(value);
27+
expect(result.all.length).toBe(1);
28+
rerender();
29+
act(() => {
30+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
31+
});
32+
expect(result.current).toBe(value);
33+
expect(result.all.length).toBe(2);
34+
});
35+
36+
it('should return the updated value after the debounce time has elapsed', () => {
37+
const value = 'mock value';
38+
const newValue = 'mock new value';
39+
const { result, rerender } = renderHook((val = value) =>
40+
useDebouncedValue(val, DEFAULT_DEBOUNCE_MS)
41+
);
42+
expect(result.current).toBe(value);
43+
rerender(newValue);
44+
act(() => {
45+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
46+
});
47+
expect(result.current).toBe(newValue);
48+
});
49+
50+
it('should not return an intermediate value if the debounce time has not elapsed', () => {
51+
const value = 'mock value';
52+
const intermediateValue = 'mock intermediate value';
53+
const newValue = 'mock new value';
54+
const { result, rerender } = renderHook((val = value) =>
55+
useDebouncedValue(val, DEFAULT_DEBOUNCE_MS)
56+
);
57+
expect(result.current).toBe(value);
58+
rerender(intermediateValue);
59+
act(() => {
60+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS - 5);
61+
});
62+
expect(result.current).toBe(value);
63+
rerender(newValue);
64+
act(() => {
65+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS - 5);
66+
});
67+
expect(result.current).toBe(value);
68+
act(() => {
69+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
70+
});
71+
expect(result.current).toBe(newValue);
72+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useEffect, useState } from 'react';
2+
3+
/**
4+
* Debounces a value.
5+
* Returns the initial value immediately.
6+
* Returns the latest value after no changes have occurred for the debounce duration.
7+
* @param value Value to debounce
8+
* @param debounceMs Amount of time to debounce
9+
* @returns The debounced value
10+
*/
11+
export function useDebouncedValue<T>(value: T, debounceMs: number) {
12+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
13+
useEffect(() => {
14+
const timeoutId = setTimeout(() => {
15+
setDebouncedValue(value);
16+
}, debounceMs);
17+
return () => {
18+
clearTimeout(timeoutId);
19+
};
20+
}, [value, debounceMs]);
21+
22+
return debouncedValue;
23+
}
24+
25+
export default useDebouncedValue;

0 commit comments

Comments
 (0)