Skip to content

Commit 369bccd

Browse files
BrunoQuaresmablink-so[bot]claude
authored
feat: establish terminal reconnection foundation (#18693)
Adds a new hook called `useWithRetry` as part of coder/internal#659 --------- Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: BrunoQuaresma <3165839+BrunoQuaresma@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ad1847 commit 369bccd

File tree

3 files changed

+436
-0
lines changed

3 files changed

+436
-0
lines changed

site/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from "./useClickable";
33
export * from "./useClickableTableRow";
44
export * from "./useClipboard";
55
export * from "./usePagination";
6+
export * from "./useWithRetry";

site/src/hooks/useWithRetry.test.ts

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { useWithRetry } from "./useWithRetry";
3+
4+
// Mock timers
5+
jest.useFakeTimers();
6+
7+
describe("useWithRetry", () => {
8+
let mockFn: jest.Mock;
9+
10+
beforeEach(() => {
11+
mockFn = jest.fn();
12+
jest.clearAllTimers();
13+
});
14+
15+
afterEach(() => {
16+
jest.clearAllMocks();
17+
});
18+
19+
it("should initialize with correct default state", () => {
20+
const { result } = renderHook(() => useWithRetry(mockFn));
21+
22+
expect(result.current.isLoading).toBe(false);
23+
expect(result.current.nextRetryAt).toBe(undefined);
24+
});
25+
26+
it("should execute function successfully on first attempt", async () => {
27+
mockFn.mockResolvedValue(undefined);
28+
29+
const { result } = renderHook(() => useWithRetry(mockFn));
30+
31+
await act(async () => {
32+
await result.current.call();
33+
});
34+
35+
expect(mockFn).toHaveBeenCalledTimes(1);
36+
expect(result.current.isLoading).toBe(false);
37+
expect(result.current.nextRetryAt).toBe(undefined);
38+
});
39+
40+
it("should set isLoading to true during execution", async () => {
41+
let resolvePromise: () => void;
42+
const promise = new Promise<void>((resolve) => {
43+
resolvePromise = resolve;
44+
});
45+
mockFn.mockReturnValue(promise);
46+
47+
const { result } = renderHook(() => useWithRetry(mockFn));
48+
49+
act(() => {
50+
result.current.call();
51+
});
52+
53+
expect(result.current.isLoading).toBe(true);
54+
55+
await act(async () => {
56+
resolvePromise!();
57+
await promise;
58+
});
59+
60+
expect(result.current.isLoading).toBe(false);
61+
});
62+
63+
it("should retry on failure with exponential backoff", async () => {
64+
mockFn
65+
.mockRejectedValueOnce(new Error("First failure"))
66+
.mockRejectedValueOnce(new Error("Second failure"))
67+
.mockResolvedValueOnce(undefined);
68+
69+
const { result } = renderHook(() => useWithRetry(mockFn));
70+
71+
// Start the call
72+
await act(async () => {
73+
await result.current.call();
74+
});
75+
76+
expect(mockFn).toHaveBeenCalledTimes(1);
77+
expect(result.current.isLoading).toBe(false);
78+
expect(result.current.nextRetryAt).not.toBe(null);
79+
80+
// Fast-forward to first retry (1 second)
81+
await act(async () => {
82+
jest.advanceTimersByTime(1000);
83+
});
84+
85+
expect(mockFn).toHaveBeenCalledTimes(2);
86+
expect(result.current.isLoading).toBe(false);
87+
expect(result.current.nextRetryAt).not.toBe(null);
88+
89+
// Fast-forward to second retry (2 seconds)
90+
await act(async () => {
91+
jest.advanceTimersByTime(2000);
92+
});
93+
94+
expect(mockFn).toHaveBeenCalledTimes(3);
95+
expect(result.current.isLoading).toBe(false);
96+
expect(result.current.nextRetryAt).toBe(undefined);
97+
});
98+
99+
it("should continue retrying without limit", async () => {
100+
mockFn.mockRejectedValue(new Error("Always fails"));
101+
102+
const { result } = renderHook(() => useWithRetry(mockFn));
103+
104+
// Start the call
105+
await act(async () => {
106+
await result.current.call();
107+
});
108+
109+
expect(mockFn).toHaveBeenCalledTimes(1);
110+
expect(result.current.isLoading).toBe(false);
111+
expect(result.current.nextRetryAt).not.toBe(null);
112+
113+
// Fast-forward through multiple retries to verify it continues
114+
for (let i = 1; i < 15; i++) {
115+
const delay = Math.min(1000 * 2 ** (i - 1), 600000); // exponential backoff with max delay
116+
await act(async () => {
117+
jest.advanceTimersByTime(delay);
118+
});
119+
expect(mockFn).toHaveBeenCalledTimes(i + 1);
120+
expect(result.current.isLoading).toBe(false);
121+
expect(result.current.nextRetryAt).not.toBe(null);
122+
}
123+
124+
// Should still be retrying after 15 attempts
125+
expect(result.current.nextRetryAt).not.toBe(null);
126+
});
127+
128+
it("should respect max delay of 10 minutes", async () => {
129+
mockFn.mockRejectedValue(new Error("Always fails"));
130+
131+
const { result } = renderHook(() => useWithRetry(mockFn));
132+
133+
// Start the call
134+
await act(async () => {
135+
await result.current.call();
136+
});
137+
138+
expect(result.current.isLoading).toBe(false);
139+
140+
// Fast-forward through several retries to reach max delay
141+
// After attempt 9, delay would be 1000 * 2^9 = 512000ms, which is less than 600000ms (10 min)
142+
// After attempt 10, delay would be 1000 * 2^10 = 1024000ms, which should be capped at 600000ms
143+
144+
// Skip to attempt 9 (delay calculation: 1000 * 2^8 = 256000ms)
145+
for (let i = 1; i < 9; i++) {
146+
const delay = 1000 * 2 ** (i - 1);
147+
await act(async () => {
148+
jest.advanceTimersByTime(delay);
149+
});
150+
}
151+
152+
expect(mockFn).toHaveBeenCalledTimes(9);
153+
expect(result.current.nextRetryAt).not.toBe(null);
154+
155+
// The 9th retry should use max delay (600000ms = 10 minutes)
156+
await act(async () => {
157+
jest.advanceTimersByTime(600000);
158+
});
159+
160+
expect(mockFn).toHaveBeenCalledTimes(10);
161+
expect(result.current.isLoading).toBe(false);
162+
expect(result.current.nextRetryAt).not.toBe(null);
163+
164+
// Continue with more retries at max delay to verify it continues indefinitely
165+
await act(async () => {
166+
jest.advanceTimersByTime(600000);
167+
});
168+
169+
expect(mockFn).toHaveBeenCalledTimes(11);
170+
expect(result.current.nextRetryAt).not.toBe(null);
171+
});
172+
173+
it("should cancel previous retry when call is invoked again", async () => {
174+
mockFn
175+
.mockRejectedValueOnce(new Error("First failure"))
176+
.mockResolvedValueOnce(undefined);
177+
178+
const { result } = renderHook(() => useWithRetry(mockFn));
179+
180+
// Start the first call
181+
await act(async () => {
182+
await result.current.call();
183+
});
184+
185+
expect(mockFn).toHaveBeenCalledTimes(1);
186+
expect(result.current.isLoading).toBe(false);
187+
expect(result.current.nextRetryAt).not.toBe(null);
188+
189+
// Call again before retry happens
190+
await act(async () => {
191+
await result.current.call();
192+
});
193+
194+
expect(mockFn).toHaveBeenCalledTimes(2);
195+
expect(result.current.isLoading).toBe(false);
196+
expect(result.current.nextRetryAt).toBe(undefined);
197+
198+
// Advance time to ensure previous retry was cancelled
199+
await act(async () => {
200+
jest.advanceTimersByTime(5000);
201+
});
202+
203+
expect(mockFn).toHaveBeenCalledTimes(2); // Should not have been called again
204+
});
205+
206+
it("should set nextRetryAt when scheduling retry", async () => {
207+
mockFn
208+
.mockRejectedValueOnce(new Error("Failure"))
209+
.mockResolvedValueOnce(undefined);
210+
211+
const { result } = renderHook(() => useWithRetry(mockFn));
212+
213+
// Start the call
214+
await act(async () => {
215+
await result.current.call();
216+
});
217+
218+
const nextRetryAt = result.current.nextRetryAt;
219+
expect(nextRetryAt).not.toBe(null);
220+
expect(nextRetryAt).toBeInstanceOf(Date);
221+
222+
// nextRetryAt should be approximately 1 second in the future
223+
const expectedTime = Date.now() + 1000;
224+
const actualTime = nextRetryAt!.getTime();
225+
expect(Math.abs(actualTime - expectedTime)).toBeLessThan(100); // Allow 100ms tolerance
226+
227+
// Advance past retry time
228+
await act(async () => {
229+
jest.advanceTimersByTime(1000);
230+
});
231+
232+
expect(result.current.nextRetryAt).toBe(undefined);
233+
});
234+
235+
it("should cleanup timer on unmount", async () => {
236+
mockFn.mockRejectedValue(new Error("Failure"));
237+
238+
const { result, unmount } = renderHook(() => useWithRetry(mockFn));
239+
240+
// Start the call to create timer
241+
await act(async () => {
242+
await result.current.call();
243+
});
244+
245+
expect(result.current.isLoading).toBe(false);
246+
expect(result.current.nextRetryAt).not.toBe(null);
247+
248+
// Unmount should cleanup timer
249+
unmount();
250+
251+
// Advance time to ensure timer was cleared
252+
await act(async () => {
253+
jest.advanceTimersByTime(5000);
254+
});
255+
256+
// Function should not have been called again
257+
expect(mockFn).toHaveBeenCalledTimes(1);
258+
});
259+
260+
it("should prevent scheduling retries when function completes after unmount", async () => {
261+
let rejectPromise: (error: Error) => void;
262+
const promise = new Promise<void>((_, reject) => {
263+
rejectPromise = reject;
264+
});
265+
mockFn.mockReturnValue(promise);
266+
267+
const { result, unmount } = renderHook(() => useWithRetry(mockFn));
268+
269+
// Start the call - this will make the function in-flight
270+
act(() => {
271+
result.current.call();
272+
});
273+
274+
expect(result.current.isLoading).toBe(true);
275+
276+
// Unmount while function is still in-flight
277+
unmount();
278+
279+
// Function completes with error after unmount
280+
await act(async () => {
281+
rejectPromise!(new Error("Failed after unmount"));
282+
await promise.catch(() => {}); // Suppress unhandled rejection
283+
});
284+
285+
// Advance time to ensure no retry timers were scheduled
286+
await act(async () => {
287+
jest.advanceTimersByTime(5000);
288+
});
289+
290+
// Function should only have been called once (no retries after unmount)
291+
expect(mockFn).toHaveBeenCalledTimes(1);
292+
});
293+
294+
it("should do nothing when call() is invoked while function is already loading", async () => {
295+
let resolvePromise: () => void;
296+
const promise = new Promise<void>((resolve) => {
297+
resolvePromise = resolve;
298+
});
299+
mockFn.mockReturnValue(promise);
300+
301+
const { result } = renderHook(() => useWithRetry(mockFn));
302+
303+
// Start the first call - this will set isLoading to true
304+
act(() => {
305+
result.current.call();
306+
});
307+
308+
expect(result.current.isLoading).toBe(true);
309+
expect(mockFn).toHaveBeenCalledTimes(1);
310+
311+
// Try to call again while loading - should do nothing
312+
act(() => {
313+
result.current.call();
314+
});
315+
316+
// Function should not have been called again
317+
expect(mockFn).toHaveBeenCalledTimes(1);
318+
expect(result.current.isLoading).toBe(true);
319+
320+
// Complete the original promise
321+
await act(async () => {
322+
resolvePromise!();
323+
await promise;
324+
});
325+
326+
expect(result.current.isLoading).toBe(false);
327+
expect(mockFn).toHaveBeenCalledTimes(1);
328+
});
329+
});

0 commit comments

Comments
 (0)