Skip to content

Commit ba24b6e

Browse files
committed
feat: update code
1 parent 6955be3 commit ba24b6e

File tree

4 files changed

+293
-231
lines changed

4 files changed

+293
-231
lines changed
Lines changed: 120 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
// useSse.test.ts
12
import { act, renderHook } from '@testing-library/react';
2-
import { afterEach, describe, expect, test, vi } from 'vitest';
3+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
34
import useSse, { ReadyState } from '../index';
45

56
class MockEventSource {
@@ -10,6 +11,7 @@ class MockEventSource {
1011
onmessage: ((this: EventSource, ev: MessageEvent) => any) | null = null;
1112
onerror: ((this: EventSource, ev: Event) => any) | null = null;
1213
private listeners: Record<string, Array<(ev: Event) => void>> = {};
14+
private openTimeout?: NodeJS.Timeout;
1315

1416
static CONNECTING = 0;
1517
static OPEN = 1;
@@ -19,9 +21,10 @@ class MockEventSource {
1921
this.url = url;
2022
this.withCredentials = Boolean(init?.withCredentials);
2123
this.readyState = MockEventSource.CONNECTING;
22-
setTimeout(() => {
24+
25+
this.openTimeout = setTimeout(() => {
2326
this.readyState = MockEventSource.OPEN;
24-
this.onopen && this.onopen(new Event('open'));
27+
this.onopen?.(new Event('open'));
2528
}, 10);
2629
}
2730

@@ -35,69 +38,153 @@ class MockEventSource {
3538
}
3639

3740
emitMessage(data: any) {
38-
this.onmessage && this.onmessage(new MessageEvent('message', { data }));
41+
if (this.readyState !== MockEventSource.OPEN) return;
42+
this.onmessage?.(new MessageEvent('message', { data }));
3943
}
4044

4145
emitError() {
42-
this.onerror && this.onerror(new Event('error'));
46+
this.onerror?.(new Event('error'));
47+
}
48+
49+
emitRetry(ms: number) {
50+
const ev = new MessageEvent('message', { data: '' });
51+
(ev as any).retry = ms;
52+
this.onmessage?.(ev);
4353
}
4454

4555
close() {
4656
this.readyState = MockEventSource.CLOSED;
57+
if (this.openTimeout) clearTimeout(this.openTimeout);
4758
}
4859
}
4960

50-
describe('useSse', () => {
61+
describe('useSse Hook', () => {
5162
const OriginalEventSource = (globalThis as any).EventSource;
5263

64+
beforeEach(() => {
65+
vi.useFakeTimers();
66+
(globalThis as any).EventSource = MockEventSource;
67+
});
68+
5369
afterEach(() => {
70+
vi.runAllTimers();
71+
vi.useRealTimers();
5472
(globalThis as any).EventSource = OriginalEventSource;
73+
vi.restoreAllMocks();
5574
});
5675

57-
test('should connect and receive message', async () => {
58-
(globalThis as any).EventSource = MockEventSource as any;
76+
test('should connect and receive message', () => {
77+
const hook = renderHook(() => useSse('/sse'));
78+
expect(hook.result.current.readyState).toBe(ReadyState.Connecting);
79+
80+
act(() => vi.advanceTimersByTime(20));
81+
expect(hook.result.current.readyState).toBe(ReadyState.Open);
82+
83+
act(() => {
84+
const es = hook.result.current.eventSource as unknown as MockEventSource;
85+
es.emitMessage('hello');
86+
});
87+
expect(hook.result.current.latestMessage?.data).toBe('hello');
5988

60-
const hooks = renderHook(() => useSse('/sse'));
89+
act(() => hook.result.current.disconnect());
90+
expect(hook.result.current.readyState).toBe(ReadyState.Closed);
91+
});
6192

62-
// not manual: should start connecting immediately
63-
expect(hooks.result.current.readyState).toBe(ReadyState.Connecting);
93+
test('manual mode should not auto connect', () => {
94+
const hook = renderHook(() => useSse('/sse', { manual: true }));
95+
expect(hook.result.current.readyState).toBe(ReadyState.Closed);
6496

65-
await act(async () => {
66-
await new Promise((r) => setTimeout(r, 20));
97+
act(() => {
98+
hook.result.current.connect();
99+
vi.advanceTimersByTime(20);
67100
});
101+
expect(hook.result.current.readyState).toBe(ReadyState.Open);
68102

69-
expect(hooks.result.current.readyState).toBe(ReadyState.Open);
103+
act(() => hook.result.current.disconnect());
104+
});
105+
106+
test('should handle custom events', () => {
107+
const onEvent = vi.fn();
108+
const hook = renderHook(() => useSse('/sse', { onEvent }));
109+
act(() => vi.advanceTimersByTime(20));
70110

71111
act(() => {
72-
const es = hooks.result.current.eventSource as unknown as MockEventSource;
73-
es.emitMessage('hello');
112+
const es = hook.result.current.eventSource as unknown as MockEventSource;
113+
es.dispatchEvent('custom', new MessageEvent('custom', { data: 'foo' }));
74114
});
75-
expect(hooks.result.current.latestMessage?.data).toBe('hello');
115+
116+
expect(onEvent).toHaveBeenCalledWith(
117+
'custom',
118+
expect.objectContaining({ data: 'foo' }),
119+
expect.any(MockEventSource),
120+
);
121+
122+
act(() => hook.result.current.disconnect());
76123
});
77124

78-
test('manual should not auto connect', async () => {
79-
(globalThis as any).EventSource = MockEventSource as any;
125+
test('should reconnect on error respecting reconnectLimit', () => {
126+
const hook = renderHook(() => useSse('/sse', { reconnectLimit: 1, reconnectInterval: 5 }));
127+
act(() => vi.advanceTimersByTime(20));
128+
expect(hook.result.current.readyState).toBe(ReadyState.Open);
129+
130+
act(() => {
131+
const es = hook.result.current.eventSource as unknown as MockEventSource;
132+
es.emitError();
133+
vi.advanceTimersByTime(20);
134+
});
135+
136+
expect(
137+
[ReadyState.Reconnecting, ReadyState.Open].includes(hook.result.current.readyState),
138+
).toBe(true);
139+
140+
act(() => hook.result.current.disconnect());
141+
});
80142

81-
const hooks = renderHook(() => useSse('/sse', { manual: true }));
82-
expect(hooks.result.current.readyState).toBe(ReadyState.Closed);
143+
test('should respect server retry when enabled', () => {
144+
const hook = renderHook(() =>
145+
useSse('/sse', { reconnectLimit: 1, reconnectInterval: 5, respectServerRetry: true }),
146+
);
147+
act(() => vi.advanceTimersByTime(20));
148+
expect(hook.result.current.readyState).toBe(ReadyState.Open);
83149

84-
await act(async () => {
85-
hooks.result.current.connect();
86-
await new Promise((r) => setTimeout(r, 20));
150+
act(() => {
151+
const es = hook.result.current.eventSource as unknown as MockEventSource;
152+
es.emitRetry(50);
153+
es.emitError();
154+
vi.advanceTimersByTime(60);
87155
});
88156

89-
expect(hooks.result.current.readyState).toBe(ReadyState.Open);
157+
expect(
158+
[ReadyState.Reconnecting, ReadyState.Open].includes(hook.result.current.readyState),
159+
).toBe(true);
160+
161+
act(() => hook.result.current.disconnect());
90162
});
91163

92-
test('disconnect should close', async () => {
93-
(globalThis as any).EventSource = MockEventSource as any;
164+
test('should trigger all callbacks', () => {
165+
const onOpen = vi.fn();
166+
const onMessage = vi.fn();
167+
const onError = vi.fn();
168+
const onReconnect = vi.fn();
169+
170+
const hook = renderHook(() => useSse('/sse', { onOpen, onMessage, onError, onReconnect }));
171+
act(() => vi.advanceTimersByTime(20));
172+
expect(onOpen).toHaveBeenCalled();
94173

95-
const hooks = renderHook(() => useSse('/sse'));
96-
await act(async () => {
97-
await new Promise((r) => setTimeout(r, 20));
174+
act(() => {
175+
const es = hook.result.current.eventSource as unknown as MockEventSource;
176+
es.emitMessage('world');
98177
});
99-
expect(hooks.result.current.readyState).toBe(ReadyState.Open);
100-
act(() => hooks.result.current.disconnect());
101-
expect(hooks.result.current.readyState).toBe(ReadyState.Closed);
178+
expect(onMessage).toHaveBeenCalled();
179+
180+
act(() => {
181+
const es = hook.result.current.eventSource as unknown as MockEventSource;
182+
es.emitError();
183+
vi.advanceTimersByTime(20);
184+
});
185+
expect(onError).toHaveBeenCalled();
186+
expect(onReconnect).toHaveBeenCalled();
187+
188+
act(() => hook.result.current.disconnect());
102189
});
103190
});

packages/hooks/src/useSse/index.en-US.md

Lines changed: 35 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,50 +5,44 @@ nav:
55

66
# useSse
77

8-
Listen to Server-Sent Events (SSE) stream with auto reconnect and lifecycle helpers.
9-
10-
### Examples
11-
12-
```tsx
13-
import React, { useMemo, useRef } from 'react';
14-
import { useSse } from 'ahooks';
15-
16-
export default () => {
17-
const historyRef = useRef<any[]>([]);
18-
const { readyState, latestMessage, connect, disconnect } = useSse('/api/sse');
19-
20-
historyRef.current = useMemo(() => historyRef.current.concat(latestMessage), [latestMessage]);
21-
22-
return (
23-
<div>
24-
<button onClick={() => connect()} style={{ marginRight: 8 }}>connect</button>
25-
<button onClick={() => disconnect()} style={{ marginRight: 8 }}>disconnect</button>
26-
<div>readyState: {readyState}</div>
27-
<div style={{ marginTop: 8 }}>
28-
{historyRef.current.map((m, i) => (
29-
<p key={i}>{m?.data}</p>
30-
))}
31-
</div>
32-
</div>
33-
);
34-
};
35-
```
8+
A hook for Server-Sent Events (SSE), which supports automatic reconnect and message callbacks.
9+
10+
## Examples
11+
12+
### Basic Usage
3613

37-
### API
14+
<code src="./demo/demo1.tsx" />
3815

39-
```ts
16+
## API
17+
18+
```typescript
4019
const { readyState, latestMessage, connect, disconnect, eventSource } = useSse(
4120
url: string,
42-
options?: {
43-
manual?: boolean;
44-
withCredentials?: boolean;
45-
reconnectLimit?: number;
46-
reconnectInterval?: number; // ms
47-
events?: string[]; // named events
48-
onOpen?: (ev: Event, instance: EventSource) => void;
49-
onMessage?: (msg: MessageEvent, instance: EventSource) => void;
50-
onError?: (ev: Event, instance: EventSource) => void;
51-
onEvent?: (eventName: string, ev: MessageEvent, instance: EventSource) => void;
52-
}
21+
options?: UseSseOptions
5322
)
5423
```
24+
25+
### Options
26+
27+
| Property | Description | Type | Default |
28+
| -------------------- | ------------------------------------------------------- | ---------------------------------------------- | ------------ |
29+
| manual | Whether to connect manually | `boolean` | `false` |
30+
| withCredentials | Whether to send cross-domain requests with credentials | `boolean` | `false` |
31+
| reconnectLimit | Maximum number of reconnection attempts | `number` | `3` |
32+
| reconnectInterval | Reconnection interval (in milliseconds) | `number` | `3000` |
33+
| respectServerRetry | Whether to respect the retry time sent by the server | `boolean` | `false` |
34+
| onOpen | Callback when the connection is successfully established| `(es: EventSource) => void` | - |
35+
| onMessage | Callback when a message is received | `(ev: MessageEvent, es: EventSource) => void` | - |
36+
| onError | Callback when an error occurs | `(ev: Event, es: EventSource) => void` | - |
37+
| onReconnect | Callback when a reconnection occurs | `(attempt: number, es: EventSource) => void` | - |
38+
| onEvent | Callback for custom events | `(event: string, ev: MessageEvent, es: EventSource) => void` | - |
39+
40+
### Result
41+
42+
| Property | Description | Type |
43+
| ------------- | ------------------------------------------ | ------------------------------------- |
44+
| readyState | The current connection state | `ReadyState` (0: Connecting, 1: Open, 2: Closed, 3: Reconnecting) |
45+
| latestMessage | The latest message received | `MessageEvent` \| `null` |
46+
| connect | Function to manually connect | `() => void` |
47+
| disconnect | Function to manually disconnect | `() => void` |
48+
| eventSource | The native EventSource instance | `EventSource` \| `null` |

0 commit comments

Comments
 (0)