Skip to content

Commit 3b8bbcc

Browse files
committed
feat(graphiql): add data fetching hooks
Adds custom React hooks for server status polling and data fetching. - Add usePolling hook for generic interval-based polling - Add useServerStatus hook for checking server health - Include comprehensive tests (386 lines) All hook tests pass
1 parent b9ab875 commit 3b8bbcc

File tree

5 files changed

+536
-0
lines changed

5 files changed

+536
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './usePolling.ts'
2+
export * from './useServerStatus.ts'
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import {usePolling} from './usePolling.ts'
2+
import {renderHook, act} from '@testing-library/react'
3+
import {vi, describe, test, expect, beforeEach, afterEach} from 'vitest'
4+
5+
describe('usePolling', () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers()
8+
})
9+
10+
afterEach(() => {
11+
vi.useRealTimers()
12+
})
13+
14+
test('calls callback immediately on mount', () => {
15+
const callback = vi.fn()
16+
renderHook(() => usePolling(callback, {interval: 1000, enabled: true}))
17+
18+
expect(callback).toHaveBeenCalledTimes(1)
19+
})
20+
21+
test('calls callback at specified interval', () => {
22+
const callback = vi.fn()
23+
renderHook(() => usePolling(callback, {interval: 1000, enabled: true}))
24+
25+
// Initial call
26+
expect(callback).toHaveBeenCalledTimes(1)
27+
28+
// After 1 second
29+
act(() => {
30+
vi.advanceTimersByTime(1000)
31+
})
32+
expect(callback).toHaveBeenCalledTimes(2)
33+
34+
// After 2 seconds total
35+
act(() => {
36+
vi.advanceTimersByTime(1000)
37+
})
38+
expect(callback).toHaveBeenCalledTimes(3)
39+
})
40+
41+
test('respects enabled=false (no polling)', () => {
42+
const callback = vi.fn()
43+
renderHook(() => usePolling(callback, {interval: 1000, enabled: false}))
44+
45+
expect(callback).not.toHaveBeenCalled()
46+
47+
act(() => {
48+
vi.advanceTimersByTime(5000)
49+
})
50+
51+
expect(callback).not.toHaveBeenCalled()
52+
})
53+
54+
test('updates when callback reference changes', () => {
55+
const callback1 = vi.fn()
56+
const callback2 = vi.fn()
57+
58+
const {rerender} = renderHook(({cb}) => usePolling(cb, {interval: 1000, enabled: true}), {
59+
initialProps: {cb: callback1},
60+
})
61+
62+
// Initial call with callback1
63+
expect(callback1).toHaveBeenCalledTimes(1)
64+
expect(callback2).not.toHaveBeenCalled()
65+
66+
// Update callback
67+
rerender({cb: callback2})
68+
69+
// Advance time - should call callback2 now
70+
act(() => {
71+
vi.advanceTimersByTime(1000)
72+
})
73+
74+
// callback1 should still be at 1, callback2 should be at 1
75+
expect(callback1).toHaveBeenCalledTimes(1)
76+
expect(callback2).toHaveBeenCalledTimes(1)
77+
})
78+
79+
test('cleans up interval on unmount', () => {
80+
const callback = vi.fn()
81+
const {unmount} = renderHook(() => usePolling(callback, {interval: 1000, enabled: true}))
82+
83+
expect(callback).toHaveBeenCalledTimes(1)
84+
85+
unmount()
86+
87+
// Advance time after unmount
88+
act(() => {
89+
vi.advanceTimersByTime(5000)
90+
})
91+
92+
// Should not have been called again
93+
expect(callback).toHaveBeenCalledTimes(1)
94+
})
95+
96+
test('handles async callbacks', async () => {
97+
const callback = vi.fn().mockResolvedValue(undefined)
98+
99+
renderHook(() => usePolling(callback, {interval: 1000, enabled: true}))
100+
101+
// Wait for initial call
102+
await act(async () => {
103+
await Promise.resolve()
104+
})
105+
106+
expect(callback).toHaveBeenCalledTimes(1)
107+
108+
// Advance timer and wait for async call
109+
await act(async () => {
110+
vi.advanceTimersByTime(1000)
111+
await Promise.resolve()
112+
})
113+
114+
expect(callback).toHaveBeenCalledTimes(2)
115+
})
116+
117+
test('catches and ignores callback errors', () => {
118+
// Suppress console.error for this test since React will report the error
119+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
120+
121+
const callback = vi.fn().mockImplementation(() => {
122+
throw new Error('Test error')
123+
})
124+
125+
// Render the hook - errors should be caught internally
126+
renderHook(() => usePolling(callback, {interval: 1000, enabled: true}))
127+
128+
expect(callback).toHaveBeenCalledTimes(1)
129+
130+
// Should continue polling despite errors
131+
act(() => {
132+
vi.advanceTimersByTime(1000)
133+
})
134+
135+
expect(callback).toHaveBeenCalledTimes(2)
136+
137+
// Restore console.error
138+
consoleErrorSpy.mockRestore()
139+
})
140+
141+
test('changes interval dynamically', () => {
142+
const callback = vi.fn()
143+
144+
const {rerender} = renderHook(({interval}) => usePolling(callback, {interval, enabled: true}), {
145+
initialProps: {interval: 1000},
146+
})
147+
148+
// Initial call
149+
expect(callback).toHaveBeenCalledTimes(1)
150+
151+
// Advance by 1 second
152+
act(() => {
153+
vi.advanceTimersByTime(1000)
154+
})
155+
expect(callback).toHaveBeenCalledTimes(2)
156+
157+
// Change interval to 500ms - this triggers immediate call and restarts interval
158+
act(() => {
159+
rerender({interval: 500})
160+
})
161+
// Rerender triggers an immediate call due to useEffect re-running
162+
expect(callback).toHaveBeenCalledTimes(3)
163+
164+
// Advance by 500ms - should call again
165+
act(() => {
166+
vi.advanceTimersByTime(500)
167+
})
168+
expect(callback).toHaveBeenCalledTimes(4)
169+
170+
// Another 500ms
171+
act(() => {
172+
vi.advanceTimersByTime(500)
173+
})
174+
expect(callback).toHaveBeenCalledTimes(5)
175+
})
176+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {useEffect, useRef} from 'react'
2+
3+
interface UsePollingOptions {
4+
// Polling interval in milliseconds
5+
interval: number
6+
// Whether polling is active (default: true)
7+
enabled?: boolean
8+
}
9+
10+
/**
11+
* Generic polling hook that calls a function at regular intervals
12+
* @param callback - Function to call on each interval
13+
* @param options - Polling configuration
14+
*/
15+
export function usePolling(callback: () => void | Promise<void>, options: UsePollingOptions) {
16+
const {interval, enabled = true} = options
17+
const callbackRef = useRef(callback)
18+
19+
// Keep callback ref up-to-date
20+
useEffect(() => {
21+
callbackRef.current = callback
22+
}, [callback])
23+
24+
useEffect(() => {
25+
if (!enabled) return
26+
27+
const executeCallback = () => {
28+
try {
29+
Promise.resolve(callbackRef.current()).catch(() => {
30+
// Intentionally ignore errors in polling callbacks
31+
})
32+
// eslint-disable-next-line no-catch-all/no-catch-all
33+
} catch {
34+
// Intentionally ignore synchronous errors in polling callbacks
35+
}
36+
}
37+
38+
// Call immediately on mount
39+
executeCallback()
40+
41+
// Set up interval
42+
const intervalId = setInterval(executeCallback, interval)
43+
44+
return () => clearInterval(intervalId)
45+
}, [interval, enabled])
46+
}

0 commit comments

Comments
 (0)