Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c115f13
feat: Phase 1 - Terminal reconnection foundation
blink-so[bot] Jul 1, 2025
c10349b
fix: Improve useRetry hook logic
blink-so[bot] Jul 1, 2025
3f45b74
fix: Complete useRetry hook implementation and tests
blink-so[bot] Jul 1, 2025
834b2e5
style: Apply biome formatting fixes to useRetry hook
blink-so[bot] Jul 1, 2025
d398265
fix: Use window.setTimeout/setInterval for browser compatibility
blink-so[bot] Jul 1, 2025
dd7adda
refactor: consolidate useRetry state with useReducer
blink-so[bot] Jul 1, 2025
5766fc0
Reset TerminalPage files to main branch state
blink-so[bot] Jul 2, 2025
b1e453b
Add useWithRetry hook for simplified retry functionality
blink-so[bot] Jul 2, 2025
d4326fb
Clean up useWithRetry hook implementation
blink-so[bot] Jul 2, 2025
8323192
Remove useRetry hook and replace with useWithRetry
blink-so[bot] Jul 2, 2025
3022566
Refactor useWithRetry hook according to specifications
blink-so[bot] Jul 2, 2025
bde014c
Preserve attemptCount when max attempts reached
blink-so[bot] Jul 2, 2025
cb363db
Fix formatting
BrunoQuaresma Jul 2, 2025
55036a4
Fix hook and tests
BrunoQuaresma Jul 2, 2025
000f0e4
feat(hooks): remove max attempts limit from useWithRetry hook
BrunoQuaresma Jul 3, 2025
f9832c0
refactor(hooks): remove attemptCount from useWithRetry state and rena…
BrunoQuaresma Jul 3, 2025
4fd4885
fix(hooks): update useWithRetry tests for nextRetryAt API and add use…
BrunoQuaresma Jul 3, 2025
7b14acd
fix(hooks): prevent race condition in useWithRetry after unmount
BrunoQuaresma Jul 3, 2025
323f6ba
Fix formatting
BrunoQuaresma Jul 3, 2025
062bfa5
fix(hooks): prevent duplicate calls to useWithRetry while loading
BrunoQuaresma Jul 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: Phase 1 - Terminal reconnection foundation
- Update ConnectionStatus type: replace 'initializing' with 'connecting'
- Create useRetry hook with exponential backoff logic
- Add comprehensive tests for useRetry hook
- Export useRetry from hooks index

Implements:
- Initial delay: 1 second
- Max delay: 30 seconds
- Backoff multiplier: 2
- Max retry attempts: 10

Co-authored-by: BrunoQuaresma <3165839+BrunoQuaresma@users.noreply.github.com>
  • Loading branch information
blink-so[bot] and BrunoQuaresma committed Jul 1, 2025
commit c115f13b4e47869fd415029fb3ad4e76f2c18cdd
1 change: 1 addition & 0 deletions site/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "./useClickable";
export * from "./useClickableTableRow";
export * from "./useClipboard";
export * from "./usePagination";
export * from "./useRetry";
330 changes: 330 additions & 0 deletions site/src/hooks/useRetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
import { act, renderHook } from "@testing-library/react";
import { useRetry } from "./useRetry";

// Mock timers
jest.useFakeTimers();

