react-realtime-hooks 1.0.2 → 1.0.3

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
@@ -350,7 +350,7 @@ 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 |
@@ -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>
@@ -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
 
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,6 +485,7 @@ 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
491
  commitState((current) => ({
@@ -502,6 +531,7 @@ var useHeartbeat = (options) => {
502
531
  return void 0;
503
532
  }, [enabled, options.intervalMs, startOnMount]);
504
533
  react.useEffect(() => () => {
534
+ generationRef.current += 1;
505
535
  intervalRef.current.cancel();
506
536
  timeoutRef.current.cancel();
507
537
  }, []);
@@ -607,6 +637,7 @@ var toProtocolsDependency = (protocols) => {
607
637
  return Array.isArray(protocols) ? protocols.join("|") : protocols;
608
638
  };
609
639
  var toHeartbeatConfig = (heartbeat) => heartbeat === void 0 || heartbeat === false ? null : heartbeat;
640
+ var isSocketActive = (socket) => socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING;
610
641
  var useWebSocket = (options) => {
611
642
  const connect = options.connect ?? true;
612
643
  const supported = isWebSocketSupported();
@@ -618,6 +649,7 @@ var useWebSocket = (options) => {
618
649
  const manualOpenRef = react.useRef(false);
619
650
  const skipCloseReconnectRef = react.useRef(false);
620
651
  const suppressReconnectRef = react.useRef(false);
652
+ const pendingCloseActionRef = react.useRef(null);
621
653
  const terminalErrorRef = react.useRef(null);
622
654
  const [openNonce, setOpenNonce] = react.useState(0);
623
655
  const [state, setState] = react.useState(
@@ -636,6 +668,7 @@ var useWebSocket = (options) => {
636
668
  const heartbeatConfig = toHeartbeatConfig(
637
669
  options.heartbeat
638
670
  );
671
+ const defaultHeartbeatAction = options.reconnect === false ? "close" : "reconnect";
639
672
  const heartbeatHookOptions = heartbeatConfig === null ? {
640
673
  enabled: false,
641
674
  intervalMs: 1e3,
@@ -671,6 +704,27 @@ var useWebSocket = (options) => {
671
704
  if (heartbeatConfig !== null && heartbeatConfig.onTimeout !== void 0) {
672
705
  heartbeatHookOptions.onTimeout = heartbeatConfig.onTimeout;
673
706
  }
707
+ if (heartbeatConfig !== null) {
708
+ const onTimeout = heartbeatHookOptions.onTimeout;
709
+ heartbeatHookOptions.onTimeout = () => {
710
+ applyHeartbeatAction(
711
+ heartbeatConfig.timeoutAction ?? defaultHeartbeatAction,
712
+ new Event("heartbeat-timeout"),
713
+ "heartbeat-timeout"
714
+ );
715
+ onTimeout?.();
716
+ };
717
+ const onError = heartbeatConfig.onError;
718
+ heartbeatHookOptions.onError = (error) => {
719
+ const event = error instanceof Event ? error : new Event("heartbeat-error");
720
+ applyHeartbeatAction(
721
+ heartbeatConfig.errorAction ?? heartbeatConfig.timeoutAction ?? defaultHeartbeatAction,
722
+ event,
723
+ "error"
724
+ );
725
+ onError?.(error);
726
+ };
727
+ }
674
728
  const heartbeat = useHeartbeat(
675
729
  heartbeatHookOptions
676
730
  );
@@ -686,10 +740,46 @@ var useWebSocket = (options) => {
686
740
  }
687
741
  socketRef.current = null;
688
742
  socketKeyRef.current = null;
689
- if (socket2.readyState === WebSocket.OPEN || socket2.readyState === WebSocket.CONNECTING) {
743
+ if (isSocketActive(socket2)) {
690
744
  socket2.close(code, reason);
691
745
  }
692
746
  });
747
+ const applyHeartbeatAction = react.useEffectEvent(
748
+ (action, error, reconnectTrigger) => {
749
+ heartbeat.stop();
750
+ if (action === "none") {
751
+ commitState((current) => ({
752
+ ...current,
753
+ lastChangedAt: Date.now(),
754
+ lastError: error
755
+ }));
756
+ return;
757
+ }
758
+ const shouldReconnect = action === "reconnect" && reconnectEnabled && (options.shouldReconnect?.(error) ?? true);
759
+ manualOpenRef.current = false;
760
+ terminalErrorRef.current = shouldReconnect ? null : error;
761
+ const socket2 = socketRef.current;
762
+ if (socket2 === null || !isSocketActive(socket2)) {
763
+ commitState((current) => ({
764
+ ...current,
765
+ lastChangedAt: Date.now(),
766
+ lastError: error,
767
+ status: shouldReconnect ? "reconnecting" : "error"
768
+ }));
769
+ if (shouldReconnect) {
770
+ reconnect.schedule(reconnectTrigger);
771
+ }
772
+ return;
773
+ }
774
+ pendingCloseActionRef.current = {
775
+ error,
776
+ reconnectTrigger: shouldReconnect ? reconnectTrigger : null
777
+ };
778
+ skipCloseReconnectRef.current = true;
779
+ suppressReconnectRef.current = true;
780
+ closeSocket();
781
+ }
782
+ );
693
783
  const parseMessage = react.useEffectEvent((event) => {
694
784
  const parser = options.parseMessage ?? defaultParseMessage;
695
785
  return parser(event);
@@ -757,9 +847,26 @@ var useWebSocket = (options) => {
757
847
  socketKeyRef.current = null;
758
848
  heartbeat.stop();
759
849
  updateBufferedAmount();
850
+ const pendingCloseAction = pendingCloseActionRef.current;
851
+ pendingCloseActionRef.current = null;
760
852
  const terminalError = terminalErrorRef.current;
761
853
  const skipCloseReconnect = skipCloseReconnectRef.current;
762
854
  skipCloseReconnectRef.current = false;
855
+ if (pendingCloseAction !== null) {
856
+ suppressReconnectRef.current = false;
857
+ commitState((current) => ({
858
+ ...current,
859
+ lastChangedAt: Date.now(),
860
+ lastCloseEvent: event,
861
+ lastError: pendingCloseAction.error ?? current.lastError,
862
+ status: pendingCloseAction.reconnectTrigger === null ? "error" : "reconnecting"
863
+ }));
864
+ options.onClose?.(event);
865
+ if (pendingCloseAction.reconnectTrigger !== null) {
866
+ reconnect.schedule(pendingCloseAction.reconnectTrigger);
867
+ }
868
+ return;
869
+ }
763
870
  if (terminalError !== null) {
764
871
  suppressReconnectRef.current = false;
765
872
  commitState((current) => ({
@@ -918,7 +1025,7 @@ var useWebSocket = (options) => {
918
1025
  if (socket2 === null) {
919
1026
  return;
920
1027
  }
921
- if (socket2.readyState === WebSocket.OPEN || socket2.readyState === WebSocket.CONNECTING) {
1028
+ if (isSocketActive(socket2)) {
922
1029
  socket2.close();
923
1030
  }
924
1031
  }, []);