Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit369bccd

Browse files
BrunoQuaresmablink-so[bot]claude
authored
feat: establish terminal reconnection foundation (#18693)
Adds a new hook called `useWithRetry` as part ofcoder/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 parent5ad1847 commit369bccd

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";
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+
letmockFn: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+
awaitact(async()=>{
32+
awaitresult.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+
letresolvePromise:()=>void;
42+
constpromise=newPromise<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+
awaitact(async()=>{
56+
resolvePromise!();
57+
awaitpromise;
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(newError("First failure"))
66+
.mockRejectedValueOnce(newError("Second failure"))
67+
.mockResolvedValueOnce(undefined);
68+
69+
const{ result}=renderHook(()=>useWithRetry(mockFn));
70+
71+
// Start the call
72+
awaitact(async()=>{
73+
awaitresult.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+
awaitact(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+
awaitact(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(newError("Always fails"));
101+
102+
const{ result}=renderHook(()=>useWithRetry(mockFn));
103+
104+
// Start the call
105+
awaitact(async()=>{
106+
awaitresult.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(leti=1;i<15;i++){
115+
constdelay=Math.min(1000*2**(i-1),600000);// exponential backoff with max delay
116+
awaitact(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(newError("Always fails"));
130+
131+
const{ result}=renderHook(()=>useWithRetry(mockFn));
132+
133+
// Start the call
134+
awaitact(async()=>{
135+
awaitresult.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(leti=1;i<9;i++){
146+
constdelay=1000*2**(i-1);
147+
awaitact(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+
awaitact(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+
awaitact(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(newError("First failure"))
176+
.mockResolvedValueOnce(undefined);
177+
178+
const{ result}=renderHook(()=>useWithRetry(mockFn));
179+
180+
// Start the first call
181+
awaitact(async()=>{
182+
awaitresult.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+
awaitact(async()=>{
191+
awaitresult.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+
awaitact(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(newError("Failure"))
209+
.mockResolvedValueOnce(undefined);
210+
211+
const{ result}=renderHook(()=>useWithRetry(mockFn));
212+
213+
// Start the call
214+
awaitact(async()=>{
215+
awaitresult.current.call();
216+
});
217+
218+
constnextRetryAt=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+
constexpectedTime=Date.now()+1000;
224+
constactualTime=nextRetryAt!.getTime();
225+
expect(Math.abs(actualTime-expectedTime)).toBeLessThan(100);// Allow 100ms tolerance
226+
227+
// Advance past retry time
228+
awaitact(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(newError("Failure"));
237+
238+
const{ result, unmount}=renderHook(()=>useWithRetry(mockFn));
239+
240+
// Start the call to create timer
241+
awaitact(async()=>{
242+
awaitresult.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+
awaitact(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+
letrejectPromise:(error:Error)=>void;
262+
constpromise=newPromise<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+
awaitact(async()=>{
281+
rejectPromise!(newError("Failed after unmount"));
282+
awaitpromise.catch(()=>{});// Suppress unhandled rejection
283+
});
284+
285+
// Advance time to ensure no retry timers were scheduled
286+
awaitact(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+
letresolvePromise:()=>void;
296+
constpromise=newPromise<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+
awaitact(async()=>{
322+
resolvePromise!();
323+
awaitpromise;
324+
});
325+
326+
expect(result.current.isLoading).toBe(false);
327+
expect(mockFn).toHaveBeenCalledTimes(1);
328+
});
329+
});

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp