Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/metal-beans-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-realtime-hooks": patch
---

Narrow the React peer dependency to React 19, align the README with the supported version, and tighten realtime error handling. This update makes onError fire consistently for transport, heartbeat, and parse failures, updates lastChangedAt on native WebSocket errors, and clears heartbeat timing state on stop so reconnects start with fresh metrics.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![Demo](https://img.shields.io/github/actions/workflow/status/volkov85/react-realtime-hooks/pages.yml?branch=main&label=demo)](https://github.com/volkov85/react-realtime-hooks/actions/workflows/pages.yml)
[![license](https://img.shields.io/npm/l/react-realtime-hooks)](https://github.com/volkov85/react-realtime-hooks/blob/main/LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-typed-3178c6)](https://www.typescriptlang.org/)
[![react](https://img.shields.io/badge/react-18.3%2B%20%7C%2019-149eca)](https://www.npmjs.com/package/react)
[![react](https://img.shields.io/badge/react-19.x-149eca)](https://www.npmjs.com/package/react)

Production-ready React hooks for WebSocket and SSE with auto-reconnect, heartbeat, typed connection state, and browser network awareness.

Expand Down Expand Up @@ -60,7 +60,7 @@ npm install react-realtime-hooks

Peer dependency:

- `react@^18.3.0 || ^19.0.0`
- `react@^19.0.0`

## How It Feels

Expand Down Expand Up @@ -354,7 +354,7 @@ export function NetworkIndicator() {
| `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on close |
| `onOpen` | `(event, socket) => void` | `undefined` | Open callback |
| `onMessage` | `(message, event) => void` | `undefined` | Message callback |
| `onError` | `(event) => void` | `undefined` | Error callback |
| `onError` | `(event) => void` | `undefined` | Called for transport, heartbeat, and parse errors |
| `onClose` | `(event) => void` | `undefined` | Close callback |

### Result
Expand Down Expand Up @@ -397,7 +397,7 @@ When you configure `useWebSocket` heartbeat, you can also set `timeoutAction` an
| `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on error |
| `onOpen` | `(event, source) => void` | `undefined` | Open callback |
| `onMessage` | `(message, event) => void` | `undefined` | Default `message` callback |
| `onError` | `(event) => void` | `undefined` | Error callback |
| `onError` | `(event) => void` | `undefined` | Called for transport and parse errors |
| `onEvent` | `(eventName, message, event) => void` | `undefined` | Named event callback |

### Result
Expand Down Expand Up @@ -512,7 +512,8 @@ When you configure `useWebSocket` heartbeat, you can also set `timeoutAction` an

- `useEventSource` is receive-only by design. SSE is not a bidirectional transport.
- `useWebSocket` heartbeat support is client-side. You still define your own server ping/pong protocol.
- If `parseMessage` throws, the hook closes the current transport, moves into `error`, stores `lastError`, and stops auto-reconnect until manual `open()` or `reconnect()`.
- If `parseMessage` throws, the hook calls `onError`, closes the current transport, moves into `error`, stores `lastError`, and stops auto-reconnect until manual `open()` or `reconnect()`.
- Stopping heartbeat clears timeout state and the previous beat/ack timestamps so a new session starts with fresh metrics.
- `connect: false` keeps the hook in `idle` until `open()` is called.
- Manual `close()` is sticky. The hook stays closed until `open()` or `reconnect()` is called.
- No transport polyfills are bundled. Provide your own runtime support where needed.
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build && npm run publint"
},
"peerDependencies": {
"react": "^18.3.0 || ^19.0.0"
"react": "^19.0.0"
},
"devDependencies": {
"@changesets/cli": "2.30.0",
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useEventSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export const useEventSource: UseEventSourceHook = <TMessage = unknown>(
suppressReconnectRef.current = true;
reconnect.cancel();
closeEventSource();
options.onError?.(parseError);
commitState((current) => ({
...current,
lastChangedAt: Date.now(),
Expand Down
6 changes: 1 addition & 5 deletions src/hooks/useHeartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,7 @@ export const useHeartbeat = <
generationRef.current += 1;
intervalRef.current.cancel();
timeoutRef.current.cancel();
commitState((current) => ({
...current,
hasTimedOut: false,
isRunning: false
}));
commitState(createInitialState(false));
};

const beat = (): void => {
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/useWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export const useWebSocket: UseWebSocketHook = <
reconnectTrigger: "heartbeat-timeout" | "error"
) => {
heartbeat.stop();
options.onError?.(error);

if (action === "none") {
commitState((current) => ({
Expand Down Expand Up @@ -329,6 +330,7 @@ export const useWebSocket: UseWebSocketHook = <
suppressReconnectRef.current = true;
reconnect.cancel();
heartbeat.stop();
options.onError?.(parseError);
commitState((current) => ({
...current,
lastChangedAt: Date.now(),
Expand All @@ -343,6 +345,7 @@ export const useWebSocket: UseWebSocketHook = <
heartbeat.stop();
commitState((current) => ({
...current,
lastChangedAt: Date.now(),
lastError: event,
status: "error"
}));
Expand Down
5 changes: 5 additions & 0 deletions test/hooks/useEventSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,11 @@ describe("useEventSource", () => {

it("closes the source and stops reconnect after parse errors", () => {
vi.useFakeTimers();
const onError = vi.fn();

const { result } = renderHook(() =>
useEventSource<number>({
onError,
reconnect: {
initialDelayMs: 0,
jitterRatio: 0
Expand Down Expand Up @@ -278,6 +280,9 @@ describe("useEventSource", () => {
expect(result.current.reconnectState?.status).toBe("stopped");
expect(source?.closeCalls).toBe(1);
expect(MockEventSource.instances).toHaveLength(1);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ type: "error" })
);
});

it("cleans up listeners on unmount", async () => {
Expand Down
32 changes: 32 additions & 0 deletions test/hooks/useHeartbeat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,36 @@ describe("useHeartbeat", () => {
expect(result.current.isRunning).toBe(false);
expect(beat).not.toHaveBeenCalled();
});

it("clears heartbeat metrics when stopped", () => {
vi.useFakeTimers();

const { result } = renderHook(() =>
useHeartbeat<string, string>({
intervalMs: 1_000,
startOnMount: false
})
);

act(() => {
result.current.start();
result.current.beat();
vi.advanceTimersByTime(250);
result.current.notifyAck("pong");
});

expect(result.current.lastBeatAt).not.toBeNull();
expect(result.current.lastAckAt).not.toBeNull();
expect(result.current.latencyMs).toBe(250);

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

expect(result.current.isRunning).toBe(false);
expect(result.current.hasTimedOut).toBe(false);
expect(result.current.lastBeatAt).toBeNull();
expect(result.current.lastAckAt).toBeNull();
expect(result.current.latencyMs).toBeNull();
});
});
43 changes: 43 additions & 0 deletions test/hooks/useWebSocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ describe("useWebSocket", () => {

it("reconnects on heartbeat timeout by default", async () => {
vi.useFakeTimers();
const onError = vi.fn();

const { result } = renderHook(() =>
useWebSocket<string, string>({
Expand All @@ -352,6 +353,7 @@ describe("useWebSocket", () => {
message: "ping",
timeoutMs: 50
},
onError,
reconnect: {
initialDelayMs: 0,
jitterRatio: 0
Expand All @@ -375,6 +377,9 @@ describe("useWebSocket", () => {
expect(MockWebSocket.instances).toHaveLength(2);
expect(result.current.status).toBe("reconnecting");
expect(result.current.lastError?.type).toBe("heartbeat-timeout");
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ type: "heartbeat-timeout" })
);
});

it("moves to error on heartbeat timeout when reconnect is disabled", () => {
Expand Down Expand Up @@ -443,9 +448,11 @@ describe("useWebSocket", () => {

it("closes the socket and stops reconnect after parse errors", () => {
vi.useFakeTimers();
const onError = vi.fn();

const { result } = renderHook(() =>
useWebSocket<number>({
onError,
reconnect: {
initialDelayMs: 0,
jitterRatio: 0
Expand Down Expand Up @@ -478,6 +485,42 @@ describe("useWebSocket", () => {
expect(result.current.reconnectState?.status).toBe("stopped");
expect(socket?.closeCalls).toBe(1);
expect(MockWebSocket.instances).toHaveLength(1);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ type: "error" })
);
});

it("updates lastChangedAt when the socket emits an error", async () => {
const onError = vi.fn();
const { result } = renderHook(() =>
useWebSocket({
onError,
url: "ws://localhost:1234"
})
);

const socket = MockWebSocket.instances[0];

act(() => {
socket?.emitOpen();
});

await waitFor(() => {
expect(result.current.status).toBe("open");
});

const openedAt = result.current.lastChangedAt;

act(() => {
socket?.emitError();
});

expect(result.current.status).toBe("error");
expect(result.current.lastChangedAt).not.toBeNull();
expect(result.current.lastChangedAt).not.toBe(openedAt);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ type: "error" })
);
});

it("cleans up listeners and timers on unmount", async () => {
Expand Down
Loading