Skip to content

Commit 47f9a57

Browse files
authored
fix: Typing in notebooks is laggy (#1977)
- We were checking the diff of the layouts on each keystroke - DashboardLayout should debounce checking that diff - Tested by displaying FPS counter, having a Code Studio with a bunch of tables in it, adding a notebook, and then typing in it. FPS is much higher after the change
1 parent 2d12ea3 commit 47f9a57

6 files changed

Lines changed: 215 additions & 22 deletions

File tree

packages/dashboard/src/DashboardLayout.tsx

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type {
1414
ReactComponentConfig,
1515
} from '@deephaven/golden-layout';
1616
import Log from '@deephaven/log';
17-
import { usePrevious } from '@deephaven/react-hooks';
17+
import { usePrevious, useThrottledCallback } from '@deephaven/react-hooks';
1818
import { RootState } from '@deephaven/redux';
1919
import { useDispatch, useSelector } from 'react-redux';
2020
import PanelManager, { ClosedPanels } from './PanelManager';
@@ -46,6 +46,8 @@ const DEFAULT_LAYOUT_CONFIG: DashboardLayoutConfig = [];
4646

4747
const DEFAULT_CALLBACK = (): void => undefined;
4848

49+
const STATE_CHANGE_THROTTLE_MS = 1000;
50+
4951
// If a component isn't registered, just pass through the props so they are saved if a plugin is loaded later
5052
const FALLBACK_CALLBACK = (props: unknown): unknown => props;
5153

@@ -195,6 +197,34 @@ export function DashboardLayout({
195197
]
196198
);
197199

200+
// Throttle the calls so that we don't flood comparing these layouts
201+
const throttledProcessDehydratedLayoutConfig = useThrottledCallback(
202+
(dehydratedLayoutConfig: DashboardLayoutConfig) => {
203+
const hasChanged =
204+
lastConfig == null ||
205+
!LayoutUtils.isEqual(lastConfig, dehydratedLayoutConfig);
206+
207+
log.debug('handleLayoutStateChanged', hasChanged, dehydratedLayoutConfig);
208+
209+
if (hasChanged) {
210+
setIsDashboardEmpty(layout.root.contentItems.length === 0);
211+
212+
setLastConfig(dehydratedLayoutConfig);
213+
214+
onLayoutChange(dehydratedLayoutConfig);
215+
216+
setLayoutChildren(layout.getReactChildren());
217+
}
218+
},
219+
STATE_CHANGE_THROTTLE_MS,
220+
{ flushOnUnmount: true }
221+
);
222+
223+
useEffect(
224+
() => () => throttledProcessDehydratedLayoutConfig.flush(),
225+
[throttledProcessDehydratedLayoutConfig]
226+
);
227+
198228
const handleLayoutStateChanged = useCallback(() => {
199229
// we don't want to emit stateChanges that happen during item drags or else
200230
// we risk the last saved state being one without that panel in the layout entirely
@@ -206,27 +236,13 @@ export function DashboardLayout({
206236
contentConfig,
207237
dehydrateComponent
208238
);
209-
const hasChanged =
210-
lastConfig == null ||
211-
!LayoutUtils.isEqual(lastConfig, dehydratedLayoutConfig);
212-
213-
log.debug(
214-
'handleLayoutStateChanged',
215-
hasChanged,
216-
contentConfig,
217-
dehydratedLayoutConfig
218-
);
219-
220-
if (hasChanged) {
221-
setIsDashboardEmpty(layout.root.contentItems.length === 0);
222-
223-
setLastConfig(dehydratedLayoutConfig);
224-
225-
onLayoutChange(dehydratedLayoutConfig);
226-
227-
setLayoutChildren(layout.getReactChildren());
228-
}
229-
}, [dehydrateComponent, isItemDragging, lastConfig, layout, onLayoutChange]);
239+
throttledProcessDehydratedLayoutConfig(dehydratedLayoutConfig);
240+
}, [
241+
dehydrateComponent,
242+
isItemDragging,
243+
layout,
244+
throttledProcessDehydratedLayoutConfig,
245+
]);
230246