describe("useRetry", () => {
const defaultOptions = {
maxAttempts: 3,
initialDelay: 1000,
maxDelay: 8000,
multiplier: 2,
};

let mockOnRetry: jest.Mock;

beforeEach(() => {
mockOnRetry = jest.fn();
jest.clearAllTimers();
});

afterEach(() => {
jest.clearAllMocks();
});

it("should initialize with correct default state", () => {
const { result } = renderHook(() =>
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
);

expect(result.current.isRetrying).toBe(false);
expect(result.current.currentDelay).toBe(null);
expect(result.current.attemptCount).toBe(0);
expect(result.current.timeUntilNextRetry).toBe(null);
});

it("should start retrying when startRetrying is called", async () => {
mockOnRetry.mockRejectedValue(new Error("Connection failed"));

const { result } = renderHook(() =>
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
);

act(() => {
result.current.startRetrying();
});

expect(result.current.attemptCount).toBe(1);
expect(result.current.isRetrying).toBe(true);

// Wait for the retry to complete
await act(async () => {
await Promise.resolve();
});

expect(mockOnRetry).toHaveBeenCalledTimes(1);
expect(result.current.isRetrying).toBe(false);
});

it("should calculate exponential backoff delays correctly", async () => {
mockOnRetry.mockRejectedValue(new Error("Connection failed"));

const { result } = renderHook(() =>
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
);

act(() => {
result.current.startRetrying();
});

// Wait for first retry to fail
await act(async () => {
await Promise.resolve();
});

// Should schedule next retry with initial delay (1000ms)
expect(result.current.currentDelay).toBe(1000);
expect(result.current.timeUntilNextRetry).toBe(1000);

// Fast forward to trigger second retry
act(() => {
jest.advanceTimersByTime(1000);
});

await act(async () => {
await Promise.resolve();
});

// Should schedule third retry with doubled delay (2000ms)
expect(result.current.currentDelay).toBe(2000);
});

it("should respect maximum delay", async () => {
mockOnRetry.mockRejectedValue(new Error("Connection failed"));

const options = {
...defaultOptions,
maxDelay: 1500, // Lower max delay
onRetry: mockOnRetry,
};

const { result } = renderHook(() => useRetry(options));

act(() => {
result.current.startRetrying();
});

// Wait for first retry to fail
await act(async () => {
await Promise.resolve();
});

// Fast forward to trigger second retry
act(() => {
jest.advanceTimersByTime(1000);
});

await act(async () => {
await Promise.resolve();
});

// Should cap at maxDelay instead of 2000ms
expect(result.current.currentDelay).toBe(1500);
});

it("should stop retrying after max attempts", async () => {
mockOnRetry.mockRejectedValue(new Error("Connection failed"));

const { result } = renderHook(() =>
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
);

act(() => {
result.current.startRetrying();
});

// Simulate all retry attempts
for (let i = 0; i < defaultOptions.maxAttempts; i++) {
await act(async () => {
await Promise.resolve();
});

if (i < defaultOptions.maxAttempts - 1) {
// Fast forward to next retry
act(() => {
jest.advanceTimersByTime(result.current.currentDelay || 0);
});
}
}

expect(mockOnRetry).toHaveBeenCalledTimes(defaultOptions.maxAttempts);
expect(result.current.attemptCount).toBe(defaultOptions.maxAttempts);
expect(result.current.currentDelay).toBe(null);
expect(result.current.timeUntilNextRetry).toBe(null);
});

it("should handle manual retry", async () => {
mockOnRetry.mockRejectedValueOnce(new Error("Connection failed"));
mockOnRetry.mockResolvedValueOnce(undefined);

const { result } = renderHook(() =>
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
);

act(() => {
result.current.startRetrying();
});

// Wait for first retry to fail
await act(async () => {
await Promise.resolve();
});

expect(result.current.currentDelay).toBe(1000);

// Trigger manual retry before automatic retry
act(() => {
result.current.retry();
});

// Should cancel automatic retry
expect(result.current.currentDelay).toBe(null);
expect(result.current.timeUntilNextRetry).toBe(null);
expect(result.current.isRetrying).toBe(true);

await act(async () => {
await Promise.resolve();
});

// Should succeed and reset state
expect(result.current.attemptCount).toBe(0);
expect(result.current.isRetrying).toBe(false);
});

it("should reset state when retry succeeds", async () => {
mockOnRetry.mockRejectedValueOnce(new Error("Connection failed"));
mockOnRetry.mockResolvedValueOnce(undefined);

const { result } = renderHook(() =>
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
);

act(() => {
result.current.startRetrying();
});

// Wait for first retry to fail
await act(async () => {
await Promise.resolve();
});

expect(result.current.attemptCount).toBe(1);

// Fast forward to trigger second retry (which will succeed)
act(() => {
jest.advanceTimersByTime(1000);
});

await act(async () => {
await Promise.resolve();
});

// Should reset all state
expect(result.current.attemptCount).toBe(0);
expect(result.current.isRetrying).toBe(false);
expect(result.current.currentDelay).toBe(null);
expect(result.current.timeUntilNextRetry).toBe(null);
});

it("should stop retrying when stopRetrying is called", async () => {
mockOnRetry.mockRejectedValue(new Error("Connection failed"));

const { result } = renderHook(() =>
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
);

act(() => {
result.current.startRetrying();
});

// Wait for first retry to fail
await act(async () => {
await Promise.resolve();
});

expect(result.current.currentDelay).toBe(1000);

// Stop retrying
act(() => {
result.current.stopRetrying();
});

// Should reset all state
expect(result.current.attemptCount).toBe(0);
expect(result.current.isRetrying).toBe(false);
expect(result.current.currentDelay).toBe(null);
expect(result.current.timeUntilNextRetry).toBe(null);

// Fast forward past when retry would have happened
act(() => {
jest.advanceTimersByTime(2000);
});

// Should not have triggered additional retries
expect(mockOnRetry).toHaveBeenCalledTimes(1);
});

it("should update countdown timer correctly", async () => {
mockOnRetry.mockRejectedValue(new Error("Connection failed"));

const { result } = renderHook(() =>
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
);

act(() => {
result.current.startRetrying();
});

// Wait for first retry to fail
await act(async () => {
await Promise.resolve();
});

expect(result.current.timeUntilNextRetry).toBe(1000);

// Advance time partially
act(() => {
jest.advanceTimersByTime(300);
});

// Should update countdown
expect(result.current.timeUntilNextRetry).toBeLessThan(1000);
expect(result.current.timeUntilNextRetry).toBeGreaterThan(0);
});

it("should handle the specified backoff configuration", async () => {
mockOnRetry.mockRejectedValue(new Error("Connection failed"));

// Test with the exact configuration from the issue
const issueConfig = {
onRetry: mockOnRetry,
maxAttempts: 10,
initialDelay: 1000, // 1 second
maxDelay: 30000, // 30 seconds
multiplier: 2,
};

const { result } = renderHook(() => useRetry(issueConfig));

act(() => {
result.current.startRetrying();
});

// Test first few delays
const expectedDelays = [1000, 2000, 4000, 8000, 16000, 30000]; // Caps at 30000

for (let i = 0; i < expectedDelays.length; i++) {
await act(async () => {
await Promise.resolve();
});

if (i < expectedDelays.length - 1) {
expect(result.current.currentDelay).toBe(expectedDelays[i]);
act(() => {
jest.advanceTimersByTime(expectedDelays[i]);
});
}
}
});
});
Loading
Loading