Skip to content

Commit dafc4c0

Browse files
authored
Merge pull request #19 from volkov85/fix/react19-error-state
fix: align React 19 support and realtime error state
2 parents ffdf210 + 19add81 commit dafc4c0

10 files changed

Lines changed: 98 additions & 12 deletions

.changeset/metal-beans-accept.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-realtime-hooks": patch
3+
---
4+
5+
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.

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![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)
66
[![license](https://img.shields.io/npm/l/react-realtime-hooks)](https://github.com/volkov85/react-realtime-hooks/blob/main/LICENSE)
77
[![TypeScript](https://img.shields.io/badge/TypeScript-typed-3178c6)](https://www.typescriptlang.org/)
8-
[![react](https://img.shields.io/badge/react-18.3%2B%20%7C%2019-149eca)](https://www.npmjs.com/package/react)
8+
[![react](https://img.shields.io/badge/react-19.x-149eca)](https://www.npmjs.com/package/react)
99

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

@@ -60,7 +60,7 @@ npm install react-realtime-hooks
6060

6161
Peer dependency:
6262

63-
- `react@^18.3.0 || ^19.0.0`
63+
- `react@^19.0.0`
6464

6565
## How It Feels
6666

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

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

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

513513
- `useEventSource` is receive-only by design. SSE is not a bidirectional transport.
514514
- `useWebSocket` heartbeat support is client-side. You still define your own server ping/pong protocol.
515-
- If `parseMessage` throws, the hook closes the current transport, moves into `error`, stores `lastError`, and stops auto-reconnect until manual `open()` or `reconnect()`.
515+
- 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()`.
516+
- Stopping heartbeat clears timeout state and the previous beat/ack timestamps so a new session starts with fresh metrics.
516517
- `connect: false` keeps the hook in `idle` until `open()` is called.
517518
- Manual `close()` is sticky. The hook stays closed until `open()` or `reconnect()` is called.
518519
- No transport polyfills are bundled. Provide your own runtime support where needed.

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build && npm run publint"
6262
},
6363
"peerDependencies": {
64-
"react": "^18.3.0 || ^19.0.0"
64+
"react": "^19.0.0"
6565
},
6666
"devDependencies": {
6767
"@changesets/cli": "2.30.0",

src/hooks/useEventSource.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export const useEventSource: UseEventSourceHook = <TMessage = unknown>(
157157
suppressReconnectRef.current = true;
158158
reconnect.cancel();
159159
closeEventSource();
160+
options.onError?.(parseError);
160161
commitState((current) => ({
161162
...current,
162163
lastChangedAt: Date.now(),

src/hooks/useHeartbeat.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,7 @@ export const useHeartbeat = <
143143
generationRef.current += 1;
144144
intervalRef.current.cancel();
145145
timeoutRef.current.cancel();
146-
commitState((current) => ({
147-
...current,
148-
hasTimedOut: false,
149-
isRunning: false
150-
}));
146+
commitState(createInitialState(false));
151147
};
152148

153149
const beat = (): void => {

src/hooks/useWebSocket.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ export const useWebSocket: UseWebSocketHook = <
235235
reconnectTrigger: "heartbeat-timeout" | "error"
236236
) => {
237237
heartbeat.stop();
238+
options.onError?.(error);
238239

239240
if (action === "none") {
240241
commitState((current) => ({
@@ -329,6 +330,7 @@ export const useWebSocket: UseWebSocketHook = <
329330
suppressReconnectRef.current = true;
330331
reconnect.cancel();
331332
heartbeat.stop();
333+
options.onError?.(parseError);
332334
commitState((current) => ({
333335
...current,
334336
lastChangedAt: Date.now(),
@@ -343,6 +345,7 @@ export const useWebSocket: UseWebSocketHook = <
343345
heartbeat.stop();
344346
commitState((current) => ({
345347
...current,
348+
lastChangedAt: Date.now(),
346349
lastError: event,
347350
status: "error"
348351
}));

test/hooks/useEventSource.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,11 @@ describe("useEventSource", () => {
243243

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

247248
const { result } = renderHook(() =>
248249
useEventSource<number>({
250+
onError,
249251
reconnect: {
250252
initialDelayMs: 0,
251253
jitterRatio: 0
@@ -278,6 +280,9 @@ describe("useEventSource", () => {
278280
expect(result.current.reconnectState?.status).toBe("stopped");
279281
expect(source?.closeCalls).toBe(1);
280282
expect(MockEventSource.instances).toHaveLength(1);
283+
expect(onError).toHaveBeenCalledWith(
284+
expect.objectContaining({ type: "error" })
285+
);
281286
});
282287

283288
it("cleans up listeners on unmount", async () => {

test/hooks/useHeartbeat.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,36 @@ describe("useHeartbeat", () => {
238238
expect(result.current.isRunning).toBe(false);
239239
expect(beat).not.toHaveBeenCalled();
240240
});
241+
242+
it("clears heartbeat metrics when stopped", () => {
243+
vi.useFakeTimers();
244+
245+
const { result } = renderHook(() =>
246+
useHeartbeat<string, string>({
247+
intervalMs: 1_000,
248+
startOnMount: false
249+
})
250+
);
251+
252+
act(() => {
253+
result.current.start();
254+
result.current.beat();
255+
vi.advanceTimersByTime(250);
256+
result.current.notifyAck("pong");
257+
});
258+
259+
expect(result.current.lastBeatAt).not.toBeNull();
260+
expect(result.current.lastAckAt).not.toBeNull();
261+
expect(result.current.latencyMs).toBe(250);
262+
263+
act(() => {
264+
result.current.stop();
265+
});
266+
267+
expect(result.current.isRunning).toBe(false);
268+
expect(result.current.hasTimedOut).toBe(false);
269+
expect(result.current.lastBeatAt).toBeNull();
270+
expect(result.current.lastAckAt).toBeNull();
271+
expect(result.current.latencyMs).toBeNull();
272+
});
241273
});

test/hooks/useWebSocket.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ describe("useWebSocket", () => {
344344

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

348349
const { result } = renderHook(() =>
349350
useWebSocket<string, string>({
@@ -352,6 +353,7 @@ describe("useWebSocket", () => {
352353
message: "ping",
353354
timeoutMs: 50
354355
},
356+
onError,
355357
reconnect: {
356358
initialDelayMs: 0,
357359
jitterRatio: 0
@@ -375,6 +377,9 @@ describe("useWebSocket", () => {
375377
expect(MockWebSocket.instances).toHaveLength(2);
376378
expect(result.current.status).toBe("reconnecting");
377379
expect(result.current.lastError?.type).toBe("heartbeat-timeout");
380+
expect(onError).toHaveBeenCalledWith(
381+
expect.objectContaining({ type: "heartbeat-timeout" })
382+
);
378383
});
379384

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

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

447453
const { result } = renderHook(() =>
448454
useWebSocket<number>({
455+
onError,
449456
reconnect: {
450457
initialDelayMs: 0,
451458
jitterRatio: 0
@@ -478,6 +485,42 @@ describe("useWebSocket", () => {
478485
expect(result.current.reconnectState?.status).toBe("stopped");
479486
expect(socket?.closeCalls).toBe(1);
480487
expect(MockWebSocket.instances).toHaveLength(1);
488+
expect(onError).toHaveBeenCalledWith(
489+
expect.objectContaining({ type: "error" })
490+
);
491+
});
492+
493+
it("updates lastChangedAt when the socket emits an error", async () => {
494+
const onError = vi.fn();
495+
const { result } = renderHook(() =>
496+
useWebSocket({
497+
onError,
498+
url: "ws://localhost:1234"
499+
})
500+
);
501+
502+
const socket = MockWebSocket.instances[0];
503+
504+
act(() => {
505+
socket?.emitOpen();
506+
});
507+
508+
await waitFor(() => {
509+
expect(result.current.status).toBe("open");
510+
});
511+
512+
const openedAt = result.current.lastChangedAt;
513+
514+
act(() => {
515+
socket?.emitError();
516+
});
517+
518+
expect(result.current.status).toBe("error");
519+
expect(result.current.lastChangedAt).not.toBeNull();
520+
expect(result.current.lastChangedAt).not.toBe(openedAt);
521+
expect(onError).toHaveBeenCalledWith(
522+
expect.objectContaining({ type: "error" })
523+
);
481524
});
482525

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

0 commit comments

Comments
 (0)