231247
const handleLayoutItemPickedUp = useCallback(
232248
(component: Container) => {

packages/react-hooks/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@deephaven/log": "file:../log",
2626
"@deephaven/utils": "file:../utils",
2727
"lodash.debounce": "^4.0.8",
28+
"lodash.throttle": "^4.1.1",
2829
"shortid": "^2.2.16"
2930
},
3031
"peerDependencies": {

packages/react-hooks/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './useCheckOverflow';
77
export * from './useContentRect';
88
export { default as useContextOrThrow } from './useContextOrThrow';
99
export * from './useDebouncedCallback';
10+
export * from './useThrottledCallback';
1011
export * from './useDelay';
1112
export * from './useDependentState';
1213
export * from './useEffectNTimesWhen';

packages/react-hooks/src/useDebouncedCallback.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ beforeEach(() => {
1010
jest.useFakeTimers();
1111
});
1212

13+
afterEach(() => {
14+
jest.useRealTimers();
15+
});
16+
1317
it('should debounce a given callback', () => {
1418
const { result } = renderHook(() =>
1519
useDebouncedCallback(callback, debounceMs)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import useThrottledCallback from './useThrottledCallback';
3+
4+
const callback = jest.fn((text: string) => undefined);
5+
const arg = 'mock.arg';
6+
const arg2 = 'mock.arg2';
7+
const throttleMs = 400;
8+
9+
beforeEach(() => {
10+
jest.clearAllMocks();
11+
jest.useFakeTimers();
12+
});
13+
14+
afterEach(() => {
15+
jest.useRealTimers();
16+
});
17+
18+
it('should throttle a given callback', () => {
19+
const { result } = renderHook(() =>
20+
useThrottledCallback(callback, throttleMs)
21+
);
22+
23+
result.current(arg);
24+
result.current(arg);
25+
26+
jest.advanceTimersByTime(5);
27+
28+
result.current(arg);
29+
30+
result.current(arg2);
31+
32+
expect(callback).toHaveBeenCalledTimes(1);
33+
expect(callback).toHaveBeenCalledWith(arg);
34+
35+
jest.clearAllMocks();
36+
37+
jest.advanceTimersByTime(throttleMs);
38+
expect(callback).toHaveBeenCalledTimes(1);
39+
expect(callback).toHaveBeenCalledWith(arg2);
40+
});
41+
42+
it('should cancel throttle if component unmounts', () => {
43+
const { result, unmount } = renderHook(() =>
44+
useThrottledCallback(callback, throttleMs)
45+
);
46+
47+
result.current(arg);
48+
result.current(arg2);
49+
50+
jest.advanceTimersByTime(throttleMs - 1);
51+
52+
expect(callback).toHaveBeenCalledTimes(1);
53+
expect(callback).toHaveBeenCalledWith(arg);
54+
callback.mockClear();
55+
56+
jest.spyOn(result.current, 'cancel');
57+
58+
unmount();
59+
60+
expect(result.current.cancel).toHaveBeenCalled();
61+
62+
jest.advanceTimersByTime(5);
63+
64+
expect(callback).not.toHaveBeenCalled();
65+
});
66+
67+
it('should call the updated callback if the ref changes', () => {
68+
const { rerender, result } = renderHook(
69+
fn => useThrottledCallback(fn, throttleMs),
70+
{
71+
initialProps: callback,
72+
}
73+
);
74+
75+
result.current(arg);
76+
result.current(arg2);
77+
78+
// Leading is always called
79+
expect(callback).toHaveBeenCalledTimes(1);
80+
expect(callback).toHaveBeenCalledWith(arg);
81+
callback.mockClear();
82+
83+
jest.advanceTimersByTime(throttleMs - 1);
84+
85+
const newCallback = jest.fn();
86+
rerender(newCallback);
87+
88+
jest.advanceTimersByTime(1);
89+
90+
expect(callback).not.toHaveBeenCalled();
91+
expect(newCallback).toHaveBeenCalledTimes(1);
92+
expect(newCallback).toHaveBeenCalledWith(arg2);
93+
});
94+
95+
it('should flush on unmount if that option is set', () => {
96+
const { result, unmount } = renderHook(() =>
97+
useThrottledCallback(callback, throttleMs, { flushOnUnmount: true })
98+
);
99+
100+
result.current(arg);
101+
result.current(arg2);
102+
103+
jest.advanceTimersByTime(throttleMs - 1);
104+
105+
expect(callback).toHaveBeenCalledTimes(1);
106+
expect(callback).toHaveBeenCalledWith(arg);
107+
callback.mockClear();
108+
109+
jest.spyOn(result.current, 'flush');
110+
111+
unmount();
112+
113+
expect(result.current.flush).toHaveBeenCalled();
114+
115+
jest.advanceTimersByTime(1);
116+
117+
expect(callback).toHaveBeenCalledTimes(1);
118+
expect(callback).toHaveBeenCalledWith(arg2);
119+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useEffect, useMemo, useRef } from 'react';
2+
import type { DebouncedFunc, ThrottleSettings } from 'lodash';
3+
import throttle from 'lodash.throttle';
4+
5+
/**
6+
* Wraps a given callback in a cancelable, throttled function. The throttled
7+
* callback is stable and will never change. It will be automatically cancelled
8+
* on unmount, unless the `flushOnUnmount` option is passed in, then it will be flushed on unmount.
9+
* At the time the throttled function is called, it will call the latest callback that has been passed in.
10+
* @param callback callback function to call with throttling
11+
* @param throttleMs throttle milliseconds
12+
* @param initialOptions lodash throttle options. Will not react to changes to this param
13+
* @returns a cancelable, throttled function
14+
*/
15+
export function useThrottledCallback<TArgs extends unknown[], TResult>(
16+
callback: (...args: TArgs) => TResult,
17+
throttleMs: number,
18+
initialOptions?: ThrottleSettings & { flushOnUnmount?: boolean }
19+
): DebouncedFunc<(...args: TArgs) => TResult> {
20+
const options = useRef(initialOptions);
21+
22+
// Use a ref for the callback
23+
// We want to keep a stable callback so the flush/cancel works as expected
24+
// So we keep a ref to the current callback, then we have a throttled callback that will just call this
25+
const callbackRef = useRef(callback);
26+
callbackRef.current = callback;
27+
28+
const throttledCallback = useMemo(
29+
() =>
30+
throttle(
31+
(...args: TArgs) => callbackRef.current(...args),
32+
throttleMs,
33+
options.current
34+
),
35+
[throttleMs]
36+
);
37+
38+
useEffect(
39+
() => () => {
40+
if (options.current?.flushOnUnmount ?? false) {
41+
throttledCallback.flush();
42+
} else {
43+
throttledCallback.cancel();
44+
}
45+
},
46+
[throttledCallback]
47+
);
48+
49+
return throttledCallback;
50+
}
51+
52+
export default useThrottledCallback;

0 commit comments

Comments
 (0)