Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions config/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const menus = [
'useTextSelection',
'useWebSocket',
'useTheme',
'useSse',
],
},
{
Expand Down
2 changes: 2 additions & 0 deletions packages/hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import useUpdateEffect from './useUpdateEffect';
import useUpdateLayoutEffect from './useUpdateLayoutEffect';
import useVirtualList from './useVirtualList';
import useWebSocket from './useWebSocket';
import useSse from './useSse';
import useWhyDidYouUpdate from './useWhyDidYouUpdate';
import useMutationObserver from './useMutationObserver';
import useTheme from './useTheme';
Expand Down Expand Up @@ -133,6 +134,7 @@ export {
useFavicon,
useCountDown,
useWebSocket,
useSse,
useLockFn,
useUnmountedRef,
useExternal,
Expand Down
190 changes: 190 additions & 0 deletions packages/hooks/src/useSse/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// useSse.test.ts
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import useSse, { ReadyState } from '../index';

class MockEventSource {
url: string;
withCredentials: boolean;
readyState: number;
onopen: ((this: EventSource, ev: Event) => any) | null = null;
onmessage: ((this: EventSource, ev: MessageEvent) => any) | null = null;
onerror: ((this: EventSource, ev: Event) => any) | null = null;
private listeners: Record<string, Array<(ev: Event) => void>> = {};
private openTimeout?: NodeJS.Timeout;

static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 2;

constructor(url: string, init?: EventSourceInit) {
this.url = url;
this.withCredentials = Boolean(init?.withCredentials);
this.readyState = MockEventSource.CONNECTING;

this.openTimeout = setTimeout(() => {
this.readyState = MockEventSource.OPEN;
this.onopen?.(new Event('open'));
}, 10);
}

addEventListener(type: string, listener: (ev: Event) => void) {
if (!this.listeners[type]) this.listeners[type] = [];
this.listeners[type].push(listener);
}

dispatchEvent(type: string, event: Event) {
this.listeners[type]?.forEach((l) => l(event));
}

emitMessage(data: any) {
if (this.readyState !== MockEventSource.OPEN) return;
this.onmessage?.(new MessageEvent('message', { data }));
}

emitError() {
this.onerror?.(new Event('error'));
}

emitRetry(ms: number) {
const ev = new MessageEvent('message', { data: '' });
(ev as any).retry = ms;
this.onmessage?.(ev);
}

close() {
this.readyState = MockEventSource.CLOSED;
if (this.openTimeout) clearTimeout(this.openTimeout);
}
}

describe('useSse Hook', () => {
const OriginalEventSource = (globalThis as any).EventSource;

beforeEach(() => {
vi.useFakeTimers();
(globalThis as any).EventSource = MockEventSource;
});

afterEach(() => {
vi.runAllTimers();
vi.useRealTimers();
(globalThis as any).EventSource = OriginalEventSource;
vi.restoreAllMocks();
});

test('should connect and receive message', () => {
const hook = renderHook(() => useSse('/sse'));
expect(hook.result.current.readyState).toBe(ReadyState.Connecting);

act(() => vi.advanceTimersByTime(20));
expect(hook.result.current.readyState).toBe(ReadyState.Open);

act(() => {
const es = hook.result.current.eventSource as unknown as MockEventSource;
es.emitMessage('hello');
});
expect(hook.result.current.latestMessage?.data).toBe('hello');

act(() => hook.result.current.disconnect());
expect(hook.result.current.readyState).toBe(ReadyState.Closed);
});

test('manual mode should not auto connect', () => {
const hook = renderHook(() => useSse('/sse', { manual: true }));
expect(hook.result.current.readyState).toBe(ReadyState.Closed);

act(() => {
hook.result.current.connect();
vi.advanceTimersByTime(20);
});
expect(hook.result.current.readyState).toBe(ReadyState.Open);

act(() => hook.result.current.disconnect());
});

test('should handle custom events', () => {
const onEvent = vi.fn();
const hook = renderHook(() => useSse('/sse', { onEvent }));
act(() => vi.advanceTimersByTime(20));

act(() => {
const es = hook.result.current.eventSource as unknown as MockEventSource;
es.dispatchEvent('custom', new MessageEvent('custom', { data: 'foo' }));
});

expect(onEvent).toHaveBeenCalledWith(
'custom',
expect.objectContaining({ data: 'foo' }),
expect.any(MockEventSource),
);

act(() => hook.result.current.disconnect());
});

test('should reconnect on error respecting reconnectLimit', () => {
const hook = renderHook(() => useSse('/sse', { reconnectLimit: 1, reconnectInterval: 5 }));
act(() => vi.advanceTimersByTime(20));
expect(hook.result.current.readyState).toBe(ReadyState.Open);

act(() => {
const es = hook.result.current.eventSource as unknown as MockEventSource;
es.emitError();
vi.advanceTimersByTime(20);
});

expect(
[ReadyState.Reconnecting, ReadyState.Open].includes(hook.result.current.readyState),
).toBe(true);

act(() => hook.result.current.disconnect());
});

test('should respect server retry when enabled', () => {
const hook = renderHook(() =>
useSse('/sse', { reconnectLimit: 1, reconnectInterval: 5, respectServerRetry: true }),
);
act(() => vi.advanceTimersByTime(20));
expect(hook.result.current.readyState).toBe(ReadyState.Open);

act(() => {
const es = hook.result.current.eventSource as unknown as MockEventSource;
es.emitRetry(50);
es.emitError();
vi.advanceTimersByTime(60);
});

expect(
[ReadyState.Reconnecting, ReadyState.Open].includes(hook.result.current.readyState),
).toBe(true);

act(() => hook.result.current.disconnect());
});

test('should trigger all callbacks', () => {
const onOpen = vi.fn();
const onMessage = vi.fn();
const onError = vi.fn();
const onReconnect = vi.fn();

const hook = renderHook(() => useSse('/sse', { onOpen, onMessage, onError, onReconnect }));
act(() => vi.advanceTimersByTime(20));
expect(onOpen).toHaveBeenCalled();

act(() => {
const es = hook.result.current.eventSource as unknown as MockEventSource;
es.emitMessage('world');
});
expect(onMessage).toHaveBeenCalled();

act(() => {
const es = hook.result.current.eventSource as unknown as MockEventSource;
es.emitError();
vi.advanceTimersByTime(20);
});
expect(onError).toHaveBeenCalled();
expect(onReconnect).toHaveBeenCalled();

act(() => hook.result.current.disconnect());
});
});
39 changes: 39 additions & 0 deletions packages/hooks/src/useSse/demo/demo1.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React, { useMemo, useRef } from 'react';
import { useSse } from 'ahooks';

enum ReadyState {
Connecting = 0,
Open = 1,
Closed = 2,
}

export default () => {
const historyRef = useRef<any[]>([]);
const { readyState, latestMessage, connect, disconnect } = useSse('/api/sse');

historyRef.current = useMemo(() => historyRef.current.concat(latestMessage), [latestMessage]);

return (
<div>
<button onClick={() => connect()} style={{ marginRight: 8 }}>
{readyState === ReadyState.Connecting ? 'connecting' : 'connect'}
</button>
<button
onClick={() => disconnect()}
style={{ marginRight: 8 }}
disabled={readyState !== ReadyState.Open}
>
disconnect
</button>
<div style={{ marginTop: 8 }}>readyState: {readyState}</div>
<div style={{ marginTop: 8 }}>
<p>received message: </p>
{historyRef.current.map((m, i) => (
<p key={i} style={{ wordWrap: 'break-word' }}>
{m?.data}
</p>
))}
</div>
</div>
);
};
48 changes: 48 additions & 0 deletions packages/hooks/src/useSse/index.en-US.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
nav:
path: /hooks
---

# useSse

A hook for Server-Sent Events (SSE), which supports automatic reconnect and message callbacks.

## Examples

### Basic Usage

<code src="./demo/demo1.tsx" />

## API

```typescript
const { readyState, latestMessage, connect, disconnect, eventSource } = useSse(
url: string,
options?: UseSseOptions
)
```

### Options

| Property | Description | Type | Default |
| -------------------- | ------------------------------------------------------- | ---------------------------------------------- | ------------ |
| manual | Whether to connect manually | `boolean` | `false` |
| withCredentials | Whether to send cross-domain requests with credentials | `boolean` | `false` |
| reconnectLimit | Maximum number of reconnection attempts | `number` | `3` |
| reconnectInterval | Reconnection interval (in milliseconds) | `number` | `3000` |
| respectServerRetry | Whether to respect the retry time sent by the server | `boolean` | `false` |
| onOpen | Callback when the connection is successfully established| `(es: EventSource) => void` | - |
| onMessage | Callback when a message is received | `(ev: MessageEvent, es: EventSource) => void` | - |
| onError | Callback when an error occurs | `(ev: Event, es: EventSource) => void` | - |
| onReconnect | Callback when a reconnection occurs | `(attempt: number, es: EventSource) => void` | - |
| onEvent | Callback for custom events | `(event: string, ev: MessageEvent, es: EventSource) => void` | - |

### Result

| Property | Description | Type |
| ------------- | ------------------------------------------ | ------------------------------------- |
| readyState | The current connection state | `ReadyState` (0: Connecting, 1: Open, 2: Closed, 3: Reconnecting) |
| latestMessage | The latest message received | `MessageEvent` \| `null` |
| connect | Function to manually connect | `() => void` |
| disconnect | Function to manually disconnect | `() => void` |
| eventSource | The native EventSource instance | `EventSource` \| `null` |
Loading
Loading