react-realtime-hooks 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![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)
6
6
  [![license](https://img.shields.io/npm/l/react-realtime-hooks)](https://github.com/volkov85/react-realtime-hooks/blob/main/LICENSE)
7
7
  [![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)
9
9
 
10
10
  Production-ready React hooks for WebSocket and SSE with auto-reconnect, heartbeat, typed connection state, and browser network awareness.
11
11
 
@@ -60,7 +60,7 @@ npm install react-realtime-hooks
60
60
 
61
61
  Peer dependency:
62
62
 
63
- - `react@^18.3.0 || ^19.0.0`
63
+ - `react@^19.0.0`
64
64
 
65
65
  ## How It Feels
66
66
 
@@ -350,11 +350,11 @@ export function NetworkIndicator() {
350
350
  | `parseMessage` | `(event) => TIncoming` | raw `event.data` | Incoming parser |
351
351
  | `serializeMessage` | `(message) => ...` | JSON/string passthrough | Outgoing serializer |
352
352
  | `reconnect` | `false \| UseReconnectOptions` | enabled | Reconnect configuration |
353
- | `heartbeat` | `false \| UseHeartbeatOptions` | disabled unless configured | Heartbeat configuration |
353
+ | `heartbeat` | `false \| UseWebSocketHeartbeatOptions` | disabled unless configured | Heartbeat configuration |
354
354
  | `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on close |
355
355
  | `onOpen` | `(event, socket) => void` | `undefined` | Open callback |
356
356
  | `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 |
358
358
  | `onClose` | `(event) => void` | `undefined` | Close callback |
359
359
 
360
360
  ### Result
@@ -375,6 +375,10 @@ export function NetworkIndicator() {
375
375
  | `reconnect` | `() => void` | Manual reconnect |
376
376
  | `send` | `(message) => boolean` | Sends an outgoing payload |
377
377
 
378
+ When you configure `useWebSocket` heartbeat, you can also set `timeoutAction` and
379
+ `errorAction` to `"none"`, `"close"`, or `"reconnect"`. The default is
380
+ `"reconnect"` when reconnect is enabled and `"close"` otherwise.
381
+
378
382
  </details>
379
383
 
380
384
  <details>
@@ -393,7 +397,7 @@ export function NetworkIndicator() {
393
397
  | `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on error |
394
398
  | `onOpen` | `(event, source) => void` | `undefined` | Open callback |
395
399
  | `onMessage` | `(message, event) => void` | `undefined` | Default `message` callback |
396
- | `onError` | `(event) => void` | `undefined` | Error callback |
400
+ | `onError` | `(event) => void` | `undefined` | Called for transport and parse errors |
397
401
  | `onEvent` | `(eventName, message, event) => void` | `undefined` | Named event callback |
398
402
 
399
403
  ### Result
@@ -464,6 +468,7 @@ export function NetworkIndicator() {
464
468
  | `startOnMount` | `boolean` | `true` | Starts immediately |
465
469
  | `onBeat` | `() => void` | `undefined` | Called on every beat |
466
470
  | `onTimeout` | `() => void` | `undefined` | Called on timeout |
471
+ | `onError` | `(error) => void` | `undefined` | Called when `beat()` throws or rejects |
467
472
 
468
473
  ### Result
469
474
 
@@ -507,7 +512,8 @@ export function NetworkIndicator() {
507
512
 
508
513
  - `useEventSource` is receive-only by design. SSE is not a bidirectional transport.
509
514
  - `useWebSocket` heartbeat support is client-side. You still define your own server ping/pong protocol.
510
- - 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.
511
517
  - `connect: false` keeps the hook in `idle` until `open()` is called.
512
518
  - Manual `close()` is sticky. The hook stays closed until `open()` or `reconnect()` is called.
513
519
  - No transport polyfills are bundled. Provide your own runtime support where needed.
package/dist/index.cjs CHANGED
@@ -404,6 +404,7 @@ var useHeartbeat = (options) => {
404
404
  const startOnMount = options.startOnMount ?? true;
405
405
  const intervalRef = react.useRef(createManagedInterval());
406
406
  const timeoutRef = react.useRef(createManagedTimeout());
407
+ const generationRef = react.useRef(0);
407
408
  const [state, setState] = react.useState(
408
409
  () => createInitialState2(enabled && startOnMount)
409
410
  );
@@ -430,8 +431,7 @@ var useHeartbeat = (options) => {
430
431
  handleTimeout();
431
432
  }, options.timeoutMs);
432
433
  });
433
- const runBeat = react.useEffectEvent(() => {
434
- const performedAt = Date.now();
434
+ const handleBeatSuccess = react.useEffectEvent((performedAt) => {
435
435
  commitState((current) => ({
436
436
  ...current,
437
437
  hasTimedOut: false,
@@ -439,7 +439,35 @@ var useHeartbeat = (options) => {
439
439
  }));
440
440
  scheduleTimeout();
441
441
  options.onBeat?.();
442
- void options.beat?.();
442
+ });
443
+ const handleBeatError = react.useEffectEvent((error) => {
444
+ timeoutRef.current.cancel();
445
+ options.onError?.(error);
446
+ });
447
+ const runBeat = react.useEffectEvent(() => {
448
+ const generation = generationRef.current;
449
+ const completeBeat = (result) => {
450
+ if (generation !== generationRef.current || result === false) {
451
+ return;
452
+ }
453
+ handleBeatSuccess(Date.now());
454
+ };
455
+ const failBeat = (error) => {
456
+ if (generation !== generationRef.current) {
457
+ return;
458
+ }
459
+ handleBeatError(error);
460
+ };
461
+ try {
462
+ const result = options.beat?.();
463
+ if (result !== null && typeof result === "object" && "then" in result) {
464
+ void Promise.resolve(result).then(completeBeat, failBeat);
465
+ return;
466
+ }
467
+ completeBeat(result);
468
+ } catch (error) {
469
+ failBeat(error);
470
+ }
443
471
  });
444
472
  const start = () => {
445
473
  if (!enabled) {
@@ -457,13 +485,10 @@ var useHeartbeat = (options) => {
457
485
  }));
458
486
  };
459
487
  const stop = () => {
488
+ generationRef.current += 1;
460
489
  intervalRef.current.cancel();
461
490
  timeoutRef.current.cancel();
462
- commitState((current) => ({
463
- ...current,
464
- hasTimedOut: false,
465
- isRunning: false
466
- }));
491
+ commitState(createInitialState2(false));
467
492
  };
468
493
  const beat = () => {
469
494
  if (!enabled) {
@@ -502,6 +527,7 @@ var useHeartbeat = (options) => {
502
527
  return void 0;
503
528
  }, [enabled, options.intervalMs, startOnMount]);
504
529
  react.useEffect(() => () => {
530
+ generationRef.current += 1;
505
531
  intervalRef.current.cancel();
506
532
  timeoutRef.current.cancel();
507
533
  }, []);
@@ -607,6 +633,7 @@ var toProtocolsDependency = (protocols) => {
607
633
  return Array.isArray(protocols) ? protocols.join("|") : protocols;
608
634
  };
609
635
  var toHeartbeatConfig = (heartbeat) => heartbeat === void 0 || heartbeat === false ? null : heartbeat;
636
+ var isSocketActive = (socket) => socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING;
610
637
  var useWebSocket = (options) => {
611
638
  const connect = options.connect ?? true;
612
639
  const supported = isWebSocketSupported();
@@ -618,6 +645,7 @@ var useWebSocket = (options) => {
618
645
  const manualOpenRef = react.useRef(false);
619
646
  const skipCloseReconnectRef = react.useRef(false);
620
647
  const suppressReconnectRef = react.useRef(false);
648
+ const pendingCloseActionRef = react.useRef(null);
621
649
  const terminalErrorRef = react.useRef(null);
622
650
  const [openNonce, setOpenNonce] = react.useState(0);
623
651
  const [state, setState] = react.useState(
@@ -636,6 +664,7 @@ var useWebSocket = (options) => {
636
664
  const heartbeatConfig = toHeartbeatConfig(
637
665
  options.heartbeat
638
666
  );
667
+ const defaultHeartbeatAction = options.reconnect === false ? "close" : "reconnect";
639
668
  const heartbeatHookOptions = heartbeatConfig === null ? {
640
669
  enabled: false,
641
670
  intervalMs: 1e3,
@@ -671,6 +700,27 @@ var useWebSocket = (options) => {
671
700
  if (heartbeatConfig !== null && heartbeatConfig.onTimeout !== void 0) {
672
701
  heartbeatHookOptions.onTimeout = heartbeatConfig.onTimeout;
673
702
  }
703
+ if (heartbeatConfig !== null) {
704
+ const onTimeout = heartbeatHookOptions.onTimeout;
705
+ heartbeatHookOptions.onTimeout = () => {
706
+ applyHeartbeatAction(
707
+ heartbeatConfig.timeoutAction ?? defaultHeartbeatAction,
708
+ new Event("heartbeat-timeout"),
709
+ "heartbeat-timeout"
710
+ );
711
+ onTimeout?.();
712
+ };
713
+ const onError = heartbeatConfig.onError;
714
+ heartbeatHookOptions.onError = (error) => {
715
+ const event = error instanceof Event ? error : new Event("heartbeat-error");
716
+ applyHeartbeatAction(
717
+ heartbeatConfig.errorAction ?? heartbeatConfig.timeoutAction ?? defaultHeartbeatAction,
718
+ event,
719
+ "error"
720
+ );
721
+ onError?.(error);
722
+ };
723
+ }
674
724
  const heartbeat = useHeartbeat(
675
725
  heartbeatHookOptions
676
726
  );
@@ -686,10 +736,47 @@ var useWebSocket = (options) => {
686
736
  }
687
737
  socketRef.current = null;
688
738
  socketKeyRef.current = null;
689
- if (socket2.readyState === WebSocket.OPEN || socket2.readyState === WebSocket.CONNECTING) {
739
+ if (isSocketActive(socket2)) {
690
740
  socket2.close(code, reason);
691
741
  }
692
742
  });
743
+ const applyHeartbeatAction = react.useEffectEvent(
744
+ (action, error, reconnectTrigger) => {
745
+ heartbeat.stop();
746
+ options.onError?.(error);
747
+ if (action === "none") {
748
+ commitState((current) => ({
749
+ ...current,
750
+ lastChangedAt: Date.now(),
751
+ lastError: error
752
+ }));
753
+ return;
754
+ }
755
+ const shouldReconnect = action === "reconnect" && reconnectEnabled && (options.shouldReconnect?.(error) ?? true);
756
+ manualOpenRef.current = false;
757
+ terminalErrorRef.current = shouldReconnect ? null : error;
758
+ const socket2 = socketRef.current;
759
+ if (socket2 === null || !isSocketActive(socket2)) {
760
+ commitState((current) => ({
761
+ ...current,
762
+ lastChangedAt: Date.now(),
763
+ lastError: error,
764
+ status: shouldReconnect ? "reconnecting" : "error"
765
+ }));
766
+ if (shouldReconnect) {
767
+ reconnect.schedule(reconnectTrigger);
768
+ }
769
+ return;
770
+ }
771
+ pendingCloseActionRef.current = {
772
+ error,
773
+ reconnectTrigger: shouldReconnect ? reconnectTrigger : null
774
+ };
775
+ skipCloseReconnectRef.current = true;
776
+ suppressReconnectRef.current = true;
777
+ closeSocket();
778
+ }
779
+ );
693
780
  const parseMessage = react.useEffectEvent((event) => {
694
781
  const parser = options.parseMessage ?? defaultParseMessage;
695
782
  return parser(event);
@@ -734,6 +821,7 @@ var useWebSocket = (options) => {
734
821
  suppressReconnectRef.current = true;
735
822
  reconnect.cancel();
736
823
  heartbeat.stop();
824
+ options.onError?.(parseError);
737
825
  commitState((current) => ({
738
826
  ...current,
739
827
  lastChangedAt: Date.now(),
@@ -747,6 +835,7 @@ var useWebSocket = (options) => {
747
835
  heartbeat.stop();
748
836
  commitState((current) => ({
749
837
  ...current,
838
+ lastChangedAt: Date.now(),
750
839
  lastError: event,
751
840
  status: "error"
752
841
  }));
@@ -757,9 +846,26 @@ var useWebSocket = (options) => {
757
846
  socketKeyRef.current = null;
758
847
  heartbeat.stop();
759
848
  updateBufferedAmount();
849
+ const pendingCloseAction = pendingCloseActionRef.current;
850
+ pendingCloseActionRef.current = null;
760
851
  const terminalError = terminalErrorRef.current;
761
852
  const skipCloseReconnect = skipCloseReconnectRef.current;
762
853
  skipCloseReconnectRef.current = false;
854
+ if (pendingCloseAction !== null) {
855
+ suppressReconnectRef.current = false;
856
+ commitState((current) => ({
857
+ ...current,
858
+ lastChangedAt: Date.now(),
859
+ lastCloseEvent: event,
860
+ lastError: pendingCloseAction.error ?? current.lastError,
861
+ status: pendingCloseAction.reconnectTrigger === null ? "error" : "reconnecting"
862
+ }));
863
+ options.onClose?.(event);
864
+ if (pendingCloseAction.reconnectTrigger !== null) {
865
+ reconnect.schedule(pendingCloseAction.reconnectTrigger);
866
+ }
867
+ return;
868
+ }
763
869
  if (terminalError !== null) {
764
870
  suppressReconnectRef.current = false;
765
871
  commitState((current) => ({
@@ -918,7 +1024,7 @@ var useWebSocket = (options) => {
918
1024
  if (socket2 === null) {
919
1025
  return;
920
1026
  }
921
- if (socket2.readyState === WebSocket.OPEN || socket2.readyState === WebSocket.CONNECTING) {
1027
+ if (isSocketActive(socket2)) {
922
1028
  socket2.close();
923
1029
  }
924
1030
  }, []);
@@ -1076,6 +1182,7 @@ var useEventSource = (options) => {
1076
1182
  suppressReconnectRef.current = true;
1077
1183
  reconnect.cancel();
1078
1184
  closeEventSource();
1185
+ options.onError?.(parseError);
1079
1186
  commitState((current) => ({
1080
1187
  ...current,
1081
1188
  lastChangedAt: Date.now(),