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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy