Skip to content

Commit 8429fee

Browse files
committed
fix: cleanup websocket connections, add debounce
1 parent 3936a8b commit 8429fee

File tree

3 files changed

+137
-37
lines changed

3 files changed

+137
-37
lines changed

site/src/DynamicForm.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Input } from "./components/Input/Input";
1212
import { Switch } from "./components/Switch/Switch";
1313
import { useUsers } from './hooks/useUsers';
1414
import { useDirectories } from './hooks/useDirectories';
15+
import { useDebouncedFunction } from './hooks/debounce';
1516

1617
export function DynamicForm() {
1718
const serverAddress = "localhost:8100";
@@ -45,14 +46,18 @@ export function DynamicForm() {
4546
} = useDirectories(serverAddress, urlTestdata);
4647

4748
const handleTestdataChange = (value: string) => {
49+
reset({});
50+
setPrevValues({});
51+
setResponse(null);
52+
setCurrentId(0);
53+
4854
const params = new URLSearchParams(window.location.search);
4955
params.set('testdata', value);
5056
const newUrl = `${window.location.pathname}?${params.toString()}`;
5157
window.history.replaceState({}, '', newUrl);
5258
setUrlTestdata(value);
5359
setPlan("");
5460
setUser("");
55-
setResponse(null);
5661
};
5762

5863
const {
@@ -234,14 +239,21 @@ export function DynamicForm() {
234239
<Controller
235240
name={param.name}
236241
control={methods.control}
237-
render={({ field }) => (
238-
<Input
239-
onChange={field.onChange}
240-
className="w-[300px]"
241-
type={mapParamTypeToInputType(param.type)}
242-
defaultValue={param.default_value}
243-
/>
244-
)}
242+
render={({ field }) => {
243+
const { debounced } = useDebouncedFunction(
244+
(e: React.ChangeEvent<HTMLInputElement>) => field.onChange(e),
245+
1000
246+
);
247+
248+
return (
249+
<Input
250+
onChange={debounced}
251+
className="w-[300px]"
252+
type={mapParamTypeToInputType(param.type)}
253+
defaultValue={param.default_value}
254+
/>
255+
);
256+
}}
245257
/>
246258
{renderDiagnostics(param.diagnostics)}
247259
</div>

site/src/hooks/debounce.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* @file Defines hooks for created debounced versions of functions and arbitrary
3+
* values.
4+
*
5+
* It is not safe to call a general-purpose debounce utility inside a React
6+
* render. It will work on the initial render, but the memory reference for the
7+
* value will change on re-renders. Most debounce functions create a "stateful"
8+
* version of a function by leveraging closure; but by calling it repeatedly,
9+
* you create multiple "pockets" of state, rather than a centralized one.
10+
*
11+
* Debounce utilities can make sense if they can be called directly outside the
12+
* component or in a useEffect call, though.
13+
*/
14+
import { useCallback, useEffect, useRef, useState } from "react";
15+
16+
type useDebouncedFunctionReturn<Args extends unknown[]> = Readonly<{
17+
debounced: (...args: Args) => void;
18+
19+
// Mainly here to make interfacing with useEffect cleanup functions easier
20+
cancelDebounce: () => void;
21+
}>;
22+
23+
/**
24+
* Creates a debounce function that is resilient to React re-renders, as well as
25+
* a function for canceling a pending debounce.
26+
*
27+
* The returned-out functions will maintain the same memory references, but the
28+
* debounce function will always "see" the most recent versions of the arguments
29+
* passed into the hook, and use them accordingly.
30+
*
31+
* If the debounce time changes while a callback has been queued to fire, the
32+
* callback will be canceled completely. You will need to restart the debounce
33+
* process by calling the returned-out function again.
34+
*/
35+
export function useDebouncedFunction<
36+
// Parameterizing on the args instead of the whole callback function type to
37+
// avoid type contra-variance issues
38+
Args extends unknown[] = unknown[],
39+
>(
40+
callback: (...args: Args) => void | Promise<void>,
41+
debounceTimeMs: number,
42+
): useDebouncedFunctionReturn<Args> {
43+
const timeoutIdRef = useRef<number | null>(null);
44+
const cancelDebounce = useCallback(() => {
45+
if (timeoutIdRef.current !== null) {
46+
window.clearTimeout(timeoutIdRef.current);
47+
}
48+
49+
timeoutIdRef.current = null;
50+
}, []);
51+
52+
const debounceTimeRef = useRef(debounceTimeMs);
53+
useEffect(() => {
54+
cancelDebounce();
55+
debounceTimeRef.current = debounceTimeMs;
56+
}, [cancelDebounce, debounceTimeMs]);
57+
58+
const callbackRef = useRef(callback);
59+
useEffect(() => {
60+
callbackRef.current = callback;
61+
}, [callback]);
62+
63+
// Returned-out function will always be synchronous, even if the callback arg
64+
// is async. Seemed dicey to try awaiting a genericized operation that can and
65+
// will likely be canceled repeatedly
66+
const debounced = useCallback(
67+
(...args: Args): void => {
68+
cancelDebounce();
69+
70+
timeoutIdRef.current = window.setTimeout(
71+
() => void callbackRef.current(...args),
72+
debounceTimeRef.current,
73+
);
74+
},
75+
[cancelDebounce],
76+
);
77+
78+
return { debounced, cancelDebounce } as const;
79+
}
80+
81+
/**
82+
* Takes any value, and returns out a debounced version of it.
83+
*/
84+
export function useDebouncedValue<T = unknown>(
85+
value: T,
86+
debounceTimeMs: number,
87+
): T {
88+
const [debouncedValue, setDebouncedValue] = useState(value);
89+
90+
useEffect(() => {
91+
const timeoutId = window.setTimeout(() => {
92+
setDebouncedValue(value);
93+
}, debounceTimeMs);
94+
95+
return () => window.clearTimeout(timeoutId);
96+
}, [value, debounceTimeMs]);
97+
98+
return debouncedValue;
99+
}

site/src/useWebSocket.ts

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
// useWebSocket.ts
22
import { useEffect, useRef, useState, useCallback } from "react";
33

4-
export function useWebSocket<T>(url: string, testdata: string, reconnectDelay = 3000) {
4+
export function useWebSocket<T>(url: string, testdata: string) {
55
const [message, setMessage] = useState<T | null>(null);
66
const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
77
const wsRef = useRef<WebSocket | null>(null);
8-
const reconnectTimeoutRef = useRef<number | null>(null);
98
const urlRef = useRef(url);
109

11-
// Update the URL ref when the URL changes
12-
useEffect(() => {
13-
urlRef.current = url;
14-
}, [url]);
15-
16-
// Memoize the connect function to avoid recreating it on every render
1710
const connectWebSocket = useCallback(() => {
1811
try {
19-
// Always use the latest URL from the ref
2012
const ws = new WebSocket(urlRef.current);
2113
wsRef.current = ws;
2214
setConnectionStatus('connecting');
@@ -44,45 +36,42 @@ export function useWebSocket<T>(url: string, testdata: string, reconnectDelay =
4436
ws.onclose = (event) => {
4537
console.log(`WebSocket closed with code ${event.code}. Reason: ${event.reason}`);
4638
setConnectionStatus('disconnected');
47-
48-
// Attempt to reconnect after delay
49-
if (reconnectTimeoutRef.current) {
50-
window.clearTimeout(reconnectTimeoutRef.current);
51-
}
52-
reconnectTimeoutRef.current = window.setTimeout(connectWebSocket, reconnectDelay);
5339
};
5440
} catch (error) {
5541
console.error("Failed to create WebSocket connection:", error);
5642
setConnectionStatus('disconnected');
57-
58-
// Attempt to reconnect after delay
59-
if (reconnectTimeoutRef.current) {
60-
window.clearTimeout(reconnectTimeoutRef.current);
61-
}
62-
reconnectTimeoutRef.current = window.setTimeout(connectWebSocket, reconnectDelay);
6343
}
64-
}, [reconnectDelay]); // Only depends on reconnectDelay, not url
44+
}, []);
6545

6646
useEffect(() => {
6747
if (!testdata) {
6848
return;
6949
}
70-
// Close any existing connection when the URL changes
50+
51+
setMessage(null);
52+
setConnectionStatus('connecting');
53+
54+
// Create new connection after a small delay to ensure cleanup completes
55+
const createConnection = () => {
56+
urlRef.current = url;
57+
connectWebSocket();
58+
};
59+
7160
if (wsRef.current) {
7261
wsRef.current.close();
62+
wsRef.current = null;
7363
}
7464

75-
connectWebSocket();
65+
const timeoutId = setTimeout(createConnection, 100);
7666

7767
return () => {
68+
clearTimeout(timeoutId);
7869
if (wsRef.current) {
7970
wsRef.current.close();
80-
}
81-
if (reconnectTimeoutRef.current) {
82-
window.clearTimeout(reconnectTimeoutRef.current);
71+
wsRef.current = null;
8372
}
8473
};
85-
}, [url, testdata, connectWebSocket]); // Reconnect when URL changes
74+
}, [testdata, connectWebSocket]); // Remove url from dependencies
8675

8776
const sendMessage = (data: unknown) => {
8877
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {

0 commit comments

Comments
 (0)